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
14 changes: 14 additions & 0 deletions .changeset/plan-complete-rollout-yes.md
Original file line number Diff line number Diff line change
@@ -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).
13 changes: 11 additions & 2 deletions packages/cli/src/cli/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
65 changes: 51 additions & 14 deletions packages/cli/src/commands/plan/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<void> {
async function confirmCompleteRollout(opts: {
assumeYes: boolean
isInteractive: boolean
}): Promise<void> {
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,
Expand Down Expand Up @@ -124,8 +152,12 @@ async function detectPlanStep(cwd: string): Promise<PlanStep> {
* 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<string, boolean> = {},
Expand Down Expand Up @@ -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(
Expand All @@ -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)
Expand All @@ -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) {
Expand Down
13 changes: 13 additions & 0 deletions packages/cli/src/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
63 changes: 63 additions & 0 deletions packages/cli/tests/e2e/plan-complete-rollout.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
7 changes: 4 additions & 3 deletions skills/stash-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|---|---|
Expand Down Expand Up @@ -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
```

Expand All @@ -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:

Expand Down
Loading