Skip to content
Open
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
10 changes: 10 additions & 0 deletions .changeset/proxy-only-db-push.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"stash": minor
"@cipherstash/wizard": minor
---

`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.

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.

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).
2 changes: 2 additions & 0 deletions packages/cli/src/bin/stash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,8 @@ Options:
Init Flags:
--supabase Use Supabase-specific setup flow
--drizzle Use Drizzle-specific setup flow
--proxy Query encrypted data via CipherStash Proxy
--no-proxy Query encrypted data directly via the SDK (default)

Plan Flags:
--complete-rollout Plan the entire encryption lifecycle (schema-add through drop)
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/commands/impl/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ function buildStateFromContext(
eqlInstalled: true,
agents,
mode: 'implement',
usesProxy: ctx.usesProxy ?? false,
}
}

Expand Down
9 changes: 9 additions & 0 deletions packages/cli/src/commands/init/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { gatherContextStep } from './steps/gather-context.js'
import { installDepsStep } from './steps/install-deps.js'
import { installEqlStep } from './steps/install-eql.js'
import { resolveDatabaseStep } from './steps/resolve-database.js'
import { resolveProxyChoiceStep } from './steps/resolve-proxy-choice.js'
import type { InitProvider, InitState } from './types.js'
import { CancelledError } from './types.js'
import { detectPackageManager, runnerCommand } from './utils.js'
Expand All @@ -32,6 +33,7 @@ const PROVIDER_MAP: Record<string, () => InitProvider> = {
const STEPS = [
authenticateStep,
resolveDatabaseStep,
resolveProxyChoiceStep,
buildSchemaStep,
installDepsStep,
installEqlStep,
Expand Down Expand Up @@ -69,6 +71,13 @@ export async function initCommand(flags: Record<string, boolean>) {

let state: InitState = {}

// Parse --proxy and --no-proxy flags; --proxy wins if both are set
if (flags.proxy) {
state.usesProxy = true
} else if (flags['no-proxy']) {
state.usesProxy = false
}

try {
for (const step of STEPS) {
state = await step.run(state, provider)
Expand Down
175 changes: 175 additions & 0 deletions packages/cli/src/commands/init/lib/__tests__/setup-prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const baseCtx: SetupPromptContext = {
handoff: 'claude-code',
mode: 'implement',
installedSkills: ['stash-encryption', 'stash-drizzle', 'stash-cli'],
usesProxy: false,
}

describe('renderSetupPrompt — orient + route (implement mode)', () => {
Expand Down Expand Up @@ -380,3 +381,177 @@ describe('renderSetupPrompt — plan mode default when planStep is unset', () =>
)
})
})

describe('renderSetupPrompt — usesProxy conditional', () => {
describe('implement mode with usesProxy: false (SDK-only)', () => {
it('drops db push step from add-new-column flow', () => {
const out = renderSetupPrompt({ ...baseCtx, usesProxy: false })
expect(out).not.toMatch(/5\.\s*Register the encryption config/)
// Step 5 should now be the wire-the-column step, not db push
expect(out).toMatch(/5\.\s*Wire the column through/)
})

it('drops register-pending-config step from rollout path', () => {
const out = renderSetupPrompt({ ...baseCtx, usesProxy: false })
// Should have only "Schema-add" as step 1, then "Dual-write" as step 2
// (no "Register pending config" in between)
expect(out).toMatch(/1\.\s*\*\*Schema-add/)
expect(out).toMatch(/2\.\s*\*\*Dual-write/)
// Register pending config should not appear in the rollout section
const rolloutSection = out.substring(
out.indexOf('#### Encryption rollout'),
out.indexOf('⛔'),
)
expect(rolloutSection).not.toMatch(/Register pending config/)
})

it('removes db push from cutover step but keeps encrypt cutover invocation', () => {
const out = renderSetupPrompt({ ...baseCtx, usesProxy: false })
const cutoverSection = out.substring(
out.indexOf('#### Encryption cutover'),
)
// Should mention encrypt cutover
expect(cutoverSection).toMatch(/encrypt cutover/)
// Should include the Proxy-conditional aside
expect(cutoverSection).toMatch(/If you use CipherStash Proxy/)
})
})

describe('implement mode with usesProxy: true (Proxy)', () => {
it('includes db push step in add-new-column flow', () => {
const out = renderSetupPrompt({ ...baseCtx, usesProxy: true })
expect(out).toMatch(/5\.\s*Register the encryption config.*db push/)
expect(out).toMatch(/6\.\s*\*\*If db push wrote pending\*\*.*db activate/)
})

it('includes register-pending-config step in rollout path', () => {
const out = renderSetupPrompt({ ...baseCtx, usesProxy: true })
expect(out).toMatch(/2\.\s*\*\*Register pending config.*db push/)
expect(out).toMatch(/3\.\s*\*\*Dual-write/)
})

it('includes full db push in cutover step', () => {
const out = renderSetupPrompt({ ...baseCtx, usesProxy: true })
const cutoverSection = out.substring(
out.indexOf('#### Encryption cutover'),
)
expect(cutoverSection).toMatch(/5\.\s*\*\*Switch the schema and re-push/)
expect(cutoverSection).toMatch(/Run.*db push.*again/)
})
})

describe('plan mode (rollout) with usesProxy conditional', () => {
it('mentions db push in rollout plan summary when usesProxy: true', () => {
const out = renderSetupPrompt({
...baseCtx,
mode: 'plan',
planStep: 'rollout',
usesProxy: true,
})
expect(out).toMatch(
/Encryption rollout.*dual-write code, and.*db push.*writes pending/,
)
})

it('notes db push as Proxy-only in rollout plan summary when usesProxy: false', () => {
const out = renderSetupPrompt({
...baseCtx,
mode: 'plan',
planStep: 'rollout',
usesProxy: false,
})
expect(out).toMatch(
/Encryption rollout.*dual-write code.*plus.*db push.*Proxy users only/,
)
})

it('includes db push in rollout PR contents when usesProxy: true', () => {
const out = renderSetupPrompt({
...baseCtx,
mode: 'plan',
planStep: 'rollout',
usesProxy: true,
})
expect(out).toMatch(/schema-add.*db push.*pending.*dual-write code/)
})

it('drops db push from rollout PR contents when usesProxy: false', () => {
const out = renderSetupPrompt({
...baseCtx,
mode: 'plan',
planStep: 'rollout',
usesProxy: false,
})
expect(out).toMatch(/schema-add.*dual-write code/)
// Should not mention "db push (pending)" in the rollout PR contents
const prSection = out.substring(
out.indexOf('migrate columns: what the rollout PR contains'),
)
expect(prSection).not.toMatch(/db push.*pending/)
})
})

describe('plan mode (cutover) with usesProxy conditional', () => {
it('includes db push in schema-rename when usesProxy: true', () => {
const out = renderSetupPrompt({
...baseCtx,
mode: 'plan',
planStep: 'cutover',
usesProxy: true,
})
expect(out).toMatch(/Schema rename and re-push/)
expect(out).toMatch(/db push.*registers the renamed/)
})

it('separates schema rename from db push when usesProxy: false', () => {
const out = renderSetupPrompt({
...baseCtx,
mode: 'plan',
planStep: 'cutover',
usesProxy: false,
})
expect(out).toMatch(/\*\*Schema rename\.\*\*.*original column/)
expect(out).toMatch(/Proxy users only.*db push/)
})

it('notes db push as Proxy-only in prose when usesProxy: false', () => {
const out = renderSetupPrompt({
...baseCtx,
mode: 'plan',
planStep: 'cutover',
usesProxy: false,
})
expect(out).toMatch(/schema-edit step.*exact rename pattern/)
expect(out).not.toMatch(/schema-edit.*db push.*step/i)
})
})

describe('plan mode (complete) with usesProxy conditional', () => {
it('includes db push steps in full lifecycle when usesProxy: true', () => {
const out = renderSetupPrompt({
...baseCtx,
mode: 'plan',
planStep: 'complete',
usesProxy: true,
})
expect(out).toMatch(/db push.*backfill.*schema rename.*db push.*cutover/)
})

it('drops both db push mentions from full lifecycle when usesProxy: false', () => {
const out = renderSetupPrompt({
...baseCtx,
mode: 'plan',
planStep: 'complete',
usesProxy: false,
})
expect(out).toMatch(/schema-add.*dual-write code.*backfill.*schema rename/)
// The lifecycle line should not have "db push" twice
const migrateSection = out.substring(
out.indexOf('**Migrate existing columns**'),
)
const firstLine = migrateSection.split('\n')[0]
const dbPushCount = (firstLine.match(/db push/g) || []).length
expect(dbPushCount).toBe(0)
})
})
})
7 changes: 6 additions & 1 deletion packages/cli/src/commands/init/lib/read-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,12 @@ export function readContextFile(cwd: string): ContextFile | undefined {
if (!existsSync(path)) return undefined
try {
const parsed = JSON.parse(readFileSync(path, 'utf-8')) as unknown
return isContextFile(parsed) ? parsed : undefined
if (!isContextFile(parsed)) return undefined
// Ensure usesProxy defaults to false for older files that don't have it
return {
...parsed,
usesProxy: parsed.usesProxy ?? false,
}
} catch {
return undefined
}
Expand Down
Loading
Loading