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
11 changes: 11 additions & 0 deletions .changeset/eql-v3-bigint-domains.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@cipherstash/stack': minor
---

Add the EQL v3 bigint domain family to the public DSL: `types.Bigint`,
`types.BigintEq`, `types.BigintOrdOre`, and `types.BigintOrd`, backed by the
`public.bigint*` concrete domains. Plaintext is a JS `bigint`, round-tripped
losslessly across the protect-ffi 0.28 boundary (i64 bounds enforced at the
FFI — out-of-range values surface as encryption errors). Index emission follows
the numeric rule: `bigint_eq` → unique (hm); `bigint_ord`/`bigint_ord_ore` →
ore (equality answered via ob).
13 changes: 13 additions & 0 deletions .changeset/eql-v3-cli-install.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"stash": minor
---

Add an EQL v3 install path to `stash eql install` via a new `--eql-version <2|3>`
flag (default `2`). v3 installs the native concrete-domain schema (`public.*`
type domains, `eql_v3` operators, `eql_v3_internal` constructors) from bundles
vendored into `packages/cli/src/sql` by `scripts/build-eql-v3-sql.mjs` (full
bundle + a Supabase variant with the two superuser-only operator-class chunks
stripped). v3 currently supports the direct install path only —
`--drizzle`/`--migration`/`--migrations-dir`/`--latest` are rejected — and the
installer keys `isInstalled`/version checks and Supabase grants to the `eql_v3`
schema.
11 changes: 11 additions & 0 deletions .changeset/rename-db-install-to-eql-install.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"stash": minor
"@cipherstash/wizard": minor
---

Rename `stash db install` to `stash eql install`. The command scaffolds
`stash.config.ts` and installs the EQL extensions, so it now lives under a
dedicated `eql` command group. `stash db install` keeps working as a
deprecated alias that prints a warning pointing at the new name. All help
text, hints, generated migration headers, and wizard steps now reference
`stash eql install`.
5 changes: 0 additions & 5 deletions .changeset/skip-v3-bigint.md

This file was deleted.

16 changes: 8 additions & 8 deletions e2e/tests/package-managers.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,18 +51,18 @@ describe('CLI init providers — package-manager-aware Next Steps', () => {
{
label: 'base',
create: createBaseProvider,
firstStep: (r) => `Set up your database: ${r} stash db install`,
firstStep: (r) => `Set up your database: ${r} stash eql install`,
},
{
label: 'drizzle',
create: createDrizzleProvider,
firstStep: (r) => `Set up your database: ${r} stash db install --drizzle`,
firstStep: (r) => `Set up your database: ${r} stash eql install --drizzle`,
},
{
label: 'supabase',
create: createSupabaseProvider,
firstStep: (r) =>
`Install EQL: ${r} stash db install --supabase (prompts for migration vs direct)`,
`Install EQL: ${r} stash eql install --supabase (prompts for migration vs direct)`,
},
]

Expand Down Expand Up @@ -158,12 +158,12 @@ describe.skipIf(!authConfigured)(
{ pm: 'yarn' as const, lockfile: 'yarn.lock' },
])('uses $pm runner when $lockfile is present', ({ pm, lockfile }) => {
const out = runWizard({ lockfile })
expect(out).toContain(`Run: ${RUNNER[pm]} stash db install`)
expect(out).toContain(`Run: ${RUNNER[pm]} stash eql install`)
})

it('falls back to npx when no lockfile and no user agent', () => {
const out = runWizard({})
expect(out).toContain('Run: npx stash db install')
expect(out).toContain('Run: npx stash eql install')
})
})

Expand All @@ -174,7 +174,7 @@ describe.skipIf(!authConfigured)(
{ pm: 'yarn' as const, userAgent: 'yarn/4.0.0 npm/? node/v20.0.0' },
])('uses $pm runner when UA is $userAgent', ({ pm, userAgent }) => {
const out = runWizard({ userAgent })
expect(out).toContain(`Run: ${RUNNER[pm]} stash db install`)
expect(out).toContain(`Run: ${RUNNER[pm]} stash eql install`)
})
})

