Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLI_INTEGRATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion TARGETS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) | ✅ |

---

Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/adapter-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
75 changes: 75 additions & 0 deletions packages/targets/deploy-wordpress/README.md
Original file line number Diff line number Diff line change
@@ -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 `<slug>-<version>.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 <plugin|theme> install <zip> --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=[<user>@]<host>[:<port>][<path>]` for a remote site |
| `path` | one of | — | `wp --path=<dir>` for a WordPress root on this machine |
| `url` | no | — | `wp --url=<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
```
37 changes: 37 additions & 0 deletions packages/targets/deploy-wordpress/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
}
228 changes: 228 additions & 0 deletions packages/targets/deploy-wordpress/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import('@profullstack/sh1pt-core')>('@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<string> {
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',
});
});
});
Loading
Loading