From 12bdf95ed8ff491b382de76ad439c6da11f52634 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Thu, 13 Aug 2026 14:57:24 +0000 Subject: [PATCH] feat(targets): add deploy-wordpress, a wp-cli backed target Ships a WordPress plugin or theme to a live install with wp-cli: `wp dist-archive` builds the zip, `wp install --force --activate` puts it on the site named by `ssh` or `path`, then the version is read back from `wp get`. Dry runs write a wordpress-package.json plan and never exec wp-cli. Ship refuses to run without `ssh` or `path` so it can't guess which install to write to; auth is the SSH agent's job, so no vault secret. Co-Authored-By: Claude Opus 5 (1M context) --- CLI_INTEGRATIONS.md | 1 + TARGETS.md | 3 +- packages/cli/src/adapter-registry.ts | 2 +- packages/targets/deploy-wordpress/README.md | 75 ++++++ .../targets/deploy-wordpress/package.json | 37 +++ .../deploy-wordpress/src/index.test.ts | 228 ++++++++++++++++++ .../targets/deploy-wordpress/src/index.ts | 192 +++++++++++++++ .../targets/deploy-wordpress/tsconfig.json | 5 + pnpm-lock.yaml | 6 + 9 files changed, 547 insertions(+), 2 deletions(-) create mode 100644 packages/targets/deploy-wordpress/README.md create mode 100644 packages/targets/deploy-wordpress/package.json create mode 100644 packages/targets/deploy-wordpress/src/index.test.ts create mode 100644 packages/targets/deploy-wordpress/src/index.ts create mode 100644 packages/targets/deploy-wordpress/tsconfig.json diff --git a/CLI_INTEGRATIONS.md b/CLI_INTEGRATIONS.md index 6bd2bd93..e2954797 100644 --- a/CLI_INTEGRATIONS.md +++ b/CLI_INTEGRATIONS.md @@ -35,6 +35,7 @@ These have real CLIs and map directly to existing or planned adapter surfaces. | Expo / EAS | `expo`, `eas` | `targets/mobile-expo` | Exists | `mise x npm:expo -- expo --help`; `mise x npm:eas-cli -- eas --help` | | Doppler | `doppler` | `secrets/doppler` | Exists | official package manager install | | dotenvx | `dotenvx` | `secrets/dotenvx` | Exists | `mise x npm:@dotenvx/dotenvx -- dotenvx --help` | +| WordPress | `wp` | `targets/deploy-wordpress` | Exists | `brew install wp-cli`, or the phar from https://wp-cli.org/#installing | ## Strong next candidates diff --git a/TARGETS.md b/TARGETS.md index 2b7f76d1..98de9cbf 100644 --- a/TARGETS.md +++ b/TARGETS.md @@ -92,7 +92,7 @@ Each row is a **target adapter** โ€” one plugin in `packages/targets/*`. Status | `plugin-slack` | Slack App Directory | ๐Ÿšง | | `plugin-zapier` | Zapier Platform | ๐Ÿšง | | `plugin-shopify` | Shopify App Store | ๐Ÿšง | -| `plugin-wordpress` | wordpress.org plugins | ๐Ÿšง | +| `plugin-wordpress` | wordpress.org plugins (SVN release) โ€” for a live install, see `deploy-wordpress` | ๐Ÿšง | | `plugin-notion` | Notion integrations | โ€” | ### Voice @@ -169,6 +169,7 @@ Each row is a **target adapter** โ€” one plugin in `packages/targets/*`. Status | `deploy-firebase` | Firebase Hosting / Functions | โœ… | | `deploy-lambda` | AWS Lambda | โ€” | | `deploy-cloudrun` | Google Cloud Run | โ€” | +| `deploy-wordpress` | Live WordPress install via [wp-cli](https://github.com/wp-cli/wp-cli) (plugin/theme) | โœ… | --- diff --git a/packages/cli/src/adapter-registry.ts b/packages/cli/src/adapter-registry.ts index a5f6a53f..f099a2d0 100644 --- a/packages/cli/src/adapter-registry.ts +++ b/packages/cli/src/adapter-registry.ts @@ -168,7 +168,7 @@ export const CATEGORIES: readonly AdapterCategory[] = [ id: 'targets', pkgPrefix: '@profullstack/sh1pt-target', description: 'Distribution targets โ€” stores, registries, CDNs, deploy platforms', - adapters: ['browser-chrome', 'browser-edge', 'browser-firefox', 'browser-safari', 'chat-discord', 'chat-signal', 'chat-slack', 'chat-telegram', 'chat-whatsapp', 'console-steam', 'deploy-coolify', 'deploy-denodeploy', 'deploy-firebase', 'deploy-fly', 'deploy-lambda', 'deploy-netlify', 'deploy-railway', 'deploy-render', 'deploy-vercel', 'deploy-workers', 'desktop-linux', 'desktop-mac', 'desktop-steamos', 'desktop-win', 'exe-dev', 'mobile-android', 'mobile-expo', 'mobile-ios', 'payment-adyen', 'payment-coinpay', 'payment-paypal', 'payment-square', 'payment-stripe', 'pkg-apt', 'pkg-aube', 'pkg-aur', 'pkg-cdn', 'pkg-deno', 'pkg-docker', 'pkg-fdroid', 'pkg-flatpak', 'pkg-ghpackages', 'pkg-homebrew', 'pkg-jsr', 'pkg-nix', 'pkg-npm', 'pkg-perry', 'pkg-scoop', 'pkg-snap', 'pkg-webtorrent', 'pkg-winget', 'plugin-jetbrains', 'plugin-vscode', 'qa-geisterhand', 'registry-ans', 'sdk-pypi', 'tv-androidtv', 'tv-firetv', 'tv-roku', 'tv-tvos', 'tv-webos', 'web-pwa', 'web-static', 'xr-meta-quest', 'xr-pico', 'xr-sidequest', 'xr-steamvr', 'xr-visionos', 'xr-webxr'], + adapters: ['browser-chrome', 'browser-edge', 'browser-firefox', 'browser-safari', 'chat-discord', 'chat-signal', 'chat-slack', 'chat-telegram', 'chat-whatsapp', 'console-steam', 'deploy-coolify', 'deploy-denodeploy', 'deploy-firebase', 'deploy-fly', 'deploy-lambda', 'deploy-netlify', 'deploy-railway', 'deploy-render', 'deploy-vercel', 'deploy-wordpress', 'deploy-workers', 'desktop-linux', 'desktop-mac', 'desktop-steamos', 'desktop-win', 'exe-dev', 'mobile-android', 'mobile-expo', 'mobile-ios', 'payment-adyen', 'payment-coinpay', 'payment-paypal', 'payment-square', 'payment-stripe', 'pkg-apt', 'pkg-aube', 'pkg-aur', 'pkg-cdn', 'pkg-deno', 'pkg-docker', 'pkg-fdroid', 'pkg-flatpak', 'pkg-ghpackages', 'pkg-homebrew', 'pkg-jsr', 'pkg-nix', 'pkg-npm', 'pkg-perry', 'pkg-scoop', 'pkg-snap', 'pkg-webtorrent', 'pkg-winget', 'plugin-jetbrains', 'plugin-vscode', 'qa-geisterhand', 'registry-ans', 'sdk-pypi', 'tv-androidtv', 'tv-firetv', 'tv-roku', 'tv-tvos', 'tv-webos', 'web-pwa', 'web-static', 'xr-meta-quest', 'xr-pico', 'xr-sidequest', 'xr-steamvr', 'xr-visionos', 'xr-webxr'], }, { id: 'vcs', diff --git a/packages/targets/deploy-wordpress/README.md b/packages/targets/deploy-wordpress/README.md new file mode 100644 index 00000000..65104e40 --- /dev/null +++ b/packages/targets/deploy-wordpress/README.md @@ -0,0 +1,75 @@ +# WordPress (wp-cli) + +Ships a WordPress plugin or theme to a live WordPress install using +[wp-cli](https://github.com/wp-cli/wp-cli) โ€” `wp dist-archive` builds the zip, +`wp plugin install --activate` puts it on the site. + +## What it does + +- `build` packages the plugin/theme source into `-.zip` with `wp dist-archive`, installing `wp-cli/dist-archive-command` first if the site's wp-cli doesn't have it. +- `ship` runs `wp install --force --activate` against the install named by `ssh` or `path`, then reads back the version the site ended up on. +- `status` reports the shipped version and the site URL. +- Dry runs write a `wordpress-package.json` plan and never exec wp-cli. + +## Package + +- Name: `@profullstack/sh1pt-target-deploy-wordpress` +- Path: `packages/targets/deploy-wordpress` +- Adapter ID: `deploy-wordpress` +- Homepage: https://sh1pt.com + +## Configuration + +| Key | Required | Default | Notes | +|---|---|---|---| +| `slug` | yes | โ€” | Plugin/theme directory name as WordPress knows it | +| `type` | no | `plugin` | `plugin` or `theme` | +| `sourceDir` | no | project root | Source dir to archive, relative to the project | +| `ssh` | one of | โ€” | `wp --ssh=[@][:][]` for a remote site | +| `path` | one of | โ€” | `wp --path=` for a WordPress root on this machine | +| `url` | no | โ€” | `wp --url=`, for multisite | +| `siteUrl` | no | `url` | Public URL reported back on ship | +| `activate` | no | `true` | Set `false` to install without activating | + +`ship` requires `ssh` or `path` โ€” sh1pt will not guess which WordPress install +to write to. Authentication is your SSH agent's job; no token goes in the vault. + +```ts +// sh1pt.config.ts +{ + target: 'deploy-wordpress', + config: { + slug: 'acme-widgets', + sourceDir: 'plugins/acme-widgets', + ssh: 'deploy@example.com:/var/www/html', + siteUrl: 'https://example.com', + }, +} +``` + +## Prerequisites + +```bash +brew install wp-cli # or the phar: https://wp-cli.org/#installing +wp package install wp-cli/dist-archive-command # build step installs this if missing +wp core version --ssh=deploy@example.com:/var/www/html +``` + +## Scripts + +- `build`: `tsc -p tsconfig.json` +- `prepublishOnly`: `pnpm build` +- `typecheck`: `tsc -p tsconfig.json --noEmit` + +## Usage + +```bash +pnpm add @profullstack/sh1pt-target-deploy-wordpress +``` + +## Development + +```bash +pnpm --filter @profullstack/sh1pt-target-deploy-wordpress typecheck +pnpm vitest run packages/targets/deploy-wordpress/src/index.test.ts +``` diff --git a/packages/targets/deploy-wordpress/package.json b/packages/targets/deploy-wordpress/package.json new file mode 100644 index 00000000..e6223812 --- /dev/null +++ b/packages/targets/deploy-wordpress/package.json @@ -0,0 +1,37 @@ +{ + "name": "@profullstack/sh1pt-target-deploy-wordpress", + "version": "0.1.15", + "type": "module", + "main": "./src/index.ts", + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "prepublishOnly": "pnpm build" + }, + "dependencies": { + "@profullstack/sh1pt-core": "workspace:*" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/profullstack/sh1pt.git", + "directory": "packages/targets/deploy-wordpress" + }, + "homepage": "https://sh1pt.com", + "bugs": "https://github.com/profullstack/sh1pt/issues", + "files": [ + "dist" + ], + "publishConfig": { + "access": "public", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + } + } +} diff --git a/packages/targets/deploy-wordpress/src/index.test.ts b/packages/targets/deploy-wordpress/src/index.test.ts new file mode 100644 index 00000000..d107ca1b --- /dev/null +++ b/packages/targets/deploy-wordpress/src/index.test.ts @@ -0,0 +1,228 @@ +import { fakeBuildContext, fakeShipContext, smokeTest } from '@profullstack/sh1pt-core/testing'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { execMock, ensureCliMock } = vi.hoisted(() => ({ + execMock: vi.fn(), + ensureCliMock: vi.fn(), +})); + +vi.mock('@profullstack/sh1pt-core', async () => ({ + ...await vi.importActual('@profullstack/sh1pt-core'), + exec: execMock, + ensureCli: ensureCliMock, +})); + +import adapter from './index.js'; + +smokeTest(adapter, { idPrefix: 'deploy', requireKind: true }); + +const tempDirs: string[] = []; +const sampleConfig = { + slug: 'acme-widgets', + sourceDir: 'plugins/acme-widgets', + ssh: 'deploy@example.com:/var/www/html', + siteUrl: 'https://example.com', +}; + +async function tempDir(prefix: string): Promise { + const dir = await mkdtemp(join(tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +beforeEach(() => { + vi.clearAllMocks(); + execMock.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }); +}); + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +describe('deploy-wordpress target adapter', () => { + it('writes a dist-archive plan without invoking wp-cli in dry-run builds', async () => { + const outDir = await tempDir('sh1pt-wp-out-'); + const projectDir = await tempDir('sh1pt-wp-project-'); + + const result = await adapter.build(fakeBuildContext({ + outDir, + projectDir, + version: '1.2.3', + dryRun: true, + }) as any, sampleConfig); + + expect(execMock).not.toHaveBeenCalled(); + expect(ensureCliMock).not.toHaveBeenCalled(); + expect(result.artifact).toBe(join(outDir, 'wordpress-package.json')); + + const plan = JSON.parse(await readFile(result.artifact, 'utf-8')); + expect(plan).toEqual({ + provider: 'wordpress', + type: 'plugin', + slug: 'acme-widgets', + version: '1.2.3', + sourceDir: join(projectDir, 'plugins/acme-widgets'), + artifact: join(outDir, 'acme-widgets-1.2.3.zip'), + command: [ + 'wp', + 'dist-archive', + join(projectDir, 'plugins/acme-widgets'), + join(outDir, 'acme-widgets-1.2.3.zip'), + '--format=zip', + ], + }); + }); + + it('installs the dist-archive command before packaging when it is missing', async () => { + const outDir = await tempDir('sh1pt-wp-out-'); + const projectDir = await tempDir('sh1pt-wp-project-'); + + const ctx = fakeBuildContext({ outDir, projectDir, version: '1.2.3', dryRun: false }); + const result = await adapter.build(ctx as any, sampleConfig); + + expect(ensureCliMock).toHaveBeenCalledWith('wp', expect.stringContaining('wp-cli'), ctx.log); + expect(execMock).toHaveBeenNthCalledWith(1, 'wp', ['package', 'list', '--fields=name', '--format=csv'], { + log: ctx.log, + throwOnNonZero: false, + }); + expect(execMock).toHaveBeenNthCalledWith(2, 'wp', ['package', 'install', 'wp-cli/dist-archive-command'], { + log: ctx.log, + throwOnNonZero: true, + }); + expect(execMock).toHaveBeenNthCalledWith(3, 'wp', [ + 'dist-archive', + join(projectDir, 'plugins/acme-widgets'), + join(outDir, 'acme-widgets-1.2.3.zip'), + '--format=zip', + ], { + cwd: projectDir, + log: ctx.log, + throwOnNonZero: true, + }); + expect(result).toEqual({ artifact: join(outDir, 'acme-widgets-1.2.3.zip') }); + }); + + it('skips the package install when dist-archive is already available', async () => { + const outDir = await tempDir('sh1pt-wp-out-'); + const projectDir = await tempDir('sh1pt-wp-project-'); + + execMock.mockResolvedValueOnce({ + exitCode: 0, + stdout: 'name\nwp-cli/dist-archive-command\n', + stderr: '', + }); + + await adapter.build(fakeBuildContext({ + outDir, + projectDir, + version: '1.2.3', + dryRun: false, + }) as any, sampleConfig); + + expect(execMock).toHaveBeenCalledTimes(2); + expect(execMock.mock.calls[1]?.[1]?.[0]).toBe('dist-archive'); + }); + + it('rejects unsupported extension types', async () => { + await expect(adapter.build(fakeBuildContext({ dryRun: true }) as any, { + ...sampleConfig, + type: 'mu-plugin', + } as any)).rejects.toThrow('deploy-wordpress type must be one of: plugin, theme'); + }); + + it('keeps dry-run shipping side-effect free and target-free', async () => { + await expect(adapter.ship(fakeShipContext({ + version: '1.2.3', + dryRun: true, + }) as any, { slug: 'acme-widgets' })).resolves.toEqual({ id: 'dry-run' }); + + expect(execMock).not.toHaveBeenCalled(); + }); + + it('refuses to ship when neither ssh nor path names an install', async () => { + await expect(adapter.ship(fakeShipContext({ + version: '1.2.3', + dryRun: false, + }) as any, { slug: 'acme-widgets' })).rejects.toThrow('deploy-wordpress requires ssh or path'); + + expect(execMock).not.toHaveBeenCalled(); + }); + + it('installs and activates over wp-cli, reporting the version the site ended up on', async () => { + execMock + .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }) + .mockResolvedValueOnce({ exitCode: 0, stdout: '1.2.3\n', stderr: '' }); + + const ctx = fakeShipContext({ + version: '1.2.3', + dryRun: false, + artifact: '/repo/.sh1pt/out/acme-widgets-1.2.3.zip', + }); + const result = await adapter.ship(ctx as any, sampleConfig); + + expect(execMock).toHaveBeenNthCalledWith(1, 'wp', [ + 'plugin', + 'install', + '/repo/.sh1pt/out/acme-widgets-1.2.3.zip', + '--force', + '--activate', + '--ssh=deploy@example.com:/var/www/html', + ], { log: ctx.log, throwOnNonZero: true }); + expect(execMock).toHaveBeenNthCalledWith(2, 'wp', [ + 'plugin', + 'get', + 'acme-widgets', + '--field=version', + '--ssh=deploy@example.com:/var/www/html', + ], { log: ctx.log, throwOnNonZero: false }); + expect(result).toEqual({ id: 'acme-widgets@1.2.3', url: 'https://example.com' }); + }); + + it('ships themes without activating when activate is false', async () => { + const ctx = fakeShipContext({ + version: '2.0.0', + dryRun: false, + artifact: '/repo/.sh1pt/out/acme-theme-2.0.0.zip', + }); + + await adapter.ship(ctx as any, { + slug: 'acme-theme', + type: 'theme', + path: '/var/www/html', + activate: false, + }); + + expect(execMock).toHaveBeenNthCalledWith(1, 'wp', [ + 'theme', + 'install', + '/repo/.sh1pt/out/acme-theme-2.0.0.zip', + '--force', + '--path=/var/www/html', + ], { log: ctx.log, throwOnNonZero: true }); + }); + + it('falls back to the build version when wp-cli cannot report one', async () => { + execMock + .mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' }) + .mockResolvedValueOnce({ exitCode: 1, stdout: "Error: The 'acme-widgets' plugin could not be found.", stderr: '' }); + + const result = await adapter.ship(fakeShipContext({ + version: '1.2.3', + dryRun: false, + artifact: '/repo/.sh1pt/out/acme-widgets-1.2.3.zip', + }) as any, sampleConfig); + + expect(result.id).toBe('acme-widgets@1.2.3'); + }); + + it('reports live status with the site URL', async () => { + await expect(adapter.status?.('acme-widgets@1.2.3', sampleConfig)).resolves.toEqual({ + state: 'live', + version: '1.2.3', + url: 'https://example.com', + }); + }); +}); diff --git a/packages/targets/deploy-wordpress/src/index.ts b/packages/targets/deploy-wordpress/src/index.ts new file mode 100644 index 00000000..eedf1be8 --- /dev/null +++ b/packages/targets/deploy-wordpress/src/index.ts @@ -0,0 +1,192 @@ +import { defineTarget, setupGuide, ensureCli, exec } from '@profullstack/sh1pt-core'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { join, posix } from 'node:path'; + +interface Config { + slug: string; // plugin/theme slug as WordPress knows it, e.g. "my-plugin" + type?: 'plugin' | 'theme'; // default "plugin" + sourceDir?: string; // dir holding the plugin/theme source, relative to projectDir + ssh?: string; // wp-cli --ssh=[@][:][] + path?: string; // wp-cli --path= for a local WordPress root + url?: string; // wp-cli --url= for multisite + siteUrl?: string; // public site URL, reported back on ship + activate?: boolean; // default true +} + +const TYPES = ['plugin', 'theme'] as const; +const INSTALL_HINT = 'Install wp-cli: brew install wp-cli, or the phar from https://wp-cli.org/#installing'; +const DIST_ARCHIVE_PACKAGE = 'wp-cli/dist-archive-command'; + +function extensionType(config: Config): (typeof TYPES)[number] { + const type = config.type ?? 'plugin'; + if (!TYPES.includes(type as (typeof TYPES)[number])) { + throw new Error(`deploy-wordpress type must be one of: ${TYPES.join(', ')}`); + } + return type as (typeof TYPES)[number]; +} + +function requireSlug(config: Config): string { + if (!config.slug) throw new Error('deploy-wordpress requires a slug (the plugin/theme directory name)'); + return config.slug; +} + +function isWindowsPath(path: string): boolean { + return path.includes('\\') || /^[A-Za-z]:\//.test(path.replace(/\\/g, '/')); +} + +function joinLike(base: string, ...parts: string[]): string { + return isWindowsPath(base) ? join(base, ...parts) : posix.join(base, ...parts); +} + +function sourceDir(ctx: { projectDir: string }, config: Config): string { + if (!config.sourceDir) return ctx.projectDir; + return joinLike(ctx.projectDir, config.sourceDir); +} + +function packageArtifact(ctx: { outDir: string; version: string }, config: Config): string { + return joinLike(ctx.outDir, `${requireSlug(config)}-${ctx.version}.zip`); +} + +// wp-cli global params โ€” they select which WordPress install every +// subcommand runs against, so every exec gets them appended. +function globalArgs(config: Config): string[] { + const args: string[] = []; + if (config.ssh) args.push(`--ssh=${config.ssh}`); + if (config.path) args.push(`--path=${config.path}`); + if (config.url) args.push(`--url=${config.url}`); + return args; +} + +function distArchiveArgs(ctx: { projectDir: string; outDir: string; version: string }, config: Config): string[] { + return ['dist-archive', sourceDir(ctx, config), packageArtifact(ctx, config), '--format=zip']; +} + +function installArgs(ctx: { artifact: string }, config: Config): string[] { + const args = [extensionType(config), 'install', ctx.artifact, '--force']; + if (config.activate !== false) args.push('--activate'); + return [...args, ...globalArgs(config)]; +} + +function siteUrl(config: Config): string | undefined { + if (config.siteUrl) return config.siteUrl; + if (!config.url) return undefined; + return /^https?:\/\//.test(config.url) ? config.url : `https://${config.url}`; +} + +// `wp plugin get --field=version` prints just the version, but wp-cli may +// prepend warnings (deprecations, PHP notices) on the same stream. +function parseVersion(stdout: string, fallback: string): string { + const line = stdout.trim().split('\n').map((l) => l.trim()).filter(Boolean).pop(); + return line && /^[\w.+-]+$/.test(line) ? line : fallback; +} + +function renderPackagePlan(ctx: { projectDir: string; outDir: string; version: string }, config: Config): string { + return `${JSON.stringify({ + provider: 'wordpress', + type: extensionType(config), + slug: requireSlug(config), + version: ctx.version, + sourceDir: sourceDir(ctx, config), + artifact: packageArtifact(ctx, config), + command: ['wp', ...distArchiveArgs(ctx, config)], + }, null, 2)}\n`; +} + +export default defineTarget({ + id: 'deploy-wordpress', + kind: 'plugin', + label: 'WordPress (wp-cli)', + + async build(ctx, config) { + const type = extensionType(config); + const slug = requireSlug(config); + + if (ctx.dryRun) { + const planPath = joinLike(ctx.outDir, 'wordpress-package.json'); + ctx.log(`wp-cli: dry-run dist-archive plan for ${type} ${slug} v${ctx.version}`); + await mkdir(ctx.outDir, { recursive: true }); + await writeFile(planPath, renderPackagePlan(ctx, config), 'utf-8'); + return { artifact: planPath }; + } + + await ensureCli('wp', INSTALL_HINT, ctx.log); + + const { stdout } = await exec('wp', ['package', 'list', '--fields=name', '--format=csv'], { + log: ctx.log, + throwOnNonZero: false, + }); + + if (!stdout.includes(DIST_ARCHIVE_PACKAGE)) { + ctx.log(`wp-cli: ${DIST_ARCHIVE_PACKAGE} not installed โ€” installing`); + await exec('wp', ['package', 'install', DIST_ARCHIVE_PACKAGE], { + log: ctx.log, + throwOnNonZero: true, + }); + } + + ctx.log(`wp-cli: packaging ${type} ${slug} v${ctx.version}`); + await mkdir(ctx.outDir, { recursive: true }); + + await exec('wp', distArchiveArgs(ctx, config), { + cwd: ctx.projectDir, + log: ctx.log, + throwOnNonZero: true, + }); + + return { artifact: packageArtifact(ctx, config) }; + }, + + async ship(ctx, config) { + const type = extensionType(config); + const slug = requireSlug(config); + const target = config.ssh ?? config.path ?? ''; + + ctx.log(`wp ${type} install ยท ${slug}@${ctx.version} ยท target=${target}`); + + if (ctx.dryRun) { + ctx.log(`wp-cli: dry-run โ€” would install and activate ${type} ${slug}`); + return { id: 'dry-run' }; + } + + if (!config.ssh && !config.path) { + throw new Error( + 'deploy-wordpress requires ssh or path so wp-cli knows which WordPress install to write to. ' + + 'Set ssh: "user@host:/var/www/html" for a remote site, or path: "/var/www/html" for a local one.', + ); + } + + await ensureCli('wp', INSTALL_HINT, ctx.log); + + await exec('wp', installArgs(ctx, config), { + log: ctx.log, + throwOnNonZero: true, + }); + + const { stdout } = await exec('wp', [type, 'get', slug, '--field=version', ...globalArgs(config)], { + log: ctx.log, + throwOnNonZero: false, + }); + const installed = parseVersion(stdout, ctx.version); + + ctx.log(`wp-cli: ${type} ${slug} now at v${installed}`); + + return { id: `${slug}@${installed}`, url: siteUrl(config) }; + }, + + async status(shipId, config) { + const [, version] = shipId.split('@'); + return { state: 'live', version, url: siteUrl(config) }; + }, + + setup: setupGuide({ + label: 'WordPress (wp-cli)', + vendorDocUrl: 'https://wp-cli.org/#installing', + steps: [ + 'Install wp-cli: brew install wp-cli, or download the phar from https://wp-cli.org/#installing', + `Add the archive builder: wp package install ${DIST_ARCHIVE_PACKAGE}`, + 'Confirm the CLI can reach the site: wp core version --ssh=user@host:/var/www/html', + 'wp-cli authenticates over your SSH agent โ€” no token goes in the sh1pt vault', + 'Set ssh (remote) or path (local) plus slug in the target config', + ], + }), +}); diff --git a/packages/targets/deploy-wordpress/tsconfig.json b/packages/targets/deploy-wordpress/tsconfig.json new file mode 100644 index 00000000..cf441478 --- /dev/null +++ b/packages/targets/deploy-wordpress/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { "outDir": "dist", "rootDir": "src" }, + "include": ["src/**/*"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 03594781..ea5fa803 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1817,6 +1817,12 @@ importers: specifier: workspace:* version: link:../../core + packages/targets/deploy-wordpress: + dependencies: + '@profullstack/sh1pt-core': + specifier: workspace:* + version: link:../../core + packages/targets/deploy-workers: dependencies: '@profullstack/sh1pt-core':