Skip to content

Commit b609c7d

Browse files
committed
fix(cli): make stash init's scaffolded client compile, and gate it
Both placeholder templates emitted `await Encryption({ schemas: [] })`. An empty schema set is a hard TS2769 against both overloads — deliberately, per S-6 — so every `stash init` left a project failing its first tsc, in the one file the CLI tells the user not to hand-edit. The old scaffold called EncryptionV3, whose `readonly AnyV3Table[]` bound accepted `[]`; collapsing it into an alias of Encryption tightened that away. Relaxing the constraint is not an option (it exists to catch a real mistake), and `stash init` has no table names in scope by design — it stopped introspecting, and `build-schema.ts` sets `schemas: []` on its own state. So the scaffold declares a sentinel table instead, which keeps the file compiling and keeps the "you haven't declared anything yet" signal: loadEncryptConfig exits 1 when `__stash_placeholder__` is the only table left, naming the file. The gap that let this ship is the more important half. packages/cli has no typecheck step (21 pre-existing errors), utils-codegen*.test.ts only `toContain`-matches fragments, and build-schema.test.ts mocks generatePlaceholderClient to '// placeholder' — so nothing anywhere compiled, parsed or executed the generated output. Both templates are now committed as `.generated.ts` fixtures compiled by a scoped tsconfig in CI, and pinned byte-for-byte to the generator by a unit test. Verified the gate reproduces the original TS2769 when the fixture is reverted. The `.generated.ts` suffix is load-bearing: biome.json already excludes it, so formatting cannot rewrite template output and break the byte comparison.
1 parent ecaeaca commit b609c7d

10 files changed

Lines changed: 309 additions & 13 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'stash': patch
3+
---
4+
5+
The client file `stash init` writes now compiles.
6+
7+
Both placeholder templates emitted `await Encryption({ schemas: [] })`, and
8+
`Encryption` requires at least one table — an empty schema set is a deliberate
9+
compile error, so it cannot be relaxed. Every `stash init` therefore left a
10+
project whose first `tsc` or `next build` failed, in a file the CLI had just
11+
told the user not to hand-edit. (The previous scaffold called `EncryptionV3`,
12+
whose looser bound accepted `[]`; collapsing that into an alias of `Encryption`
13+
tightened it.)
14+
15+
The scaffold now declares a single sentinel table, `__stash_placeholder__`, so
16+
the file typechecks as written. `stash encrypt` commands refuse to run while
17+
that table is still the only one declared, and say so — rather than failing
18+
later with a confusing "table not found".
19+
20+
Nothing in the repo compiled this output before: `packages/cli` has no
21+
typecheck step, the codegen tests only string-match fragments of the template,
22+
and the step test stubs the generator out entirely. Both templates are now
23+
committed as fixtures that CI typechecks, pinned byte-for-byte to the generator
24+
so they cannot drift.

‎.github/workflows/tests.yml‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,15 @@ jobs:
193193
- name: Typecheck (stack — emitted declarations, not source)
194194
run: pnpm exec turbo run test:types:dist --filter @cipherstash/stack
195195

