diff --git a/docs/README.md b/docs/README.md index 0403f53..9ab3440 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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 ` — connecting a repository to the release infrastructure in one command | ## Status diff --git a/docs/plugin-scaffold.md b/docs/plugin-scaffold.md new file mode 100644 index 0000000..c1f4261 --- /dev/null +++ b/docs/plugin-scaffold.md @@ -0,0 +1,45 @@ +# Plugin scaffold + +`linchpin plugin scaffold ` 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` | `./` | 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. diff --git a/docs/repo-tasks.md b/docs/repo-tasks.md index 81c07d4..c5016bd 100644 --- a/docs/repo-tasks.md +++ b/docs/repo-tasks.md @@ -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. diff --git a/docs/skills-conventions.md b/docs/skills-conventions.md index 8ae4de5..030b59d 100644 --- a/docs/skills-conventions.md +++ b/docs/skills-conventions.md @@ -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 diff --git a/src/cli/commands/index.ts b/src/cli/commands/index.ts index ba67b24..4602f82 100644 --- a/src/cli/commands/index.ts +++ b/src/cli/commands/index.ts @@ -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'; @@ -14,6 +15,7 @@ import { wtCommand } from './wt.js'; */ export const COMMANDS: readonly CommandDefinition[] = [ wtCommand, + pluginScaffoldCommand, shellInitCommand, versionCommand, updateCommand, diff --git a/src/cli/commands/plugin-scaffold.ts b/src/cli/commands/plugin-scaffold.ts new file mode 100644 index 0000000..9225ba2 --- /dev/null +++ b/src/cli/commands/plugin-scaffold.ts @@ -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/') + .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 ./'), + 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' } + : {}), + }); + } + }, +}); diff --git a/src/core/plugin-scaffold-pin.json b/src/core/plugin-scaffold-pin.json new file mode 100644 index 0000000..44b0fad --- /dev/null +++ b/src/core/plugin-scaffold-pin.json @@ -0,0 +1,5 @@ +{ + "repo": "linchpin/plugin-scaffold", + "tag": "v0.1.0", + "sha": "bfe22dd413997562d015f4fa1a1f4b0bc54a1e40" +} diff --git a/src/core/plugin-scaffold.ts b/src/core/plugin-scaffold.ts new file mode 100644 index 0000000..0807164 --- /dev/null +++ b/src/core/plugin-scaffold.ts @@ -0,0 +1,494 @@ +import { + cpSync, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { homedir } from 'node:os'; +import { dirname, join, relative, resolve, sep } from 'node:path'; + +import { runCommand } from './exec.js'; + +export interface PluginScaffoldPin { + readonly repo: string; + readonly tag: string; + readonly sha: string; +} + +/** Keep in lockstep with plugin-scaffold-pin.json. */ +export const PLUGIN_SCAFFOLD_PIN: PluginScaffoldPin = { + repo: 'linchpin/plugin-scaffold', + tag: 'v0.1.0', + sha: 'bfe22dd413997562d015f4fa1a1f4b0bc54a1e40', +}; + +export const CHANNELS = ['private', 'wporg', 'self-hosted'] as const; +export type Channel = (typeof CHANNELS)[number]; + +export interface ScaffoldOptions { + readonly slug: string; + readonly name?: string; + readonly description?: string; + readonly php?: string; + readonly channel?: Channel; + readonly withBlocks?: boolean; + readonly dest: string; + readonly ref?: string; + readonly force?: boolean; + readonly dryRun?: boolean; + readonly git?: boolean; + readonly install?: boolean; +} + +export interface ScaffoldResult { + readonly dest: string; + readonly slug: string; + readonly name: string; + readonly files: readonly string[]; + readonly wrote: boolean; + readonly templateRoot: string; +} + +const SKIP_FROM_TEMPLATE = new Set([ + '.git', + 'node_modules', + 'vendor', + 'overlays', + '.phpunit.cache', + '.php-cs-fixer.cache', +]); + +const SLUG_PATTERN = /^[a-z][a-z0-9-]*$/; +const BANNER_URL = 'https://assets.linchpin.com/github/linchpin-github-repo-banner.jpg'; + +export function loadPin(): PluginScaffoldPin { + return PLUGIN_SCAFFOLD_PIN; +} + +export function titleCaseSlug(slug: string): string { + return slug + .split('-') + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' '); +} + +export function studlySlug(slug: string): string { + return slug + .split('-') + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join('_'); +} + +export function constSlug(slug: string): string { + return slug.toUpperCase().replace(/-/g, '_'); +} + +export function assertValidSlug(slug: string): void { + if (!SLUG_PATTERN.test(slug)) { + throw new Error( + `Invalid plugin slug "${slug}". Use a lowercase kebab-case name starting with a letter.` + ); + } +} + +export function cacheDirectory(): string { + return process.env.LINCHPIN_CACHE_DIR ?? join(homedir(), '.linchpin', 'cache'); +} + +/** + * A local directory, or a cached clone of the pinned (or overridden) ref. + */ +export function resolveTemplateRoot(ref?: string): string { + const pin = loadPin(); + const requested = ref ?? pin.tag; + + if (requested.startsWith('file:')) { + return resolve(requested.slice('file:'.length)); + } + + if (requested.startsWith('/') || requested.startsWith('.')) { + return resolve(requested); + } + + const dest = join(cacheDirectory(), 'plugin-scaffold', requested.replace(/[^a-zA-Z0-9._-]/g, '_')); + + if (existsSync(join(dest, 'plugin-scaffold.php'))) { + return dest; + } + + mkdirSync(dirname(dest), { recursive: true }); + if (existsSync(dest)) { + rmSync(dest, { recursive: true, force: true }); + } + + const result = runCommand( + 'gh', + ['repo', 'clone', pin.repo, dest, '--', '--depth', '1', '--branch', requested], + { allowFailure: true } + ); + + if (!result.ok) { + throw new Error( + `Could not fetch ${pin.repo}@${requested}. ${result.stderr.trim() || result.stdout.trim()}` + ); + } + + return dest; +} + +function listFiles(root: string): string[] { + const out: string[] = []; + + const walk = (dir: string): void => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + walk(full); + continue; + } + out.push(full); + } + }; + + walk(root); + return out.sort(); +} + +function isInsideGitRepo(dir: string): boolean { + const result = runCommand('git', ['rev-parse', '--show-toplevel'], { + cwd: dir, + allowFailure: true, + }); + return result.ok; +} + +function destinationOccupied(dest: string): boolean { + if (!existsSync(dest)) { + return false; + } + + return readdirSync(dest).length > 0; +} + +function copyTemplate(templateRoot: string, dest: string): void { + mkdirSync(dest, { recursive: true }); + + for (const entry of readdirSync(templateRoot, { withFileTypes: true })) { + if (SKIP_FROM_TEMPLATE.has(entry.name)) { + continue; + } + + cpSync(join(templateRoot, entry.name), join(dest, entry.name), { recursive: true }); + } +} + +function copyOverlay(templateRoot: string, dest: string, name: string): void { + const overlay = join(templateRoot, 'overlays', name); + if (!existsSync(overlay)) { + throw new Error(`Overlay "${name}" is missing from ${templateRoot}`); + } + + for (const entry of readdirSync(overlay, { withFileTypes: true })) { + if (entry.name === 'package-scripts.json') { + continue; + } + + cpSync(join(overlay, entry.name), join(dest, entry.name), { recursive: true }); + } + + const scriptsPath = join(overlay, 'package-scripts.json'); + if (existsSync(scriptsPath)) { + mergePackageScripts(dest, JSON.parse(readFileSync(scriptsPath, 'utf8')) as Record); + } +} + +function mergePackageScripts(dest: string, extra: Record): void { + const packagePath = join(dest, 'package.json'); + const pkg = JSON.parse(readFileSync(packagePath, 'utf8')) as { scripts?: Record }; + pkg.scripts = { ...pkg.scripts, ...extra }; + writeFileSync(packagePath, `${JSON.stringify(pkg, null, 2)}\n`); +} + +function insertUpdateUri(dest: string, slug: string): void { + const mainFile = join(dest, `${slug}.php`); + const source = readFileSync(mainFile, 'utf8'); + if (source.includes('Update URI:')) { + return; + } + + const updated = source.replace( + /(\s*\*\s*Plugin URI:\s*.+\n)/, + `$1 * Update URI: https://api.linchpin.com/updates/${slug}\n` + ); + writeFileSync(mainFile, updated); +} + +function renameIdentity(dest: string, slug: string): void { + const replacements: ReadonlyArray = [ + ['Linchpin\\Plugin_Scaffold', `Linchpin\\${studlySlug(slug)}`], + ['linchpin/plugin-scaffold', `linchpin/${slug}`], + ['plugin-scaffold', slug], + ['Plugin_Scaffold', studlySlug(slug)], + ['PLUGIN_SCAFFOLD', constSlug(slug)], + ]; + + for (const file of listFiles(dest)) { + const raw = readFileSync(file); + if (raw.includes(0)) { + continue; + } + + let text = raw.toString('utf8'); + if (text.includes(BANNER_URL)) { + const parts = text.split(BANNER_URL); + const rewritten = parts.map((part, index) => { + if (index === parts.length - 1) { + return applyReplacements(part, replacements); + } + return applyReplacements(part, replacements); + }); + // Banner URL is spliced back in unchanged. + text = rewritten.join(BANNER_URL); + } else { + text = applyReplacements(text, replacements); + } + + writeFileSync(file, text); + } + + renamePaths(dest, 'plugin-scaffold', slug); +} + +function applyReplacements( + text: string, + replacements: ReadonlyArray +): string { + let next = text; + for (const [from, to] of replacements) { + next = next.split(from).join(to); + } + return next; +} + +function renamePaths(root: string, from: string, to: string): void { + const entries = listFiles(root) + .map((file) => relative(root, file)) + .filter((rel) => rel.includes(from)) + .sort((a, b) => b.length - a.length); + + for (const rel of entries) { + const source = join(root, rel); + const targetRel = rel.split(from).join(to); + const target = join(root, targetRel); + mkdirSync(dirname(target), { recursive: true }); + cpSync(source, target); + rmSync(source); + } + + pruneEmptyDirs(root); +} + +function pruneEmptyDirs(root: string): void { + const walk = (dir: string): void => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (!entry.isDirectory()) { + continue; + } + const full = join(dir, entry.name); + walk(full); + if (readdirSync(full).length === 0) { + rmSync(full, { recursive: true, force: true }); + } + } + }; + + walk(root); +} + +function applyDisplayIdentity( + dest: string, + slug: string, + name: string, + description: string, + php: string +): void { + const mainFile = join(dest, `${slug}.php`); + let phpSource = readFileSync(mainFile, 'utf8'); + phpSource = phpSource.replace(/^(\s*\*\s*Plugin Name:\s*).+$/m, `$1${name}`); + phpSource = phpSource.replace(/^(\s*\*\s*Description:\s*).+$/m, `$1${description}`); + if (php !== '8.3') { + phpSource = phpSource.replace(/Requires PHP:\s*8\.3/, `Requires PHP: ${php}`); + } + writeFileSync(mainFile, phpSource); + + const readmePath = join(dest, 'README.md'); + let readme = readFileSync(readmePath, 'utf8'); + readme = readme.replace(/^# .+$/m, `# ${name}`); + readme = readme.replace(/^(# .+\n\n).+(\n)/, `$1${description}$2`); + writeFileSync(readmePath, readme); + + const composerPath = join(dest, 'composer.json'); + const composer = JSON.parse(readFileSync(composerPath, 'utf8')) as { + description?: string; + require?: Record; + config?: { platform?: { php?: string } }; + }; + composer.description = description; + if (php !== '8.3') { + if (composer.require) { + composer.require.php = `>=${php}`; + } + if (composer.config?.platform) { + composer.config.platform.php = `${php}.0`; + } + } + writeFileSync(composerPath, `${JSON.stringify(composer, null, 2)}\n`); +} + +export function leftoverIdentity(dest: string): string[] { + const leftovers: string[] = []; + + for (const file of listFiles(dest)) { + const raw = readFileSync(file); + if (raw.includes(0)) { + continue; + } + + const text = raw.toString('utf8'); + const withoutBanner = text.split(BANNER_URL).join(''); + if ( + withoutBanner.includes('plugin-scaffold') || + withoutBanner.includes('Plugin_Scaffold') || + withoutBanner.includes('PLUGIN_SCAFFOLD') + ) { + leftovers.push(relative(dest, file)); + } + } + + return leftovers; +} + +export function collectPlannedFiles( + templateRoot: string, + options: Pick +): string[] { + const names = readdirSync(templateRoot).filter((name) => !SKIP_FROM_TEMPLATE.has(name)); + const planned = new Set(names); + + if (options.withBlocks) { + planned.add('blocks'); + planned.add(join('includes', 'Controller', 'Blocks.php')); + } + + if (options.channel === 'wporg') { + planned.add('readme.txt'); + planned.add('.wordpress-org'); + } + + return [...planned].sort(); +} + +/** + * Render a Linchpin plugin from the house standard. + */ +export function scaffoldPlugin(options: ScaffoldOptions): ScaffoldResult { + assertValidSlug(options.slug); + + const dest = resolve(options.dest); + const name = options.name?.trim() || titleCaseSlug(options.slug); + const description = + options.description?.trim() || + `A WordPress plugin generated from the Linchpin plugin standard.`; + const php = options.php ?? '8.3'; + const channel = options.channel ?? 'private'; + const templateRoot = resolveTemplateRoot(options.ref); + + if (!existsSync(join(templateRoot, 'plugin-scaffold.php'))) { + throw new Error(`Template at ${templateRoot} is not a plugin-scaffold tree.`); + } + + if (destinationOccupied(dest) && !options.force) { + throw new Error( + `Destination ${dest} is not empty. Pass --force to overwrite, or choose another --path.` + ); + } + + const planned = collectPlannedFiles(templateRoot, options); + + if (options.dryRun) { + return { + dest, + slug: options.slug, + name, + files: planned, + wrote: false, + templateRoot, + }; + } + + if (existsSync(dest) && options.force) { + rmSync(dest, { recursive: true, force: true }); + } + + copyTemplate(templateRoot, dest); + + if (options.withBlocks) { + copyOverlay(templateRoot, dest, 'blocks'); + } + + if (channel === 'wporg') { + copyOverlay(templateRoot, dest, 'wporg'); + } + + if (channel === 'self-hosted') { + copyOverlay(templateRoot, dest, 'self-hosted'); + } + + renameIdentity(dest, options.slug); + applyDisplayIdentity(dest, options.slug, name, description, php); + + if (channel === 'self-hosted') { + insertUpdateUri(dest, options.slug); + } + + const leftovers = leftoverIdentity(dest); + if (leftovers.length > 0) { + throw new Error( + `Rename left plugin-scaffold identity in: ${leftovers.slice(0, 8).join(', ')}` + ); + } + + if (options.git && !isInsideGitRepo(dest)) { + runCommand('git', ['init', '-b', 'main'], { cwd: dest }); + } + + if (options.install) { + runCommand('composer', ['install'], { cwd: dest, inherit: true }); + runCommand('npm', ['install'], { cwd: dest, inherit: true }); + } + + const files = listFiles(dest).map((file) => relative(dest, file).split(sep).join('/')); + + return { + dest, + slug: options.slug, + name, + files, + wrote: true, + templateRoot, + }; +} + +export function assertBannerIntact(dest: string): void { + const readme = readFileSync(join(dest, 'README.md'), 'utf8'); + if (!readme.includes(BANNER_URL)) { + throw new Error('README.md is missing the house banner URL.'); + } +} + +export { BANNER_URL }; diff --git a/src/index.ts b/src/index.ts index e733fd9..90bac7a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -148,6 +148,23 @@ export { type HookPhase, } from './core/hooks.js'; +export { + BANNER_URL, + CHANNELS, + PLUGIN_SCAFFOLD_PIN, + assertValidSlug, + leftoverIdentity, + loadPin, + resolveTemplateRoot, + scaffoldPlugin, + studlySlug, + titleCaseSlug, + type Channel, + type PluginScaffoldPin, + type ScaffoldOptions, + type ScaffoldResult, +} from './core/plugin-scaffold.js'; + export { readManifest, readVersion, type Manifest } from './version.js'; export { diff --git a/test/fixtures/plugin-scaffold/.github/workflows/php.yml b/test/fixtures/plugin-scaffold/.github/workflows/php.yml new file mode 100644 index 0000000..0a99a77 --- /dev/null +++ b/test/fixtures/plugin-scaffold/.github/workflows/php.yml @@ -0,0 +1,4 @@ +name: PHP +jobs: + php: + uses: linchpin/actions/.github/workflows/php-checks.yml@v4 diff --git a/test/fixtures/plugin-scaffold/README.md b/test/fixtures/plugin-scaffold/README.md new file mode 100644 index 0000000..f625d83 --- /dev/null +++ b/test/fixtures/plugin-scaffold/README.md @@ -0,0 +1,38 @@ +# Plugin Scaffold + +The Linchpin WordPress plugin standard. + +See [CHANGELOG.md](CHANGELOG.md) for release history. + + +## Latest Release: 0.1.0 + + +| Workflow | Status | +|----------|--------| +| Release | ![Release Status](https://github.com/linchpin/plugin-scaffold/actions/workflows/release-please.yml/badge.svg) | +| PHP | ![PHP Status](https://github.com/linchpin/plugin-scaffold/actions/workflows/php.yml/badge.svg) | + +--- + +## What this is + +A fixture. + +## Install + +```bash +composer require linchpin/plugin-scaffold +``` + +## Development + +```bash +composer test +``` + +## License + +GPL-2.0-or-later. © Linchpin. + +![Linchpin an award winning digital agency building immersive, high performing web experiences](https://assets.linchpin.com/github/linchpin-github-repo-banner.jpg) diff --git a/test/fixtures/plugin-scaffold/composer.json b/test/fixtures/plugin-scaffold/composer.json new file mode 100644 index 0000000..36c932e --- /dev/null +++ b/test/fixtures/plugin-scaffold/composer.json @@ -0,0 +1,17 @@ +{ + "name": "linchpin/plugin-scaffold", + "description": "The Linchpin WordPress plugin standard.", + "require": { + "php": ">=8.3" + }, + "scripts": { + "php-lint": "parallel-lint .", + "phpstan": "phpstan analyse", + "check-branch-cs": "Linchpin\\Composer\\Actions::check_branch_cs" + }, + "config": { + "platform": { + "php": "8.3.0" + } + } +} diff --git a/test/fixtures/plugin-scaffold/includes/Core/Bootstrap.php b/test/fixtures/plugin-scaffold/includes/Core/Bootstrap.php new file mode 100644 index 0000000..df6dc6e --- /dev/null +++ b/test/fixtures/plugin-scaffold/includes/Core/Bootstrap.php @@ -0,0 +1,5 @@ + { + lib = await import(LIB); +}); + +function tempDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'linchpin-scaffold-')); +} + +function lastNonEmptyLine(text) { + return text + .split('\n') + .map((line) => line.trimEnd()) + .filter((line) => line.length > 0) + .at(-1); +} + +test('plugin scaffold help is registered', () => { + const help = runCli(process.cwd(), ['plugin', 'scaffold', '--help']); + assert.equal(help.code, 0, help.stderr); + assert.match(help.stdout, /Generate a Linchpin WordPress plugin/); + assert.match(help.stdout, /--with-blocks/); + assert.match(help.stdout, /--channel/); +}); + +test('invalid slug is a validation error', () => { + const dest = tempDir(); + const result = runCli(process.cwd(), [ + 'plugin', + 'scaffold', + 'Acme', + '--path', + dest, + '--ref', + FIXTURE, + ]); + + assert.equal(result.code, 2); + assert.match(result.stderr, /Invalid plugin slug/); +}); + +test('dry-run writes nothing', () => { + const dest = path.join(tempDir(), 'acme'); + const result = runCli(process.cwd(), [ + 'plugin', + 'scaffold', + 'acme', + '--path', + dest, + '--ref', + FIXTURE, + '--dry-run', + ]); + + assert.equal(result.code, 0, result.stderr); + assert.equal(fs.existsSync(dest), false); + assert.match(result.stdout, /Would write acme/); +}); + +test('renders acme from the fixture', () => { + const dest = path.join(tempDir(), 'acme'); + const result = runCli(process.cwd(), [ + 'plugin', + 'scaffold', + 'acme', + '--name', + 'Acme', + '--description', + 'A test plugin.', + '--path', + dest, + '--ref', + FIXTURE, + ]); + + assert.equal(result.code, 0, result.stderr); + assert.equal(fs.existsSync(path.join(dest, 'acme.php')), true); + assert.equal(fs.existsSync(path.join(dest, 'plugin-scaffold.php')), false); + + const main = fs.readFileSync(path.join(dest, 'acme.php'), 'utf8'); + assert.match(main, /Plugin Name:\s+Acme/); + assert.match(main, /Description:\s+A test plugin\./); + assert.match(main, /x-release-please-start-version/); + assert.match(main, /Text Domain:\s+acme/); + assert.match(main, /@package Linchpin\\Acme/); + assert.doesNotMatch(main, /plugin-scaffold/); + assert.doesNotMatch(main, /Plugin_Scaffold/); + + const composer = JSON.parse(fs.readFileSync(path.join(dest, 'composer.json'), 'utf8')); + assert.equal(composer.name, 'linchpin/acme'); + assert.ok(composer.scripts['php-lint']); + assert.ok(composer.scripts.phpstan); + assert.ok(composer.scripts['check-branch-cs']); + + const phpWorkflow = fs.readFileSync(path.join(dest, '.github/workflows/php.yml'), 'utf8'); + assert.match(phpWorkflow, /php-checks\.yml@v4/); + + const readme = fs.readFileSync(path.join(dest, 'README.md'), 'utf8'); + assert.match(readme, /^# Acme/m); + assert.match(readme, /A test plugin\./); + assert.match(readme, /x-release-please-start-version/); + assert.match(readme, /Latest Release:/); + assert.match(readme, /linchpin\/acme\/actions/); + assert.match(readme, /GPL-2\.0-or-later/); + assert.equal( + lastNonEmptyLine(readme), + `![Linchpin an award winning digital agency building immersive, high performing web experiences](${BANNER})` + ); + assert.doesNotMatch(readme.replaceAll(BANNER, ''), /plugin-scaffold/); + + const leftovers = lib.leftoverIdentity(dest); + assert.deepEqual(leftovers, []); +}); + +test('with-blocks and wporg overlays', () => { + const dest = path.join(tempDir(), 'acme'); + const result = runCli(process.cwd(), [ + 'plugin', + 'scaffold', + 'acme', + '--path', + dest, + '--ref', + FIXTURE, + '--with-blocks', + '--channel=wporg', + ]); + + assert.equal(result.code, 0, result.stderr); + assert.equal(fs.existsSync(path.join(dest, 'includes/Controller/Blocks.php')), true); + assert.equal(fs.existsSync(path.join(dest, 'blocks/src/example/block.json')), true); + assert.equal(fs.existsSync(path.join(dest, 'readme.txt')), true); + + const pkg = JSON.parse(fs.readFileSync(path.join(dest, 'package.json'), 'utf8')); + assert.equal(pkg.scripts['build:all'], 'npm run build && npm run build --prefix blocks'); + + const main = fs.readFileSync(path.join(dest, 'acme.php'), 'utf8'); + assert.doesNotMatch(main, /Update URI:/); +}); + +test('self-hosted adds Update URI and does not add readme.txt', () => { + const dest = path.join(tempDir(), 'acme'); + const result = runCli(process.cwd(), [ + 'plugin', + 'scaffold', + 'acme', + '--path', + dest, + '--ref', + FIXTURE, + '--channel=self-hosted', + ]); + + assert.equal(result.code, 0, result.stderr); + const main = fs.readFileSync(path.join(dest, 'acme.php'), 'utf8'); + assert.match(main, /Update URI:\s+https:\/\/api\.linchpin\.com\/updates\/acme/); + assert.equal(fs.existsSync(path.join(dest, 'readme.txt')), false); +}); + +test('refuses a non-empty destination without --force', () => { + const dest = path.join(tempDir(), 'acme'); + fs.mkdirSync(dest, { recursive: true }); + fs.writeFileSync(path.join(dest, 'keep.txt'), 'nope'); + + const result = runCli(process.cwd(), [ + 'plugin', + 'scaffold', + 'acme', + '--path', + dest, + '--ref', + FIXTURE, + ]); + + assert.equal(result.code, 5); + assert.match(result.stderr, /not empty/); + assert.equal(fs.readFileSync(path.join(dest, 'keep.txt'), 'utf8'), 'nope'); +});