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
29 changes: 29 additions & 0 deletions .changeset/honest-noninteractive-init.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
'stash': minor
---

`stash init` is honest non-interactively — it no longer reports success for a
setup that didn't fully complete.

- **Fails on version skew.** A non-interactive run can't reconcile an
already-installed `@cipherstash/*` package that's *older* than this CLI
expects (it won't mutate an install without consent), so instead of warning
and proceeding — scaffolding against mismatched packages and then claiming
success — it now refuses with a non-zero exit and the exact align command.
Interactive runs still offer to align. A *newer* install stays a warn (the
install is likely fine; update the CLI instead).
- **No false "Setup complete".** If the EQL extension isn't installed at the
end — and the integration isn't one that installs it out-of-band — the
summary reads "Setup incomplete" and init exits non-zero, pointing at
`stash eql install`. Integrations that install EQL via a migration are
reported honestly rather than as failures: Prisma Next (installs it via
`migration apply`) and the Drizzle flow, which *generates* an EQL migration
and now says "EQL migration generated — apply it with `drizzle-kit migrate`"
instead of claiming the extension is already installed.
- **Honest checkmarks.** The summary no longer claims "Database connection
verified" (init resolves a URL but doesn't open a connection) — it now says
"Database URL resolved" — and only shows "Encryption client scaffolded" when
a client was actually written (skipped for Prisma Next).
- **No false "skills loaded".** The agent handoff prompt only points at the
skills directory when skills were actually copied (a stripped build installs
none), instead of telling the agent to read files that aren't there.
36 changes: 31 additions & 5 deletions packages/cli/src/commands/db/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,28 @@ async function resolveInstallContext(
return { databaseUrl, clientPath }
}

export async function installCommand(options: InstallOptions) {
/**
* What `installCommand` actually did — so callers (notably `stash init`'s
* completion gate) can tell "EQL is now in the database" from "a migration
* file was written but nothing was applied yet".
*
* - `installed` / `already-installed`: EQL is present in the target database.
* - `migration-generated`: a migration file was written (Drizzle, or the
* Supabase `--migration` mode); the user still has to APPLY it
* (`drizzle-kit migrate` / `supabase db push`) before EQL exists in the DB.
* - `dry-run`: nothing was changed.
*
* Terminal error paths call `process.exit(1)` and never return an outcome.
*/
export type InstallOutcome =
| 'installed'
| 'already-installed'
| 'migration-generated'
| 'dry-run'

export async function installCommand(
options: InstallOptions,
): Promise<InstallOutcome> {
p.intro(runnerCommand(detectPackageManager(), 'stash eql install'))

// Validate mutually-exclusive / supabase-required flags BEFORE doing any
Expand Down Expand Up @@ -238,7 +259,9 @@ export async function installCommand(options: InstallOptions) {
supabase: resolved.supabase,
excludeOperatorFamily: resolved.excludeOperatorFamily,
})
return
// A Drizzle migration file was written — NOT applied. EQL only lands in
// the database once the user runs `drizzle-kit migrate`.
return 'migration-generated'
}

// Supabase non-Drizzle path: pick between writing a migration file and
Expand Down Expand Up @@ -270,7 +293,9 @@ export async function installCommand(options: InstallOptions) {
force: options.force,
dryRun: options.dryRun,
})
return
// Migration file written, not applied — the user runs it via their
// Supabase migration workflow (`supabase db push`).
return 'migration-generated'
}
// mode === 'direct' — fall through to existing direct-install behavior.
}
Expand All @@ -282,7 +307,7 @@ export async function installCommand(options: InstallOptions) {
: `Would use bundled EQL${eqlVersion === 3 ? ' v3' : ''} install script`
p.note(`${source}\nWould execute the SQL against the database`, 'Dry Run')
p.outro('Dry run complete.')
return
return 'dry-run'
}