196+
# `stash init` writes a client file into the user's project and tells them
197+
# not to hand-edit it. Nothing compiled that file, so tightening
198+
# `Encryption` to require a non-empty schema set left every `stash init`
199+
# emitting a project that fails its first `tsc` — with CI green (#772
200+
# review). The fixtures are pinned byte-for-byte to the generator by
201+
# `placeholder-client-fixture.test.ts`.
202+
- name: Typecheck (stash init's scaffolded client)
203+
run: pnpm --filter stash run typecheck:scaffold
204+
196205
- name: Lint — no hardcoded package-manager runners
197206
run: pnpm run lint:runners
198207

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/**
2+
* CipherStash encryption client — placeholder.
3+
*
4+
* `stash init` wrote this file. It is intentionally NOT a real Drizzle
5+
* schema. Your existing schema files (typically under `src/db/`) remain
6+
* authoritative — your agent will edit those directly when you encrypt a
7+
* column, then update the `Encryption({ schemas: [...] })` call below
8+
* to reference the encrypted tables you declared there.
9+
*
10+
* Until that happens, the encryption client is initialised with a single
11+
* placeholder table so that this file compiles, and `stash encrypt`
12+
* commands refuse to run and point back here.
13+
*
14+
* This project uses EQL v3. Encrypted columns are concrete Postgres domains
15+
* built with the `types.*` factories from `@cipherstash/stack-drizzle`.
16+
* Each domain's query capabilities are FIXED by the type you pick — there is
17+
* no capability config object. Choose the factory whose capabilities you need:
18+
* types.Text / types.Integer / … storage only (encrypt/decrypt, no queries)
19+
* types.TextEq / types.IntegerEq equality (eq, inArray)
20+
* types.IntegerOrd / types.DateOrd equality + order/range (gt/lt/between/sort)
21+
* types.TextMatch free-text match only
22+
* types.TextSearch equality + order/range + free-text
23+
* types.Json encrypted-JSONB containment + selectors
24+
*
25+
* --- Pattern reference (copy into your real schema, do NOT use as-is) ---
26+
*
27+
* Encrypted twin column for an existing populated column (path 3 — lifecycle):
28+
*
29+
* import { pgTable, integer, text } from 'drizzle-orm/pg-core'
30+
* import { types } from '@cipherstash/stack-drizzle'
31+
*
32+
* export const users = pgTable('users', {
33+
* id: integer('id').primaryKey().generatedAlwaysAsIdentity(),
34+
* email: text('email').notNull(), // existing plaintext, unchanged for now
35+
* email_encrypted: types.TextSearch('email_encrypted'), // encrypted twin, NULLABLE — never .notNull()
36+
* })
37+
*
38+
* Net-new encrypted column (path 1 — declare encrypted from the start):
39+
*
40+
* export const orders = pgTable('orders', {
41+
* id: integer('id').primaryKey().generatedAlwaysAsIdentity(),
42+
* billing_address: types.TextEq('billing_address'),
43+
* })
44+
*
45+
* Once you have encrypted tables declared, harvest them and pass to Encryption():
46+
*
47+
* import { extractEncryptionSchema } from '@cipherstash/stack-drizzle'
48+
* import { Encryption } from '@cipherstash/stack/v3'
49+
* import { users, orders } from './db/schema'
50+
*
51+
* export const encryptionClient = await Encryption({
52+
* schemas: [extractEncryptionSchema(users), extractEncryptionSchema(orders)],
53+
* })
54+
*/
55+
import { Encryption, encryptedTable, types } from '@cipherstash/stack/v3'
56+
57+
// REPLACE THIS. It exists only so this file compiles before you have declared
58+
// any encrypted tables — `Encryption` requires at least one. Swap it for your
59+
// real tables (see the patterns above); `stash encrypt` refuses to run while
60+
// the placeholder is still here.
61+
export const placeholderTable = encryptedTable('__stash_placeholder__', {
62+
replace_me: types.Text('replace_me'),
63+
})
64+
65+
export const encryptionClient = await Encryption({ schemas: [placeholderTable] })
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/**
2+
* CipherStash encryption client — placeholder.
3+
*
4+
* `stash init` wrote this file. It is intentionally NOT a real schema
5+
* definition. Your existing schema files remain authoritative — your
6+
* agent will declare encrypted columns there and update the
7+
* `Encryption({ schemas: [...] })` call below to reference them.
8+
*
9+
* Until that happens, the encryption client is initialised with a single
10+
* placeholder table so that this file compiles, and `stash encrypt`
11+
* commands refuse to run and point back here.
12+
*
13+
* This project uses EQL v3. Encrypted columns are concrete Postgres domains
14+
* built with the `types.*` factories from `@cipherstash/stack/eql/v3`
15+
* (also re-exported from `@cipherstash/stack/v3`). Each domain's query
16+
* capabilities are FIXED by the type you pick — there is no chainable
17+
* capability tuner. Choose the factory whose capabilities you need:
18+
* types.Text / types.Integer / … storage only (encrypt/decrypt, no queries)
19+
* types.TextEq / types.IntegerEq equality
20+
* types.IntegerOrd / types.DateOrd equality + order/range
21+
* types.TextMatch free-text match only
22+
* types.TextSearch equality + order/range + free-text
23+
* types.Json encrypted-JSONB containment + selectors
24+
*
25+
* --- Pattern reference (copy into your real schema, do NOT use as-is) ---
26+
*
27+
* Encrypted twin column for an existing populated column (path 3 — lifecycle):
28+
*
29+
* import { encryptedTable, types } from '@cipherstash/stack/v3'
30+
*
31+
* export const users = encryptedTable('users', {
32+
* email_encrypted: types.TextSearch('email_encrypted'),
33+
* })
34+
*
35+
* Net-new encrypted column (path 1 — declare encrypted from the start):
36+
*
37+
* export const orders = encryptedTable('orders', {
38+
* billing_address: types.TextEq('billing_address'),
39+
* })
40+
*
41+
* Once you have encrypted tables declared, pass them to Encryption():
42+
*
43+
* import { Encryption } from '@cipherstash/stack/v3'
44+
* import { users, orders } from './db/schema'
45+
*
46+
* export const encryptionClient = await Encryption({
47+
* schemas: [users, orders],
48+
* })
49+
*/
50+
import { Encryption, encryptedTable, types } from '@cipherstash/stack/v3'
51+
52+
// REPLACE THIS. It exists only so this file compiles before you have declared
53+
// any encrypted tables — `Encryption` requires at least one. Swap it for your
54+
// real tables (see the patterns above); `stash encrypt` refuses to run while
55+
// the placeholder is still here.
56+
export const placeholderTable = encryptedTable('__stash_placeholder__', {
57+
replace_me: types.Text('replace_me'),
58+
})
59+
60+
export const encryptionClient = await Encryption({ schemas: [placeholderTable] })

‎packages/cli/package.json‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "stash",
33
"version": "1.0.0-rc.4",
4-
"description": "CipherStash CLI — the one stash command for auth, init, encryption schema, database setup, and secrets.",
4+
"description": "CipherStash CLI \u2014 the one stash command for auth, init, encryption schema, database setup, and secrets.",
55
"repository": {
66
"type": "git",
77
"url": "git+https://github.com/cipherstash/stack.git",
@@ -41,6 +41,7 @@
4141
"postbuild": "chmod +x ./dist/bin/stash.js",
4242
"dev": "tsup --watch",
4343
"test": "vitest run",
44+
"typecheck:scaffold": "tsc -p tsconfig.scaffold.json",
4445
"test:e2e": "vitest run --config vitest.integration.config.ts",
4546
"lint": "biome check ."
4647
},
@@ -56,7 +57,7 @@
5657
"posthog-node": "^5.41.0",
5758
"zod": "^3.25.76"
5859
},
59-
"//optionalDependencies": "@cipherstash/auth ships per-platform native bindings as optional peerDependencies. pnpm does not auto-install platform-matched optional peer deps, so we declare them here as optionalDependencies — pnpm then picks the binary matching the host's os/cpu (from each sub-package's own package.json) and ignores the rest. All seven names share a single catalog entry to keep them in lockstep.",
60+
"//optionalDependencies": "@cipherstash/auth ships per-platform native bindings as optional peerDependencies. pnpm does not auto-install platform-matched optional peer deps, so we declare them here as optionalDependencies \u2014 pnpm then picks the binary matching the host's os/cpu (from each sub-package's own package.json) and ignores the rest. All seven names share a single catalog entry to keep them in lockstep.",
6061
"optionalDependencies": {
6162
"@cipherstash/auth-darwin-arm64": "catalog:repo",
6263
"@cipherstash/auth-darwin-x64": "catalog:repo",
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/**
2+
* Binds `__fixtures__/scaffold/*.ts` to what `generatePlaceholderClient`
3+
* actually emits.
4+
*
5+
* The fixtures are the only thing in the repo that COMPILES the file
6+
* `stash init` writes into a user's project — `tsconfig.scaffold.json` and the
7+
* CI step that runs it exist for that. But a typechecked fixture is worthless
8+
* if the generator can drift away from it, and the existing codegen tests only
9+
* `toContain`-match fragments while `build-schema.test.ts` stubs the generator
10+
* out entirely. That combination is how `Encryption({ schemas: [] })` shipped:
11+
* every test was green and no compiler ever saw the output (#772 review,
12+
* finding 6).
13+
*
14+
* So: byte-for-byte, both directions. Change a template, regenerate the
15+
* fixture; the compiler then has an opinion about it.
16+
*/
17+
import { readFileSync } from 'node:fs'
18+
import path from 'node:path'
19+
import { fileURLToPath } from 'node:url'
20+
import { describe, expect, it } from 'vitest'
21+
import { PLACEHOLDER_TABLE_NAME } from '@/config/index.js'
22+
import { generatePlaceholderClient } from '../utils.js'
23+
24+
const FIXTURE_DIR = path.resolve(
25+
fileURLToPath(import.meta.url),
26+
'../../../../../__fixtures__/scaffold',
27+
)
28+
29+
const fixture = (name: string) =>
30+
readFileSync(path.join(FIXTURE_DIR, name), 'utf-8')
31+
32+
describe('the scaffolded client fixtures match the generator', () => {
33+
// Named `.generated.ts` so biome.json's existing exclusion leaves them alone:
34+
// they are template OUTPUT, and reformatting them would break the byte-for-byte
35+
// comparison below (which is the only thing tying the compiler to the generator).
36+
it.each([
37+
['generic.generated.ts', 'postgresql'],
38+
['drizzle.generated.ts', 'drizzle'],
39+
] as const)('%s', (file, integration) => {
40+
expect(fixture(file)).toBe(generatePlaceholderClient(integration))
41+
})
42+
43+
// `supabase` shares the generic template; pin that so a future split does not
44+
// silently leave the supabase path ungated.
45+
it('supabase reuses the generic template', () => {
46+
expect(generatePlaceholderClient('supabase')).toBe(
47+
fixture('generic.generated.ts'),
48+
)
49+
})
50+
})
51+
52+
describe('the scaffold compiles because it declares a table', () => {
53+
it.each([
54+
'generic.generated.ts',
55+
'drizzle.generated.ts',
56+
])('%s passes a non-empty schema set', (file) => {
57+
const body = fixture(file)
58+
// The empty form is a hard TS2769 against both overloads — `Encryption`
59+
// requires at least one table by design (S-6), so the scaffold cannot go
60+
// back to `schemas: []` without breaking every project it is written into.
61+
expect(body).not.toContain('Encryption({ schemas: [] })')
62+
expect(body).toContain('schemas: [placeholderTable]')
63+
})
64+
65+
it.each([
66+
'generic.generated.ts',
67+
'drizzle.generated.ts',
68+
])('%s uses the sentinel name the config loader refuses', (file) => {
69+
// `loadEncryptConfig` exits 1 when this is the only table left, so the
70+
// two must agree — otherwise the user gets a confusing "table not found"
71+
// from whichever command runs next instead of "you never replaced this".
72+
expect(fixture(file)).toContain(`'${PLACEHOLDER_TABLE_NAME}'`)
73+
})
74+
})

‎packages/cli/src/commands/init/utils.ts‎

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -384,9 +384,9 @@ const DRIZZLE_PLACEHOLDER = `/**
384384
* column, then update the \`Encryption({ schemas: [...] })\` call below
385385
* to reference the encrypted tables you declared there.
386386
*
387-
* Until that happens, the encryption client is initialised with no
388-
* schemas, and \`stash encrypt\` commands will surface a clear error
389-
* pointing at this file.
387+
* Until that happens, the encryption client is initialised with a single
388+
* placeholder table so that this file compiles, and \`stash encrypt\`
389+
* commands refuse to run and point back here.
390390
*
391391
* This project uses EQL v3. Encrypted columns are concrete Postgres domains
392392
* built with the \`types.*\` factories from \`@cipherstash/stack-drizzle\`.
@@ -429,9 +429,17 @@ const DRIZZLE_PLACEHOLDER = `/**
429429
* schemas: [extractEncryptionSchema(users), extractEncryptionSchema(orders)],
430430
* })
431431
*/
432-
import { Encryption } from '@cipherstash/stack/v3'
432+
import { Encryption, encryptedTable, types } from '@cipherstash/stack/v3'
433+
434+
// REPLACE THIS. It exists only so this file compiles before you have declared
435+
// any encrypted tables — \`Encryption\` requires at least one. Swap it for your
436+
// real tables (see the patterns above); \`stash encrypt\` refuses to run while
437+
// the placeholder is still here.
438+
export const placeholderTable = encryptedTable('__stash_placeholder__', {
439+
replace_me: types.Text('replace_me'),
440+
})
433441
434-
export const encryptionClient = await Encryption({ schemas: [] })
442+
export const encryptionClient = await Encryption({ schemas: [placeholderTable] })
435443
`
436444

437445
const GENERIC_PLACEHOLDER = `/**
@@ -442,9 +450,9 @@ const GENERIC_PLACEHOLDER = `/**
442450
* agent will declare encrypted columns there and update the
443451
* \`Encryption({ schemas: [...] })\` call below to reference them.
444452
*
445-
* Until that happens, the encryption client is initialised with no
446-
* schemas, and \`stash encrypt\` commands will surface a clear error
447-
* pointing at this file.
453+
* Until that happens, the encryption client is initialised with a single
454+
* placeholder table so that this file compiles, and \`stash encrypt\`
455+
* commands refuse to run and point back here.
448456
*
449457
* This project uses EQL v3. Encrypted columns are concrete Postgres domains
450458
* built with the \`types.*\` factories from \`@cipherstash/stack/eql/v3\`
@@ -483,7 +491,15 @@ const GENERIC_PLACEHOLDER = `/**
483491
* schemas: [users, orders],
484492
* })
485493
*/
486-
import { Encryption } from '@cipherstash/stack/v3'
494+
import { Encryption, encryptedTable, types } from '@cipherstash/stack/v3'
495+
496+
// REPLACE THIS. It exists only so this file compiles before you have declared
497+
// any encrypted tables — \`Encryption\` requires at least one. Swap it for your
498+
// real tables (see the patterns above); \`stash encrypt\` refuses to run while
499+
// the placeholder is still here.
500+
export const placeholderTable = encryptedTable('__stash_placeholder__', {
501+
replace_me: types.Text('replace_me'),
502+
})
487503
488-
export const encryptionClient = await Encryption({ schemas: [] })
504+
export const encryptionClient = await Encryption({ schemas: [placeholderTable] })
489505
`

‎packages/cli/src/config/index.ts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,5 +238,27 @@ export async function loadEncryptConfig(
238238
)
239239
process.exit(1)
240240
}
241+
242+
// `stash init` scaffolds a client holding one placeholder table, because
243+
// `Encryption` requires a non-empty schema set and the scaffold has no real
244+
// tables to name yet. Reaching here with only that table means the user never
245+
// replaced it — which used to surface as a confusing "table not found" from
246+
// whichever command ran next.
247+
const tables = Object.keys(config.tables ?? {})
248+
if (tables.length === 1 && tables[0] === PLACEHOLDER_TABLE_NAME) {
249+
console.error(
250+
`Error: ${encryptClientPath} still contains the placeholder table \`${PLACEHOLDER_TABLE_NAME}\` that \`stash init\` wrote.\n\nDeclare your encrypted columns and pass those tables to Encryption({ schemas: [...] }) in that file, then re-run this command.`,
251+
)
252+
process.exit(1)
253+
}
254+
241255
return config
242256
}
257+
258+
/**
259+
* The table name `stash init`'s scaffold uses so the file it writes compiles.
260+
*
261+
* Kept in sync with the templates in `commands/init/utils.ts` by
262+
* `__tests__/placeholder-client-fixture.test.ts`, which also typechecks them.
263+
*/
264+
export const PLACEHOLDER_TABLE_NAME = '__stash_placeholder__'
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{
2+
// Compiles the exact files `stash init` writes into a user's project.
3+
//
4+
// Nothing else in the repo did. `packages/cli` has no typecheck script (21
5+
// pre-existing errors), the codegen tests only string-match the templates,
6+
// and `build-schema.test.ts` stubs the generator out entirely — so when
7+
// `Encryption` was tightened to require a non-empty schema set, both
8+
// placeholders started emitting a file that fails `tsc` on first build, in a
9+
// file the CLI had just told the user not to hand-edit, and CI stayed green
10+
// (#772 review, finding 6).
11+
//
12+
// Deliberately scoped to the fixtures so it gates the scaffold without
13+
// waiting on the rest of the package to compile.
14+
"extends": "../../tsconfig.json",
15+
"compilerOptions": {
16+
"noEmit": true,
17+
"module": "ESNext",
18+
"moduleResolution": "bundler",
19+
"target": "ESNext",
20+
"lib": ["ESNext"],
21+
"strict": true,
22+
"skipLibCheck": true
23+
},
24+
"include": ["__fixtures__/scaffold/*.ts"]
25+
}

