Skip to content

Commit b0634df

Browse files
authored
feat(cli): make plan --complete-rollout automatable with --yes + honest exit (rc.2 M2) (#686)
* feat(cli): make `plan --complete-rollout` automatable with --yes + honest exit (rc.2 M2) `--complete-rollout` skips the production-deploy gate, so it requires explicit consent. Previously that was an interactive p.confirm with no bypass: a non-interactive run auto-cancelled (default-no) and exited 0 via CancelledError — so CI/agents got exit 0 but no plan was drafted, and would assume one existed. - New `--yes` flag confirms the gate-skip without a prompt (automation path). - Non-interactive `--complete-rollout` WITHOUT `--yes` now refuses with a non-zero exit (CliExit(1)) and points at --yes, instead of silently cancelling with exit 0. An interactive decline stays exit 0 (a deliberate 'no' is not a failure). - isInteractive is computed once up front (the confirm needs it too). - Registry + help + `stash-cli` skill updated; messages.plan.* leaders added for e2e stability. Tests: 2 pty-less e2e (refuses+exit-1 without --yes; --yes confirms + proceeds), plus manifest resolves the new flag. Suite 540 unit / 64 e2e green, code:check clean. Changeset: stash minor. Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w * refactor(cli): use shared isInteractive() in plan; drop dead cli param Review follow-up on the M2 complete-rollout change (finding #1 + #3; also Copilot's sole inline comment): - plan's interactivity gate re-inlined `process.stdin.isTTY && CI !== 'true'`, which only matches exact lowercase `true` — it misses `CI=1`/`CI=TRUE` and so diverged from the shared `isInteractive()` (config/tty.ts, whose `isCiEnv()` matches `/^(1|true)$/i`) and from the case-insensitive contract the stash-cli skill documents. Swap the inline for `isInteractiveTty()` (compute-once preserved) so a CI=1 runner with a TTY takes the non-interactive path. - Drop the unused `cli` parameter from `confirmCompleteRollout`'s opts (the refusal hardcodes the `--yes` hint; `cli` was threaded but never read). No behaviour change on the documented CI=true / TTY paths. plan-complete-rollout e2e 2/2 and plan unit 22/22 green; build + biome clean. Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
1 parent f188c7a commit b0634df

6 files changed

Lines changed: 156 additions & 19 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'stash': minor
3+
---
4+
5+
`stash plan --complete-rollout` is now automatable and has an honest exit code.
6+
It skips the production-deploy gate, so it needs explicit consent — previously
7+
that was an interactive prompt with no bypass, so a non-interactive run
8+
auto-cancelled (default-no) and exited **0** without drafting a plan, leaving
9+
automation to assume a plan existed.
10+
11+
- New `--yes` flag confirms the gate-skip without a prompt (for CI/agents).
12+
- Without `--yes`, a non-interactive `--complete-rollout` run now **refuses
13+
with a non-zero exit** and points at `--yes`, instead of silently succeeding.
14+
- Interactive behaviour is unchanged (default-no confirm).

packages/cli/src/cli/registry.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -149,12 +149,21 @@ export const registry: CommandGroup[] = [
149149
{
150150
name: 'plan',
151151
summary: 'Draft a reviewable encryption plan at .cipherstash/plan.md',
152-
examples: ['plan', 'plan --target claude-code'],
152+
examples: [
153+
'plan',
154+
'plan --target claude-code',
155+
'plan --complete-rollout --yes --target claude-code',
156+
],
153157
flags: [
154158
{
155159
name: '--complete-rollout',
156160
description:
157-
'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.',
161+
'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).',
162+
},
163+
{
164+
name: '--yes',
165+
description:
166+
"Confirm --complete-rollout's gate-skip without a prompt (for automation / CI). No effect without --complete-rollout.",
158167
},
159168
{
160169
name: '--target',

packages/cli/src/commands/plan/index.ts

Lines changed: 51 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { resolve } from 'node:path'
33
import { readManifest } from '@cipherstash/migrate'
44
import * as p from '@clack/prompts'
55
import { CliExit } from '../../cli/exit.js'
6+
import { isInteractive as isInteractiveTty } from '../../config/tty.js'
7+
import { messages } from '../../messages.js'
68
import {
79
HANDOFF_CHOICES,
810
howToProceedStep,
@@ -44,17 +46,43 @@ function buildStateFromContext(
4446
}
4547

4648
/**
47-
* Confirm the user wants to skip the production-deploy gate. Default-no is
48-
* the security stance — the warning has to be a deliberate `y` press, not
49-
* a stray Enter.
49+
* Confirm the user wants to skip the production-deploy gate.
50+
*
51+
* The gate-skip is a deliberate act, so it needs explicit consent:
52+
* - interactive TTY → a default-no `p.confirm` (a stray Enter is "no").
53+
* - `--yes` → the non-interactive consent flag; proceed, after logging the
54+
* warnings so the record shows what was skipped.
55+
* - non-interactive without `--yes` → REFUSE with a non-zero exit. We must
56+
* not silently cancel-with-0: automation that asked for a complete-rollout
57+
* plan and got exit 0 would assume a plan exists when none was drafted.
5058
*/
51-
async function confirmCompleteRollout(): Promise<void> {
59+
async function confirmCompleteRollout(opts: {
60+
assumeYes: boolean
61+
isInteractive: boolean
62+
}): Promise<void> {
5263
p.log.warn(
5364
'`--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.',
5465
)
5566
p.log.warn(
5667
'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.',
5768
)
69+
70+
if (opts.assumeYes) {
71+
p.log.info(
72+
`${messages.plan.completeRolloutConfirmed} by your explicit confirmation.`,
73+
)
74+
return
75+
}
76+
77+
if (!opts.isInteractive) {
78+
p.log.error(
79+
`${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).`,
80+
)
81+
// Non-zero: the requested plan was NOT drafted. Distinct from a user's
82+
// interactive decline (a deliberate "no" → exit 0 via CancelledError).
83+
throw new CliExit(1)
84+
}
85+
5886
const ok = await p.confirm({
5987
message: 'Proceed with a complete-rollout plan?',
6088
initialValue: false,
@@ -124,8 +152,12 @@ async function detectPlanStep(cwd: string): Promise<PlanStep> {
124152
* Flags:
125153
* `--complete-rollout` — escape hatch for databases without a deployed
126154
* application. Plans schema-add through drop in
127-
* one document with no deploy gate. Confirms
128-
* (default-no) before generating.
155+
* one document with no deploy gate. Needs explicit
156+
* confirmation: an interactive default-no prompt, or
157+
* `--yes` non-interactively (else it refuses with a
158+
* non-zero exit rather than silently doing nothing).
159+
* `--yes` — confirm `--complete-rollout`'s gate-skip without a
160+
* prompt, for automation. No effect without it.
129161
*/
130162
export async function planCommand(
131163
flags: Record<string, boolean> = {},
@@ -154,6 +186,15 @@ export async function planCommand(
154186

155187
p.intro('CipherStash Plan')
156188

189+
// Interactive only when stdin is a real TTY and we're not in CI — via the
190+
// shared `isInteractive()` (config/tty.ts) so this gate stays identical to
191+
// every other prompt gate (its `isCiEnv()` treats `CI=1`/`CI=TRUE` as CI too,
192+
// which a bare `CI !== 'true'` inline would miss). Keying off
193+
// `process.stdout.isTTY` alone is wrong: a redirected stdin still hangs the
194+
// agent-target picker (clack `select` reads from /dev/tty). Computed up here
195+
// because the complete-rollout confirmation needs it too.
196+
const isInteractive = isInteractiveTty()
197+
157198
try {
158199
if (existsSync(resolve(cwd, PLAN_REL_PATH))) {
159200
p.log.warn(
@@ -163,7 +204,10 @@ export async function planCommand(
163204

164205
let planStep: PlanStep
165206
if (flags['complete-rollout']) {
166-
await confirmCompleteRollout()
207+
await confirmCompleteRollout({
208+
assumeYes: flags.yes ?? false,
209+
isInteractive,
210+
})
167211
planStep = 'complete'
168212
} else {
169213
planStep = await detectPlanStep(cwd)
@@ -181,13 +225,6 @@ export async function planCommand(
181225
const agents = detectAgents(cwd, process.env)
182226
const state = buildStateFromContext(ctx, agents, planStep)
183227

184-
// Interactive only when stdin is a real TTY and we're not in CI — the
185-
// same gate `stash impl` and the encrypt commands use. Keying off
186-
// `process.stdout.isTTY` alone is wrong: a redirected stdin still
187-
// hangs the agent-target picker (clack `select` reads from /dev/tty).
188-
const isInteractive =
189-
Boolean(process.stdin.isTTY) && process.env.CI !== 'true'
190-
191228
// Non-interactive without --target would hang on the agent-target
192229
// picker. Exit cleanly with a hint so automation users discover the flag.
193230
if (!target && !isInteractive) {

packages/cli/src/messages.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,19 @@ export const messages = {
119119
nameRequiresValue: '--name requires a value',
120120
unexpectedArgument: 'Unexpected argument',
121121
},
122+
plan: {
123+
/**
124+
* Stable leader of the refusal shown when `plan --complete-rollout` runs
125+
* non-interactively without `--yes`. The e2e suite asserts on it; the
126+
* `--yes` hint is appended at the call site. Exits non-zero — the plan
127+
* was NOT drafted, so automation must not read exit 0 as success.
128+
*/
129+
completeRolloutNeedsYes:
130+
'`--complete-rollout` skips the production-deploy gate and needs explicit confirmation',
131+
/** Shown when `--yes` confirms the gate-skip (bypasses the prompt). */
132+
completeRolloutConfirmed:
133+
'Proceeding with --yes: the production-deploy gate is skipped',
134+
},
122135
telemetry: {
123136
/**
124137
* The one-time first-run notice. Printed to stderr so it never pollutes
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
2+
import { tmpdir } from 'node:os'
3+
import { join } from 'node:path'
4+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
5+
import { messages } from '../../src/messages.js'
6+
import { runPiped } from '../helpers/spawn-piped.js'
7+
8+
/**
9+
* `stash plan --complete-rollout` skips the production-deploy gate, so it needs
10+
* explicit consent. Non-interactively that means `--yes` — without it the
11+
* command must REFUSE with a non-zero exit, never silently cancel-with-0 (which
12+
* would let automation assume a plan was drafted when none was).
13+
*
14+
* These cases resolve entirely before any agent handoff, so they need no DB and
15+
* no network — just a minimal `.cipherstash/context.json` so the command gets
16+
* past its "run init first" guard.
17+
*/
18+
describe('stash plan --complete-rollout — non-interactive consent', () => {
19+
let dir: string
20+
beforeEach(() => {
21+
dir = mkdtempSync(join(tmpdir(), 'plan-complete-rollout-e2e-'))
22+
mkdirSync(join(dir, '.cipherstash'), { recursive: true })
23+
writeFileSync(
24+
join(dir, '.cipherstash', 'context.json'),
25+
JSON.stringify({
26+
integration: 'postgresql',
27+
packageManager: 'npm',
28+
schemas: [],
29+
}),
30+
)
31+
})
32+
afterEach(() => {
33+
rmSync(dir, { recursive: true, force: true })
34+
})
35+
36+
it('refuses without --yes and exits 1 (does not silently succeed)', async () => {
37+
const r = await runPiped(['plan', '--complete-rollout'], {
38+
cwd: dir,
39+
timeoutMs: 12000,
40+
})
41+
expect(r.timedOut).toBe(false)
42+
expect(r.exitCode).toBe(1)
43+
const out = r.stdout + r.stderr
44+
expect(out).toContain(messages.plan.completeRolloutNeedsYes)
45+
expect(out).toContain('--yes')
46+
})
47+
48+
it('--yes confirms the gate-skip without a prompt (does not exit 1 on consent)', async () => {
49+
const r = await runPiped(['plan', '--complete-rollout', '--yes'], {
50+
cwd: dir,
51+
timeoutMs: 12000,
52+
})
53+
expect(r.timedOut).toBe(false)
54+
// With --yes the gate-skip is confirmed; the command proceeds past the
55+
// refusal (it then stops at the no-agent-target hint, exit 0 — no plan is
56+
// drafted without a --target, which is the existing non-interactive
57+
// behaviour and not what this flag governs).
58+
expect(r.exitCode).toBe(0)
59+
const out = r.stdout + r.stderr
60+
expect(out).toContain(messages.plan.completeRolloutConfirmed)
61+
expect(out).not.toContain(messages.plan.completeRolloutNeedsYes)
62+
})
63+
})

skills/stash-cli/SKILL.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ If a command fails on authentication, re-run `stash auth login`. Do not inspect
123123

124124
A command is interactive only when **stdin is a TTY and `CI` is not set** to `1`/`true` (case-insensitive). Otherwise prompts are skipped.
125125

126-
There is **no global `--non-interactive`, `--yes`, or `--json` flag.** Each command carries its own escape hatch:
126+
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:
127127

128128
| Need | Escape hatch |
129129
|---|---|
@@ -238,7 +238,8 @@ Flags: `--supabase`, `--drizzle`, `--prisma-next`, `--proxy` / `--no-proxy`, `--
238238

239239
```bash
240240
stash plan
241-
stash plan --complete-rollout
241+
stash plan --complete-rollout # interactive: default-no confirm
242+
stash plan --complete-rollout --yes --target claude-code # non-interactive / CI
242243
stash plan --target claude-code
243244
```
244245

@@ -250,7 +251,7 @@ Pre-flights `.cipherstash/context.json` (errors with "Run `stash init` first" if
250251
|---|---|
251252
| No `dual_writing` event recorded | **Encryption rollout** — schema-add + dual-write code. Ends at the deploy gate. |
252253
| A column has `dual_writing` or later | **Encryption cutover** — backfill, schema rename, read-path switch, drop. Requires the rollout to be deployed. |
253-
| `--complete-rollout` passed | **Complete rollout** — schema-add through drop, no deploy gate. Default-no confirm with a loud warning. |
254+
| `--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). |
254255

255256
The agent writes a machine-readable header into the plan:
256257

0 commit comments

Comments
 (0)