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 docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ cache flush, or environment fixup around each worktree operation.
| [Hooks](hooks.md) | The 12 hook points, the environment contract, and why hooks are sourced rather than executed |
| [Agent integration](agent-integration.md) | Output modes, exit codes, and the approval-friction problem this CLI exists to remove |
| [Conventions from the skills library](skills-conventions.md) | The conventions every command must honour, extracted from `linchpin/skills` |
| [Plugin scaffold](plugin-scaffold.md) | `linchpin plugin scaffold` — generate a plugin from `linchpin/plugin-scaffold` |
| [Repo tasks](repo-tasks.md) | **Spec, not yet built.** `linchpin repo <task>` — connecting a repository to the release infrastructure in one command |

## Status
Expand Down
45 changes: 45 additions & 0 deletions docs/plugin-scaffold.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Plugin scaffold

`linchpin plugin scaffold <slug>` writes a new WordPress plugin from the house standard at [`linchpin/plugin-scaffold`](https://github.com/linchpin/plugin-scaffold).

It is the WP-CLI `wp scaffold plugin` analog for how Linchpin actually ships plugins: `{slug}.php`, `includes/` Bootstrap and Controller, `linchpin/coding-standards`, `linchpin/actions@v4` callers, release-please, husky, `.distignore`, and the house README (release line, badges, license, banner).

This command writes a **local directory**. It does not create a GitHub repository or set secrets.

## This is not `linchpin repo plugin --scaffold`

[Repo tasks](repo-tasks.md) uses `--scaffold` for a different job: render *workflow* templates onto an *existing* repository and open a PR. Keep that name for that job.

## Usage

```bash
linchpin plugin scaffold acme
linchpin plugin scaffold acme --with-blocks --channel=wporg
linchpin plugin scaffold acme --path ~/GitHub/acme --dry-run
```

| Flag | Default | Meaning |
| --- | --- | --- |
| `--name` | title-cased slug | Plugin Name and README title |
| `--description` | generated one-liner | Plugin header and README one-liner |
| `--php` | `8.3` | Requires PHP |
| `--channel` | `private` | `private`, `wporg`, or `self-hosted` |
| `--with-blocks` | off | Nested `blocks/` workspace and an example block |
| `--path` | `./<slug>` | Destination |
| `--ref` | pinned tag | Tag, sha, or a local template path |
| `--force` | off | Overwrite a non-empty destination |
| `--dry-run` | off | Print the plan, write nothing |
| `--git` | off | `git init` when the destination is not already inside a repo |
| `--install` | off | `composer install` and `npm install` |

The pin lives in `src/core/plugin-scaffold-pin.json` (and `PLUGIN_SCAFFOLD_PIN`). Default runtime fetch is `gh repo clone linchpin/plugin-scaffold` at that tag, cached under `~/.linchpin/cache/plugin-scaffold/`. Tests use `test/fixtures/plugin-scaffold/` via `--ref`.

## After it writes

```bash
cd acme
linchpin wt config init
# later: gh repo create, then linchpin repo plugin --connect
```

What a generated plugin must contain is owned by `wp-plugin-standards`. This command is that skill's executable form for greenfield repos.
5 changes: 5 additions & 0 deletions docs/repo-tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,11 @@ so it is testable without a single credential.
`gh secret set`. The org-shadow refusal lands here.
3. **Template rendering and `--scaffold`.** Render, compare, open the PR. The missing-file case
first; drift is the same code path with a different PR body.

This `--scaffold` is **not** `linchpin plugin scaffold`. That command creates a new plugin
tree from [`linchpin/plugin-scaffold`](https://github.com/linchpin/plugin-scaffold). See
[plugin-scaffold.md](plugin-scaffold.md). This one lands workflow files on a repo that
already exists.
4. **Post-write proof.** Dispatch the dry-run workflow and wait on the conclusion, so a task
only reports connected once something has actually run green.

Expand Down
5 changes: 5 additions & 0 deletions docs/skills-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,11 @@ any unclassified one. The skills settle several:
| `skills install`, `skills update` | `write` | Overwrites installed skill directories in place |
| `agent setup` | `write` | Writes settings and hook config |
| `json set/patch/merge` | `write` | — |
| `plugin scaffold` | `write` | `wp-plugin-standards` is the owner of what a generated plugin must contain |

`plugin scaffold` is that skill's executable form for a *new* plugin tree. The standard repo
is [`linchpin/plugin-scaffold`](https://github.com/linchpin/plugin-scaffold). See
[plugin-scaffold.md](plugin-scaffold.md). Do not invent a second file list in this CLI.

`skills install` deserves a note: the library's installer **`rmSync` + `cpSync` each destination
skill directory**, and `write-a-linchpin-skill` warns *never hand-edit skills in a consuming
Expand Down
2 changes: 2 additions & 0 deletions src/cli/commands/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { CommandDefinition } from '../registry.js';

import { pluginScaffoldCommand } from './plugin-scaffold.js';
import { shellInitCommand } from './shell-init.js';
import { updateCommand } from './update.js';
import { versionCommand } from './version.js';
Expand All @@ -14,6 +15,7 @@ import { wtCommand } from './wt.js';
*/
export const COMMANDS: readonly CommandDefinition[] = [
wtCommand,
pluginScaffoldCommand,
shellInitCommand,
versionCommand,
updateCommand,
Expand Down
148 changes: 148 additions & 0 deletions src/cli/commands/plugin-scaffold.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { z } from 'zod';

import {
BANNER_URL,
CHANNELS,
loadPin,
scaffoldPlugin,
} from '../../core/plugin-scaffold.js';
import { EXIT_CODES, UserError } from '../errors.js';
import { defineCommand } from '../registry.js';

/**
* `linchpin plugin scaffold` — generate a Linchpin WordPress plugin.
*
* Writes a local tree from a pinned ref of linchpin/plugin-scaffold. It does
* not create a GitHub repository or set secrets.
*/
export const pluginScaffoldCommand = defineCommand({
meta: {
name: 'plugin scaffold',
summary: 'Generate a Linchpin WordPress plugin from the house standard',
description:
'Copies a pinned ref of linchpin/plugin-scaffold, renames the identity,\n' +
'and writes a plugin directory that matches wp-plugin-standards.\n' +
'GitHub repo creation and secret wiring are separate.',
group: 'wordpress',
examples: [
'linchpin plugin scaffold acme',
'linchpin plugin scaffold acme --with-blocks --channel=wporg',
'linchpin plugin scaffold acme --path ~/GitHub/acme --dry-run',
],
},
effect: 'write',
args: z.object({
slug: z
.string()
.describe('Plugin slug, text domain, and composer package linchpin/<slug>')
.meta({ positional: true, valueName: 'slug' }),
name: z
.string()
.optional()
.describe('Plugin Name header and README title. Defaults to a title-cased slug'),
description: z
.string()
.optional()
.describe('Plugin header and README one-liner'),
php: z.string().default('8.3').describe('Requires PHP / composer platform PHP'),
channel: z
.enum(CHANNELS)
.default('private')
.describe('Distribution channel: private, wporg, or self-hosted'),
withBlocks: z
.boolean()
.default(false)
.describe('Add the nested blocks workspace and an example block'),
path: z
.string()
.optional()
.describe('Destination directory. Defaults to ./<slug>'),
ref: z
.string()
.optional()
.describe('Override the pinned tag, sha, or a local template path'),
force: z
.boolean()
.default(false)
.describe('Overwrite a non-empty destination'),
dryRun: z
.boolean()
.default(false)
.describe('Print the files that would be written, without writing'),
git: z
.boolean()
.default(false)
.describe('git init when the destination is not already inside a repository'),
install: z
.boolean()
.default(false)
.describe('Run composer install and npm install after writing'),
}),
handler: async (args, ctx) => {
const dest = args.path ?? `./${args.slug}`;

try {
const result = scaffoldPlugin({
slug: args.slug,
dest,
php: args.php,
channel: args.channel,
withBlocks: args.withBlocks,
force: args.force,
dryRun: args.dryRun,
git: args.git,
install: args.install,
...(args.name === undefined ? {} : { name: args.name }),
...(args.description === undefined ? {} : { description: args.description }),
...(args.ref === undefined ? {} : { ref: args.ref }),
});

const pin = loadPin();
const lines = args.dryRun
? [
`Would write ${result.slug} to ${result.dest}`,
`Template: ${result.templateRoot}`,
...result.files.map((file) => ` ${file}`),
]
: [
`Wrote ${result.name} (${result.slug}) to ${result.dest}`,
`Template: ${pin.repo}@${pin.tag} (${pin.sha.slice(0, 7)})`,
`${result.files.length} files`,
'',
'Next:',
' linchpin wt config init',
' gh repo create linchpin/' + result.slug + ' --private --source . --push',
];

ctx.output.result(
'plugin_scaffold',
{
dest: result.dest,
slug: result.slug,
name: result.name,
wrote: result.wrote,
fileCount: result.files.length,
files: result.files,
templateRoot: result.templateRoot,
bannerUrl: BANNER_URL,
pin,
},
{ human: lines.join('\n') }
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);

throw new UserError(message, {
exitCode: message.startsWith('Invalid plugin slug')
? EXIT_CODES.validation
: message.includes('not empty')
? EXIT_CODES.refused
: EXIT_CODES.precondition,
code: 'plugin_scaffold_failed',
...(message.includes('not empty')
? { remedy: 'Pass --force to overwrite, or choose another --path' }
: {}),
});
}
},
});
5 changes: 5 additions & 0 deletions src/core/plugin-scaffold-pin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"repo": "linchpin/plugin-scaffold",
"tag": "v0.1.0",
"sha": "bfe22dd413997562d015f4fa1a1f4b0bc54a1e40"
}
Loading
Loading