const installer = new EQLInstaller({
Expand Down Expand Up @@ -331,7 +356,7 @@ export async function installCommand(options: InstallOptions) {
if (installed) {
p.log.info('Use --force to re-run the install script.')
p.outro('Nothing to do.')
return
return 'already-installed'
}
}

Expand Down Expand Up @@ -370,6 +395,7 @@ export async function installCommand(options: InstallOptions) {

printNextSteps()
p.outro('Done!')
return 'installed'
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { beforeEach, expect, it, vi } from 'vitest'
import type { InitState } from '../../../init/types.js'

// The launch prompt's skills clause is the unit under test. Mock the skill
// installer so we control whether any skills were "copied", and stub the
// artifact writer / agent spawner so no files are written and no process is
// spawned. `impl.test.ts` mocks `howToProceedStep.run` out entirely, so
// nothing there drives this prompt — this file is its dedicated coverage.
const installSkills = vi.hoisted(() => vi.fn())
vi.mock('../../../init/lib/install-skills.js', () => ({ installSkills }))
vi.mock('../../../init/lib/handoff-helpers.js', () => ({
writeArtifacts: vi.fn(),
spawnAgent: vi.fn(async () => 0),
}))
vi.mock('@clack/prompts', () => ({
note: vi.fn(),
log: { success: vi.fn(), info: vi.fn(), warn: vi.fn() },
}))

import * as p from '@clack/prompts'
import { handoffClaudeStep } from '../handoff-claude.js'

// `agents` undefined → Claude "not installed" path, which routes the launch
// prompt into a `p.note` (rather than spawning). That note's body is what we
// assert on.
const state = { integration: 'postgresql' } as unknown as InitState

beforeEach(() => vi.clearAllMocks())

it('launch prompt omits the skills dir when no skills were copied', async () => {
installSkills.mockReturnValue([])
await handoffClaudeStep.run(state)
expect(vi.mocked(p.note).mock.calls[0][0]).not.toContain('.claude/skills/')
})

it('launch prompt names the skills dir when skills were copied', async () => {
installSkills.mockReturnValue(['stash-encryption'])
await handoffClaudeStep.run(state)
expect(vi.mocked(p.note).mock.calls[0][0]).toContain('.claude/skills/')
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { beforeEach, expect, it, vi } from 'vitest'
import type { InitState } from '../../../init/types.js'

// Same seam as the handoff-claude test: the launch prompt's skills clause is
// the unit under test. Mock the skill installer to control whether skills were
// "copied". The Codex handoff also writes AGENTS.md to disk before printing
// the prompt, so stub `node:fs` and the AGENTS.md builders to keep the test
// hermetic (no file lands in the repo) — the assertion is purely on the
// launch-prompt text.
const installSkills = vi.hoisted(() => vi.fn())
vi.mock('../../../init/lib/install-skills.js', () => ({ installSkills }))
vi.mock('../../../init/lib/handoff-helpers.js', () => ({
writeArtifacts: vi.fn(),
spawnAgent: vi.fn(async () => 0),
}))
vi.mock('../../../init/lib/build-agents-md.js', () => ({
buildAgentsMdBody: vi.fn(() => '# managed doctrine'),
}))
vi.mock('../../../init/lib/sentinel-upsert.js', () => ({
upsertManagedBlock: vi.fn(() => '# AGENTS.md'),
}))
vi.mock('node:fs', () => ({
existsSync: vi.fn(() => false),
readFileSync: vi.fn(() => ''),
writeFileSync: vi.fn(),
}))
vi.mock('@clack/prompts', () => ({
note: vi.fn(),
log: { success: vi.fn(), info: vi.fn(), warn: vi.fn() },
}))

import * as p from '@clack/prompts'
import { handoffCodexStep } from '../handoff-codex.js'

// `agents` undefined → Codex "not installed" path, which routes the launch
// prompt into a `p.note`.
const state = { integration: 'postgresql' } as unknown as InitState

beforeEach(() => vi.clearAllMocks())

it('launch prompt drops .codex/skills/ but keeps AGENTS.md when no skills copied', async () => {
installSkills.mockReturnValue([])
await handoffCodexStep.run(state)
const body = vi.mocked(p.note).mock.calls[0][0]
expect(body).not.toContain('.codex/skills/')
// The durable rules live in AGENTS.md regardless, so the prompt still names it.
expect(body).toContain('AGENTS.md')
})

it('launch prompt names .codex/skills/ when skills were copied', async () => {
installSkills.mockReturnValue(['stash-encryption'])
await handoffCodexStep.run(state)
expect(vi.mocked(p.note).mock.calls[0][0]).toContain('.codex/skills/')
})
11 changes: 9 additions & 2 deletions packages/cli/src/commands/impl/steps/handoff-claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,17 @@ export const handoffClaudeStep: HandoffStep = {
writeArtifacts(cwd, state, 'claude-code', installed)

const mode = state.mode ?? 'implement'
// Only point the agent at the skills dir when skills were actually copied.
// A stripped CLI build (no bundled skills) returns [] — claiming they're
// there would send the agent to read files that don't exist.
const skillsClause =
installed.length > 0
Comment thread
coderdan marked this conversation as resolved.
? `The installed skills under ${CLAUDE_SKILLS_DIR}/ have the rules; `
: ''
const launchPrompt =
mode === 'plan'
? `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.`
: `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.`
? `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.`
: `Read ${SETUP_PROMPT_REL_PATH} and complete the setup steps. ${skillsClause}${CONTEXT_REL_PATH} has the project facts.`
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if (!state.agents?.cli.claudeCode) {
p.note(
Expand Down
11 changes: 9 additions & 2 deletions packages/cli/src/commands/impl/steps/handoff-codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,17 @@ export const handoffCodexStep: HandoffStep = {
writeArtifacts(cwd, state, 'codex', installed)

const mode = state.mode ?? 'implement'
// Only reference the skills dir when skills were actually copied (a
// stripped build returns []); the durable rules live in AGENTS.md either
// way, so the prompt stays useful without them.
const skillsClause =
installed.length > 0
Comment thread
coderdan marked this conversation as resolved.
? `the skills under ${CODEX_SKILLS_DIR}/ have the API details; `
: ''
const launchPrompt =
mode === 'plan'
? `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.`
: `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.`
? `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.`
: `Read ${SETUP_PROMPT_REL_PATH} and complete the setup steps. AGENTS.md has the durable rules; ${skillsClause}${CONTEXT_REL_PATH} has the project facts.`

if (!state.agents?.cli.codex) {
p.note(
Expand Down
106 changes: 105 additions & 1 deletion packages/cli/src/commands/init/__tests__/init-command.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import * as p from '@clack/prompts'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { CliExit } from '../../../cli/exit.js'
import { messages } from '../../../messages.js'
import type { InitState } from '../types.js'

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

vi.mock('../steps/authenticate.js', () => ({
authenticateStep: { id: 'authenticate', name: 'Authenticate', run: authRun },
Expand All @@ -26,7 +33,10 @@ vi.mock('../steps/install-deps.js', () => ({
installDepsStep: { id: 'install-deps', ...passthrough },
}))
vi.mock('../steps/install-eql.js', () => ({
installEqlStep: { id: 'install-eql', ...passthrough },
// A successful init installs EQL — the default mark keeps the honest-summary
// gate (`eqlPending` → exit 1) happy for the region-threading runs. The
// honest-summary tests override `eqlRun` per case.
installEqlStep: { id: 'install-eql', run: eqlRun },
}))
vi.mock('../steps/gather-context.js', () => ({
gatherContextStep: { id: 'gather-context', ...passthrough },
Expand Down Expand Up @@ -70,3 +80,97 @@ describe('initCommand — region threading', () => {
expect(stateArg.regionFlag).toBeUndefined()
})
})

describe('initCommand — honest summary', () => {
it('exits non-zero and reports "Setup incomplete" when EQL was not installed', async () => {
eqlRun.mockImplementationOnce(async (s: InitState) => ({
...s,
eqlInstalled: false,
}))

await expect(initCommand({}, {})).rejects.toBeInstanceOf(CliExit)
// The summary titles the run as incomplete, and the EQL fix is surfaced.
expect(vi.mocked(p.note)).toHaveBeenCalledWith(
expect.any(String),
messages.init.setupIncomplete,
)
expect(vi.mocked(p.log.error)).toHaveBeenCalledWith(
expect.stringContaining(messages.init.eqlNotInstalled),
)
})

it('completes (no throw) when EQL was not installed but the integration is prisma-next', async () => {
// Prisma Next installs EQL via `migration apply`, so eqlInstalled=false is
// expected there and must NOT be treated as an incomplete setup.
eqlRun.mockImplementationOnce(async (s: InitState) => ({
...s,
integration: 'prisma-next',
eqlInstalled: false,
}))

await expect(initCommand({}, {})).resolves.toBeUndefined()
expect(vi.mocked(p.note)).toHaveBeenCalledWith(
expect.any(String),
'Setup complete',
)
})

it('reports a generated Drizzle migration honestly — not installed, not incomplete', async () => {
// Differential review (PR #687): the Drizzle flow GENERATES an EQL
// migration; `installEqlStep` returns eqlMigrationPending (not
// eqlInstalled). The summary must neither claim "✓ EQL extension
// installed" nor hard-fail as "Setup incomplete" — it should say a
// migration was generated and point at `drizzle-kit migrate`, then exit 0.
eqlRun.mockImplementationOnce(async (s: InitState) => ({
...s,
integration: 'drizzle',
eqlInstalled: false,
eqlMigrationPending: true,
}))

await expect(initCommand({}, {})).resolves.toBeUndefined()

const summary = vi
.mocked(p.note)
.mock.calls.find(([, title]) => title === 'Setup complete')
expect(summary).toBeDefined()
const body = summary?.[0] as string
expect(body).toContain('EQL migration generated')
expect(body).toContain('drizzle-kit migrate')
expect(body).not.toContain('✓ EQL extension installed')
})

it('summary says "kept (existing file)" when an existing client is kept', async () => {
// The three-way encryption-client checkmark fork was untested — the keep
// path (`build-schema` sets clientFilePath + schemaGenerated: false) now
// produces a different string with nothing locking it. This fails against
// the pre-change code, which always claimed "scaffolded".
eqlRun.mockImplementationOnce(async (s: InitState) => ({
...s,
eqlInstalled: true,
clientFilePath: './src/encryption/index.ts',
schemaGenerated: false,
}))

await initCommand({}, {})
expect(vi.mocked(p.note)).toHaveBeenCalledWith(
expect.stringContaining('✓ Encryption client kept (existing file)'),
'Setup complete',
)
})

it('summary says "scaffolded" when a placeholder was written', async () => {
eqlRun.mockImplementationOnce(async (s: InitState) => ({
...s,
eqlInstalled: true,
clientFilePath: './src/encryption/index.ts',
schemaGenerated: true,
}))

await initCommand({}, {})
expect(vi.mocked(p.note)).toHaveBeenCalledWith(
expect.stringContaining('✓ Encryption client scaffolded'),
'Setup complete',
)
})
})
Loading
Loading