diff --git a/.changeset/plan-complete-rollout-yes.md b/.changeset/plan-complete-rollout-yes.md new file mode 100644 index 00000000..a59c9d49 --- /dev/null +++ b/.changeset/plan-complete-rollout-yes.md @@ -0,0 +1,14 @@ +--- +'stash': minor +--- + +`stash plan --complete-rollout` is now automatable and has an honest exit code. +It skips the production-deploy gate, so it needs explicit consent — previously +that was an interactive prompt with no bypass, so a non-interactive run +auto-cancelled (default-no) and exited **0** without drafting a plan, leaving +automation to assume a plan existed. + +- New `--yes` flag confirms the gate-skip without a prompt (for CI/agents). +- Without `--yes`, a non-interactive `--complete-rollout` run now **refuses + with a non-zero exit** and points at `--yes`, instead of silently succeeding. +- Interactive behaviour is unchanged (default-no confirm). diff --git a/packages/cli/src/cli/registry.ts b/packages/cli/src/cli/registry.ts index ce64a201..1f639dc9 100644 --- a/packages/cli/src/cli/registry.ts +++ b/packages/cli/src/cli/registry.ts @@ -149,12 +149,21 @@ export const registry: CommandGroup[] = [ { name: 'plan', summary: 'Draft a reviewable encryption plan at .cipherstash/plan.md', - examples: ['plan', 'plan --target claude-code'], + examples: [ + 'plan', + 'plan --target claude-code', + 'plan --complete-rollout --yes --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.', + '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. Needs confirmation — an interactive prompt, or --yes non-interactively (else it exits non-zero without drafting).', + }, + { + name: '--yes', + description: + "Confirm --complete-rollout's gate-skip without a prompt (for automation / CI). No effect without --complete-rollout.", }, { name: '--target', diff --git a/packages/cli/src/commands/plan/index.ts b/packages/cli/src/commands/plan/index.ts index e780e3a6..5569d5bb 100644 --- a/packages/cli/src/commands/plan/index.ts +++ b/packages/cli/src/commands/plan/index.ts @@ -3,6 +3,8 @@ import { resolve } from 'node:path' import { readManifest } from '@cipherstash/migrate' import * as p from '@clack/prompts' import { CliExit } from '../../cli/exit.js' +import { isInteractive as isInteractiveTty } from '../../config/tty.js' +import { messages } from '../../messages.js' import { HANDOFF_CHOICES, howToProceedStep, @@ -44,17 +46,43 @@ function buildStateFromContext( } /** - * Confirm the user wants to skip the production-deploy gate. Default-no is - * the security stance — the warning has to be a deliberate `y` press, not - * a stray Enter. + * Confirm the user wants to skip the production-deploy gate. + * + * The gate-skip is a deliberate act, so it needs explicit consent: + * - interactive TTY → a default-no `p.confirm` (a stray Enter is "no"). + * - `--yes` → the non-interactive consent flag; proceed, after logging the + * warnings so the record shows what was skipped. + * - non-interactive without `--yes` → REFUSE with a non-zero exit. We must + * not silently cancel-with-0: automation that asked for a complete-rollout + * plan and got exit 0 would assume a plan exists when none was drafted. */ -async function confirmCompleteRollout(): Promise { +async function confirmCompleteRollout(opts: { + assumeYes: boolean + isInteractive: boolean +}): Promise { p.log.warn( '`--complete-rollout` plans the full encryption lifecycle (schema-add through drop) in one document. It SKIPS the production-deploy gate that protects backfill from running before dual-writes are live.', ) p.log.warn( 'Only safe when this database is not backing a deployed application — local development, ephemeral test environments, or freshly seeded sandboxes. If a deployed app writes to this database, rows inserted during the planned backfill will land in plaintext only and you will need a recovery pass.', ) + + if (opts.assumeYes) { + p.log.info( + `${messages.plan.completeRolloutConfirmed} by your explicit confirmation.`, + ) + return + } + + if (!opts.isInteractive) { + p.log.error( + `${messages.plan.completeRolloutNeedsYes}. Re-run with \`--yes\` to confirm non-interactively — only safe when no deployed application writes to this database (see the warnings above).`, + ) + // Non-zero: the requested plan was NOT drafted. Distinct from a user's + // interactive decline (a deliberate "no" → exit 0 via CancelledError). + throw new CliExit(1) + } + const ok = await p.confirm({ message: 'Proceed with a complete-rollout plan?', initialValue: false, @@ -124,8 +152,12 @@ async function detectPlanStep(cwd: string): Promise { * Flags: * `--complete-rollout` — escape hatch for databases without a deployed * application. Plans schema-add through drop in - * one document with no deploy gate. Confirms - * (default-no) before generating. + * one document with no deploy gate. Needs explicit + * confirmation: an interactive default-no prompt, or + * `--yes` non-interactively (else it refuses with a + * non-zero exit rather than silently doing nothing). + * `--yes` — confirm `--complete-rollout`'s gate-skip without a + * prompt, for automation. No effect without it. */ export async function planCommand( flags: Record = {}, @@ -154,6 +186,15 @@ export async function planCommand( p.intro('CipherStash Plan') + // Interactive only when stdin is a real TTY and we're not in CI — via the + // shared `isInteractive()` (config/tty.ts) so this gate stays identical to + // every other prompt gate (its `isCiEnv()` treats `CI=1`/`CI=TRUE` as CI too, + // which a bare `CI !== 'true'` inline would miss). Keying off + // `process.stdout.isTTY` alone is wrong: a redirected stdin still hangs the + // agent-target picker (clack `select` reads from /dev/tty). Computed up here + // because the complete-rollout confirmation needs it too. + const isInteractive = isInteractiveTty() + try { if (existsSync(resolve(cwd, PLAN_REL_PATH))) { p.log.warn( @@ -163,7 +204,10 @@ export async function planCommand( let planStep: PlanStep if (flags['complete-rollout']) { - await confirmCompleteRollout() + await confirmCompleteRollout({ + assumeYes: flags.yes ?? false, + isInteractive, + }) planStep = 'complete' } else { planStep = await detectPlanStep(cwd) @@ -181,13 +225,6 @@ export async function planCommand( const agents = detectAgents(cwd, process.env) const state = buildStateFromContext(ctx, agents, planStep) - // Interactive only when stdin is a real TTY and we're not in CI — the - // same gate `stash impl` and the encrypt commands use. Keying off - // `process.stdout.isTTY` alone is wrong: a redirected stdin still - // hangs the agent-target picker (clack `select` reads from /dev/tty). - const isInteractive = - Boolean(process.stdin.isTTY) && process.env.CI !== 'true' - // Non-interactive without --target would hang on the agent-target // picker. Exit cleanly with a hint so automation users discover the flag. if (!target && !isInteractive) { diff --git a/packages/cli/src/messages.ts b/packages/cli/src/messages.ts index b97c4090..00ce16e3 100644 --- a/packages/cli/src/messages.ts +++ b/packages/cli/src/messages.ts @@ -119,6 +119,19 @@ export const messages = { nameRequiresValue: '--name requires a value', unexpectedArgument: 'Unexpected argument', }, + plan: { + /** + * Stable leader of the refusal shown when `plan --complete-rollout` runs + * non-interactively without `--yes`. The e2e suite asserts on it; the + * `--yes` hint is appended at the call site. Exits non-zero — the plan + * was NOT drafted, so automation must not read exit 0 as success. + */ + completeRolloutNeedsYes: + '`--complete-rollout` skips the production-deploy gate and needs explicit confirmation', + /** Shown when `--yes` confirms the gate-skip (bypasses the prompt). */ + completeRolloutConfirmed: + 'Proceeding with --yes: the production-deploy gate is skipped', + }, telemetry: { /** * The one-time first-run notice. Printed to stderr so it never pollutes diff --git a/packages/cli/tests/e2e/plan-complete-rollout.e2e.test.ts b/packages/cli/tests/e2e/plan-complete-rollout.e2e.test.ts new file mode 100644 index 00000000..fc41dd75 --- /dev/null +++ b/packages/cli/tests/e2e/plan-complete-rollout.e2e.test.ts @@ -0,0 +1,63 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { messages } from '../../src/messages.js' +import { runPiped } from '../helpers/spawn-piped.js' + +/** + * `stash plan --complete-rollout` skips the production-deploy gate, so it needs + * explicit consent. Non-interactively that means `--yes` — without it the + * command must REFUSE with a non-zero exit, never silently cancel-with-0 (which + * would let automation assume a plan was drafted when none was). + * + * These cases resolve entirely before any agent handoff, so they need no DB and + * no network — just a minimal `.cipherstash/context.json` so the command gets + * past its "run init first" guard. + */ +describe('stash plan --complete-rollout — non-interactive consent', () => { + let dir: string + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'plan-complete-rollout-e2e-')) + mkdirSync(join(dir, '.cipherstash'), { recursive: true }) + writeFileSync( + join(dir, '.cipherstash', 'context.json'), + JSON.stringify({ + integration: 'postgresql', + packageManager: 'npm', + schemas: [], + }), + ) + }) + afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + }) + + it('refuses without --yes and exits 1 (does not silently succeed)', async () => { + const r = await runPiped(['plan', '--complete-rollout'], { + cwd: dir, + timeoutMs: 12000, + }) + expect(r.timedOut).toBe(false) + expect(r.exitCode).toBe(1) + const out = r.stdout + r.stderr + expect(out).toContain(messages.plan.completeRolloutNeedsYes) + expect(out).toContain('--yes') + }) + + it('--yes confirms the gate-skip without a prompt (does not exit 1 on consent)', async () => { + const r = await runPiped(['plan', '--complete-rollout', '--yes'], { + cwd: dir, + timeoutMs: 12000, + }) + expect(r.timedOut).toBe(false) + // With --yes the gate-skip is confirmed; the command proceeds past the + // refusal (it then stops at the no-agent-target hint, exit 0 — no plan is + // drafted without a --target, which is the existing non-interactive + // behaviour and not what this flag governs). + expect(r.exitCode).toBe(0) + const out = r.stdout + r.stderr + expect(out).toContain(messages.plan.completeRolloutConfirmed) + expect(out).not.toContain(messages.plan.completeRolloutNeedsYes) + }) +}) diff --git a/skills/stash-cli/SKILL.md b/skills/stash-cli/SKILL.md index 595c3db2..a075af6e 100644 --- a/skills/stash-cli/SKILL.md +++ b/skills/stash-cli/SKILL.md @@ -123,7 +123,7 @@ If a command fails on authentication, re-run `stash auth login`. Do not inspect A command is interactive only when **stdin is a TTY and `CI` is not set** to `1`/`true` (case-insensitive). Otherwise prompts are skipped. -There is **no global `--non-interactive`, `--yes`, or `--json` flag.** Each command carries its own escape hatch: +There is **no global `--non-interactive` or `--json` flag** (and no global `--yes` — but a few commands carry a scoped one, e.g. `plan --complete-rollout --yes`). Each command carries its own escape hatch: | Need | Escape hatch | |---|---| @@ -238,7 +238,8 @@ Flags: `--supabase`, `--drizzle`, `--prisma-next`, `--proxy` / `--no-proxy`, `-- ```bash stash plan -stash plan --complete-rollout +stash plan --complete-rollout # interactive: default-no confirm +stash plan --complete-rollout --yes --target claude-code # non-interactive / CI stash plan --target claude-code ``` @@ -250,7 +251,7 @@ Pre-flights `.cipherstash/context.json` (errors with "Run `stash init` first" if |---|---| | No `dual_writing` event recorded | **Encryption rollout** — schema-add + dual-write code. Ends at the deploy gate. | | A column has `dual_writing` or later | **Encryption cutover** — backfill, schema rename, read-path switch, drop. Requires the rollout to be deployed. | -| `--complete-rollout` passed | **Complete rollout** — schema-add through drop, no deploy gate. Default-no confirm with a loud warning. | +| `--complete-rollout` passed | **Complete rollout** — schema-add through drop, no deploy gate. Needs consent: an interactive default-no confirm, or `--yes` non-interactively (without it, a non-interactive run **exits non-zero without drafting** rather than silently doing nothing). | The agent writes a machine-readable header into the plan: