Skip to content
Open
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
5 changes: 4 additions & 1 deletion .changeset/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,8 @@
"access": "restricted",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": []
"ignore": [],
"___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": {
"onlyUpdatePeerDependentsWhenOutOfRange": true
}
Comment on lines +11 to +13
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, check the actual .changeset/config.json file
cat .changeset/config.json

Repository: cipherstash/stack

Length of output: 448


🏁 Script executed:

# Run the verification script from the review comment to enumerate peer dependency ranges
#!/bin/bash
set -euo pipefail

echo "=== All internal `@cipherstash` peer dependency ranges ==="
fd package.json | while read -r file; do
  jq -r --arg f "$file" '
    if .peerDependencies then
      .peerDependencies
      | to_entries[]
      | select(.key | startswith("@cipherstash/"))
      | [$f, .key, .value] | `@tsv`
    else empty end
  ' "$file" 2>/dev/null
done | sort

echo
echo "=== Potentially non-propagating ranges with onlyUpdatePeerDependentsWhenOutOfRange=true ==="
fd package.json | while read -r file; do
  jq -r --arg f "$file" '
    if .peerDependencies then
      .peerDependencies
      | to_entries[]
      | select(.key | startswith("@cipherstash/"))
      | select(.value | test("^(workspace:\\*|\\*|>=|\\^|~)"))
      | [$f, .key, .value] | `@tsv`
    else empty end
  ' "$file" 2>/dev/null
done | sort

Repository: cipherstash/stack

Length of output: 590


🌐 Web query:

Changesets onlyUpdatePeerDependentsWhenOutOfRange option behavior documentation

💡 Result:

onlyUpdatePeerDependentsWhenOutOfRange is an experimental Changesets config flag (boolean, default false) that you set under ___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH in .changeset/config.json. When set to true, Changesets will only bump packages that peer-depend on another package when the updated peer’s new version would no longer satisfy the dependent’s peerDependencies version range (“leaving the range”). In other words: if the peer’s new version is still within the existing peer range, the peer-dependent won’t be bumped just because the peer changed. [1][2]

Example:

{
  "___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": {
    "onlyUpdatePeerDependentsWhenOutOfRange": true
  }
}

Sources: Changesets “Experimental Options” docs [1] and the release note entry that introduced the flag [2].

[1] (changesets-docs.vercel.app)
[2] (newreleases.io)

Citations:


Global peer-dependent bump behavior changed; this can silently skip coordinated releases.

Lines 11–13 enable onlyUpdatePeerDependentsWhenOutOfRange: true, which suppresses automatic bumping of peer dependents when internal peers update but remain within the dependent's version range. With existing peer ranges like workspace:* (packages/protect-dynamodb) and >=... (packages/drizzle), peer dependents will not be bumped unless the peer version goes out of range—causing silent version coordination drift. This is particularly problematic for workspace:* dependencies, where updates rarely exceed range bounds.

Consider removing this flag or setting it to an empty object:

Suggested config change
   "ignore": [],
-  "___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": {
-    "onlyUpdatePeerDependentsWhenOutOfRange": true
-  }
+  "___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": {}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": {
"onlyUpdatePeerDependentsWhenOutOfRange": true
}
"___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": {}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.changeset/config.json around lines 11 - 13, The changeset config currently
sets the experimental option onlyUpdatePeerDependentsWhenOutOfRange: true which
prevents automatic peer-dependent bumps when internal peers update within
declared ranges (e.g., workspace:* in protect-dynamodb or >= ranges in drizzle),
causing silent coordination drift; revert this by removing
onlyUpdatePeerDependentsWhenOutOfRange or set the option to an empty object
(disable the experimental behavior) in the changeset configuration so peer
dependents are bumped as before.

}
5 changes: 5 additions & 0 deletions .changeset/curvy-bushes-mix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cipherstash/stack": minor
---

Exposed a public method on the Encryption client to expose the build Encryption schema.
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Use the exact API name in the changeset note.

Line 5 doesn’t match the actual public surface added in this PR. The note should reference EncryptionClient.getEncryptConfig() explicitly to avoid confusing consumers.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.changeset/curvy-bushes-mix.md at line 5, The changeset note currently uses
a vague description for the public API added; update the text to reference the
exact API name EncryptionClient.getEncryptConfig() so consumers see the precise
public surface added. Edit the line mentioning the exposed method and replace it
with "Exposed EncryptionClient.getEncryptConfig() to build the encryption
schema" (or similar) ensuring the symbol EncryptionClient.getEncryptConfig()
appears verbatim.

5 changes: 5 additions & 0 deletions .changeset/soft-times-tease.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cipherstash/stack-forge": minor
---

Initial release of the `stash-forge` CLI utility.
1 change: 1 addition & 0 deletions examples/basic/encrypt.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'dotenv/config'

import { Encryption, encryptedColumn, encryptedTable } from '@cipherstash/stack'