Expand All @@ -184,15 +184,15 @@ describe.skipIf(!authConfigured)(
lockfile: 'pnpm-lock.yaml',
userAgent: 'bun/1.1.40 npm/? node/v22.3.0',
})
expect(out).toContain('Run: bunx stash db install')
expect(out).toContain('Run: bunx stash eql install')
})

it('npm user agent is ignored in favour of a lockfile', () => {
const out = runWizard({
lockfile: 'bun.lock',
userAgent: 'npm/10.2.4 node/v20.0.0',
})
expect(out).toContain('Run: bunx stash db install')
expect(out).toContain('Run: bunx stash eql install')
})
})
},
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ exercise the same code paths.
tweak only needs to land in one place. Add to `messages.ts` only when a
test actually asserts on the string — premature extraction is worse
than copy-paste here. For literals tests don't touch (e.g. command
names like `init`, `db install`), keep them inline.
names like `init`, `eql install`), keep them inline.
- **Telemetry.** The CLI source no longer imports `posthog-node` (analytics
moved to `packages/wizard`). The dep is still listed in `package.json`
and should be removed in a follow-up. If you re-introduce telemetry to
Expand Down
20 changes: 10 additions & 10 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export default defineConfig({

The CLI loads `.env` files automatically before reading the config, so `process.env` references work without extra setup. The config file is resolved by walking up from the current working directory.

Commands that consume `stash.config.ts`: `db install`, `db upgrade`, `db push`, `db validate`, `db status`, `db test-connection`, `schema build`. `db install` will scaffold `stash.config.ts` for you if it's missing.
Commands that consume `stash.config.ts`: `eql install`, `db upgrade`, `db push`, `db validate`, `db status`, `db test-connection`, `schema build`. `eql install` will scaffold `stash.config.ts` for you if it's missing.

---

Expand All @@ -83,10 +83,10 @@ npx stash init [--supabase] [--drizzle]
What `init` does, in order:

1. **Authenticate** — re-uses an existing token if found, otherwise opens the browser device-code flow.
2. **Resolve `DATABASE_URL`** — flag → env → `supabase status` → interactive prompt → hard-fail. The same resolver `db install` uses.
2. **Resolve `DATABASE_URL`** — flag → env → `supabase status` → interactive prompt → hard-fail. The same resolver `eql install` uses.
3. **Generate the encryption client** — connects to your database, lists tables, and prompts you to multi-select which columns to encrypt. Writes `./src/encryption/index.ts` with the right shape for the detected ORM (Drizzle / Supabase / plain Postgres). Falls back to a placeholder if the database has no tables yet.
4. **Install dependencies** — `@cipherstash/stack` (runtime) and `stash` (dev), with a confirmation prompt.
5. **Install EQL** — runs `stash db install` against the resolved URL after a y/N confirm.
5. **Install EQL** — runs `stash eql install` against the resolved URL after a y/N confirm.
6. **Hand off** — four-option menu (Claude Code / Codex / CipherStash Agent / write `AGENTS.md`). See the Quickstart section above for what each option writes and spawns.

The full pipeline state — integration, columns, env-key names, paths, versions — is captured in `.cipherstash/context.json`. The action plan at `.cipherstash/setup-prompt.md` tells whichever agent picks up next what's already done and what's left.
Expand Down Expand Up @@ -156,14 +156,14 @@ npx stash secrets delete -n DATABASE_URL -e production -y

---

### `npx stash db install`
### `npx stash eql install`

Configure your database and install CipherStash EQL extensions in a single command. Run this after `npx stash init`.
Configure your database and install CipherStash EQL extensions in a single command. Run this after `npx stash init`. (`npx stash db install` is a deprecated alias — it still works but prints a warning.)

When `stash.config.ts` is missing, the command auto-detects your encryption client file (or asks for the path) and writes the config before installing. Supabase and Drizzle are detected from your `DATABASE_URL` and project files, so the matching flags default on. Install uses bundled SQL for offline, deterministic runs.

```bash
npx stash db install [options]
npx stash eql install [options]
```

| Flag | Description |
Expand Down Expand Up @@ -198,7 +198,7 @@ npx stash db upgrade [options]
| `--exclude-operator-family` | Skip operator family creation |
| `--latest` | Fetch the latest EQL from GitHub |

The install SQL is idempotent and safe to re-run. If EQL is not installed, the command suggests running `npx stash db install` instead.
The install SQL is idempotent and safe to re-run. If EQL is not installed, the command suggests running `npx stash eql install` instead.

---

Expand Down Expand Up @@ -300,10 +300,10 @@ Reads `databaseUrl` from `stash.config.ts`.

## Drizzle migration mode

Use `--drizzle` with `npx stash db install` to add EQL installation to your Drizzle migration history instead of applying it directly. `--drizzle` is auto-detected when your project has `drizzle-orm`, `drizzle-kit`, or a `drizzle.config.*` file, so you usually don't need to pass it explicitly.
Use `--drizzle` with `npx stash eql install` to add EQL installation to your Drizzle migration history instead of applying it directly. `--drizzle` is auto-detected when your project has `drizzle-orm`, `drizzle-kit`, or a `drizzle.config.*` file, so you usually don't need to pass it explicitly.

```bash
npx stash db install --drizzle
npx stash eql install --drizzle
npx drizzle-kit migrate
```

Expand All @@ -315,7 +315,7 @@ How it works:
With a custom name or output directory:

```bash
npx stash db install --drizzle --name setup-eql --out ./migrations
npx stash eql install --drizzle --name setup-eql --out ./migrations
npx drizzle-kit migrate
```

Expand Down
81 changes: 81 additions & 0 deletions packages/cli/scripts/build-eql-v3-sql.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
#!/usr/bin/env node
/**
* Vendor the EQL v3 SQL bundles into packages/cli/src/sql/.
*
* Source of truth: the generated monolith checked in at
* packages/stack/__tests__/fixtures/eql-v3/cipherstash-encrypt-v3.sql
* (itself generated from the upstream encrypt-query-language repo).
*
* Outputs:
* - cipherstash-encrypt-v3.sql — full bundle, byte-identical copy
* - cipherstash-encrypt-v3-supabase.sql — Supabase variant with the two
* `CREATE OPERATOR CLASS`/`FAMILY` chunks removed (they need superuser,
* which Supabase does not grant)
*
* The Supabase strip mirrors the upstream build's `**\/*operator_class.sql`
* exclusion glob: the monolith annotates every constituent file with a
* `--! @file <path>` marker, so the variant drops each
* `--! @file .../operator_class.sql` chunk up to the next `--! @file` marker.
*
* TEMPORARY vendoring strategy (sync risk): once upstream publishes v3 release
* artifacts (like the eql-2.x `cipherstash-encrypt[-supabase].sql` assets),
* regenerate these from the release instead and record the version.
*
* Usage: node packages/cli/scripts/build-eql-v3-sql.mjs
*/
import { readFileSync, writeFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'

const here = dirname(fileURLToPath(import.meta.url))
const source = resolve(
here,
'../../stack/__tests__/fixtures/eql-v3/cipherstash-encrypt-v3.sql',
)
const outDir = resolve(here, '../src/sql')

const FILE_MARKER = /^--! @file (.+)$/
const EXCLUDE = /operator_class\.sql$/

function stripOperatorClassChunks(sql) {
const lines = sql.split('\n')
const out = []
let skipping = false
let removedChunks = 0

for (const line of lines) {
const marker = line.match(FILE_MARKER)
if (marker) {
skipping = EXCLUDE.test(marker[1])
if (skipping) removedChunks++
}
if (!skipping) out.push(line)
}

if (removedChunks !== 2) {
throw new Error(
`Expected to remove exactly 2 operator_class chunks, removed ${removedChunks} — the bundle layout changed; review the strip logic.`,
)
}

const stripped = out.join('\n')
if (/CREATE OPERATOR (CLASS|FAMILY)/.test(stripped)) {
throw new Error(
'Stripped bundle still contains CREATE OPERATOR CLASS/FAMILY statements.',
)
}

return stripped
}

const sql = readFileSync(source, 'utf8')

writeFileSync(resolve(outDir, 'cipherstash-encrypt-v3.sql'), sql)
writeFileSync(
resolve(outDir, 'cipherstash-encrypt-v3-supabase.sql'),
stripOperatorClassChunks(sql),
)

console.log(
'Wrote cipherstash-encrypt-v3.sql and cipherstash-encrypt-v3-supabase.sql',
)
85 changes: 85 additions & 0 deletions packages/cli/src/__tests__/installer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,91 @@ describe('EQLInstaller', () => {
expect(SUPABASE_PERMISSIONS_SQL).toContain('service_role')
})

it('installs the v3 bundle and grants eql_v3 permissions with eqlVersion: 3 + supabase', async () => {
mockConnect.mockResolvedValue(undefined)
mockQuery.mockResolvedValue({ rows: [], rowCount: 0 })
mockEnd.mockResolvedValue(undefined)

const { EQLInstaller, SUPABASE_PERMISSIONS_SQL_V3 } = await import(
'@/installer/index.ts'
)
const installer = new EQLInstaller({
databaseUrl: 'postgresql://localhost:5432/test',
})

await installer.install({ eqlVersion: 3, supabase: true })

const otherCalls = mockQuery.mock.calls
.map((call: unknown[]) => call[0])
.filter(
(sql: unknown): sql is string =>
typeof sql === 'string' &&
sql !== 'BEGIN' &&
sql !== 'COMMIT' &&
sql !== 'ROLLBACK',
)

expect(otherCalls).toHaveLength(2)
// The bundled SQL is the v3 Supabase variant: creates eql_v3, no
// operator classes/families (they need superuser).
expect(otherCalls[0]).toContain('eql_v3')
expect(otherCalls[0]).not.toContain('CREATE OPERATOR CLASS')
expect(otherCalls[0]).not.toContain('CREATE OPERATOR FAMILY')
// The grants are keyed to eql_v3, not eql_v2.
expect(otherCalls[1]).toBe(SUPABASE_PERMISSIONS_SQL_V3)
expect(SUPABASE_PERMISSIONS_SQL_V3).toContain('eql_v3')
expect(SUPABASE_PERMISSIONS_SQL_V3).not.toContain('eql_v2')
})

it('installs the full v3 bundle (with operator classes) without supabase', async () => {
mockConnect.mockResolvedValue(undefined)
mockQuery.mockResolvedValue({ rows: [], rowCount: 0 })
mockEnd.mockResolvedValue(undefined)

const { EQLInstaller } = await import('@/installer/index.ts')
const installer = new EQLInstaller({
databaseUrl: 'postgresql://localhost:5432/test',
})

await installer.install({ eqlVersion: 3 })

const sqlCall = mockQuery.mock.calls.find(
(call: string[]) =>
typeof call[0] === 'string' &&
call[0] !== 'BEGIN' &&
call[0] !== 'COMMIT',
)
expect(sqlCall).toBeDefined()
expect(sqlCall?.[0]).toContain('eql_v3')
expect(sqlCall?.[0]).toContain('CREATE OPERATOR CLASS')
})

it('rejects latest: true for eqlVersion: 3', async () => {
const { EQLInstaller } = await import('@/installer/index.ts')
const installer = new EQLInstaller({
databaseUrl: 'postgresql://localhost:5432/test',
})

await expect(
installer.install({ eqlVersion: 3, latest: true }),
).rejects.toThrow('not supported for EQL v3')
})

it('checks the eql_v3 schema for isInstalled({ eqlVersion: 3 })', async () => {
mockConnect.mockResolvedValue(undefined)
mockQuery.mockResolvedValue({ rows: [], rowCount: 0 })
mockEnd.mockResolvedValue(undefined)

const { EQLInstaller } = await import('@/installer/index.ts')
const installer = new EQLInstaller({
databaseUrl: 'postgresql://localhost:5432/test',
})

await installer.isInstalled({ eqlVersion: 3 })

expect(mockQuery).toHaveBeenCalledWith(expect.any(String), ['eql_v3'])
})

it('rolls back on SQL execution failure', async () => {
mockConnect.mockResolvedValue(undefined)
mockEnd.mockResolvedValue(undefined)
Expand Down
Loading
Loading