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
50 changes: 12 additions & 38 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,16 @@ The single CLI for CipherStash. It handles authentication, project initializatio
```bash
npm install -D @cipherstash/cli
npx @cipherstash/cli auth login # authenticate with CipherStash
npx @cipherstash/cli init # scaffold encryption schema and stash.config.ts
npx @cipherstash/cli db setup # connect to your database and install EQL
npx @cipherstash/cli init # scaffold encryption schema and install dependencies
npx @cipherstash/cli db install # scaffold stash.config.ts (if missing) and install EQL
npx @cipherstash/cli wizard # AI agent wires encryption into your codebase
```

What each step does:

- `auth login` — opens a browser-based device code flow and saves a token to `~/.cipherstash/auth.json`.
- `init` — generates your encryption client file and installs `@cipherstash/cli` as a dev dependency. Pass `--supabase` or `--drizzle` for provider-specific setup.
- `db setup` — detects your encryption client, prompts for a database URL, writes `stash.config.ts`, and installs EQL extensions.
- `db install` — detects your encryption client, writes `stash.config.ts` if it's missing, and installs EQL extensions in a single step.
- `wizard` — reads your codebase with an AI agent (uses the CipherStash-hosted LLM gateway, no Anthropic API key required) and modifies your schema files in place.

---
Expand All @@ -30,13 +30,13 @@ What each step does:

```
npx @cipherstash/cli init
└── npx @cipherstash/cli db setup
└── npx @cipherstash/cli db install
└── npx @cipherstash/cli wizard ← fast path: AI edits your files
OR
Edit schema files by hand ← escape hatch
```

`npx @cipherstash/cli wizard` is the recommended path after `db setup`. It detects your framework (Drizzle, Supabase, Prisma, raw SQL), introspects your database, and integrates encryption directly into your existing schema definitions. If you prefer to write the schema by hand, skip the wizard and edit your encryption client file directly.
`npx @cipherstash/cli wizard` is the recommended path after `db install`. It detects your framework (Drizzle, Supabase, Prisma, raw SQL), introspects your database, and integrates encryption directly into your existing schema definitions. If you prefer to write the schema by hand, skip the wizard and edit your encryption client file directly.

---

Expand All @@ -60,7 +60,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 setup`, `db push`, `db validate`, `db status`, `db test-connection`, `schema build`.
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.

---

Expand All @@ -79,7 +79,7 @@ npx @cipherstash/cli init [--supabase] [--drizzle]
| `--supabase` | Use the Supabase-specific setup flow |
| `--drizzle` | Use the Drizzle-specific setup flow |

After `init` completes, the Next Steps output tells you to run `npx @cipherstash/cli db setup`, then either `npx @cipherstash/cli wizard` or edit the schema manually.
After `init` completes, the Next Steps output tells you to run `npx @cipherstash/cli db install`, then either `npx @cipherstash/cli wizard` or edit the schema manually.

---

Expand All @@ -105,7 +105,7 @@ npx @cipherstash/cli wizard

Prerequisites:
- Authenticated (`npx @cipherstash/cli auth login` completed).
- `stash.config.ts` present (run `npx @cipherstash/cli db setup` first).
- `stash.config.ts` present (run `npx @cipherstash/cli db install` first; it will scaffold the config if missing).

Supported integrations: Drizzle ORM, Supabase JS Client, Prisma (experimental), raw SQL / other.

Expand Down Expand Up @@ -150,37 +150,11 @@ npx @cipherstash/cli secrets delete -n DATABASE_URL -e production -y

---

### `npx @cipherstash/cli db setup`

Configure your database and install EQL extensions. Run this after `npx @cipherstash/cli init`.

```bash
npx @cipherstash/cli db setup [options]
```

The interactive wizard:
1. Auto-detects your encryption client file (or asks for the path).
2. Prompts for a database URL (pre-fills from `DATABASE_URL`).
3. Writes `stash.config.ts`.
4. Asks which PostgreSQL provider you use to pick the right install flags.
5. Installs EQL extensions.

| Flag | Description |
|------|-------------|
| `--force` | Overwrite existing `stash.config.ts` and reinstall EQL |
| `--dry-run` | Show what would happen without making changes |
| `--supabase` | Skip provider selection and use Supabase-compatible install |
| `--drizzle` | Generate a Drizzle migration instead of direct install |
| `--exclude-operator-family` | Skip operator family creation |
| `--latest` | Fetch the latest EQL from GitHub instead of the bundled version |
| `--name <value>` | Migration name (Drizzle mode, default: `install-eql`) |
| `--out <value>` | Drizzle output directory (default: `drizzle`) |

---

### `npx @cipherstash/cli db install`

Install CipherStash EQL extensions into your database. Uses bundled SQL by default for offline, deterministic installs.
Configure your database and install CipherStash EQL extensions in a single command. Run this after `npx @cipherstash/cli init`.

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 @cipherstash/cli db install [options]
Expand Down Expand Up @@ -320,7 +294,7 @@ Reads `databaseUrl` from `stash.config.ts`.

## Drizzle migration mode

Use `--drizzle` with `npx @cipherstash/cli db install` (or `npx @cipherstash/cli db setup`) to add EQL installation to your Drizzle migration history instead of applying it directly.
Use `--drizzle` with `npx @cipherstash/cli 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.