‎skills/stash-cli/SKILL.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,7 @@ Seven mechanical steps, no agent handoff. It prompts only when it can't pick a s
221221
1. **Authenticate** — silent when a valid token exists.
222222
2. **Resolve database** — per the resolution order above; verifies the connection.
223223
3. **Resolve proxy choice** — CipherStash Proxy or direct SDK access (the default). Stored as `usesProxy` in `context.json`. Set by `--proxy` / `--no-proxy`; non-TTY without a flag defaults to SDK.
224-
4. **Build schema** — auto-detects Drizzle (`drizzle.config.*`, `drizzle-orm`/`drizzle-kit`), Supabase (from the `DATABASE_URL` host), and Prisma Next. Writes a placeholder encryption client; prompts only if a file already exists there.
224+
4. **Build schema** — auto-detects Drizzle (`drizzle.config.*`, `drizzle-orm`/`drizzle-kit`), Supabase (from the `DATABASE_URL` host), and Prisma Next. Writes a placeholder encryption client; prompts only if a file already exists there. The placeholder declares one sentinel table, `__stash_placeholder__`, because `Encryption` requires at least one — replace it with the tables you actually encrypt. Until you do, `stash encrypt` commands exit 1 and point back at that file.
225225
5. **Install dependencies** — one combined prompt for `@cipherstash/stack` and `stash`. Skipped when both are present.
226226
6. **Install EQL** — always EQL v3. **Drizzle** projects generate a v3 install migration (the same output as `eql migration --drizzle`, including the `cs_migrations` tracking schema) so the install lands in your migration history — apply it with `drizzle-kit migrate`; requires `drizzle-kit` to be installed and configured. **Prisma Next** is skipped (it installs EQL via `prisma-next migrate`). Everything else runs `eql install` directly against the resolved database, and is skipped when EQL is already installed.
227227
7. **Gather context** — detects available coding agents and writes `.cipherstash/context.json`.

0 commit comments

Comments
 (0)