Skip to content

Commit 27520d6

Browse files
committed
feat(cli): honest non-interactive init — fail on skew, no false success (rc.2 M4)
Non-interactive `stash init` reported success for setups that didn't fully complete. Three honesty gaps closed: 1. Version skew. A non-interactive run can't reconcile a `behind` skew (it won't mutate an install without consent), so instead of warning-and- proceeding — scaffolding against packages older than this CLI expects and then claiming success — it now REFUSES with a non-zero exit (CliExit(1)) and the exact align command. The #661/#666 no-mutation principle is preserved (execSync still not called); only the post-warning action changes from proceed to fail. `ahead` skew stays a warn (install likely fine; update the CLI). 2. False 'Setup complete'. The summary header and the auth/db/scaffold checkmarks were unconditional. Now: when EQL is required but not installed (eqlInstalled=false AND integration !== prisma-next), the summary reads 'Setup incomplete' and init exits non-zero, pointing at `stash eql install`. 'Database connection verified' → 'Database URL resolved' (init resolves a URL, never opens a connection). 'Encryption client scaffolded' is shown only when a client was written (clientFilePath set; skipped for prisma-next). 3. False 'skills loaded'. The Claude/Codex handoff launch prompt referenced the skills dir unconditionally; a stripped build installs no skills, so it told the agent to read files that aren't there. The clause is now conditional on installSkills() actually copying something. Tests: install-deps skew tests updated to assert the refusal (still never mutates); new init-command honest-summary tests (EQL-missing → exit 1 + 'Setup incomplete'; prisma-next eqlInstalled=false → completes). Skill + doc comments updated. Suite 542 unit / 62 e2e green, code:check clean. Changeset: stash minor. Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
1 parent f188c7a commit 27520d6

9 files changed

Lines changed: 164 additions & 16 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
'stash': minor
3+
---
4+
5+
`stash init` is honest non-interactively — it no longer reports success for a
6+
setup that didn't fully complete.
7+
8+
- **Fails on version skew.** A non-interactive run can't reconcile an
9+
already-installed `@cipherstash/*` package that's *older* than this CLI
10+
expects (it won't mutate an install without consent), so instead of warning
11+
and proceeding — scaffolding against mismatched packages and then claiming
12+
success — it now refuses with a non-zero exit and the exact align command.
13+
Interactive runs still offer to align. A *newer* install stays a warn (the
14+
install is likely fine; update the CLI instead).
15+
- **No false "Setup complete".** If the EQL extension isn't installed at the
16+
end (and the integration isn't Prisma Next, which installs it via
17+
`migration apply`), the summary reads "Setup incomplete" and init exits
18+
non-zero, pointing at `stash eql install`.
19+
- **Honest checkmarks.** The summary no longer claims "Database connection
20+
verified" (init resolves a URL but doesn't open a connection) — it now says
21+
"Database URL resolved" — and only shows "Encryption client scaffolded" when
22+
a client was actually written (skipped for Prisma Next).
23+
- **No false "skills loaded".** The agent handoff prompt only points at the
24+
skills directory when skills were actually copied (a stripped build installs
25+
none), instead of telling the agent to read files that aren't there.

packages/cli/src/commands/impl/steps/handoff-claude.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,17 @@ export const handoffClaudeStep: HandoffStep = {
3535
writeArtifacts(cwd, state, 'claude-code', installed)
3636

3737
const mode = state.mode ?? 'implement'
38+
// Only point the agent at the skills dir when skills were actually copied.
39+
// A stripped CLI build (no bundled skills) returns [] — claiming they're
40+
// there would send the agent to read files that don't exist.
41+
const skillsClause =
42+
installed.length > 0
43+
? `The installed skills under ${CLAUDE_SKILLS_DIR}/ have the rules; `
44+
: ''
3845
const launchPrompt =
3946
mode === 'plan'
40-
? `Read ${SETUP_PROMPT_REL_PATH} and produce the planning deliverable it describes. The installed skills under ${CLAUDE_SKILLS_DIR}/ have the rules; ${CONTEXT_REL_PATH} has the project facts. Do not edit code or run mutating commands during this phase.`
41-
: `Read ${SETUP_PROMPT_REL_PATH} and complete the setup steps. The installed skills under ${CLAUDE_SKILLS_DIR}/ have the rules; ${CONTEXT_REL_PATH} has the project facts.`
47+
? `Read ${SETUP_PROMPT_REL_PATH} and produce the planning deliverable it describes. ${skillsClause}${CONTEXT_REL_PATH} has the project facts. Do not edit code or run mutating commands during this phase.`
48+
: `Read ${SETUP_PROMPT_REL_PATH} and complete the setup steps. ${skillsClause}${CONTEXT_REL_PATH} has the project facts.`
4249

4350
if (!state.agents?.cli.claudeCode) {
4451
p.note(

packages/cli/src/commands/impl/steps/handoff-codex.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,17 @@ export const handoffCodexStep: HandoffStep = {
5454
writeArtifacts(cwd, state, 'codex', installed)
5555

5656
const mode = state.mode ?? 'implement'
57+
// Only reference the skills dir when skills were actually copied (a
58+
// stripped build returns []); the durable rules live in AGENTS.md either
59+
// way, so the prompt stays useful without them.
60+
const skillsClause =
61+
installed.length > 0
62+
? `the skills under ${CODEX_SKILLS_DIR}/ have the API details; `
63+
: ''
5764
const launchPrompt =
5865
mode === 'plan'
59-
? `Read ${SETUP_PROMPT_REL_PATH} and produce the planning deliverable it describes. AGENTS.md has the durable rules; the skills under ${CODEX_SKILLS_DIR}/ have the API details; ${CONTEXT_REL_PATH} has the project facts. Do not edit code or run mutating commands during this phase.`
60-
: `Read ${SETUP_PROMPT_REL_PATH} and complete the setup steps. AGENTS.md has the durable rules; the skills under ${CODEX_SKILLS_DIR}/ have the API details; ${CONTEXT_REL_PATH} has the project facts.`
66+
? `Read ${SETUP_PROMPT_REL_PATH} and produce the planning deliverable it describes. AGENTS.md has the durable rules; ${skillsClause}${CONTEXT_REL_PATH} has the project facts. Do not edit code or run mutating commands during this phase.`
67+
: `Read ${SETUP_PROMPT_REL_PATH} and complete the setup steps. AGENTS.md has the durable rules; ${skillsClause}${CONTEXT_REL_PATH} has the project facts.`
6168

6269
if (!state.agents?.cli.codex) {
6370
p.note(

packages/cli/src/commands/init/__tests__/init-command.test.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1+
import * as p from '@clack/prompts'
12
import { beforeEach, describe, expect, it, vi } from 'vitest'
3+
import { CliExit } from '../../../cli/exit.js'
4+
import { messages } from '../../../messages.js'
25
import type { InitState } from '../types.js'
36

47
// `--region` is the non-interactive escape hatch for `stash init`; it must land
@@ -9,6 +12,10 @@ import type { InitState } from '../types.js'
912
// (`authenticate`, `install-deps`) out of the fast suite.
1013
const authRun = vi.hoisted(() => vi.fn(async (state: InitState) => state))
1114
const passthrough = { run: async (s: InitState) => s }
15+
// Controllable so the honest-summary tests can vary whether EQL installed.
16+
const eqlRun = vi.hoisted(() =>
17+
vi.fn(async (s: InitState) => ({ ...s, eqlInstalled: true })),
18+
)
1219

1320
vi.mock('../steps/authenticate.js', () => ({
1421
authenticateStep: { id: 'authenticate', name: 'Authenticate', run: authRun },
@@ -26,7 +33,10 @@ vi.mock('../steps/install-deps.js', () => ({
2633
installDepsStep: { id: 'install-deps', ...passthrough },
2734
}))
2835
vi.mock('../steps/install-eql.js', () => ({
29-
installEqlStep: { id: 'install-eql', ...passthrough },
36+
// A successful init installs EQL — the default mark keeps the honest-summary
37+
// gate (`eqlPending` → exit 1) happy for the region-threading runs. The
38+
// honest-summary tests override `eqlRun` per case.
39+
installEqlStep: { id: 'install-eql', run: eqlRun },
3040
}))
3141
vi.mock('../steps/gather-context.js', () => ({
3242
gatherContextStep: { id: 'gather-context', ...passthrough },
@@ -70,3 +80,38 @@ describe('initCommand — region threading', () => {
7080
expect(stateArg.regionFlag).toBeUndefined()
7181
})
7282
})
83+
84+
describe('initCommand — honest summary', () => {
85+
it('exits non-zero and reports "Setup incomplete" when EQL was not installed', async () => {
86+
eqlRun.mockImplementationOnce(async (s: InitState) => ({
87+
...s,
88+
eqlInstalled: false,
89+
}))
90+
91+
await expect(initCommand({}, {})).rejects.toBeInstanceOf(CliExit)
92+
// The summary titles the run as incomplete, and the EQL fix is surfaced.
93+
expect(vi.mocked(p.note)).toHaveBeenCalledWith(
94+
expect.any(String),
95+
messages.init.setupIncomplete,
96+
)
97+
expect(vi.mocked(p.log.error)).toHaveBeenCalledWith(
98+
expect.stringContaining(messages.init.eqlNotInstalled),
99+
)
100+
})
101+
102+
it('completes (no throw) when EQL was not installed but the integration is prisma-next', async () => {
103+
// Prisma Next installs EQL via `migration apply`, so eqlInstalled=false is
104+
// expected there and must NOT be treated as an incomplete setup.
105+
eqlRun.mockImplementationOnce(async (s: InitState) => ({
106+
...s,
107+
integration: 'prisma-next',
108+
eqlInstalled: false,
109+
}))
110+
111+
await expect(initCommand({}, {})).resolves.toBeUndefined()
112+
expect(vi.mocked(p.note)).toHaveBeenCalledWith(
113+
expect.any(String),
114+
'Setup complete',
115+
)
116+
})
117+
})

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

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as p from '@clack/prompts'
22
import { CliExit } from '../../cli/exit.js'
3+
import { messages } from '../../messages.js'
34
import { HANDOFF_CHOICES } from '../impl/steps/how-to-proceed.js'
45
import { planCommand } from '../plan/index.js'
56
import { createBaseProvider } from './providers/base.js'
@@ -99,17 +100,39 @@ export async function initCommand(
99100

100101
const pm = detectPackageManager()
101102
const cli = runnerCommand(pm, 'stash')
103+
// Only claim what actually happened. Auth throws on failure (reaching here
104+
// means it succeeded); the database step *resolves* a URL but never opens a
105+
// connection, so don't claim "verified"; the client scaffold is skipped for
106+
// Prisma Next (no `clientFilePath` on state).
102107
const checkmarks: string[] = [
103108
'✓ Authenticated to CipherStash',
104-
'✓ Database connection verified',
105-
'✓ Encryption client scaffolded',
109+
'✓ Database URL resolved',
106110
]
111+
if (state.clientFilePath) {
112+
checkmarks.push('✓ Encryption client scaffolded')
113+
}
107114
if (state.stackInstalled) {
108115
checkmarks.push('✓ `@cipherstash/stack` installed')
109116
}
110117
if (state.cliInstalled) checkmarks.push('✓ `stash` CLI installed')
111118
if (state.eqlInstalled) checkmarks.push('✓ EQL extension installed')
112119

120+
// EQL is required for encryption. Prisma Next installs it via `migration
121+
// apply` (so `eqlInstalled` is false by design there); every other
122+
// integration needs it installed here. If it's missing, setup is NOT
123+
// complete — say so and exit non-zero so automation can't read a false
124+
// success from a run where encryption would fail at query time.
125+
const eqlPending =
126+
!state.eqlInstalled && state.integration !== 'prisma-next'
127+
if (eqlPending) {
128+
checkmarks.push('✗ EQL extension NOT installed')
129+
p.note(checkmarks.join('\n'), messages.init.setupIncomplete)
130+
p.log.error(
131+
`${messages.init.eqlNotInstalled} Run \`${cli} eql install\` before running any encryption.`,
132+
)
133+
throw new CliExit(1)
134+
}
135+
113136
p.note(checkmarks.join('\n'), 'Setup complete')
114137

115138
// Offer to chain straight into `stash plan` so first-time users don't

packages/cli/src/commands/init/steps/__tests__/install-deps.test.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { beforeEach, describe, expect, it, vi } from 'vitest'
2+
import { CliExit } from '../../../../cli/exit.js'
23
import type { InitProvider, InitState } from '../../types.js'
34

45
const execSyncMock = vi.hoisted(() => vi.fn())
@@ -199,15 +200,20 @@ describe('installDepsStep', () => {
199200
)
200201
})
201202

202-
it('non-interactive: warns on skew, prints align commands, never mutates', async () => {
203+
it('non-interactive: REFUSES on skew (exit 1), prints align commands, never mutates', async () => {
203204
vi.mocked(isInteractive).mockReturnValue(false)
204205
present('@cipherstash/stack', 'stash')
205206
resolvedVersions({
206207
'@cipherstash/stack': '0.19.0',
207208
stash: FIXTURE_VERSIONS.stash,
208209
})
209210

210-
const result = await installDepsStep.run(baseState, provider)
211+
// M4: a non-interactive run can't reconcile a `behind` skew (won't mutate
212+
// without consent), so it refuses with a non-zero exit rather than
213+
// proceeding and reporting a false success.
214+
await expect(
215+
installDepsStep.run(baseState, provider),
216+
).rejects.toBeInstanceOf(CliExit)
211217

212218
expect(p.log.warn).toHaveBeenCalledWith(
213219
expect.stringContaining('@cipherstash/stack: installed 0.19.0'),
@@ -217,9 +223,8 @@ describe('installDepsStep', () => {
217223
expect.stringContaining('npm install @cipherstash/stack@9.9.9-test.1'),
218224
'Version skew',
219225
)
226+
// Never mutates — the #661/#666 principle survives the refusal.
220227
expect(execSyncMock).not.toHaveBeenCalled()
221-
expect(result.stackInstalled).toBe(true)
222-
expect(result.cliInstalled).toBe(true)
223228
})
224229

225230
it('a NEWER install gets an update-stash warning, never a downgrade command', async () => {
@@ -284,7 +289,11 @@ describe('installDepsStep', () => {
284289
stash: FIXTURE_VERSIONS.stash,
285290
})
286291

287-
await installDepsStep.run(baseState, provider)
292+
// An unreadable manifest classifies as `behind` skew, so a non-interactive
293+
// run refuses (exit 1) after warning — same as any other skew.
294+
await expect(
295+
installDepsStep.run(baseState, provider),
296+
).rejects.toBeInstanceOf(CliExit)
288297

289298
expect(p.log.warn).toHaveBeenCalledWith(
290299
expect.stringContaining(

packages/cli/src/commands/init/steps/install-deps.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { execSync } from 'node:child_process'
22
import * as p from '@clack/prompts'
3+
import { CliExit } from '../../../cli/exit.js'
34
import { isInteractive } from '../../../config/tty.js'
5+
import { messages } from '../../../messages.js'
46
import {
57
compareVersions,
68
expectedVersion,
@@ -146,9 +148,12 @@ function splitProdDev(packages: readonly string[]): {
146148
* before any prompt or early exit, so no path (decline, partial failure,
147149
* everything-already-present) proceeds silently on a stale or placeholder
148150
* install. Interactively, init offers to align the skewed packages to this
149-
* release in the same confirm as the missing installs; non-interactively it
150-
* NEVER mutates an existing install — it warns, prints the exact align
151-
* commands, and proceeds.
151+
* release in the same confirm as the missing installs. Non-interactively it
152+
* still NEVER mutates an existing install without consent — but rather than
153+
* proceeding on a `behind` skew (which would scaffold against packages older
154+
* than this CLI expects and then report a false success), it REFUSES with a
155+
* non-zero exit and the exact align commands (M4). An `ahead` skew is not
156+
* fatal — the install is likely fine and the fix is updating the CLI.
152157
*
153158
* When everything is already present at matching versions this logs a
154159
* success line and moves on with no prompts.
@@ -193,6 +198,22 @@ export const installDepsStep: InitStep = {
193198
p.log.warn(`Version skew detected:\n ${skewLines(skewed)}`)
194199
}
195200

201+
// A non-interactive run can't reconcile a `behind` skew: it won't mutate an
202+
// existing install without consent (the #661/#666 rule), so proceeding
203+
// would scaffold config/client against packages older than this CLI
204+
// expects and then report a false success. Refuse with a non-zero exit and
205+
// the exact align commands, instead of warning-and-continuing. (Interactive
206+
// runs still offer to align — see below. `ahead` skew is handled
207+
// separately: the install is likely fine, so it warns and proceeds.)
208+
if (skewed.length > 0 && !isInteractive()) {
209+
p.note(
210+
`Align these packages, then re-run init:\n ${alignCommands.join('\n ')}`,
211+
'Version skew',
212+
)
213+
p.log.error(messages.init.skewNonInteractive)
214+
throw new CliExit(1)
215+
}
216+
196217
// What's missing outright (pinned, prod/dev split).
197218
const missing: string[] = []
198219
if (!stackPresent) missing.push(STACK_PACKAGE)

packages/cli/src/messages.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,17 @@ export const messages = {
119119
nameRequiresValue: '--name requires a value',
120120
unexpectedArgument: 'Unexpected argument',
121121
},
122+
init: {
123+
/**
124+
* Honest non-interactive init. These exit non-zero so automation never
125+
* reads a false success — the e2e suite asserts on the leaders.
126+
*/
127+
setupIncomplete: 'Setup incomplete',
128+
eqlNotInstalled: 'EQL is not installed — encryption queries will fail.',
129+
/** Shown when a non-interactive run hits version skew it won't reconcile. */
130+
skewNonInteractive:
131+
'Version skew on already-installed packages — refusing to proceed non-interactively. Align the packages (below) and re-run, or run init interactively.',
132+
},
122133
telemetry: {
123134
/**
124135
* The one-time first-run notice. Printed to stderr so it never pollutes

skills/stash-cli/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ npx stash init # PostgreSQL / Drizzle / Prisma
3636
npx stash init --supabase # Supabase
3737
```
3838

39-
`stash init` installs the CLI as a project dev dependency, so subsequent commands can drop the `npx`. The CLI is package-manager aware — before init, use whichever one-shot runner your project uses (`npx`, `pnpm dlx`, `bunx`, `yarn dlx`). Installs are **pinned to the exact `@cipherstash/*` versions this CLI release shipped with** (never bare dist-tags, which can lag behind a release), and init warns if an already-installed package's resolved version differs from the release's. Treat that warning as a real problem — but the fix depends on direction, and init says which applies: an **older** install should be aligned to the release (init offers the exact command); a **newer** install must NOT be downgraded — update the `stash` CLI to the matching release instead (init prints that command too).
39+
`stash init` installs the CLI as a project dev dependency, so subsequent commands can drop the `npx`. The CLI is package-manager aware — before init, use whichever one-shot runner your project uses (`npx`, `pnpm dlx`, `bunx`, `yarn dlx`). Installs are **pinned to the exact `@cipherstash/*` versions this CLI release shipped with** (never bare dist-tags, which can lag behind a release), and init flags any already-installed `@cipherstash/*` package whose resolved version differs from the release's. The fix depends on direction, and init says which applies: an **older** install should be aligned to the release (init offers the exact command); a **newer** install must NOT be downgraded — update the `stash` CLI to the matching release instead (init prints that command too). **Non-interactively, an older ("behind") skew is fatal** — init refuses with a non-zero exit and the align command rather than scaffolding against mismatched packages and reporting a false success. Interactively it offers to align. Likewise, if the EQL extension isn't installed at the end (and the integration isn't Prisma Next, which installs it via `migration apply`), init reports **"Setup incomplete"** and exits non-zero — it never claims a setup is complete when encryption would fail at query time.
4040

4141
**If you are an agent, do this first:**
4242

0 commit comments

Comments
 (0)