export const users = encryptedTable('users', {
Expand Down
4 changes: 3 additions & 1 deletion examples/basic/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@
"description": "",
"dependencies": {
"@cipherstash/stack": "workspace:*",
"dotenv": "^16.4.7"
"dotenv": "^16.6.1",
"pg": "8.13.1"
},
"devDependencies": {
"@cipherstash/stack-forge": "workspace:*",
"tsx": "catalog:repo",
"typescript": "catalog:repo"
}
Expand Down
6 changes: 6 additions & 0 deletions examples/basic/stash.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { defineConfig } from '@cipherstash/stack-forge'

export default defineConfig({
databaseUrl: process.env.DATABASE_URL!,
client: './encrypt.ts',
})
Comment on lines +3 to +6
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fail fast when DATABASE_URL is missing instead of relying on !.

Line 4 can pass undefined at runtime and fail later with a less clear error. Add an explicit guard so the example fails with an actionable message.

Suggested patch
 import { defineConfig } from '@cipherstash/stack-forge'
 
+const databaseUrl = process.env.DATABASE_URL
+if (!databaseUrl) {
+  throw new Error('DATABASE_URL is required')
+}
+
 export default defineConfig({
-  databaseUrl: process.env.DATABASE_URL!,
+  databaseUrl,
 })

As per coding guidelines, "Example apps should be small, idiomatic, and runnable as-is with documented env vars; never leak secrets in code or logs".

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export default defineConfig({
databaseUrl: process.env.DATABASE_URL!,
})
import { defineConfig } from '@cipherstash/stack-forge'
const databaseUrl = process.env.DATABASE_URL
if (!databaseUrl) {
throw new Error('DATABASE_URL is required')
}
export default defineConfig({
databaseUrl,
})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@examples/basic/stash.config.ts` around lines 3 - 5, The current export uses
process.env.DATABASE_URL! which can be undefined at runtime; update the
defineConfig call so it explicitly checks process.env.DATABASE_URL and throws a
clear, actionable error if missing (e.g., before calling defineConfig or
inline), ensuring the databaseUrl property is always a non-empty string;
reference the defineConfig export and the databaseUrl property that currently
reads process.env.DATABASE_URL to locate where to add the guard and error
message.

289 changes: 289 additions & 0 deletions packages/stack-forge/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,289 @@
# @cipherstash/stack-forge

Dev-time CLI and library for managing [CipherStash EQL](https://github.com/cipherstash/encrypt-query-language) (Encrypted Query Language) in your PostgreSQL database.

[![npm version](https://img.shields.io/npm/v/@cipherstash/stack-forge.svg?style=for-the-badge&labelColor=000000)](https://www.npmjs.com/package/@cipherstash/stack-forge)
[![License: MIT](https://img.shields.io/npm/l/@cipherstash/stack-forge.svg?style=for-the-badge&labelColor=000000)](https://github.com/cipherstash/protectjs/blob/main/LICENSE.md)
[![TypeScript](https://img.shields.io/badge/TypeScript-first-blue?style=for-the-badge&labelColor=000000)](https://www.typescriptlang.org/)

---

## Why stack-forge?

`@cipherstash/stack` is the runtime encryption SDK — it should stay lean and free of heavy dependencies like `pg`. `@cipherstash/stack-forge` is a **devDependency** that handles database tooling: installing EQL extensions, checking permissions, and managing schema lifecycle.

Think of it like Prisma or Drizzle Kit — a companion CLI that sets up the database while the main SDK handles runtime operations.

## Install

```bash
npm install -D @cipherstash/stack-forge
```

Or with your preferred package manager:

```bash
pnpm add -D @cipherstash/stack-forge
yarn add -D @cipherstash/stack-forge
bun add -D @cipherstash/stack-forge
```

## Quick Start

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.

### 1. Create a config file

Create `stash.config.ts` in your project root:

```typescript
import { defineConfig } from '@cipherstash/stack-forge'

export default defineConfig({
databaseUrl: process.env.DATABASE_URL!,
})
```

### 2. Add a `.env` file

```env
DATABASE_URL=postgresql://user:password@localhost:5432/mydb
```

### 3. Install EQL

```bash
npx stash-forge install
```

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

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`.

**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.

---

## Configuration

The `stash.config.ts` file is the single source of truth for stack-forge. It uses the `defineConfig` helper for type safety.

```typescript
import { defineConfig } from '@cipherstash/stack-forge'

export default defineConfig({
// Required: PostgreSQL connection string
databaseUrl: process.env.DATABASE_URL!,

// Optional: path to your encryption client (default: './src/encryption/index.ts')
// Used by `stash-forge push` to load the encryption schema
client: './src/encryption/index.ts',

// Optional: CipherStash workspace and credentials (for future schema sync)
workspaceId: process.env.CS_WORKSPACE_ID,
clientAccessKey: process.env.CS_CLIENT_ACCESS_KEY,
})
```

| Option | Required | Description |
|--------|----------|-------------|
| `databaseUrl` | Yes | PostgreSQL connection string |
| `client` | No | Path to encryption client file (default: `'./src/encryption/index.ts'`). Used by `push` to load the encryption schema. |
| `workspaceId` | No | CipherStash workspace ID |
| `clientAccessKey` | No | CipherStash client access key |

The CLI automatically loads `.env` files before evaluating the config, so `process.env` references work out of the box.

The config file is resolved by walking up from the current working directory, similar to how `tsconfig.json` resolution works.

---

## CLI Reference

```
stash-forge <command> [options]
```

### `install`

Install the CipherStash EQL extensions into your database.

```bash
npx stash-forge install [options]
```

| Option | Description |
|--------|-------------|
| `--dry-run` | Show what would happen without making changes |
| `--force` | Reinstall even if EQL is already installed |
| `--supabase` | Use Supabase-compatible install (excludes operator families + grants Supabase roles) |
| `--exclude-operator-family` | Skip operator family creation (for non-superuser database roles) |
| `--drizzle` | Generate a Drizzle migration instead of direct install |
| `--name <value>` | Migration name when using `--drizzle` (default: `install-eql`) |
| `--out <value>` | Drizzle output directory when using `--drizzle` (default: `drizzle`) |

**Standard install:**

```bash
npx stash-forge install
```

**Supabase install:**

```bash
npx stash-forge install --supabase
```

The `--supabase` flag:
- Downloads the Supabase-specific SQL variant (no `CREATE OPERATOR FAMILY`)
- Grants `USAGE`, table, routine, and sequence permissions on the `eql_v2` schema to `anon`, `authenticated`, and `service_role`

**Preview changes first:**

```bash
npx stash-forge install --dry-run
```

#### `install --drizzle`

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.

```bash
npx stash-forge install --drizzle
npx drizzle-kit migrate
```

**How it works:**

1. Runs `drizzle-kit generate --custom --name=<name>` to create an empty migration.
2. Downloads the EQL install script from the [EQL GitHub releases](https://github.com/cipherstash/encrypt-query-language/releases/latest).
3. Writes the EQL SQL into the generated migration file.

With a custom migration name or output directory:

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

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`).

### `push`

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`).

```bash
npx stash-forge push [options]
```

| Option | Description |
|--------|-------------|
| `--dry-run` | Load and validate the schema, then print it as JSON. No database changes. |

**Push schema to the database:**

```bash
npx stash-forge push
```

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.

**Preview your encryption schema without writing to the database:**

```bash
npx stash-forge push --dry-run
```

### Permission Pre-checks (install)

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

- `CREATE` on the database (for `CREATE SCHEMA` and `CREATE EXTENSION`)
- `CREATE` on the `public` schema (for `CREATE TYPE public.eql_v2_encrypted`)
- `SUPERUSER` or extension owner (for `CREATE EXTENSION pgcrypto`, if not already installed)

If permissions are insufficient, the CLI exits with a clear message listing what's missing.

### Planned Commands

The following commands are defined but not yet implemented:

| Command | Description |
|---------|-------------|
| `init` | Initialize CipherStash Forge in your project |
| `migrate` | Run pending encrypt config migrations |
| `status` | Show EQL installation status |

---

## Programmatic API

You can also use stack-forge as a library:

```typescript
import { EQLInstaller, loadStashConfig } from '@cipherstash/stack-forge'

// Load config from stash.config.ts
const config = await loadStashConfig()

// Create an installer
const installer = new EQLInstaller({
databaseUrl: config.databaseUrl,
})

// Check permissions before installing
const permissions = await installer.checkPermissions()
if (!permissions.ok) {
console.error('Missing permissions:', permissions.missing)
process.exit(1)
}

// Check if already installed
if (await installer.isInstalled()) {
console.log('EQL is already installed')
} else {
await installer.install()
}
```

### `EQLInstaller`

| Method | Returns | Description |
|--------|---------|-------------|
| `checkPermissions()` | `Promise<PermissionCheckResult>` | Check if the database role has required permissions |
| `isInstalled()` | `Promise<boolean>` | Check if the `eql_v2` schema exists |
| `getInstalledVersion()` | `Promise<string \| null>` | Get the installed EQL version (or `null`) |
| `install(options?)` | `Promise<void>` | Download and execute the EQL install SQL in a transaction |

#### Install Options

```typescript
await installer.install({
excludeOperatorFamily: true, // Skip CREATE OPERATOR FAMILY
supabase: true, // Supabase mode (implies excludeOperatorFamily + grants roles)
})
```

### `defineConfig`

Type-safe identity function for `stash.config.ts`:

```typescript
import { defineConfig } from '@cipherstash/stack-forge'

export default defineConfig({
databaseUrl: process.env.DATABASE_URL!,
})
```

### `loadStashConfig`

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

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

const config = await loadStashConfig()
// config.databaseUrl — guaranteed to be a non-empty string
// config.client — path to encryption client (default: './src/encryption/index.ts')
```
Loading