```bash
npx @cipherstash/cli db install --drizzle
Expand Down
88 changes: 88 additions & 0 deletions packages/cli/src/__tests__/detect.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { detectDrizzle, detectSupabase } from '../commands/db/detect.js'

describe('detectSupabase', () => {
it.each([
['postgres://user:pass@db.abc.supabase.co:5432/postgres', true],
['postgres://user:pass@db.abc.supabase.com:5432/postgres', true],
[
'postgres://user:pass@aws-0-us-east-1.pooler.supabase.com:6543/postgres',
true,
],
['postgres://user:pass@localhost:5432/postgres', false],
['postgres://user:pass@db.neon.tech:5432/neondb', false],
['postgres://user:pass@ondemand.aws.neon.tech/neondb', false],
])('returns %s for %s', (url, expected) => {
expect(detectSupabase(url)).toBe(expected)
})

it('returns false on undefined or malformed URLs', () => {
expect(detectSupabase(undefined)).toBe(false)
expect(detectSupabase('not a url')).toBe(false)
expect(detectSupabase('')).toBe(false)
})
})

describe('detectDrizzle', () => {
let tmpDir: string

beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'stash-detect-drizzle-'))
})

afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true })
})

it('returns true when a drizzle.config.ts exists', () => {
fs.writeFileSync(
path.join(tmpDir, 'drizzle.config.ts'),
'export default {}',
)
expect(detectDrizzle(tmpDir)).toBe(true)
})

it('returns true for drizzle.config.js', () => {
fs.writeFileSync(
path.join(tmpDir, 'drizzle.config.js'),
'module.exports = {}',
)
expect(detectDrizzle(tmpDir)).toBe(true)
})

it('returns true when package.json lists drizzle-orm', () => {
fs.writeFileSync(
path.join(tmpDir, 'package.json'),
JSON.stringify({ dependencies: { 'drizzle-orm': '^0.40.0' } }),
)
expect(detectDrizzle(tmpDir)).toBe(true)
})

it('returns true when package.json lists drizzle-kit as devDep', () => {
fs.writeFileSync(
path.join(tmpDir, 'package.json'),
JSON.stringify({ devDependencies: { 'drizzle-kit': '^0.30.0' } }),
)
expect(detectDrizzle(tmpDir)).toBe(true)
})

it('returns false when nothing indicates drizzle', () => {
fs.writeFileSync(
path.join(tmpDir, 'package.json'),
JSON.stringify({ dependencies: { prisma: '^5.0.0' } }),
)
expect(detectDrizzle(tmpDir)).toBe(false)
})

it('returns false on an empty directory', () => {
expect(detectDrizzle(tmpDir)).toBe(false)
})

it('tolerates an unreadable package.json', () => {
fs.writeFileSync(path.join(tmpDir, 'package.json'), '{ not valid json')
expect(detectDrizzle(tmpDir)).toBe(false)
})
})
105 changes: 105 additions & 0 deletions packages/cli/src/__tests__/rewrite-migrations.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { rewriteEncryptedAlterColumns } from '../commands/db/rewrite-migrations.js'

describe('rewriteEncryptedAlterColumns', () => {
let tmpDir: string

beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'stash-rewrite-'))
})

afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true })
})

it('rewrites an in-place ALTER COLUMN with the bare type name', async () => {
const original = `ALTER TABLE "transactions" ALTER COLUMN "amount" SET DATA TYPE eql_v2_encrypted;\n`
const filePath = path.join(tmpDir, '0002_alter.sql')
fs.writeFileSync(filePath, original)

const rewritten = await rewriteEncryptedAlterColumns(tmpDir)

expect(rewritten).toEqual([filePath])
const updated = fs.readFileSync(filePath, 'utf-8')
expect(updated).toContain(
'ALTER TABLE "transactions" ADD COLUMN "amount__cipherstash_tmp" "public"."eql_v2_encrypted";',
)
expect(updated).toContain(
'ALTER TABLE "transactions" DROP COLUMN "amount";',
)
expect(updated).toContain(
'ALTER TABLE "transactions" RENAME COLUMN "amount__cipherstash_tmp" TO "amount";',
)
expect(updated).not.toContain('SET DATA TYPE')
})

it('rewrites the schema-qualified form produced by drizzle-kit', async () => {
const original =
'ALTER TABLE "users" ALTER COLUMN "email" SET DATA TYPE "public"."eql_v2_encrypted";\n'
const filePath = path.join(tmpDir, '0003_alter.sql')
fs.writeFileSync(filePath, original)

await rewriteEncryptedAlterColumns(tmpDir)

const updated = fs.readFileSync(filePath, 'utf-8')
expect(updated).toContain(
'ALTER TABLE "users" ADD COLUMN "email__cipherstash_tmp" "public"."eql_v2_encrypted";',
)
expect(updated).not.toContain('SET DATA TYPE')
})

it('leaves unrelated migrations untouched', async () => {
const original =
'CREATE TABLE "widgets" ("id" integer PRIMARY KEY, "name" text);\n'
const filePath = path.join(tmpDir, '0001_init.sql')
fs.writeFileSync(filePath, original)

const rewritten = await rewriteEncryptedAlterColumns(tmpDir)

expect(rewritten).toEqual([])
expect(fs.readFileSync(filePath, 'utf-8')).toBe(original)
})

it('skips the file passed in options.skip', async () => {
const install = path.join(tmpDir, '0000_install-eql.sql')
const alter = path.join(tmpDir, '0002_alter.sql')
fs.writeFileSync(install, 'CREATE SCHEMA eql_v2;\n')
fs.writeFileSync(
alter,
'ALTER TABLE "t" ALTER COLUMN "c" SET DATA TYPE eql_v2_encrypted;',
)

const rewritten = await rewriteEncryptedAlterColumns(tmpDir, {
skip: install,
})
expect(rewritten).toEqual([alter])
expect(fs.readFileSync(install, 'utf-8')).toBe('CREATE SCHEMA eql_v2;\n')
})

it('returns an empty list when the directory does not exist', async () => {
const missing = path.join(tmpDir, 'does-not-exist')
const rewritten = await rewriteEncryptedAlterColumns(missing)
expect(rewritten).toEqual([])
})

it('handles multiple ALTER statements in one file', async () => {
const original = [
'ALTER TABLE "a" ALTER COLUMN "x" SET DATA TYPE eql_v2_encrypted;',
'ALTER TABLE "a" ALTER COLUMN "y" SET DATA TYPE eql_v2_encrypted;',
'CREATE INDEX "a_z" ON "a" ("z");',
].join('\n')
const filePath = path.join(tmpDir, '0004_multi.sql')
fs.writeFileSync(filePath, original)

await rewriteEncryptedAlterColumns(tmpDir)

const updated = fs.readFileSync(filePath, 'utf-8')
expect(updated.match(/ADD COLUMN/g)?.length).toBe(2)
expect(updated.match(/DROP COLUMN/g)?.length).toBe(2)
// Non-matching statement preserved
expect(updated).toContain('CREATE INDEX "a_z" ON "a" ("z");')
})
})
Loading
Loading