Skip to content

Commit 1be8f81

Browse files
committed
feat: stash-forge push command
1 parent 60ce44a commit 1be8f81

File tree

17 files changed

+517
-39
lines changed

17 files changed

+517
-39
lines changed

.changeset/config.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,8 @@
77
"access": "restricted",
88
"baseBranch": "main",
99
"updateInternalDependencies": "patch",
10-
"ignore": []
10+
"ignore": [],
11+
"___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": {
12+
"onlyUpdatePeerDependentsWhenOutOfRange": true
13+
}
1114
}

.changeset/curvy-bushes-mix.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@cipherstash/stack": minor
3+
---
4+
5+
Exposed a public method on the Encryption client to expose the build Encryption schema.

examples/basic/encrypt.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import 'dotenv/config'
2+
23
import { Encryption, encryptedColumn, encryptedTable } from '@cipherstash/stack'
34

45
export const users = encryptedTable('users', {

examples/basic/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212
"description": "",
1313
"dependencies": {
1414
"@cipherstash/stack": "workspace:*",
15-
"dotenv": "^16.4.7"
15+
"dotenv": "^16.6.1",
16+
"pg": "8.13.1"
1617
},
1718
"devDependencies": {
1819
"@cipherstash/stack-forge": "workspace:*",

examples/basic/stash.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@ import { defineConfig } from '@cipherstash/stack-forge'
22

33
export default defineConfig({
44
databaseUrl: process.env.DATABASE_URL!,
5+
client: './encrypt.ts',
56
})

packages/stack-forge/README.md

Lines changed: 79 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ bun add -D @cipherstash/stack-forge
3030

3131
## Quick Start
3232

33+
You can install EQL in two ways: **direct install** (connects to the DB and runs the SQL) or **Drizzle migration** (generates a migration file; you run `drizzle-kit migrate` yourself). The steps below use the direct install path.
34+
3335
### 1. Create a config file
3436

3537
Create `stash.config.ts` in your project root:
@@ -56,6 +58,10 @@ npx stash-forge install
5658

5759
That's it. EQL is now installed in your database.
5860

61+
If your encryption client lives elsewhere, set `client` in `stash.config.ts` (e.g. `client: './lib/encryption.ts'`). That path is used by `stash-forge push`.
62+
63+
**Using Drizzle?** To install EQL via your migration pipeline instead, run `npx stash-forge install --drizzle`, then `npx drizzle-kit migrate`. See [install --drizzle](#install---drizzle) below.
64+
5965
---
6066

6167
## Configuration
@@ -68,9 +74,24 @@ import { defineConfig } from '@cipherstash/stack-forge'
6874
export default defineConfig({
6975
// Required: PostgreSQL connection string
7076
databaseUrl: process.env.DATABASE_URL!,
77+
78+
// Optional: path to your encryption client (default: './src/encryption/index.ts')
79+
// Used by `stash-forge push` to load the encryption schema
80+
client: './src/encryption/index.ts',
81+
82+
// Optional: CipherStash workspace and credentials (for future schema sync)
83+
workspaceId: process.env.CS_WORKSPACE_ID,
84+
clientAccessKey: process.env.CS_CLIENT_ACCESS_KEY,
7185
})
7286
```
7387

88+
| Option | Required | Description |
89+
|--------|----------|-------------|
90+
| `databaseUrl` | Yes | PostgreSQL connection string |
91+
| `client` | No | Path to encryption client file (default: `'./src/encryption/index.ts'`). Used by `push` to load the encryption schema. |
92+
| `workspaceId` | No | CipherStash workspace ID |
93+
| `clientAccessKey` | No | CipherStash client access key |
94+
7495
The CLI automatically loads `.env` files before evaluating the config, so `process.env` references work out of the box.
7596

7697
The config file is resolved by walking up from the current working directory, similar to how `tsconfig.json` resolution works.
@@ -97,6 +118,9 @@ npx stash-forge install [options]
97118
| `--force` | Reinstall even if EQL is already installed |
98119
| `--supabase` | Use Supabase-compatible install (excludes operator families + grants Supabase roles) |
99120
| `--exclude-operator-family` | Skip operator family creation (for non-superuser database roles) |
121+
| `--drizzle` | Generate a Drizzle migration instead of direct install |
122+
| `--name <value>` | Migration name when using `--drizzle` (default: `install-eql`) |
123+
| `--out <value>` | Drizzle output directory when using `--drizzle` (default: `drizzle`) |
100124

101125
**Standard install:**
102126

@@ -120,7 +144,57 @@ The `--supabase` flag:
120144
npx stash-forge install --dry-run
121145
```
122146

123-
### Permission Pre-checks
147+
#### `install --drizzle`
148+
149+
If you use [Drizzle ORM](https://orm.drizzle.team/) and want EQL installation as part of your migration history, use the `--drizzle` flag. It creates a Drizzle migration file containing the EQL install SQL, then you run your normal Drizzle migrations to apply it.
150+
151+
```bash
152+
npx stash-forge install --drizzle
153+
npx drizzle-kit migrate
154+
```
155+
156+
**How it works:**
157+
158+
1. Runs `drizzle-kit generate --custom --name=<name>` to create an empty migration.
159+
2. Downloads the EQL install script from the [EQL GitHub releases](https://github.com/cipherstash/encrypt-query-language/releases/latest).
160+
3. Writes the EQL SQL into the generated migration file.
161+
162+
With a custom migration name or output directory:
163+
164+
```bash
165+
npx stash-forge install --drizzle --name setup-eql --out ./migrations
166+
npx drizzle-kit migrate
167+
```
168+
169+
You need `drizzle-kit` installed in your project (`npm install -D drizzle-kit`). The `--out` directory must match your Drizzle config (e.g. `drizzle.config.ts`).
170+
171+
### `push`
172+
173+
Load your encryption schema from the file specified by `client` in `stash.config.ts` and apply it to the database (or preview with `--dry-run`).
174+
175+
```bash
176+
npx stash-forge push [options]
177+
```
178+
179+
| Option | Description |
180+
|--------|-------------|
181+
| `--dry-run` | Load and validate the schema, then print it as JSON. No database changes. |
182+
183+
**Push schema to the database:**
184+
185+
```bash
186+
npx stash-forge push
187+
```
188+
189+
This connects to Postgres, marks any existing rows in `eql_v2_configuration` as `inactive`, and inserts the current encrypt config as a new row with state `active`. Your runtime encryption (e.g. `@cipherstash/stack`) reads the active configuration from this table.
190+
191+
**Preview your encryption schema without writing to the database:**
192+
193+
```bash
194+
npx stash-forge push --dry-run
195+
```
196+
197+
### Permission Pre-checks (install)
124198

125199
Before installing, `stash-forge` verifies that the connected database role has the required permissions:
126200

@@ -137,8 +211,7 @@ The following commands are defined but not yet implemented:
137211
| Command | Description |
138212
|---------|-------------|
139213
| `init` | Initialize CipherStash Forge in your project |
140-
| `push` | Push encryption schema to database |
141-
| `migrate` | Run pending EQL migrations |
214+
| `migrate` | Run pending encrypt config migrations |
142215
| `status` | Show EQL installation status |
143216

144217
---
@@ -205,11 +278,12 @@ export default defineConfig({
205278

206279
### `loadStashConfig`
207280

208-
Finds and loads the nearest `stash.config.ts`, validates it with Zod, and returns the typed config:
281+
Finds and loads the nearest `stash.config.ts`, validates it with Zod, applies defaults (e.g. `client`), and returns the typed config:
209282

210283
```typescript
211284
import { loadStashConfig } from '@cipherstash/stack-forge'
212285

213286
const config = await loadStashConfig()
214-
// config.databaseUrl is guaranteed to be a non-empty string
287+
// config.databaseUrl — guaranteed to be a non-empty string
288+
// config.client — path to encryption client (default: './src/encryption/index.ts')
215289
```

packages/stack-forge/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
"zod": "3.24.2"
4747
},
4848
"devDependencies": {
49+
"@cipherstash/stack": "workspace:*",
4950
"@types/pg": "^8.11.11",
5051
"tsup": "catalog:repo",
5152
"tsx": "catalog:repo",

packages/stack-forge/src/bin/stash-forge.ts

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,45 +2,63 @@ import { config } from 'dotenv'
22
config()
33

44
import * as p from '@clack/prompts'
5-
import { installCommand } from '../commands/index.js'
5+
import { installCommand, pushCommand } from '../commands/index.js'
66

77
const HELP = `
8-
CipherStash Forge v0.1.0
9-
8+
CipherStash Forge
109
Usage: stash-forge <command> [options]
1110
1211
Commands:
1312
install Install EQL extensions into your database
1413
init Initialize CipherStash Forge in your project
1514
push Push encryption schema to database
16-
migrate Run pending EQL migrations
15+
migrate Run pending encrypt config migrations
1716
status Show EQL installation status
1817
1918
Options:
2019
--help, -h Show help
2120
--version, -v Show version
2221
--force (install) Reinstall even if already installed
23-
--dry-run (install) Show what would happen without making changes
22+
--dry-run (install, push) Show what would happen without making changes
2423
--supabase (install) Use Supabase-compatible install and grant role permissions
24+
--drizzle (install) Generate a Drizzle migration instead of direct install
2525
--exclude-operator-family (install) Skip operator family creation (for non-superuser roles)
2626
`.trim()
2727

28-
function parseArgs(argv: string[]) {
28+
interface ParsedArgs {
29+
command: string | undefined
30+
flags: Record<string, boolean>
31+
values: Record<string, string>
32+
}
33+
34+
function parseArgs(argv: string[]): ParsedArgs {
2935
const args = argv.slice(2)
3036
const command = args[0]
3137
const flags: Record<string, boolean> = {}
38+
const values: Record<string, string> = {}
3239

33-
for (const arg of args.slice(1)) {
40+
const rest = args.slice(1)
41+
for (let i = 0; i < rest.length; i++) {
42+
const arg = rest[i]
3443
if (arg.startsWith('--')) {
35-
flags[arg.slice(2)] = true
44+
const key = arg.slice(2)
45+
const nextArg = rest[i + 1]
46+
47+
// If the next argument exists and is not a flag, treat it as a value
48+
if (nextArg !== undefined && !nextArg.startsWith('--')) {
49+
values[key] = nextArg
50+
i++ // Skip the value argument
51+
} else {
52+
flags[key] = true
53+
}
3654
}
3755
}
3856

39-
return { command, flags }
57+
return { command, flags, values }
4058
}
4159

4260
async function main() {
43-
const { command, flags } = parseArgs(process.argv)
61+
const { command, flags, values } = parseArgs(process.argv)
4462

4563
if (!command || flags.help || command === '--help' || command === '-h') {
4664
console.log(HELP)
@@ -59,10 +77,15 @@ async function main() {
5977
dryRun: flags['dry-run'],
6078
supabase: flags.supabase,
6179
excludeOperatorFamily: flags['exclude-operator-family'],
80+
drizzle: flags.drizzle,
81+
name: values.name,
82+
out: values.out,
6283
})
6384
break
64-
case 'init':
6585
case 'push':
86+
await pushCommand({ dryRun: flags['dry-run'] })
87+
break
88+
case 'init':
6689
case 'migrate':
6790
case 'status':
6891
p.log.warn(`"stash-forge ${command}" is not yet implemented.`)
Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
export { initCommand } from './init.js'
22
export { installCommand } from './install.js'
3-
export { migrateCommand } from './migrate.js'
43
export { pushCommand } from './push.js'
54
export { statusCommand } from './status.js'

0 commit comments

Comments
 (0)