Skip to content

Commit 871e2f2

Browse files
authored
Merge pull request #448 from cipherstash/fix/make-agents-less-insistent-on-stash-db-push
fix(cli, wizard): make `stash db push` opt-in for Proxy users only
2 parents 71e8888 + f322aae commit 871e2f2

18 files changed

Lines changed: 511 additions & 68 deletions

File tree

.changeset/proxy-only-db-push.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"stash": minor
3+
"@cipherstash/wizard": minor
4+
---
5+
6+
`stash db push` is no longer included by default in `stash plan` / `stash impl` agent prompts or the wizard's post-agent step. SDK users (Drizzle, Supabase, plain PostgreSQL) no longer see `stash db push` baked into their rollout/cutover walkthroughs — the encryption config lives in app code, so the database doesn't need a copy.
7+
8+
Pass `--proxy` to `stash init` (or answer the new interactive prompt) if you query encrypted data via [CipherStash Proxy](https://github.com/cipherstash/proxy). The choice is persisted to `.cipherstash/context.json` as `usesProxy` and is honoured by `stash plan`, `stash impl`, and the wizard's post-agent step. Existing `.cipherstash/context.json` files without the field default to SDK-only.
9+
10+
Known gap: `stash encrypt cutover` currently requires a pending EQL config registered via `stash db push`, so SDK-only users running the migrate-existing-column flow will hit a "No pending EQL configuration" error from cutover. Workaround: run `stash db push` once before `stash encrypt cutover`. Decoupling cutover from EQL config for SDK-only users is tracked as a follow-up to [#447](https://github.com/cipherstash/stack/issues/447).

packages/cli/src/bin/stash.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,8 @@ Init Flags:
110110
--supabase Use Supabase-specific setup flow
111111
--drizzle Use Drizzle-specific setup flow
112112
--prisma-next Use Prisma Next-specific setup flow (EQL bundle installed via prisma-next migration apply)
113+
--proxy Query encrypted data via CipherStash Proxy
114+
--no-proxy Query encrypted data directly via the SDK (default)
113115
114116
Plan Flags:
115117
--complete-rollout Plan the entire encryption lifecycle (schema-add through drop)

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ function buildStateFromContext(
3737
eqlInstalled: true,
3838
agents,
3939
mode: 'implement',
40+
usesProxy: ctx.usesProxy ?? false,
4041
}
4142
}
4243

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { gatherContextStep } from './steps/gather-context.js'
1010
import { installDepsStep } from './steps/install-deps.js'
1111
import { installEqlStep } from './steps/install-eql.js'
1212
import { resolveDatabaseStep } from './steps/resolve-database.js'
13+
import { resolveProxyChoiceStep } from './steps/resolve-proxy-choice.js'
1314
import type { InitProvider, InitState } from './types.js'
1415
import { CancelledError } from './types.js'
1516
import { detectPackageManager, runnerCommand } from './utils.js'
@@ -34,6 +35,7 @@ const PROVIDER_MAP: Record<string, () => InitProvider> = {
3435
const STEPS = [
3536
authenticateStep,
3637
resolveDatabaseStep,
38+
resolveProxyChoiceStep,
3739
buildSchemaStep,
3840
installDepsStep,
3941
installEqlStep,
@@ -71,6 +73,13 @@ export async function initCommand(flags: Record<string, boolean>) {
7173

7274
let state: InitState = {}
7375

76+
// Parse --proxy and --no-proxy flags; --proxy wins if both are set
77+
if (flags.proxy) {
78+
state.usesProxy = true
79+
} else if (flags['no-proxy']) {
80+
state.usesProxy = false
81+
}
82+
7483
try {
7584
for (const step of STEPS) {
7685
state = await step.run(state, provider)

packages/cli/src/commands/init/lib/__tests__/setup-prompt.test.ts

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const baseCtx: SetupPromptContext = {
1212
handoff: 'claude-code',
1313
mode: 'implement',
1414
installedSkills: ['stash-encryption', 'stash-drizzle', 'stash-cli'],
15+
usesProxy: false,
1516
}
1617

1718
describe('renderSetupPrompt — orient + route (implement mode)', () => {
@@ -380,3 +381,179 @@ describe('renderSetupPrompt — plan mode default when planStep is unset', () =>
380381
)
381382
})
382383
})
384+
385+
describe('renderSetupPrompt — usesProxy conditional', () => {
386+
describe('implement mode with usesProxy: false (SDK-only)', () => {
387+
it('drops db push step from add-new-column flow', () => {
388+
const out = renderSetupPrompt({ ...baseCtx, usesProxy: false })
389+
expect(out).not.toMatch(/5\.\s*Register the encryption config/)
390+
// Step 5 should now be the wire-the-column step, not db push
391+
expect(out).toMatch(/5\.\s*Wire the column through/)
392+
})
393+
394+
it('drops register-pending-config step from rollout path', () => {
395+
const out = renderSetupPrompt({ ...baseCtx, usesProxy: false })
396+
// Should have only "Schema-add" as step 1, then "Dual-write" as step 2
397+
// (no "Register pending config" in between)
398+
expect(out).toMatch(/1\.\s*\*\*Schema-add/)
399+
expect(out).toMatch(/2\.\s*\*\*Dual-write/)
400+
// Register pending config should not appear in the rollout section
401+
const rolloutSection = out.substring(
402+
out.indexOf('#### Encryption rollout'),
403+
out.indexOf('⛔'),
404+
)
405+
expect(rolloutSection).not.toMatch(/Register pending config/)
406+
})
407+
408+
it('keeps encrypt cutover invocation and notes the pending-config workaround', () => {
409+
const out = renderSetupPrompt({ ...baseCtx, usesProxy: false })
410+
const cutoverSection = out.substring(
411+
out.indexOf('#### Encryption cutover'),
412+
)
413+
// Should mention encrypt cutover
414+
expect(cutoverSection).toMatch(/encrypt cutover/)
415+
// SDK-only setups still hit the pending-config gap, so the cutover
416+
// step must call out the `db push` workaround for that error.
417+
expect(cutoverSection).toMatch(/No pending EQL configuration/)
418+
expect(cutoverSection).toMatch(/db push/)
419+
})
420+
})
421+
422+
describe('implement mode with usesProxy: true (Proxy)', () => {
423+
it('includes db push step in add-new-column flow', () => {
424+
const out = renderSetupPrompt({ ...baseCtx, usesProxy: true })
425+
expect(out).toMatch(/5\.\s*Register the encryption config.*db push/)
426+
expect(out).toMatch(/6\.\s*\*\*If db push wrote pending\*\*.*db activate/)
427+
})
428+
429+
it('includes register-pending-config step in rollout path', () => {
430+
const out = renderSetupPrompt({ ...baseCtx, usesProxy: true })
431+
expect(out).toMatch(/2\.\s*\*\*Register pending config.*db push/)
432+
expect(out).toMatch(/3\.\s*\*\*Dual-write/)
433+
})
434+
435+
it('includes full db push in cutover step', () => {
436+
const out = renderSetupPrompt({ ...baseCtx, usesProxy: true })
437+
const cutoverSection = out.substring(
438+
out.indexOf('#### Encryption cutover'),
439+
)
440+
expect(cutoverSection).toMatch(/5\.\s*\*\*Switch the schema and re-push/)
441+
expect(cutoverSection).toMatch(/Run.*db push.*again/)
442+
})
443+
})
444+
445+
describe('plan mode (rollout) with usesProxy conditional', () => {
446+
it('mentions db push in rollout plan summary when usesProxy: true', () => {
447+
const out = renderSetupPrompt({
448+
...baseCtx,
449+
mode: 'plan',
450+
planStep: 'rollout',
451+
usesProxy: true,
452+
})
453+
expect(out).toMatch(
454+
/Encryption rollout.*dual-write code, and.*db push.*writes pending/,
455+
)
456+
})
457+
458+
it('notes db push as Proxy-only in rollout plan summary when usesProxy: false', () => {
459+
const out = renderSetupPrompt({
460+
...baseCtx,
461+
mode: 'plan',
462+
planStep: 'rollout',
463+
usesProxy: false,
464+
})
465+
expect(out).toMatch(
466+
/Encryption rollout.*dual-write code.*plus.*db push.*Proxy users only/,
467+
)
468+
})
469+
470+
it('includes db push in rollout PR contents when usesProxy: true', () => {
471+
const out = renderSetupPrompt({
472+
...baseCtx,
473+
mode: 'plan',
474+
planStep: 'rollout',
475+
usesProxy: true,
476+
})
477+
expect(out).toMatch(/schema-add.*db push.*pending.*dual-write code/)
478+
})
479+
480+
it('drops db push from rollout PR contents when usesProxy: false', () => {
481+
const out = renderSetupPrompt({
482+
...baseCtx,
483+
mode: 'plan',
484+
planStep: 'rollout',
485+
usesProxy: false,
486+
})
487+
expect(out).toMatch(/schema-add.*dual-write code/)
488+
// Should not mention "db push (pending)" in the rollout PR contents
489+
const prSection = out.substring(
490+
out.indexOf('migrate columns: what the rollout PR contains'),
491+
)
492+
expect(prSection).not.toMatch(/db push.*pending/)
493+
})
494+
})
495+
496+
describe('plan mode (cutover) with usesProxy conditional', () => {
497+
it('includes db push in schema-rename when usesProxy: true', () => {
498+
const out = renderSetupPrompt({
499+
...baseCtx,
500+
mode: 'plan',
501+
planStep: 'cutover',
502+
usesProxy: true,
503+
})
504+
expect(out).toMatch(/Schema rename and re-push/)
505+
expect(out).toMatch(/db push.*registers the renamed/)
506+
})
507+
508+
it('separates schema rename from db push when usesProxy: false', () => {
509+
const out = renderSetupPrompt({
510+
...baseCtx,
511+
mode: 'plan',
512+
planStep: 'cutover',
513+
usesProxy: false,
514+
})
515+
expect(out).toMatch(/\*\*Schema rename\.\*\*.*original column/)
516+
expect(out).toMatch(/Proxy users only.*db push/)
517+
})
518+
519+
it('notes db push as Proxy-only in prose when usesProxy: false', () => {
520+
const out = renderSetupPrompt({
521+
...baseCtx,
522+
mode: 'plan',
523+
planStep: 'cutover',
524+
usesProxy: false,
525+
})
526+
expect(out).toMatch(/schema-edit step.*exact rename pattern/)
527+
expect(out).not.toMatch(/schema-edit.*db push.*step/i)
528+
})
529+
})
530+
531+
describe('plan mode (complete) with usesProxy conditional', () => {
532+
it('includes db push steps in full lifecycle when usesProxy: true', () => {
533+
const out = renderSetupPrompt({
534+
...baseCtx,
535+
mode: 'plan',
536+
planStep: 'complete',
537+
usesProxy: true,
538+
})
539+
expect(out).toMatch(/db push.*backfill.*schema rename.*db push.*cutover/)
540+
})
541+
542+
it('drops both db push mentions from full lifecycle when usesProxy: false', () => {
543+
const out = renderSetupPrompt({
544+
...baseCtx,
545+
mode: 'plan',
546+
planStep: 'complete',
547+
usesProxy: false,
548+
})
549+
expect(out).toMatch(/schema-add.*dual-write code.*backfill.*schema rename/)
550+
// The lifecycle line should not have "db push" twice
551+
const migrateSection = out.substring(
552+
out.indexOf('**Migrate existing columns**'),
553+
)
554+
const firstLine = migrateSection.split('\n')[0]
555+
const dbPushCount = (firstLine.match(/db push/g) || []).length
556+
expect(dbPushCount).toBe(0)
557+
})
558+
})
559+
})

packages/cli/src/commands/init/lib/read-context.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,14 @@ export function readContextFile(cwd: string): ContextFile | undefined {
3737
if (!existsSync(path)) return undefined
3838
try {
3939
const parsed = JSON.parse(readFileSync(path, 'utf-8')) as unknown
40-
return isContextFile(parsed) ? parsed : undefined
40+
if (!isContextFile(parsed)) return undefined
41+
// Normalize usesProxy to a strict boolean: older files lack it and a
42+
// hand-edited file could hold any JSON value, so coerce to `true` only
43+
// when it is exactly `true` and `false` otherwise.
44+
return {
45+
...parsed,
46+
usesProxy: parsed.usesProxy === true,
47+
}
4148
} catch {
4249
return undefined
4350
}

0 commit comments

Comments
 (0)