Skip to content

Commit 552bc58

Browse files
committed
fix(cli): drop "Path 1/2/3" jargon from agent setup prompt
The orient-and-route prompt the agent reads after `stash init` referred to the two supported flows as "Path 1" and "Path 3" (with "Path 2" as the not-supported in-place case). The agent then surfaced that numbering verbatim to end users, who have no context for it — the gaps in the numbering came from an internal conversation about the scenario taxonomy, not anything the user should care about. Replace the labels with the two intended actions ("Add a new encrypted column" and "Migrate an existing column to encrypted"), and reframe the not-supported case as a brief "Converting in place is not supported" callout rather than a third numbered path. The migrate flow now also opens with a one-line note on why it's staged (parallel twin + dual-write + rename) so the user has the model before reading the steps. Tests updated to assert the new headings and the staged-twin mention.
1 parent 7dcff02 commit 552bc58

2 files changed

Lines changed: 42 additions & 36 deletions

File tree

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

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -30,16 +30,20 @@ describe('renderSetupPrompt — orient + route', () => {
3030
expect(out).toMatch(/orientation message/)
3131
})
3232

33-
it('describes both supported paths and explicitly forbids path 2', () => {
33+
it('describes both supported flows and explicitly forbids in-place conversion', () => {
3434
const out = renderSetupPrompt(baseCtx)
35-
expect(out).toContain('Path 1 — Add a new encrypted column from scratch')
36-
expect(out).toContain(
37-
'Path 3 — Migrate an existing populated column to encrypted',
38-
)
39-
expect(out).toContain('Path 2 — Convert a column in place (NOT SUPPORTED)')
35+
expect(out).toContain('### Add a new encrypted column')
36+
expect(out).toContain('### Migrate an existing column to encrypted')
37+
expect(out).toContain('### Converting in place is not supported')
4038
})
4139

42-
it('names the lifecycle CLI commands inline in path 3', () => {
40+
it('mentions the staged twin model in the migrate-existing flow', () => {
41+
const out = renderSetupPrompt(baseCtx)
42+
expect(out).toMatch(/<col>_encrypted/)
43+
expect(out).toMatch(/dual-?writ/i)
44+
})
45+
46+
it('names the lifecycle CLI commands inline in the migrate-existing flow', () => {
4347
const out = renderSetupPrompt(baseCtx)
4448
expect(out).toContain('pnpm dlx stash encrypt backfill')
4549
expect(out).toContain('pnpm dlx stash encrypt cutover')
@@ -48,7 +52,7 @@ describe('renderSetupPrompt — orient + route', () => {
4852
expect(out).toContain('--force')
4953
})
5054

51-
it('emits drizzle-kit commands in path 1 for drizzle integration', () => {
55+
it('emits drizzle-kit commands in the add-new-column flow for drizzle integration', () => {
5256
const out = renderSetupPrompt(baseCtx)
5357
expect(out).toContain('pnpm exec drizzle-kit generate')
5458
expect(out).toContain('pnpm exec drizzle-kit migrate')
@@ -63,7 +67,7 @@ describe('renderSetupPrompt — orient + route', () => {
6367
expect(out).toContain('supabase migration new')
6468
})
6569

66-
it('uses the right runner per package manager in path 1', () => {
70+
it('uses the right runner per package manager in the add-new-column flow', () => {
6771
const npm = renderSetupPrompt({ ...baseCtx, packageManager: 'npm' })
6872
const bun = renderSetupPrompt({ ...baseCtx, packageManager: 'bun' })
6973
const yarn = renderSetupPrompt({ ...baseCtx, packageManager: 'yarn' })
@@ -115,22 +119,22 @@ describe('renderSetupPrompt — orient + route', () => {
115119
installedSkills: [],
116120
})
117121
expect(out).not.toMatch(/the {2,}skill/)
118-
// Still describes both paths so the agent can route.
119-
expect(out).toContain('Path 1')
120-
expect(out).toContain('Path 3')
122+
// Still describes both flows so the agent can route.
123+
expect(out).toContain('### Add a new encrypted column')
124+
expect(out).toContain('### Migrate an existing column to encrypted')
121125
})
122126

123127
it('preserves stop-and-ask invariants', () => {
124128
const out = renderSetupPrompt(baseCtx)
125129
expect(out).toContain('## Stop and ask the user when')
126-
expect(out).toMatch(/path 2/i)
130+
expect(out).toMatch(/convert a populated column in place/i)
127131
})
128132

129133
it('flags the bundler exclusion for projects using @cipherstash/stack', () => {
130134
// Skipping serverExternalPackages / webpack externals is the most
131135
// common Next.js footgun — the agent missed it on the spike project.
132-
// The prompt should call this out explicitly in the path-1 walkthrough
133-
// so it's visible without having to read the skill.
136+
// The prompt should call this out explicitly in the add-new-column
137+
// walkthrough so it's visible without having to read the skill.
134138
const out = renderSetupPrompt(baseCtx)
135139
expect(out).toContain('serverExternalPackages')
136140
expect(out).toContain('@cipherstash/protect-ffi')

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

Lines changed: 23 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ function rulesLocation(handoff: HandoffChoice): string {
105105
*/
106106
const SKILL_PURPOSES: Record<string, string> = {
107107
'stash-encryption':
108-
'the encryption API, schema definition, and the column-migration lifecycle (the source of truth for path 3)',
108+
'the encryption API, schema definition, and the column-migration lifecycle (the source of truth for migrating an existing column)',
109109
'stash-drizzle':
110110
'Drizzle-specific patterns: declaring encrypted columns, query operators, the migrating-an-existing-column worked example',
111111
'stash-supabase':
@@ -141,13 +141,13 @@ function renderSkillIndex(installedSkills: string[]): string {
141141
*
142142
* 1. Confirms what setup is complete.
143143
* 2. Names the skills loaded and what each is for.
144-
* 3. Explains the two real paths for encrypting a column (path 1 = new
145-
* column from scratch, path 3 = migrate an existing populated column
146-
* via the lifecycle). Path 2 (in-place conversion) is explicitly not
147-
* supported.
144+
* 3. Explains the two real options for encrypting a column (add a new
145+
* encrypted column from scratch, or migrate an existing populated
146+
* column via the staged-twin lifecycle). In-place conversion is not
147+
* supported and called out as such.
148148
* 4. Tells the agent its FIRST response should be a routing question, not
149149
* an action.
150-
* 5. Lists the "stop and ask" rules that override path mechanics.
150+
* 5. Lists the "stop and ask" rules that override flow mechanics.
151151
*/
152152
export function renderSetupPrompt(ctx: SetupPromptContext): string {
153153
const cli = runnerCommand(ctx.packageManager, 'stash')
@@ -179,7 +179,7 @@ export function renderSetupPrompt(ctx: SetupPromptContext): string {
179179
'',
180180
`Integration: \`${ctx.integration}\` · Package manager: \`${ctx.packageManager}\``,
181181
'',
182-
'`stash init` has finished its mechanical setup. Your job is **not** to start editing schema or running migrations immediately. Your job is to **orient the user with the two real paths for encrypting a column, then ask which one they want before touching anything**. Pick concrete table/column names from `.cipherstash/context.json` when describing the paths so the user can recognise their own data.',
182+
'`stash init` has finished its mechanical setup. Your job is **not** to start editing schema or running migrations immediately. Your job is to **orient the user with the two real options for encrypting a column, then ask which one they want before touching anything**. Pick concrete table/column names from `.cipherstash/context.json` when describing the options so the user can recognise their own data.',
183183
'',
184184
'## What `stash init` already did',
185185
'',
@@ -191,53 +191,55 @@ export function renderSetupPrompt(ctx: SetupPromptContext): string {
191191
'',
192192
renderSkillIndex(ctx.installedSkills),
193193
'',
194-
'Read the skills before answering API or pattern questions. The doctrine in `AGENTS.md` (or its inlined equivalent) covers the invariants that apply to *any* path — never log plaintext, never `.notNull()` on creation, etc.',
194+
'Read the skills before answering API or pattern questions. The doctrine in `AGENTS.md` (or its inlined equivalent) covers the invariants that apply regardless of which flow you take — never log plaintext, never `.notNull()` on creation, etc.',
195195
'',
196-
'## The two paths',
196+
'## The two options',
197197
'',
198198
"There are exactly two supported ways to encrypt a column. Recognise which one applies to the user's request before doing anything.",
199199
'',
200-
'### Path 1 — Add a new encrypted column from scratch',
200+
'### Add a new encrypted column',
201201
'',
202-
'Use when the user wants a column that **does not yet exist** in the database (no plaintext predecessor). This is normal Drizzle / Supabase work plus the encryption client patterns from the integration skill.',
202+
'Use when the column **does not yet exist** in the database (no plaintext predecessor to preserve). This is normal Drizzle / Supabase work plus the encryption client patterns from the integration skill.',
203203
'',
204204
"1. **If this is the first encrypted column in the project, configure the bundler exclusion first.** `@cipherstash/stack` cannot be bundled (it wraps a native FFI module). Next.js: add `serverExternalPackages: ['@cipherstash/stack', '@cipherstash/protect-ffi']` to `next.config.*`. Webpack: `externals`. esbuild: `external`. Vite SSR: `ssr.external`. Without this, the encryption client crashes at runtime with `Cannot find module '@cipherstash/protect-ffi-*'`. See the `stash-encryption` skill's Installation section for the full snippets.",
205205
"2. Edit the user's real schema file (`src/db/schema.ts` or wherever they keep it) to declare the new encrypted column. Use the patterns in the integration skill — `encryptedType` for Drizzle, `encryptedColumn` for Supabase. Encrypted columns must be **nullable `jsonb`** at creation time. Never `.notNull()`.",
206206
`3. Generate the schema migration${migration ? ` — \`${migration.generate}\` (${migration.tool})` : " using the project's existing migration tooling"}.`,
207207
`4. Show the user the generated SQL before applying${migration ? ` — \`${migration.apply}\`` : ''}.`,
208208
`5. Register the encryption config — \`${cli} db push\`. If the project has no active EQL config yet (first encrypted column ever), this writes directly to active and you can skip step 6. If an active config already exists, push writes \`pending\` and prints a next-step note.`,
209-
`6. **If db push wrote pending**, promote it to active — \`${cli} db activate\`. (Use \`${cli} db activate\` for path 1 because no rename is needed; \`${cli} encrypt cutover\` is for path 3 where columns are being renamed.)`,
209+
`6. **If db push wrote pending**, promote it to active — \`${cli} db activate\`. (Use \`${cli} db activate\` here because no rename is needed; \`${cli} encrypt cutover\` is reserved for the migrate-existing-column flow.)`,
210210
'7. Wire the column through the application code: insert paths encrypt before write, select paths decrypt after read, query paths use the right operator (`protectOps.eq`, etc. — see the integration skill).',
211211
'8. Verify with a round-trip: insert a record, select it back, confirm the value decrypts and the search ops work.',
212212
'',
213-
'### Path 3 — Migrate an existing populated column to encrypted',
213+
'### Migrate an existing column to encrypted',
214214
'',
215-
"Use when the column **already exists** in the user's database and contains live data that must be preserved. Drives the `stash encrypt` lifecycle — see the `stash-encryption` skill for the full model.",
215+
"Use when the column **already exists** in the user's database and contains live data that must be preserved.",
216216
'',
217-
"1. **Schema-add.** Add an `<col>_encrypted` twin column to the user's real schema file. Generate and apply the schema migration. The encrypted twin must be nullable `jsonb`. **If this is the first encrypted column in the project, configure the bundler exclusion now** — see path 1 step 1 for the snippets. Without it, importing the encryption client at backfill time will crash.",
217+
"Why it's staged: there is no atomic way to replace a populated column with an encrypted one without corrupting data. Instead the lifecycle adds a parallel `<col>_encrypted` twin, dual-writes from the app while existing rows are backfilled, then renames the twin into the original column name and drops the old plaintext. The `stash encrypt` CLI commands drive each step; the `stash-encryption` skill has the full model.",
218+
'',
219+
"1. **Schema-add.** Add an `<col>_encrypted` twin column (nullable `jsonb`) alongside the existing plaintext column in the user's real schema file. Generate and apply the schema migration. **If this is the first encrypted column in the project, configure the bundler exclusion now** — see the snippets in the previous section. Without it, importing the encryption client at backfill time will crash.",
218220
`2. **Register pending config** — \`${cli} db push\`. With an existing active config, this writes the new column-set as \`pending\`. Cutover (step 5) will promote it. (If this is the very first push for the project, db push writes active directly — fine, the rest of the flow still works.)`,
219221
'3. **Dual-write.** Edit the application code so every insert/update writes to *both* `<col>` (plaintext, unchanged) and `<col>_encrypted` (ciphertext via the encryption client). Reads still come from the plaintext column. Ship that code change.',
220222
`4. **Backfill.** Run \`${cli} encrypt backfill --table <T> --column <c>\`. The CLI prompts the user (or accepts \`--confirm-dual-writes-deployed\` non-interactively) to confirm dual-writes are live, then chunks through the existing rows. Resumable; checkpoints to \`cs_migrations\` after every chunk. SIGINT-safe.`,
221223
`5. **Switch the schema and re-push, then cutover.** Update the schema file to declare the encrypted column under its final name (drop \`_encrypted\` suffix, switch \`<col>\` to \`encryptedType\`). Run \`${cli} db push\` again — pending now reflects the renamed shape. Then \`${cli} encrypt cutover --table <T> --column <c>\` runs the rename in one transaction (\`<col>\` → \`<col>_plaintext\`, \`<col>_encrypted\` → \`<col>\`) and promotes pending → active. Application reads of \`<col>\` now return decrypted ciphertext transparently — no read-path code change.`,
222224
'6. **Remove the dual-write code.** The plaintext column is now `<col>_plaintext` and is no longer authoritative. Delete the dual-write logic from the persistence layer.',
223-
`7. **Drop.** Run \`${cli} encrypt drop --table <T> --column <c>\`. Generates a migration that removes the now-unused \`<col>_plaintext\`. Apply with the project\'s normal migration tooling.`,
225+
`7. **Drop.** Run \`${cli} encrypt drop --table <T> --column <c>\`. Generates a migration that removes the now-unused \`<col>_plaintext\`. Apply with the project's normal migration tooling.`,
224226
'',
225227
'Recovery: if the user reports that backfill ran *before* the dual-write code was actually live, drift is expected (rows written during the backfill window land in plaintext only). Re-run with `--force` to encrypt every plaintext row regardless of current state.',
226228
'',
227-
'### Path 2 — Convert a column in place (NOT SUPPORTED)',
229+
'### Converting in place is not supported',
228230
'',
229-
'There is no supported way to drop the plaintext column and replace it with an encrypted column atomically while preserving data. Any "just swap the type" path corrupts data or loses constraints. If the user asks for this, explain why it doesn\'t work and route them to path 3 instead. The only legitimate way to clobber a column with no data is path 1 — and only when there is genuinely no data to preserve.',
231+
'There is no supported way to drop a populated plaintext column and replace it with an encrypted column atomically — any "just swap the type" approach corrupts data or loses constraints. If the user asks for that, explain why it doesn\'t work and route them to the migrate-existing-column flow above. The only situation where you can clobber a column without staging is when there is genuinely no data to preserve, which is just the add-new-column flow.',
230232
'',
231233
'## Your first response',
232234
'',
233-
'Before any edits, send the user a short orientation message. Confirm setup is complete, list the skills loaded with one-line purposes, summarise the two paths in your own words, and end with a clear question — *"Which would you like to do? You can name a specific table+column or describe what you\'re trying to protect."* Reference concrete tables/columns from `.cipherstash/context.json` when it helps.',
235+
'Before any edits, send the user a short orientation message. Confirm setup is complete, list the skills loaded with one-line purposes, summarise the two options in your own words, and end with a clear question — *"Which would you like to do? You can name a specific table+column or describe what you\'re trying to protect."* Reference concrete tables/columns from `.cipherstash/context.json` when it helps.',
234236
'',
235-
'Once the user answers, execute the relevant path. Show diffs / generated SQL before applying. Pause for review at every database-mutating step.',
237+
'Once the user answers, execute the relevant flow. Show diffs / generated SQL before applying. Pause for review at every database-mutating step.',
236238
'',
237239
'## Stop and ask the user when',
238240
'',
239241
bullet(
240-
"The user asks for path 2 (convert in place). Explain why it doesn't work, suggest path 3.",
242+
"The user asks to convert a populated column in place. Explain why it doesn't work and offer the migrate-existing-column flow instead.",
241243
),
242244
bullet(
243245
"A column the user names is already encrypted (`eql_v2_encrypted` udt) but with a different EQL config than they've described. This is the post-cutover re-encryption case (`stash encrypt update`, not yet shipped) — surface it instead of guessing.",

0 commit comments

Comments
 (0)