From 715e489067c1fce761479ebc3338e49a65f0f3f8 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 8 Jul 2026 13:38:06 +1000 Subject: [PATCH 1/2] feat(cli): command-descriptor registry + `stash manifest --json` (CIP-3431) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of docs/plans/cli-help-and-manifest.md: a single source of truth for command metadata so help/docs can't drift from the real command set. - src/cli/registry.ts: Flag/CommandDescriptor/CommandGroup types + the full command surface (groups, summaries, flags; long/examples for key commands), populated from the existing HELP surface and per-command flag parsing. - src/cli/manifest.ts: buildManifest(version) → { name, version, groups[] }, the exact contract the docs generator (cipherstash/docs#45) targets. version comes from the CLI's package.json. - `stash manifest --json` emits the structured surface; `stash manifest` prints a grouped human-readable list. Pure metadata — no native binary required. Additive and non-breaking; the top-level HELP string is untouched. Rendering help from the registry (phases 2–4) is the documented follow-on. Tests: unit coverage for buildManifest shape/version/hidden-exclusion; e2e for `manifest --json`, `manifest`, and `--help` listing the command. --- .changeset/stash-cli-manifest.md | 17 + packages/cli/src/bin/main.ts | 8 + .../cli/src/cli/__tests__/manifest.test.ts | 77 +++ packages/cli/src/cli/manifest.ts | 57 ++ packages/cli/src/cli/registry.ts | 525 ++++++++++++++++++ packages/cli/src/commands/index.ts | 1 + packages/cli/src/commands/manifest/index.ts | 34 ++ packages/cli/tests/e2e/manifest.e2e.test.ts | 59 ++ 8 files changed, 778 insertions(+) create mode 100644 .changeset/stash-cli-manifest.md create mode 100644 packages/cli/src/cli/__tests__/manifest.test.ts create mode 100644 packages/cli/src/cli/manifest.ts create mode 100644 packages/cli/src/cli/registry.ts create mode 100644 packages/cli/src/commands/manifest/index.ts create mode 100644 packages/cli/tests/e2e/manifest.e2e.test.ts diff --git a/.changeset/stash-cli-manifest.md b/.changeset/stash-cli-manifest.md new file mode 100644 index 00000000..4d9bbe14 --- /dev/null +++ b/.changeset/stash-cli-manifest.md @@ -0,0 +1,17 @@ +--- +"stash": minor +--- + +Add a command-descriptor registry and `stash manifest --json` — a structured, +versioned command surface for the docs generator and agents to consume instead +of scraping `--help`. + +- `stash manifest --json` emits `{ name, version, groups[] }`, where each command + carries its summary, optional long description, examples, and flags. `version` + comes from the CLI's own `package.json`, so a page generated from the manifest + is always stamped with the version it describes. +- `stash manifest` (no flag) prints a grouped, human-readable command list. +- The registry (`src/cli/registry.ts`) is the single source of truth for command + metadata. This is phase 1 of `docs/plans/cli-help-and-manifest.md`; rendering + the top-level and per-command `--help` from the same registry is the + documented follow-on. diff --git a/packages/cli/src/bin/main.ts b/packages/cli/src/bin/main.ts index 4214d45e..a0b01f33 100644 --- a/packages/cli/src/bin/main.ts +++ b/packages/cli/src/bin/main.ts @@ -27,6 +27,7 @@ import { implCommand, initCommand, installCommand, + manifestCommand, planCommand, statusCommand, testConnectionCommand, @@ -89,6 +90,7 @@ Commands: auth Authenticate with CipherStash wizard AI-guided encryption setup (reads your codebase) doctor Diagnose install problems (native binaries, runtime) + manifest Print the structured, versioned command surface (--json for docs/agents) eql install Scaffold stash.config.ts (if missing) and install EQL extensions eql upgrade Upgrade EQL extensions to the latest version @@ -195,6 +197,7 @@ Examples: ${STASH} db push ${STASH} schema build ${STASH} doctor + ${STASH} manifest --json # structured command surface for docs / agents `.trim() interface ParsedArgs { @@ -519,6 +522,11 @@ export async function run() { case 'env': await envCommand({ write: flags.write }) break + case 'manifest': + // Pure metadata (no native code) — safe to run anywhere, including when + // the native binary is missing. + manifestCommand({ json: flags.json, version: pkg.version }) + break case 'wizard': { // Forward everything after `stash wizard` verbatim. The wizard package // owns its own flag parsing; we don't try to interpret its surface diff --git a/packages/cli/src/cli/__tests__/manifest.test.ts b/packages/cli/src/cli/__tests__/manifest.test.ts new file mode 100644 index 00000000..1fd44763 --- /dev/null +++ b/packages/cli/src/cli/__tests__/manifest.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest' +import { buildManifest } from '../manifest.js' +import { registry } from '../registry.js' + +describe('buildManifest', () => { + it('stamps the name and the passed-in version', () => { + const m = buildManifest('9.9.9') + expect(m.name).toBe('stash') + expect(m.version).toBe('9.9.9') + }) + + it('emits every non-hidden command across all groups', () => { + const m = buildManifest('0.0.0') + const registryCount = registry + .flatMap((g) => g.commands) + .filter((c) => !c.hidden).length + const manifestCount = m.groups.flatMap((g) => g.commands).length + expect(manifestCount).toBe(registryCount) + expect(m.groups.length).toBe(registry.length) + }) + + it('excludes hidden commands', () => { + // No hidden command should ever surface in the manifest. + const hidden = registry + .flatMap((g) => g.commands) + .filter((c) => c.hidden) + .map((c) => c.name) + const names = buildManifest('0.0.0') + .groups.flatMap((g) => g.commands) + .map((c) => c.name) + for (const h of hidden) expect(names).not.toContain(h) + }) + + it('gives every command a non-empty name and summary', () => { + for (const group of buildManifest('0.0.0').groups) { + expect(group.title.length).toBeGreaterThan(0) + for (const cmd of group.commands) { + expect(cmd.name.length).toBeGreaterThan(0) + expect(cmd.summary.length).toBeGreaterThan(0) + } + } + }) + + it('includes the worked-example auth login descriptor with its flags', () => { + const cmds = buildManifest('0.0.0').groups.flatMap((g) => g.commands) + const authLogin = cmds.find((c) => c.name === 'auth login') + expect(authLogin).toBeDefined() + expect(authLogin?.long).toContain('device authorization flow') + const flagNames = authLogin?.flags?.map((f) => f.name) ?? [] + expect(flagNames).toContain('--region') + expect(flagNames).toContain('--json') + expect(flagNames).toContain('--no-open') + }) + + it('surfaces the shared --database-url flag with its env var', () => { + const eqlInstall = buildManifest('0.0.0') + .groups.flatMap((g) => g.commands) + .find((c) => c.name === 'eql install') + const dbUrl = eqlInstall?.flags?.find((f) => f.name === '--database-url') + expect(dbUrl?.env).toBe('DATABASE_URL') + expect(dbUrl?.value).toBe('') + }) + + it('drops undefined optionals so the JSON round-trips cleanly', () => { + const m = buildManifest('1.2.3') + const json = JSON.stringify(m) + expect(JSON.parse(json)).toEqual(m) + // A summary-only command must not carry empty long/examples/flags keys. + const wizard = m.groups + .flatMap((g) => g.commands) + .find((c) => c.name === 'wizard') + expect(wizard).toEqual({ + name: 'wizard', + summary: 'AI-guided encryption setup (reads your codebase)', + }) + }) +}) diff --git a/packages/cli/src/cli/manifest.ts b/packages/cli/src/cli/manifest.ts new file mode 100644 index 00000000..18fbead8 --- /dev/null +++ b/packages/cli/src/cli/manifest.ts @@ -0,0 +1,57 @@ +/** + * `stash manifest --json` — the structured, versioned command surface the docs + * generator (cipherstash/docs#45) and agents consume instead of scraping + * `--help`. Built from the command-descriptor registry so it can't drift from + * the real command set. See `docs/plans/cli-help-and-manifest.md`. + */ +import { type CommandDescriptor, type Flag, registry } from './registry.js' + +/** A flag as it appears in the manifest (registry `Flag`, unchanged shape). */ +export type ManifestFlag = Flag + +/** A command as it appears in the manifest — the descriptor minus `hidden`. */ +export interface ManifestCommand { + name: string + summary: string + long?: string + examples?: string[] + flags?: ManifestFlag[] +} + +export interface ManifestGroup { + title: string + commands: ManifestCommand[] +} + +export interface Manifest { + name: string + version: string + groups: ManifestGroup[] +} + +/** Drop `hidden` and undefined optionals so the JSON is clean and stable. */ +function toManifestCommand(cmd: CommandDescriptor): ManifestCommand { + const out: ManifestCommand = { name: cmd.name, summary: cmd.summary } + if (cmd.long !== undefined) out.long = cmd.long + if (cmd.examples !== undefined) out.examples = cmd.examples + if (cmd.flags !== undefined) out.flags = cmd.flags + return out +} + +/** + * Build the manifest for a given CLI version. `version` is threaded in from the + * CLI's own `package.json`, so a page generated from the manifest is always + * stamped with the exact version it describes. Hidden commands are excluded. + */ +export function buildManifest(version: string): Manifest { + return { + name: 'stash', + version, + groups: registry.map((group) => ({ + title: group.title, + commands: group.commands + .filter((cmd) => !cmd.hidden) + .map(toManifestCommand), + })), + } +} diff --git a/packages/cli/src/cli/registry.ts b/packages/cli/src/cli/registry.ts new file mode 100644 index 00000000..5d2a01c4 --- /dev/null +++ b/packages/cli/src/cli/registry.ts @@ -0,0 +1,525 @@ +/** + * The command-descriptor registry — one descriptor per `stash` command, grouped + * for display. This is the single source of truth for command metadata that the + * `stash manifest --json` surface (and, in later phases, `--help` rendering) is + * generated from, so help/docs can't drift from the real command set. + * + * See `docs/plans/cli-help-and-manifest.md`. Phase 1 (this file) is metadata + * only — dispatch in `bin/main.ts` is unchanged; a later phase renders the + * top-level and per-command help from these descriptors and wires `run`. + */ + +/** A single flag/option on a command. */ +export interface Flag { + /** Long form including dashes, e.g. `--supabase`. */ + name: string + /** Value placeholder for value-taking flags, e.g. ``; omit for booleans. */ + value?: string + description: string + /** Default value, when the flag has one worth surfacing. */ + default?: string + /** Env var that also sets this, e.g. `DATABASE_URL`. */ + env?: string +} + +/** A command's metadata. `name` is the full path, e.g. `"auth login"`. */ +export interface CommandDescriptor { + /** Full command path without the `stash` prefix, e.g. `"eql install"`. */ + name: string + /** One-line summary (the `Commands:` text in `--help`). */ + summary: string + /** Rich multi-paragraph help (cobra `Long`). */ + long?: string + /** Curated per-command examples, each WITHOUT the runner prefix, e.g. `"auth login"`. */ + examples?: string[] + flags?: Flag[] + /** Deprecated aliases / commands hidden from help + manifest (e.g. old `db install`). */ + hidden?: boolean +} + +/** A display group of related commands. */ +export interface CommandGroup { + title: string + commands: CommandDescriptor[] +} + +/** + * Shared `--database-url` flag — every db/eql/schema command accepts it, so it's + * declared once and referenced, not copy-pasted. + */ +const DATABASE_URL_FLAG: Flag = { + name: '--database-url', + value: '', + description: + 'Override DATABASE_URL for this run only — never written to disk.', + env: 'DATABASE_URL', +} + +const REGION_FLAG: Flag = { + name: '--region', + value: '', + description: + 'Region to authenticate against (e.g. us-east-1). Skips the interactive region picker.', + env: 'STASH_REGION', +} + +/** + * The registry. Ordered as it should render in help. Populated from the existing + * `HELP` surface in `bin/main.ts` plus the per-command flag parsing in the + * command modules, so it is authoritative for the current CLI. + */ +export const registry: CommandGroup[] = [ + { + title: 'Setup & workflow', + commands: [ + { + name: 'init', + summary: 'Initialize CipherStash for your project', + long: [ + 'Set up CipherStash end-to-end: authenticate, introspect your database,', + 'install dependencies, install EQL, and hand off the rest to your local', + 'coding agent. Every prompt has a non-interactive escape hatch, so init', + 'never blocks waiting on a TTY (CI, agents, pipes).', + ].join('\n'), + examples: [ + 'init', + 'init --supabase', + 'init --prisma-next', + 'init --region us-east-1', + ], + flags: [ + { + name: '--supabase', + description: 'Use Supabase-specific setup flow.', + }, + { + name: '--drizzle', + description: 'Use Drizzle-specific setup flow.', + }, + { + name: '--prisma-next', + description: + 'Use Prisma Next-specific setup flow (EQL bundle installed via prisma-next migration apply).', + }, + { + name: '--proxy', + description: 'Query encrypted data via CipherStash Proxy.', + }, + { + name: '--no-proxy', + description: 'Query encrypted data directly via the SDK.', + default: 'true', + }, + { + ...REGION_FLAG, + description: + 'Region to authenticate against (e.g. us-east-1). Skips the interactive region picker. Required for non-interactive init when not already logged in.', + }, + ], + }, + { + name: 'plan', + summary: 'Draft a reviewable encryption plan at .cipherstash/plan.md', + examples: ['plan', 'plan --target claude-code'], + flags: [ + { + name: '--complete-rollout', + description: + 'Plan the entire encryption lifecycle (schema-add through drop) in one document. Skips the production-deploy gate; only safe when this database is not backing a deployed application.', + }, + { + name: '--target', + value: '', + description: + 'Skip the agent-target picker and hand off directly to one of claude-code | codex | agents-md | wizard. Safe in non-TTY contexts.', + }, + ], + }, + { + name: 'impl', + summary: 'Execute the plan with a local agent', + examples: [ + 'impl', + 'impl --continue-without-plan', + 'impl --target claude-code', + ], + flags: [ + { + name: '--continue-without-plan', + description: + 'Skip planning and go straight to implementation (interactively confirms before proceeding).', + }, + { + name: '--target', + value: '', + description: + 'Skip the agent-target picker and hand off directly to one of claude-code | codex | agents-md | wizard. Safe in non-TTY contexts.', + }, + ], + }, + { + name: 'status', + summary: 'Displays implementation status', + flags: [ + { + name: '--quest', + description: + 'Force the quest-log output (emoji + progress bars) even in non-TTY contexts.', + }, + { + name: '--plain', + description: 'Force the plain-text output even in TTY contexts.', + }, + { + name: '--json', + description: 'Emit a structured JSON document instead.', + }, + ], + }, + { + name: 'wizard', + summary: 'AI-guided encryption setup (reads your codebase)', + }, + { + name: 'doctor', + summary: 'Diagnose install problems (native binaries, runtime)', + }, + { + name: 'manifest', + summary: 'Print the structured, versioned command surface', + long: [ + 'Emit the CLI command surface as data. `--json` produces the machine-', + 'readable manifest the docs generator and agents consume; without it a', + 'grouped command list is printed. The manifest is stamped with the CLI', + 'version, so a page generated from it always names the version it describes.', + ].join('\n'), + examples: ['manifest --json', 'manifest'], + flags: [ + { + name: '--json', + description: + 'Emit the structured JSON manifest instead of a text list.', + }, + ], + }, + ], + }, + { + title: 'Auth', + commands: [ + { + name: 'auth login', + summary: 'Authenticate with CipherStash', + long: [ + 'Runs the OAuth 2.0 device authorization flow:', + '1. Pick a region for your workspace.', + '2. Approve in the browser — the URL is printed, so it works over SSH/headless.', + '3. The CLI polls until you approve, then stores a short-lived token.', + "4. Your device is bound to the workspace's default keyset, so later", + ' commands authenticate without a fresh login.', + ].join('\n'), + examples: [ + 'auth login', + 'auth login --region us-east-1', + 'auth login --supabase', + 'auth login --region us-east-1 --json', + ], + flags: [ + REGION_FLAG, + { + name: '--json', + description: + 'Emit newline-delimited JSON events instead of prose. The first event (authorization_required) carries the device verification URL for a human to open. Implies no prompt and no browser auto-open.', + }, + { + name: '--no-open', + description: + "Don't auto-open the verification URL in a browser (already implied by --json).", + }, + { + name: '--supabase', + description: 'Track Supabase as the referrer.', + }, + { name: '--drizzle', description: 'Track Drizzle as the referrer.' }, + ], + }, + { + name: 'auth regions', + summary: 'List the regions you can authenticate against', + examples: ['auth regions', 'auth regions --json'], + flags: [ + { + name: '--json', + description: + 'Emit machine-readable [{ slug, label }] instead of a text list.', + }, + ], + }, + ], + }, + { + title: 'EQL', + commands: [ + { + name: 'eql install', + summary: + 'Scaffold stash.config.ts (if missing) and install EQL extensions', + flags: [ + { + name: '--force', + description: 'Reinstall / overwrite even if already installed.', + }, + { + name: '--dry-run', + description: 'Show what would happen without making changes.', + }, + { + name: '--supabase', + description: + 'Use Supabase-compatible mode (auto-detected from DATABASE_URL).', + }, + { + name: '--drizzle', + description: + 'Generate a Drizzle migration instead of direct install (auto-detected from project).', + }, + { + name: '--migration', + description: + 'Write a Supabase migration file instead of running SQL directly (requires --supabase).', + }, + { + name: '--direct', + description: + 'Run the SQL directly against the database (requires --supabase; mutually exclusive with --migration).', + }, + { + name: '--migrations-dir', + value: '', + description: + 'Override the Supabase migrations directory (requires --supabase).', + default: 'supabase/migrations', + }, + { + name: '--exclude-operator-family', + description: 'Skip operator family creation.', + }, + { + name: '--eql-version', + value: '<2|3>', + description: + 'EQL generation to target. v3 is the native eql_v3.* domain schema (direct install only for now).', + default: '2', + }, + { + name: '--latest', + description: 'Fetch the latest EQL from GitHub (v2 only).', + }, + DATABASE_URL_FLAG, + ], + }, + { + name: 'eql upgrade', + summary: 'Upgrade EQL extensions to the latest version', + flags: [ + { + name: '--dry-run', + description: 'Show what would happen without making changes.', + }, + { + name: '--supabase', + description: 'Use Supabase-compatible mode.', + }, + { + name: '--exclude-operator-family', + description: 'Skip operator family creation.', + }, + { + name: '--eql-version', + value: '<2|3>', + description: 'EQL generation to target.', + default: '2', + }, + { + name: '--latest', + description: 'Fetch the latest EQL from GitHub (v2 only).', + }, + DATABASE_URL_FLAG, + ], + }, + { + name: 'eql status', + summary: 'Show EQL installation status', + flags: [DATABASE_URL_FLAG], + }, + ], + }, + { + title: 'Database', + commands: [ + { + name: 'db push', + summary: + 'Push encryption schema (writes pending if active config already exists)', + flags: [ + { + name: '--dry-run', + description: 'Show what would happen without making changes.', + }, + DATABASE_URL_FLAG, + ], + }, + { + name: 'db activate', + summary: + 'Promote pending → active without renames (use after additive db push)', + flags: [DATABASE_URL_FLAG], + }, + { + name: 'db validate', + summary: 'Validate encryption schema', + flags: [ + { + name: '--supabase', + description: 'Use Supabase-compatible mode.', + }, + { + name: '--exclude-operator-family', + description: 'Skip operator family creation.', + }, + DATABASE_URL_FLAG, + ], + }, + { + name: 'db migrate', + summary: 'Run pending encrypt config migrations', + flags: [DATABASE_URL_FLAG], + }, + { + name: 'db test-connection', + summary: 'Test database connectivity', + flags: [DATABASE_URL_FLAG], + }, + ], + }, + { + title: 'Schema', + commands: [ + { + name: 'schema build', + summary: 'Build an encryption schema from your database', + flags: [ + { + name: '--supabase', + description: 'Use Supabase-compatible mode.', + }, + DATABASE_URL_FLAG, + ], + }, + ], + }, + { + title: 'Encrypt', + commands: [ + { + name: 'encrypt status', + summary: 'Show per-column migration status (phase, progress, drift)', + flags: [ + { name: '--table', value: '', description: 'Target table.' }, + { name: '--column', value: '', description: 'Target column.' }, + ], + }, + { + name: 'encrypt plan', + summary: 'Diff intent (.cipherstash/migrations.json) vs observed state', + flags: [ + { name: '--table', value: '', description: 'Target table.' }, + { name: '--column', value: '', description: 'Target column.' }, + ], + }, + { + name: 'encrypt backfill', + summary: 'Resumably encrypt plaintext into the encrypted column', + flags: [ + { name: '--table', value: '', description: 'Target table.' }, + { name: '--column', value: '', description: 'Target column.' }, + { + name: '--pk-column', + value: '', + description: 'Primary-key column used to page through rows.', + }, + { + name: '--chunk-size', + value: '', + description: 'Rows encrypted per batch.', + }, + { + name: '--encrypted-column', + value: '', + description: 'Destination encrypted column.', + }, + { + name: '--schema-column-key', + value: '', + description: 'Schema key identifying the column config.', + }, + { + name: '--confirm-dual-writes-deployed', + description: + 'Assert the app is dual-writing before backfilling (safety gate).', + }, + { + name: '--force', + description: 'Proceed past non-fatal safety checks.', + }, + ], + }, + { + name: 'encrypt cutover', + summary: 'Rename swap encrypted → primary column', + flags: [ + { name: '--table', value: '', description: 'Target table.' }, + { name: '--column', value: '', description: 'Target column.' }, + { + name: '--proxy-url', + value: '', + description: 'Proxy URL to verify against.', + }, + { + name: '--migrations-dir', + value: '', + description: 'Directory to write the rename migration into.', + }, + ], + }, + { + name: 'encrypt drop', + summary: 'Generate a migration to drop the plaintext column', + flags: [ + { name: '--table', value: '', description: 'Target table.' }, + { name: '--column', value: '', description: 'Target column.' }, + { + name: '--migrations-dir', + value: '', + description: 'Directory to write the drop migration into.', + }, + ], + }, + ], + }, + { + title: 'Experimental', + commands: [ + { + name: 'env', + summary: '(experimental) Print production env vars for deployment', + flags: [ + { + name: '--write', + description: 'Write the vars to a file instead of printing them.', + }, + ], + }, + ], + }, +] diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index 6225b683..9069fe67 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -6,6 +6,7 @@ export { upgradeCommand } from './db/upgrade.js' export { envCommand } from './env/index.js' export { implCommand } from './impl/index.js' export { initCommand } from './init/index.js' +export { manifestCommand } from './manifest/index.js' export { planCommand } from './plan/index.js' export { statusCommand } from './status/index.js' export { wizardCommand } from './wizard/index.js' diff --git a/packages/cli/src/commands/manifest/index.ts b/packages/cli/src/commands/manifest/index.ts new file mode 100644 index 00000000..fbaabf05 --- /dev/null +++ b/packages/cli/src/commands/manifest/index.ts @@ -0,0 +1,34 @@ +import { buildManifest } from '../../cli/manifest.js' + +export interface ManifestCommandOptions { + /** Emit the structured JSON manifest instead of the human-readable list. */ + json?: boolean + /** CLI version, threaded from the caller's package.json. */ + version: string +} + +/** + * `stash manifest` — print the CLI's command surface. + * + * `--json` emits the machine-readable manifest (docs generator / agents); + * without it a grouped, human-readable command list is printed. Pure metadata: + * loads no native code, so it runs anywhere `stash` does. + */ +export function manifestCommand(opts: ManifestCommandOptions): void { + const manifest = buildManifest(opts.version) + + if (opts.json) { + console.log(JSON.stringify(manifest, null, 2)) + return + } + + console.log(`stash ${manifest.version}\n`) + for (const group of manifest.groups) { + console.log(`${group.title}:`) + for (const cmd of group.commands) { + console.log(` ${cmd.name.padEnd(20)} ${cmd.summary}`) + } + console.log('') + } + console.log('Run `stash manifest --json` for the machine-readable surface.') +} diff --git a/packages/cli/tests/e2e/manifest.e2e.test.ts b/packages/cli/tests/e2e/manifest.e2e.test.ts new file mode 100644 index 00000000..449b0969 --- /dev/null +++ b/packages/cli/tests/e2e/manifest.e2e.test.ts @@ -0,0 +1,59 @@ +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { runPiped } from '../helpers/spawn-piped.js' + +/** + * `stash manifest` is pure metadata (no network, no native binary), so these run + * fast and deterministically. They guard the contract the docs generator and + * agents depend on: a versioned, grouped, machine-readable command surface. + */ + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const pkg = JSON.parse( + readFileSync(join(__dirname, '../../package.json'), 'utf-8'), +) as { version: string } + +describe('stash manifest --json', () => { + it('emits a versioned, grouped manifest and exits 0', async () => { + const r = await runPiped(['manifest', '--json']) + expect(r.exitCode).toBe(0) + const m = JSON.parse(r.stdout) as { + name: string + version: string + groups: Array<{ + title: string + commands: Array<{ name: string; summary: string }> + }> + } + expect(m.name).toBe('stash') + // Stamped with the CLI's own version, so generated docs name it correctly. + expect(m.version).toBe(pkg.version) + expect(Array.isArray(m.groups)).toBe(true) + expect(m.groups.length).toBeGreaterThan(0) + + const names = m.groups.flatMap((g) => g.commands.map((c) => c.name)) + expect(names).toContain('auth login') + expect(names).toContain('eql install') + expect(names).toContain('manifest') + }) +}) + +describe('stash manifest (no --json)', () => { + it('prints a grouped human-readable list and exits 0', async () => { + const r = await runPiped(['manifest']) + expect(r.exitCode).toBe(0) + expect(r.stdout).toContain(`stash ${pkg.version}`) + expect(r.stdout).toContain('auth login') + expect(r.stdout).toContain('--json') + }) +}) + +describe('stash --help', () => { + it('lists the manifest command', async () => { + const r = await runPiped(['--help']) + expect(r.exitCode).toBe(0) + expect(r.stdout).toContain('manifest') + }) +}) From d6c7a482e73138994a69de9598bfa0d762cbfa84 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 8 Jul 2026 14:50:18 +1000 Subject: [PATCH 2/2] fix(cli): correct manifest drift + close test gaps (PR #572 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registry-vs-reality fixes (the drift this PR exists to prevent): - encrypt status/plan: drop --table/--column — both dispatch to zero-arg commands (statusCommand()/planCommand()) that ignore them - eql install: add the real --name/--out flags (forwarded to the drizzle path) - db migrate: mark summary "(not yet implemented)" and drop the --database-url flag it never reads (dispatch only prints a not-implemented warning) Cleanup / correctness: - manifest: toManifestCommand now defensively copies examples/flags so the manifest can't alias the registry's shared flag singletons (mutation safety) - registry: extract TABLE/COLUMN/DRY_RUN/EXCLUDE_OPERATOR_FAMILY/SUPABASE_COMPAT shared flag consts (were copy-pasted 3-5x), matching the DATABASE_URL_FLAG pattern - manifest: drop the single-use `ManifestFlag = Flag` alias - soften the "single source of truth" claim (registry docstring + changeset): it's additive; HELP stays authoritative for --help until phase 2 Tests: - buildManifest gains a `groups` injection seam so the hidden-command filter is driven by a stub (was vacuously green against the real registry) - add positive assertions for examples passthrough and the defensive-copy fix --- .changeset/stash-cli-manifest.md | 9 +- .../cli/src/cli/__tests__/manifest.test.ts | 48 +++++-- packages/cli/src/cli/manifest.ts | 34 +++-- packages/cli/src/cli/registry.ts | 131 +++++++++--------- 4 files changed, 135 insertions(+), 87 deletions(-) diff --git a/.changeset/stash-cli-manifest.md b/.changeset/stash-cli-manifest.md index 4d9bbe14..e8221594 100644 --- a/.changeset/stash-cli-manifest.md +++ b/.changeset/stash-cli-manifest.md @@ -11,7 +11,8 @@ of scraping `--help`. comes from the CLI's own `package.json`, so a page generated from the manifest is always stamped with the version it describes. - `stash manifest` (no flag) prints a grouped, human-readable command list. -- The registry (`src/cli/registry.ts`) is the single source of truth for command - metadata. This is phase 1 of `docs/plans/cli-help-and-manifest.md`; rendering - the top-level and per-command `--help` from the same registry is the - documented follow-on. +- The registry (`src/cli/registry.ts`) is intended to become the single source of + truth for command metadata. This is phase 1 of + `docs/plans/cli-help-and-manifest.md`; it is additive — `bin/main.ts` still + hand-maintains the `HELP` string that renders `--help`, so until the documented + follow-on renders `--help` from the registry the two are kept in sync by hand. diff --git a/packages/cli/src/cli/__tests__/manifest.test.ts b/packages/cli/src/cli/__tests__/manifest.test.ts index 1fd44763..51d91b7d 100644 --- a/packages/cli/src/cli/__tests__/manifest.test.ts +++ b/packages/cli/src/cli/__tests__/manifest.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { buildManifest } from '../manifest.js' -import { registry } from '../registry.js' +import { type CommandGroup, registry } from '../registry.js' describe('buildManifest', () => { it('stamps the name and the passed-in version', () => { @@ -19,16 +19,46 @@ describe('buildManifest', () => { expect(m.groups.length).toBe(registry.length) }) - it('excludes hidden commands', () => { - // No hidden command should ever surface in the manifest. - const hidden = registry - .flatMap((g) => g.commands) - .filter((c) => c.hidden) - .map((c) => c.name) - const names = buildManifest('0.0.0') + it('drops commands marked hidden', () => { + // Drive the hidden filter with a stub so coverage doesn't depend on the + // live registry happening to contain a hidden command (it currently + // contains none — a real-registry assertion would be vacuously green). + const groups: CommandGroup[] = [ + { + title: 'T', + commands: [ + { name: 'shown', summary: 's' }, + { name: 'gone', summary: 'g', hidden: true }, + ], + }, + ] + const names = buildManifest('0.0.0', groups) .groups.flatMap((g) => g.commands) .map((c) => c.name) - for (const h of hidden) expect(names).not.toContain(h) + expect(names).toEqual(['shown']) + }) + + it('carries examples through into the manifest', () => { + // The one optional-field passthrough not covered by the auth-login (long + + // flags) or wizard (all-undefined) cases. + const init = buildManifest('0.0.0') + .groups.flatMap((g) => g.commands) + .find((c) => c.name === 'init') + expect(init?.examples).toContain('init --supabase') + }) + + it('defensively copies flags so a consumer cannot corrupt the registry', () => { + const dbUrlOf = (m: ReturnType) => + m.groups + .flatMap((g) => g.commands) + .flatMap((c) => c.flags ?? []) + .find((f) => f.name === '--database-url') + + const first = dbUrlOf(buildManifest('0.0.0')) + expect(first).toBeDefined() + // Mutate a manifest flag; the shared registry singleton must be untouched. + ;(first as { description: string }).description = 'MUTATED' + expect(dbUrlOf(buildManifest('0.0.0'))?.description).not.toBe('MUTATED') }) it('gives every command a non-empty name and summary', () => { diff --git a/packages/cli/src/cli/manifest.ts b/packages/cli/src/cli/manifest.ts index 18fbead8..5df78f08 100644 --- a/packages/cli/src/cli/manifest.ts +++ b/packages/cli/src/cli/manifest.ts @@ -4,10 +4,12 @@ * `--help`. Built from the command-descriptor registry so it can't drift from * the real command set. See `docs/plans/cli-help-and-manifest.md`. */ -import { type CommandDescriptor, type Flag, registry } from './registry.js' - -/** A flag as it appears in the manifest (registry `Flag`, unchanged shape). */ -export type ManifestFlag = Flag +import { + type CommandDescriptor, + type CommandGroup, + type Flag, + registry, +} from './registry.js' /** A command as it appears in the manifest — the descriptor minus `hidden`. */ export interface ManifestCommand { @@ -15,7 +17,7 @@ export interface ManifestCommand { summary: string long?: string examples?: string[] - flags?: ManifestFlag[] + flags?: Flag[] } export interface ManifestGroup { @@ -29,12 +31,17 @@ export interface Manifest { groups: ManifestGroup[] } -/** Drop `hidden` and undefined optionals so the JSON is clean and stable. */ +/** + * Drop `hidden` and undefined optionals, and defensively copy `examples` / + * `flags` so the manifest never aliases the registry's arrays or its shared + * flag singletons (`DATABASE_URL_FLAG`, …) — a consumer that mutates a manifest + * entry must not corrupt the registry or other commands sharing that object. + */ function toManifestCommand(cmd: CommandDescriptor): ManifestCommand { const out: ManifestCommand = { name: cmd.name, summary: cmd.summary } if (cmd.long !== undefined) out.long = cmd.long - if (cmd.examples !== undefined) out.examples = cmd.examples - if (cmd.flags !== undefined) out.flags = cmd.flags + if (cmd.examples !== undefined) out.examples = [...cmd.examples] + if (cmd.flags !== undefined) out.flags = cmd.flags.map((f) => ({ ...f })) return out } @@ -42,12 +49,19 @@ function toManifestCommand(cmd: CommandDescriptor): ManifestCommand { * Build the manifest for a given CLI version. `version` is threaded in from the * CLI's own `package.json`, so a page generated from the manifest is always * stamped with the exact version it describes. Hidden commands are excluded. + * + * `groups` defaults to the real `registry`; it's an injection seam so tests can + * drive the hidden-command filter with a stub instead of depending on whether + * the live registry happens to contain a hidden command. */ -export function buildManifest(version: string): Manifest { +export function buildManifest( + version: string, + groups: CommandGroup[] = registry, +): Manifest { return { name: 'stash', version, - groups: registry.map((group) => ({ + groups: groups.map((group) => ({ title: group.title, commands: group.commands .filter((cmd) => !cmd.hidden) diff --git a/packages/cli/src/cli/registry.ts b/packages/cli/src/cli/registry.ts index 5d2a01c4..3be476c4 100644 --- a/packages/cli/src/cli/registry.ts +++ b/packages/cli/src/cli/registry.ts @@ -1,12 +1,13 @@ /** * The command-descriptor registry — one descriptor per `stash` command, grouped - * for display. This is the single source of truth for command metadata that the - * `stash manifest --json` surface (and, in later phases, `--help` rendering) is - * generated from, so help/docs can't drift from the real command set. + * for display. It backs the `stash manifest --json` surface and is *intended* to + * become the single source of truth for command metadata. * - * See `docs/plans/cli-help-and-manifest.md`. Phase 1 (this file) is metadata - * only — dispatch in `bin/main.ts` is unchanged; a later phase renders the - * top-level and per-command help from these descriptors and wires `run`. + * ⚠️ Phase 1 (this file) is additive: `bin/main.ts` still hand-maintains the + * `HELP` string that renders `--help`, so until a later phase renders `--help` + * from these descriptors, the two are maintained separately and MUST be kept in + * sync — a command/flag/summary edit belongs in both places or `--help` and + * `stash manifest` will diverge. See `docs/plans/cli-help-and-manifest.md`. */ /** A single flag/option on a command. */ @@ -63,6 +64,32 @@ const REGION_FLAG: Flag = { env: 'STASH_REGION', } +// Flags shared verbatim across several commands — declared once so a description +// edit lands in one place and can't drift. (`--supabase` only shares the +// "compatible mode" spelling; `init`/`auth` use different wording, kept inline.) +const DRY_RUN_FLAG: Flag = { + name: '--dry-run', + description: 'Show what would happen without making changes.', +} +const EXCLUDE_OPERATOR_FAMILY_FLAG: Flag = { + name: '--exclude-operator-family', + description: 'Skip operator family creation.', +} +const SUPABASE_COMPAT_FLAG: Flag = { + name: '--supabase', + description: 'Use Supabase-compatible mode.', +} +const TABLE_FLAG: Flag = { + name: '--table', + value: '', + description: 'Target table.', +} +const COLUMN_FLAG: Flag = { + name: '--column', + value: '', + description: 'Target column.', +} + /** * The registry. Ordered as it should render in help. Populated from the existing * `HELP` surface in `bin/main.ts` plus the per-command flag parsing in the @@ -269,10 +296,7 @@ export const registry: CommandGroup[] = [ name: '--force', description: 'Reinstall / overwrite even if already installed.', }, - { - name: '--dry-run', - description: 'Show what would happen without making changes.', - }, + DRY_RUN_FLAG, { name: '--supabase', description: @@ -300,10 +324,7 @@ export const registry: CommandGroup[] = [ 'Override the Supabase migrations directory (requires --supabase).', default: 'supabase/migrations', }, - { - name: '--exclude-operator-family', - description: 'Skip operator family creation.', - }, + EXCLUDE_OPERATOR_FAMILY_FLAG, { name: '--eql-version', value: '<2|3>', @@ -315,6 +336,18 @@ export const registry: CommandGroup[] = [ name: '--latest', description: 'Fetch the latest EQL from GitHub (v2 only).', }, + { + name: '--name', + value: '', + description: + 'With --drizzle: name for the generated migration (defaults to a scaffold name).', + }, + { + name: '--out', + value: '', + description: + 'With --drizzle: directory to write the generated migration into.', + }, DATABASE_URL_FLAG, ], }, @@ -322,18 +355,9 @@ export const registry: CommandGroup[] = [ name: 'eql upgrade', summary: 'Upgrade EQL extensions to the latest version', flags: [ - { - name: '--dry-run', - description: 'Show what would happen without making changes.', - }, - { - name: '--supabase', - description: 'Use Supabase-compatible mode.', - }, - { - name: '--exclude-operator-family', - description: 'Skip operator family creation.', - }, + DRY_RUN_FLAG, + SUPABASE_COMPAT_FLAG, + EXCLUDE_OPERATOR_FAMILY_FLAG, { name: '--eql-version', value: '<2|3>', @@ -361,13 +385,7 @@ export const registry: CommandGroup[] = [ name: 'db push', summary: 'Push encryption schema (writes pending if active config already exists)', - flags: [ - { - name: '--dry-run', - description: 'Show what would happen without making changes.', - }, - DATABASE_URL_FLAG, - ], + flags: [DRY_RUN_FLAG, DATABASE_URL_FLAG], }, { name: 'db activate', @@ -379,21 +397,17 @@ export const registry: CommandGroup[] = [ name: 'db validate', summary: 'Validate encryption schema', flags: [ - { - name: '--supabase', - description: 'Use Supabase-compatible mode.', - }, - { - name: '--exclude-operator-family', - description: 'Skip operator family creation.', - }, + SUPABASE_COMPAT_FLAG, + EXCLUDE_OPERATOR_FAMILY_FLAG, DATABASE_URL_FLAG, ], }, { + // Dispatch currently only prints a "not yet implemented" warning and + // reads no flags — describe that rather than advertising a working + // command with a --database-url override it never consumes. name: 'db migrate', - summary: 'Run pending encrypt config migrations', - flags: [DATABASE_URL_FLAG], + summary: 'Run pending encrypt config migrations (not yet implemented)', }, { name: 'db test-connection', @@ -408,13 +422,7 @@ export const registry: CommandGroup[] = [ { name: 'schema build', summary: 'Build an encryption schema from your database', - flags: [ - { - name: '--supabase', - description: 'Use Supabase-compatible mode.', - }, - DATABASE_URL_FLAG, - ], + flags: [SUPABASE_COMPAT_FLAG, DATABASE_URL_FLAG], }, ], }, @@ -422,27 +430,22 @@ export const registry: CommandGroup[] = [ title: 'Encrypt', commands: [ { + // `encrypt status` / `encrypt plan` dispatch to zero-arg commands + // (main.ts calls statusCommand()/planCommand() with no values), so they + // take no flags — don't advertise --table/--column the CLI ignores. name: 'encrypt status', summary: 'Show per-column migration status (phase, progress, drift)', - flags: [ - { name: '--table', value: '', description: 'Target table.' }, - { name: '--column', value: '', description: 'Target column.' }, - ], }, { name: 'encrypt plan', summary: 'Diff intent (.cipherstash/migrations.json) vs observed state', - flags: [ - { name: '--table', value: '', description: 'Target table.' }, - { name: '--column', value: '', description: 'Target column.' }, - ], }, { name: 'encrypt backfill', summary: 'Resumably encrypt plaintext into the encrypted column', flags: [ - { name: '--table', value: '', description: 'Target table.' }, - { name: '--column', value: '', description: 'Target column.' }, + TABLE_FLAG, + COLUMN_FLAG, { name: '--pk-column', value: '', @@ -478,8 +481,8 @@ export const registry: CommandGroup[] = [ name: 'encrypt cutover', summary: 'Rename swap encrypted → primary column', flags: [ - { name: '--table', value: '', description: 'Target table.' }, - { name: '--column', value: '', description: 'Target column.' }, + TABLE_FLAG, + COLUMN_FLAG, { name: '--proxy-url', value: '', @@ -496,8 +499,8 @@ export const registry: CommandGroup[] = [ name: 'encrypt drop', summary: 'Generate a migration to drop the plaintext column', flags: [ - { name: '--table', value: '', description: 'Target table.' }, - { name: '--column', value: '', description: 'Target column.' }, + TABLE_FLAG, + COLUMN_FLAG, { name: '--migrations-dir', value: '',