From 2b907a1a597483a678b3d3d5a83c233a85b642e2 Mon Sep 17 00:00:00 2001 From: CJ Brewer Date: Wed, 11 Mar 2026 09:14:09 -0600 Subject: [PATCH] feat: improve cli dx --- .changeset/eager-cougars-watch.md | 6 + examples/basic/src/encryption/index.ts | 11 +- packages/stack-forge/README.md | 56 +- packages/stack-forge/src/bin/stash-forge.ts | 29 +- packages/stack-forge/src/commands/index.ts | 2 +- packages/stack-forge/src/commands/init.ts | 655 +++++------------- packages/stack/README.md | 28 +- packages/stack/src/bin/commands/init/index.ts | 17 +- .../src/bin/commands/init/providers/base.ts | 17 +- .../bin/commands/init/providers/supabase.ts | 16 +- .../bin/commands/init/steps/authenticate.ts | 28 - .../bin/commands/init/steps/build-schema.ts | 205 ++++++ .../init/steps/detect-database-url.ts | 50 -- .../bin/commands/init/steps/install-eql.ts | 48 -- .../bin/commands/init/steps/install-forge.ts | 55 ++ .../commands/init/steps/select-connection.ts | 9 +- .../bin/commands/init/steps/select-region.ts | 25 - .../commands/init/steps/select-workspace.ts | 49 -- packages/stack/src/bin/commands/init/stubs.ts | 85 --- packages/stack/src/bin/commands/init/types.ts | 49 +- packages/stack/src/bin/commands/init/utils.ts | 169 +++++ skills/stash-forge/SKILL.md | 68 +- 22 files changed, 786 insertions(+), 891 deletions(-) create mode 100644 .changeset/eager-cougars-watch.md delete mode 100644 packages/stack/src/bin/commands/init/steps/authenticate.ts create mode 100644 packages/stack/src/bin/commands/init/steps/build-schema.ts delete mode 100644 packages/stack/src/bin/commands/init/steps/detect-database-url.ts delete mode 100644 packages/stack/src/bin/commands/init/steps/install-eql.ts create mode 100644 packages/stack/src/bin/commands/init/steps/install-forge.ts delete mode 100644 packages/stack/src/bin/commands/init/steps/select-region.ts delete mode 100644 packages/stack/src/bin/commands/init/steps/select-workspace.ts delete mode 100644 packages/stack/src/bin/commands/init/stubs.ts create mode 100644 packages/stack/src/bin/commands/init/utils.ts diff --git a/.changeset/eager-cougars-watch.md b/.changeset/eager-cougars-watch.md new file mode 100644 index 00000000..bac4ab8b --- /dev/null +++ b/.changeset/eager-cougars-watch.md @@ -0,0 +1,6 @@ +--- +"@cipherstash/basic-example": minor +"@cipherstash/stack": minor +--- + +Improve CLI user experience for developer onboarding. diff --git a/examples/basic/src/encryption/index.ts b/examples/basic/src/encryption/index.ts index 99d1df4e..24533880 100644 --- a/examples/basic/src/encryption/index.ts +++ b/examples/basic/src/encryption/index.ts @@ -1,12 +1,13 @@ import { encryptedTable, encryptedColumn } from '@cipherstash/stack/schema' import { Encryption } from '@cipherstash/stack' -export const helloTable = encryptedTable('hello', { - world: encryptedColumn('world').equality().orderAndRange(), - name: encryptedColumn('name').equality().freeTextSearch(), - age: encryptedColumn('age').dataType('number').equality().orderAndRange(), +export const usersTable = encryptedTable('users', { + email: encryptedColumn('email') + .equality() + .orderAndRange() + .freeTextSearch(), }) export const encryptionClient = await Encryption({ - schemas: [helloTable], + schemas: [usersTable], }) diff --git a/packages/stack-forge/README.md b/packages/stack-forge/README.md index 24209a8c..296128df 100644 --- a/packages/stack-forge/README.md +++ b/packages/stack-forge/README.md @@ -30,25 +30,27 @@ bun add -D @cipherstash/stack-forge ## Quick Start -The fastest way to get started is with the interactive `init` command: +First, initialize your project with the `stash` CLI (from `@cipherstash/stack`): ```bash -npx stash-forge init +npx stash init ``` -This will: -1. Check if `@cipherstash/stack` is installed and offer to install it -2. Ask for your database URL -3. Ask which integration you're using (Drizzle, Supabase, or plain PostgreSQL) -4. Let you build an encryption schema interactively or use a placeholder -5. Generate `stash.config.ts` and your encryption client file +This generates your encryption schema and installs `@cipherstash/stack-forge` as a dev dependency. -Then install EQL in your database: +Then set up your database and install EQL: ```bash -npx stash-forge install +npx stash-forge setup ``` +This will: +1. Auto-detect your encryption client file (or ask for the path) +2. Ask for your database URL +3. Generate `stash.config.ts` +4. Ask which Postgres provider you're using (Supabase, Neon, AWS RDS, etc.) to determine the right install flags +5. Install EQL extensions in your database + That's it. EQL is now installed and your encryption schema is ready. ### Manual setup @@ -64,6 +66,7 @@ import { defineConfig } from '@cipherstash/stack-forge' export default defineConfig({ databaseUrl: process.env.DATABASE_URL!, + client: './src/encryption/index.ts', }) ``` @@ -117,25 +120,34 @@ The config file is resolved by walking up from the current working directory, si stash-forge [options] ``` -### `init` +### `setup` -Initialize CipherStash Forge in your project with an interactive wizard. +Configure your database and install EQL extensions. Run this after `stash init` has set up your encryption schema. ```bash -npx stash-forge init +npx stash-forge setup [options] ``` The wizard will: -- Check if `@cipherstash/stack` is installed and prompt to install it (detects your package manager automatically) +- Auto-detect your encryption client file by scanning common locations (`./src/encryption/index.ts`, etc.), then confirm with you or ask for the path if not found - Ask for your database URL (pre-fills from `DATABASE_URL` env var) -- Ask which integration you're using (Drizzle ORM, Supabase, or plain PostgreSQL) -- Ask where to create the encryption client file -- If the client file already exists, ask whether to keep it or overwrite -- Let you choose between building a schema interactively or using a placeholder: - - **Build a schema:** asks for table name, column names, data types, and search operations for each column - - **Placeholder:** generates an example `users` table with `email` and `name` columns -- Generate `stash.config.ts` and the encryption client file -- Print next steps with links to the [CipherStash dashboard](https://dashboard.cipherstash.com/sign-in) for credentials +- Generate `stash.config.ts` with the database URL and client path +- Ask which Postgres provider you're using to determine the right install flags: + - **Supabase** — uses `--supabase` (no operator families + Supabase role grants) + - **Neon, Vercel Postgres, PlanetScale, Prisma Postgres** — uses `--exclude-operator-family` + - **AWS RDS, Other / Self-hosted** — standard install +- Install EQL extensions in your database + +If `--supabase` is passed as a flag, the provider selection is skipped. + +| Option | 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 using the bundled version | ### `install` diff --git a/packages/stack-forge/src/bin/stash-forge.ts b/packages/stack-forge/src/bin/stash-forge.ts index 42c50974..9a9a0118 100644 --- a/packages/stack-forge/src/bin/stash-forge.ts +++ b/packages/stack-forge/src/bin/stash-forge.ts @@ -3,9 +3,9 @@ config() import * as p from '@clack/prompts' import { - initCommand, installCommand, pushCommand, + setupCommand, statusCommand, testConnectionCommand, upgradeCommand, @@ -19,7 +19,7 @@ Usage: stash-forge [options] Commands: install Install EQL extensions into your database upgrade Upgrade EQL extensions to the latest version - init Initialize CipherStash Forge in your project + setup Configure database and install EQL extensions push Push encryption schema to database (CipherStash Proxy only) validate Validate encryption schema for common misconfigurations migrate Run pending encrypt config migrations @@ -29,12 +29,12 @@ Commands: Options: --help, -h Show help --version, -v Show version - --force (install) Reinstall even if already installed - --dry-run (install, push, upgrade) Show what would happen without making changes - --supabase (install, upgrade, validate) Use Supabase-compatible install and grant role permissions - --drizzle (install) Generate a Drizzle migration instead of direct install - --exclude-operator-family (install, upgrade, validate) Skip operator family creation (for non-superuser roles) - --latest (install, upgrade) Fetch the latest EQL from GitHub instead of using the bundled version + --force (setup, install) Reinstall even if already installed + --dry-run (setup, install, push, upgrade) Show what would happen without making changes + --supabase (setup, install, upgrade, validate) Use Supabase-compatible install and grant role permissions + --drizzle (setup, install) Generate a Drizzle migration instead of direct install + --exclude-operator-family (setup, install, upgrade, validate) Skip operator family creation (for non-superuser roles) + --latest (setup, install, upgrade) Fetch the latest EQL from GitHub instead of using the bundled version `.trim() interface ParsedArgs { @@ -115,8 +115,17 @@ async function main() { case 'status': await statusCommand() break - case 'init': - await initCommand() + case 'setup': + await setupCommand({ + force: flags.force, + dryRun: flags['dry-run'], + supabase: flags.supabase, + excludeOperatorFamily: flags['exclude-operator-family'], + drizzle: flags.drizzle, + latest: flags.latest, + name: values.name, + out: values.out, + }) break case 'test-connection': await testConnectionCommand() diff --git a/packages/stack-forge/src/commands/index.ts b/packages/stack-forge/src/commands/index.ts index 1707df61..af47f411 100644 --- a/packages/stack-forge/src/commands/index.ts +++ b/packages/stack-forge/src/commands/index.ts @@ -1,4 +1,4 @@ -export { initCommand } from './init.js' +export { setupCommand } from './init.js' export { installCommand } from './install.js' export { pushCommand } from './push.js' export { statusCommand } from './status.js' diff --git a/packages/stack-forge/src/commands/init.ts b/packages/stack-forge/src/commands/init.ts index ce839582..f8c7bdd5 100644 --- a/packages/stack-forge/src/commands/init.ts +++ b/packages/stack-forge/src/commands/init.ts @@ -1,133 +1,70 @@ -import { execSync } from 'node:child_process' -import { existsSync, mkdirSync, writeFileSync } from 'node:fs' -import { dirname, resolve } from 'node:path' +import { existsSync, writeFileSync } from 'node:fs' +import { resolve } from 'node:path' import * as p from '@clack/prompts' - -type Integration = 'drizzle' | 'supabase' | 'postgresql' -type DataType = 'string' | 'number' | 'boolean' | 'date' | 'json' -type SearchOp = 'equality' | 'orderAndRange' | 'freeTextSearch' - -interface ColumnDef { - name: string - dataType: DataType - searchOps: SearchOp[] -} - -interface SchemaDef { - tableName: string - columns: ColumnDef[] -} +import { installCommand } from './install.js' const CONFIG_FILENAME = 'stash.config.ts' -export async function initCommand() { - p.intro('stash-forge init') +/** + * Common locations where an encryption client file might live. + * Checked in order of priority during auto-detection. + */ +const COMMON_CLIENT_PATHS = [ + './src/encryption/index.ts', + './src/encryption.ts', + './encryption/index.ts', + './encryption.ts', + './src/lib/encryption/index.ts', + './src/lib/encryption.ts', +] as const + +export interface SetupOptions { + force?: boolean + dryRun?: boolean + supabase?: boolean + excludeOperatorFamily?: boolean + drizzle?: boolean + latest?: boolean + name?: string + out?: string +} - // Check if stash.config.ts already exists - const configPath = resolve(process.cwd(), CONFIG_FILENAME) - if (existsSync(configPath)) { - p.log.warn(`${CONFIG_FILENAME} already exists. Skipping initialization.`) - p.log.info( - `Delete ${CONFIG_FILENAME} and re-run "stash-forge init" to start fresh.`, - ) - p.outro('Nothing to do.') - return +/** + * Scans the project for an existing encryption client file at common locations. + * Returns the first match, or `undefined` if none found. + */ +function detectClientPath(): string | undefined { + const cwd = process.cwd() + for (const candidate of COMMON_CLIENT_PATHS) { + if (existsSync(resolve(cwd, candidate))) { + return candidate + } } + return undefined +} - // 1. Check if @cipherstash/stack is installed, prompt to install if not - const stackInstalled = isPackageInstalled('@cipherstash/stack') - - if (!stackInstalled) { - const pm = detectPackageManager() - const installCmd = - pm === 'yarn' - ? 'yarn add @cipherstash/stack' - : `${pm} install @cipherstash/stack` +/** + * Prompts the user to confirm a detected client path or enter one manually. + * Returns the confirmed path, or `undefined` if the user cancels. + */ +async function resolveClientPath(): Promise { + const detected = detectClientPath() - const shouldInstall = await p.confirm({ - message: `@cipherstash/stack is not installed. Install it now? (${installCmd})`, + if (detected) { + const useDetected = await p.confirm({ + message: `Found encryption client at ${detected}. Use this path?`, initialValue: true, }) - if (p.isCancel(shouldInstall)) { - p.cancel('Setup cancelled.') - process.exit(0) - } - - if (shouldInstall) { - const s = p.spinner() - s.start('Installing @cipherstash/stack...') - - try { - execSync(installCmd, { stdio: 'pipe', encoding: 'utf-8' }) - s.stop('@cipherstash/stack installed.') - } catch (error) { - s.stop('Failed to install @cipherstash/stack.') - p.log.error( - error instanceof Error ? error.message : 'Unknown error occurred.', - ) - p.log.info(`You can install it manually: ${installCmd}`) - p.outro('Initialization aborted.') - process.exit(1) - } - } else { - p.log.info( - 'Continuing without @cipherstash/stack. You can install it later.', - ) - } - } - - // 2. Ask for database URL - const databaseUrl = await p.text({ - message: 'What is your database URL?', - placeholder: 'postgresql://user:password@localhost:5432/mydb', - defaultValue: process.env.DATABASE_URL, - initialValue: process.env.DATABASE_URL, - validate(value) { - if (!value || value.trim().length === 0) { - return 'Database URL is required.' - } - }, - }) - - if (p.isCancel(databaseUrl)) { - p.cancel('Setup cancelled.') - process.exit(0) + if (p.isCancel(useDetected)) return undefined + if (useDetected) return detected } - // 3. Ask which integration - const integration = await p.select({ - message: 'Which integration are you using?', - options: [ - { - value: 'drizzle', - label: 'Drizzle ORM', - hint: 'encryptedType column type with query operators', - }, - { - value: 'supabase', - label: 'Supabase', - hint: 'encryptedSupabase wrapper with transparent encryption', - }, - { - value: 'postgresql', - label: 'Plain PostgreSQL', - hint: 'encryptedTable/encryptedColumn with raw queries', - }, - ], - }) - - if (p.isCancel(integration)) { - p.cancel('Setup cancelled.') - process.exit(0) - } - - // 4. Ask for encryption client file path const clientPath = await p.text({ - message: 'Where should the encryption client file be created?', + message: 'Where is your encryption client file?', placeholder: './src/encryption/index.ts', defaultValue: './src/encryption/index.ts', - initialValue: './src/encryption/index.ts', + initialValue: detected ?? './src/encryption/index.ts', validate(value) { if (!value || value.trim().length === 0) { return 'Client file path is required.' @@ -138,405 +75,159 @@ export async function initCommand() { }, }) - if (p.isCancel(clientPath)) { - p.cancel('Setup cancelled.') - process.exit(0) - } - - // 5. Check if encryption client already exists - const resolvedClientPath = resolve(process.cwd(), clientPath) - const clientExists = existsSync(resolvedClientPath) - - let schema: SchemaDef | undefined - let skipClientGeneration = false - - if (clientExists) { - p.log.warn(`${clientPath} already exists.`) - - const overwriteChoice = await p.select<'keep' | 'overwrite'>({ - message: 'What would you like to do?', - options: [ - { - value: 'keep', - label: 'Keep existing file', - hint: 'skip schema setup and keep your current encryption client', - }, - { - value: 'overwrite', - label: 'Overwrite with a new schema', - hint: 'replace the file with a new encryption schema', - }, - ], - }) - - if (p.isCancel(overwriteChoice)) { - p.cancel('Setup cancelled.') - process.exit(0) - } - - if (overwriteChoice === 'keep') { - skipClientGeneration = true - } - } - - if (!skipClientGeneration) { - // 6. Ask whether to build a schema or use a placeholder - const schemaChoice = await p.select<'build' | 'placeholder'>({ - message: 'How would you like to set up your encryption schema?', - options: [ - { - value: 'build', - label: 'Build a schema now', - hint: 'interactive wizard to define your table and encrypted columns', - }, - { - value: 'placeholder', - label: 'Use a placeholder schema', - hint: 'generates an example schema you can edit later', - }, - ], - }) - - if (p.isCancel(schemaChoice)) { - p.cancel('Setup cancelled.') - process.exit(0) - } - - if (schemaChoice === 'build') { - schema = await buildSchema() - if (!schema) { - p.cancel('Setup cancelled.') - process.exit(0) - } - } - } - - // 7. Generate stash.config.ts - const configContent = generateConfig(clientPath) - writeFileSync(configPath, configContent, 'utf-8') - p.log.success(`Created ${CONFIG_FILENAME}`) + if (p.isCancel(clientPath)) return undefined + return clientPath +} - // 8. Generate encryption client file - if (skipClientGeneration) { - p.log.info(`Keeping existing ${clientPath}`) - } else { - const clientDir = dirname(resolvedClientPath) - mkdirSync(clientDir, { recursive: true }) +function generateConfig(clientPath: string): string { + return `import { defineConfig } from '@cipherstash/stack-forge' - const clientContent = schema - ? generateClientFromSchema(integration, schema) - : generatePlaceholderClient(integration) - writeFileSync(resolvedClientPath, clientContent, 'utf-8') - p.log.success(`${clientExists ? 'Overwrote' : 'Created'} ${clientPath}`) - } +export default defineConfig({ + databaseUrl: process.env.DATABASE_URL!, + client: '${clientPath}', +}) +` +} - // 8. Print next steps - const remainingSteps: string[] = [] +export async function setupCommand(options: SetupOptions = {}) { + p.intro('stash-forge setup') - if (!stackInstalled && !isPackageInstalled('@cipherstash/stack')) { - const pm = detectPackageManager() - const installCmd = - pm === 'yarn' - ? 'yarn add @cipherstash/stack' - : `${pm} install @cipherstash/stack` - remainingSteps.push(`Install dependencies:\n ${installCmd}`) + // 1. Check if stash.config.ts already exists + const configPath = resolve(process.cwd(), CONFIG_FILENAME) + if (existsSync(configPath) && !options.force) { + p.log.warn(`${CONFIG_FILENAME} already exists. Skipping setup.`) + p.log.info( + `Use --force to overwrite, or delete ${CONFIG_FILENAME} and re-run "stash-forge setup".`, + ) + p.outro('Nothing to do.') + return } - if ( - integration === 'supabase' && - !isPackageInstalled('@supabase/supabase-js') - ) { - const pm = detectPackageManager() - const installCmd = - pm === 'yarn' - ? 'yarn add @supabase/supabase-js' - : `${pm} install @supabase/supabase-js` - remainingSteps.push(`Install Supabase client:\n ${installCmd}`) + // 2. Auto-detect encryption client file path + const clientPath = await resolveClientPath() + if (!clientPath) { + p.cancel('Setup cancelled.') + process.exit(0) } - remainingSteps.push( - 'Set up your CipherStash credentials:\n Sign in at https://dashboard.cipherstash.com/sign-in\n Then set: CS_WORKSPACE_CRN, CS_CLIENT_ID, CS_CLIENT_KEY, CS_CLIENT_ACCESS_KEY', - 'Install the EQL extension in your database:\n npx stash-forge install', - `Edit your encryption schema in ${clientPath}`, - '(Optional) Push your encryption schema if using CipherStash Proxy:\n npx stash-forge push', - ) - - p.note( - remainingSteps.map((s, i) => `${i + 1}. ${s}`).join('\n\n'), - 'Next Steps', - ) - p.outro('CipherStash Forge initialized!') -} - -// --------------------------------------------------------------------------- -// Interactive schema builder -// --------------------------------------------------------------------------- - -async function buildSchema(): Promise { - const tableName = await p.text({ - message: 'What is the name of your table?', - placeholder: 'users', + // 3. Collect database URL + const databaseUrl = await p.text({ + message: 'What is your database URL?', + placeholder: 'postgresql://user:password@localhost:5432/mydb', + defaultValue: process.env.DATABASE_URL, + initialValue: process.env.DATABASE_URL, validate(value) { if (!value || value.trim().length === 0) { - return 'Table name is required.' - } - if (!/^[a-z_][a-z0-9_]*$/i.test(value)) { - return 'Table name must be a valid identifier (letters, numbers, underscores).' + return 'Database URL is required.' } }, }) - if (p.isCancel(tableName)) return undefined - - const columns: ColumnDef[] = [] - - p.log.info('Add encrypted columns to your table. You can add more later.') - - while (true) { - const column = await addColumn(columns.length + 1) - if (!column) return undefined // cancelled - - columns.push(column) - - const addMore = await p.confirm({ - message: 'Add another encrypted column?', - initialValue: false, - }) - - if (p.isCancel(addMore)) return undefined - if (!addMore) break + if (p.isCancel(databaseUrl)) { + p.cancel('Setup cancelled.') + process.exit(0) } - p.log.success( - `Schema defined: ${tableName} with ${columns.length} encrypted column${columns.length !== 1 ? 's' : ''}`, - ) - - return { tableName, columns } -} - -async function addColumn(index: number): Promise { - const name = await p.text({ - message: `Column ${index} name:`, - placeholder: index === 1 ? 'email' : 'name', - validate(value) { - if (!value || value.trim().length === 0) { - return 'Column name is required.' - } - if (!/^[a-z_][a-z0-9_]*$/i.test(value)) { - return 'Column name must be a valid identifier.' - } - }, - }) - - if (p.isCancel(name)) return undefined - - const dataType = await p.select({ - message: `Data type for "${name}":`, - options: [ - { value: 'string', label: 'string', hint: 'text, email, name, etc.' }, - { value: 'number', label: 'number', hint: 'integer or decimal' }, - { value: 'boolean', label: 'boolean' }, - { value: 'date', label: 'date', hint: 'Date object' }, - { value: 'json', label: 'json', hint: 'structured JSON data' }, - ], - }) - - if (p.isCancel(dataType)) return undefined - - // Build search operation options based on data type - const searchOptions: { value: SearchOp; label: string; hint: string }[] = [ - { - value: 'equality', - label: 'Exact match', - hint: 'eq, neq, in', - }, - { - value: 'orderAndRange', - label: 'Order and range', - hint: 'gt, gte, lt, lte, between, sorting', - }, - ] - - // Only offer free-text search for string types - if (dataType === 'string') { - searchOptions.push({ - value: 'freeTextSearch', - label: 'Free-text search', - hint: 'like, ilike, substring matching', - }) - } + // 4. Generate stash.config.ts + const configContent = generateConfig(clientPath) + writeFileSync(configPath, configContent, 'utf-8') + p.log.success(`Created ${CONFIG_FILENAME}`) - const searchOps = await p.multiselect({ - message: `Search operations for "${name}":`, - options: searchOptions, - required: false, + // 5. Install EQL extensions + const shouldInstall = await p.confirm({ + message: 'Install EQL extensions in your database now?', + initialValue: true, }) - if (p.isCancel(searchOps)) return undefined - - return { name, dataType, searchOps } -} - -// --------------------------------------------------------------------------- -// Code generation -// --------------------------------------------------------------------------- - -function generateConfig(clientPath: string): string { - return `import { defineConfig } from '@cipherstash/stack-forge' - -export default defineConfig({ - databaseUrl: process.env.DATABASE_URL!, - client: '${clientPath}', -}) -` -} - -function generateClientFromSchema( - integration: Integration, - schema: SchemaDef, -): string { - switch (integration) { - case 'drizzle': - return generateDrizzleFromSchema(schema) - case 'supabase': - case 'postgresql': - return generateSchemaFromDef(schema) - } -} - -function generatePlaceholderClient(integration: Integration): string { - const placeholder: SchemaDef = { - tableName: 'users', - columns: [ - { name: 'email', dataType: 'string', searchOps: ['equality', 'freeTextSearch'] }, - { name: 'name', dataType: 'string', searchOps: ['equality', 'freeTextSearch'] }, - ], + if (p.isCancel(shouldInstall)) { + p.cancel('Setup cancelled.') + process.exit(0) } - switch (integration) { - case 'drizzle': - return generateDrizzleFromSchema(placeholder) - case 'supabase': - case 'postgresql': - return generateSchemaFromDef(placeholder) + if (!shouldInstall) { + p.note( + 'You can install EQL later:\n npx stash-forge install', + 'Skipped Installation', + ) + p.outro('CipherStash Forge setup complete!') + return } -} -function generateDrizzleFromSchema(schema: SchemaDef): string { - const varName = toCamelCase(schema.tableName) + 'Table' - const schemaVarName = toCamelCase(schema.tableName) + 'Schema' + // 6. Determine install flags from database provider + const installOptions = await resolveInstallOptions(options) - const columnDefs = schema.columns.map((col) => { - const opts: string[] = [] - if (col.dataType !== 'string') { - opts.push(`dataType: '${col.dataType}'`) - } - if (col.searchOps.includes('equality')) { - opts.push('equality: true') - } - if (col.searchOps.includes('orderAndRange')) { - opts.push('orderAndRange: true') - } - if (col.searchOps.includes('freeTextSearch')) { - opts.push('freeTextSearch: true') - } - - const tsType = drizzleTsType(col.dataType) - const optsStr = opts.length > 0 ? `, {\n ${opts.join(',\n ')},\n }` : '' - return ` ${col.name}: encryptedType<${tsType}>('${col.name}'${optsStr}),` + await installCommand({ + ...installOptions, + drizzle: options.drizzle, + latest: options.latest, + name: options.name, + out: options.out, }) - - return `import { pgTable, integer, timestamp } from 'drizzle-orm/pg-core' -import { encryptedType, extractEncryptionSchema } from '@cipherstash/stack/drizzle' -import { Encryption } from '@cipherstash/stack' - -export const ${varName} = pgTable('${schema.tableName}', { - id: integer('id').primaryKey().generatedAlwaysAsIdentity(), -${columnDefs.join('\n')} - createdAt: timestamp('created_at').defaultNow(), -}) - -const ${schemaVarName} = extractEncryptionSchema(${varName}) - -export const encryptionClient = await Encryption({ - schemas: [${schemaVarName}], -}) -` } -function generateSchemaFromDef(schema: SchemaDef): string { - const varName = toCamelCase(schema.tableName) + 'Table' - - const columnDefs = schema.columns.map((col) => { - const parts: string[] = [` ${col.name}: encryptedColumn('${col.name}')`] - - if (col.dataType !== 'string') { - parts.push(`.dataType('${col.dataType}')`) - } - - for (const op of col.searchOps) { - parts.push(`.${op}()`) +type DatabaseProvider = + | 'supabase' + | 'neon' + | 'vercel-postgres' + | 'aws-rds' + | 'planetscale' + | 'prisma-postgres' + | 'other' + +/** + * Resolves install flags based on the user's database provider. + * Skips the prompt if `--supabase` was already passed as a CLI flag. + */ +async function resolveInstallOptions( + options: SetupOptions, +): Promise> { + // If --supabase was already passed, skip the prompt + if (options.supabase) { + return { + force: options.force, + dryRun: options.dryRun, + supabase: true, } + } - return parts.join('\n ') + ',' + const provider = await p.select({ + message: 'What Postgres database are you using?', + options: [ + { value: 'supabase', label: 'Supabase' }, + { value: 'neon', label: 'Neon' }, + { value: 'vercel-postgres', label: 'Vercel Postgres' }, + { value: 'aws-rds', label: 'AWS RDS' }, + { value: 'planetscale', label: 'PlanetScale' }, + { value: 'prisma-postgres', label: 'Prisma Postgres' }, + { value: 'other', label: 'Other / Self-hosted' }, + ], }) - return `import { encryptedTable, encryptedColumn } from '@cipherstash/stack/schema' -import { Encryption } from '@cipherstash/stack' - -export const ${varName} = encryptedTable('${schema.tableName}', { -${columnDefs.join('\n')} -}) - -export const encryptionClient = await Encryption({ - schemas: [${varName}], -}) -` -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function toCamelCase(str: string): string { - return str.replace(/_([a-z])/g, (_, c) => c.toUpperCase()) -} - -function drizzleTsType(dataType: DataType): string { - switch (dataType) { - case 'string': - return 'string' - case 'number': - return 'number' - case 'boolean': - return 'boolean' - case 'date': - return 'Date' - case 'json': - return 'Record' + if (p.isCancel(provider)) { + p.cancel('Setup cancelled.') + process.exit(0) } -} -function isPackageInstalled(packageName: string): boolean { - try { - require.resolve(`${packageName}/package.json`, { - paths: [process.cwd()], - }) - return true - } catch { - return false + switch (provider) { + case 'supabase': + return { + force: options.force, + dryRun: options.dryRun, + supabase: true, + } + case 'neon': + case 'vercel-postgres': + case 'planetscale': + case 'prisma-postgres': + return { + force: options.force, + dryRun: options.dryRun, + excludeOperatorFamily: true, + } + default: + return { + force: options.force, + dryRun: options.dryRun, + } } -} - -function detectPackageManager(): 'npm' | 'pnpm' | 'yarn' | 'bun' { - const cwd = process.cwd() - - if ( - existsSync(resolve(cwd, 'bun.lockb')) || - existsSync(resolve(cwd, 'bun.lock')) - ) - return 'bun' - if (existsSync(resolve(cwd, 'pnpm-lock.yaml'))) return 'pnpm' - if (existsSync(resolve(cwd, 'yarn.lock'))) return 'yarn' - return 'npm' -} +} \ No newline at end of file diff --git a/packages/stack/README.md b/packages/stack/README.md index 2a47bd5a..cb9a2b02 100644 --- a/packages/stack/README.md +++ b/packages/stack/README.md @@ -85,7 +85,7 @@ if (decrypted.failure) { - **Bulk operations** - Encrypt or decrypt thousands of values in a single ZeroKMS call (`bulkEncrypt`, `bulkDecrypt`, `bulkEncryptModels`, `bulkDecryptModels`). - **Identity-aware encryption** - Tie encryption to a user's JWT via `LockContext`, so only that user can decrypt. - **Secrets management** - Store, retrieve, list, and delete encrypted secrets with the `Secrets` class. -- **CLI (`stash`)** - Manage secrets from the terminal without writing code. +- **CLI (`stash`)** - Initialize projects, manage secrets, and set up encryption from the terminal. - **TypeScript-first** - Strongly typed schemas, results, and model operations with full generics support. ## Schema Definition @@ -430,6 +430,30 @@ await secrets.delete("DATABASE_URL") The `stash` CLI is bundled with the package and available after install. +### `stash init` + +Initialize CipherStash for your project with an interactive wizard. + +```bash +npx stash init +npx stash init --supabase +``` + +The wizard will: +1. Choose your database connection method (Drizzle ORM, Supabase JS, Prisma, or Raw SQL) +2. Build an encryption schema interactively or use a placeholder, then generate the encryption client file +3. Install `@cipherstash/stack-forge` as a dev dependency for database tooling + +After `stash init`, create a CipherStash account at [dashboard.cipherstash.com/sign-up](https://dashboard.cipherstash.com/sign-up) to get your credentials, then run `npx stash-forge setup` to configure your database connection. + +| Flag | Description | +|------|-------------| +| `--supabase` | Use Supabase-specific setup flow | + +### `stash secrets` + +Manage encrypted secrets from the terminal. + ```bash npx stash secrets set -name DATABASE_URL -value "postgres://..." -environment production npx stash secrets get -name DATABASE_URL -environment production @@ -437,8 +461,6 @@ npx stash secrets list -environment production npx stash secrets delete -name DATABASE_URL -environment production ``` -### Commands - | Command | Flags | Aliases | Description | |-----|----|-----|-------| | `stash secrets set` | `-name`, `-value`, `-environment` | `-n`, `-V`, `-e` | Encrypt and store a secret | diff --git a/packages/stack/src/bin/commands/init/index.ts b/packages/stack/src/bin/commands/init/index.ts index 03a02069..d22e0c38 100644 --- a/packages/stack/src/bin/commands/init/index.ts +++ b/packages/stack/src/bin/commands/init/index.ts @@ -1,13 +1,10 @@ import * as p from '@clack/prompts' import { createBaseProvider } from './providers/base.js' import { createSupabaseProvider } from './providers/supabase.js' -import { authenticateStep } from './steps/authenticate.js' -import { detectDatabaseUrlStep } from './steps/detect-database-url.js' -import { installEqlStep } from './steps/install-eql.js' +import { buildSchemaStep } from './steps/build-schema.js' +import { installForgeStep } from './steps/install-forge.js' import { nextStepsStep } from './steps/next-steps.js' import { selectConnectionStep } from './steps/select-connection.js' -import { selectRegionStep } from './steps/select-region.js' -import { selectWorkspaceStep } from './steps/select-workspace.js' import type { InitProvider, InitState } from './types.js' import { CancelledError } from './types.js' @@ -16,12 +13,9 @@ const PROVIDER_MAP: Record InitProvider> = { } const STEPS = [ - authenticateStep, - selectWorkspaceStep, - selectRegionStep, selectConnectionStep, - detectDatabaseUrlStep, - installEqlStep, + buildSchemaStep, + installForgeStep, nextStepsStep, ] @@ -38,9 +32,6 @@ export async function initCommand(flags: Record) { const provider = resolveProvider(flags) p.intro('CipherStash Stack Setup') - p.log.warn( - 'This command is a prototype and a sneak peek at what\'s next. It doesn\'t perform any actual setup yet.', - ) p.log.info(provider.introMessage) let state: InitState = {} diff --git a/packages/stack/src/bin/commands/init/providers/base.ts b/packages/stack/src/bin/commands/init/providers/base.ts index 7bd8f234..b1cd8c5d 100644 --- a/packages/stack/src/bin/commands/init/providers/base.ts +++ b/packages/stack/src/bin/commands/init/providers/base.ts @@ -5,25 +5,22 @@ export function createBaseProvider(): InitProvider { name: 'base', introMessage: 'Setting up CipherStash for your project...', connectionOptions: [ - { value: 'drizzle', label: 'Drizzle ORM', hint: 'recommended' }, + { value: 'drizzle', label: 'Drizzle ORM' }, + { value: 'supabase-js', label: 'Supabase JS Client' }, { value: 'prisma', label: 'Prisma' }, { value: 'raw-sql', label: 'Raw SQL / pg' }, ], getNextSteps(state: InitState): string[] { const steps = [ - 'Install @cipherstash/stack: npm install @cipherstash/stack', + 'Create a CipherStash account and get your credentials:\n https://dashboard.cipherstash.com/sign-up\n Then set: CS_WORKSPACE_CRN, CS_CLIENT_ID, CS_CLIENT_KEY, CS_CLIENT_ACCESS_KEY', + 'Set up your database: npx stash-forge setup', ] - if (state.connectionMethod === 'drizzle') { - steps.push('Import encryptedType from @cipherstash/stack/drizzle') - } else if (state.connectionMethod === 'prisma') { - steps.push('Set up Prisma with @cipherstash/stack') + if (state.clientFilePath) { + steps.push(`Edit your encryption schema: ${state.clientFilePath}`) } - steps.push( - 'Define your encrypted schema', - 'Read the docs: https://docs.cipherstash.com', - ) + steps.push('Read the docs: https://cipherstash.com/docs') return steps }, diff --git a/packages/stack/src/bin/commands/init/providers/supabase.ts b/packages/stack/src/bin/commands/init/providers/supabase.ts index 66c051cd..ccd80b97 100644 --- a/packages/stack/src/bin/commands/init/providers/supabase.ts +++ b/packages/stack/src/bin/commands/init/providers/supabase.ts @@ -16,22 +16,16 @@ export function createSupabaseProvider(): InitProvider { ], getNextSteps(state: InitState): string[] { const steps = [ - 'Install @cipherstash/stack: npm install @cipherstash/stack', + 'Create a CipherStash account and get your credentials:\n https://dashboard.cipherstash.com/sign-up\n Then set: CS_WORKSPACE_CRN, CS_CLIENT_ID, CS_CLIENT_KEY, CS_CLIENT_ACCESS_KEY', + 'Set up your database: npx stash-forge setup', ] - if (state.connectionMethod === 'supabase-js') { - steps.push('Import encryptedSupabase from @cipherstash/stack/supabase') - } else if (state.connectionMethod === 'drizzle') { - steps.push('Import encryptedType from @cipherstash/stack/drizzle') - } else if (state.connectionMethod === 'prisma') { - steps.push('Set up Prisma with @cipherstash/stack') + if (state.clientFilePath) { + steps.push(`Edit your encryption schema: ${state.clientFilePath}`) } steps.push( - 'Define your encrypted schema', - 'Supabase guides: https://cipherstash.com/docs/supabase/encrypt-user-data', - 'Multi-tenant encryption: https://docs.cipherstash.com/docs/multi-tenant', - 'Migrating existing data: https://docs.cipherstash.com/docs/migration', + 'Supabase guides: https://cipherstash.com/docs/stack/encryption/supabase', 'Need help? #supabase in Discord or support@cipherstash.com', ) diff --git a/packages/stack/src/bin/commands/init/steps/authenticate.ts b/packages/stack/src/bin/commands/init/steps/authenticate.ts deleted file mode 100644 index 6cf1ecd6..00000000 --- a/packages/stack/src/bin/commands/init/steps/authenticate.ts +++ /dev/null @@ -1,28 +0,0 @@ -import * as p from '@clack/prompts' -import { pollForToken, startDeviceCodeAuth } from '../stubs.js' -import type { InitProvider, InitState, InitStep } from '../types.js' -import { CancelledError } from '../types.js' - -export const authenticateStep: InitStep = { - id: 'authenticate', - name: 'Authenticate', - async run(state: InitState, _provider: InitProvider): Promise { - const s = p.spinner() - - s.start('Starting authentication...') - const { verificationUrl, userCode, deviceCode } = - await startDeviceCodeAuth() - s.stop('Authentication started') - - p.note( - `Open: ${verificationUrl}\nCode: ${userCode}`, - 'Authenticate with CipherStash', - ) - - s.start('Waiting for authentication...') - const token = await pollForToken(deviceCode) - s.stop('Authenticated successfully') - - return { ...state, accessToken: token.accessToken } - }, -} diff --git a/packages/stack/src/bin/commands/init/steps/build-schema.ts b/packages/stack/src/bin/commands/init/steps/build-schema.ts new file mode 100644 index 00000000..fd7a7165 --- /dev/null +++ b/packages/stack/src/bin/commands/init/steps/build-schema.ts @@ -0,0 +1,205 @@ +import { existsSync, mkdirSync, writeFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import * as p from '@clack/prompts' +import type { + ColumnDef, + DataType, + InitProvider, + InitState, + InitStep, + SearchOp, +} from '../types.js' +import { CancelledError, toIntegration } from '../types.js' +import { + generateClientFromSchema, + generatePlaceholderClient, +} from '../utils.js' + +const DEFAULT_CLIENT_PATH = './src/encryption/index.ts' + +async function addColumn(index: number): Promise { + const name = await p.text({ + message: `Column ${index} name:`, + placeholder: index === 1 ? 'email' : 'name', + validate(value) { + if (!value || value.trim().length === 0) { + return 'Column name is required.' + } + if (!/^[a-z_][a-z0-9_]*$/i.test(value)) { + return 'Column name must be a valid identifier.' + } + }, + }) + + if (p.isCancel(name)) return undefined + + const dataType = await p.select({ + message: `Data type for "${name}":`, + options: [ + { value: 'string', label: 'string', hint: 'text, email, name, etc.' }, + { value: 'number', label: 'number', hint: 'integer or decimal' }, + { value: 'boolean', label: 'boolean' }, + { value: 'date', label: 'date', hint: 'Date object' }, + { value: 'json', label: 'json', hint: 'structured JSON data' }, + ], + }) + + if (p.isCancel(dataType)) return undefined + + const searchOptions: Array<{ value: SearchOp; label: string; hint: string }> = + [ + { value: 'equality', label: 'Exact match', hint: 'eq, neq, in' }, + { + value: 'orderAndRange', + label: 'Order and range', + hint: 'gt, gte, lt, lte, between, sorting', + }, + ] + + if (dataType === 'string') { + searchOptions.push({ + value: 'freeTextSearch', + label: 'Free-text search', + hint: 'like, ilike, substring matching', + }) + } + + const searchOps = await p.multiselect({ + message: `Search operations for "${name}":`, + options: searchOptions, + required: false, + }) + + if (p.isCancel(searchOps)) return undefined + + return { name, dataType, searchOps } +} + +async function buildSchema(): Promise< + { tableName: string; columns: ColumnDef[] } | undefined +> { + const tableName = await p.text({ + message: 'What is the name of your table?', + placeholder: 'users', + validate(value) { + if (!value || value.trim().length === 0) { + return 'Table name is required.' + } + if (!/^[a-z_][a-z0-9_]*$/i.test(value)) { + return 'Table name must be a valid identifier (letters, numbers, underscores).' + } + }, + }) + + if (p.isCancel(tableName)) return undefined + + const columns: ColumnDef[] = [] + + p.log.info('Add encrypted columns to your table. You can add more later.') + + while (true) { + const column = await addColumn(columns.length + 1) + if (!column) return undefined + + columns.push(column) + + const addMore = await p.confirm({ + message: 'Add another encrypted column?', + initialValue: false, + }) + + if (p.isCancel(addMore)) return undefined + if (!addMore) break + } + + p.log.success( + `Schema defined: ${tableName} with ${columns.length} encrypted column${columns.length !== 1 ? 's' : ''}`, + ) + + return { tableName, columns } +} + +export const buildSchemaStep: InitStep = { + id: 'build-schema', + name: 'Build encryption schema', + async run(state: InitState, _provider: InitProvider): Promise { + if (!state.connectionMethod) { + p.log.warn('Skipping schema generation (no connection method selected)') + return { ...state, schemaGenerated: false } + } + + const integration = toIntegration(state.connectionMethod) + + const clientFilePath = await p.text({ + message: 'Where should we create your encryption client?', + placeholder: DEFAULT_CLIENT_PATH, + defaultValue: DEFAULT_CLIENT_PATH, + }) + + if (p.isCancel(clientFilePath)) throw new CancelledError() + + const resolvedPath = resolve(process.cwd(), clientFilePath) + + // If the file already exists, ask what to do + if (existsSync(resolvedPath)) { + const action = await p.select({ + message: `${clientFilePath} already exists. What would you like to do?`, + options: [ + { + value: 'keep', + label: 'Keep existing file', + hint: 'skip code generation', + }, + { value: 'overwrite', label: 'Overwrite with new schema' }, + ], + }) + + if (p.isCancel(action)) throw new CancelledError() + + if (action === 'keep') { + p.log.info('Keeping existing encryption client file.') + return { ...state, clientFilePath, schemaGenerated: false } + } + } + + // Ask whether to build a schema interactively or use a placeholder + const schemaChoice = await p.select({ + message: 'How would you like to set up your encryption schema?', + options: [ + { + value: 'build', + label: 'Build schema now', + hint: 'interactive wizard', + }, + { + value: 'placeholder', + label: 'Use placeholder schema', + hint: 'edit later', + }, + ], + }) + + if (p.isCancel(schemaChoice)) throw new CancelledError() + + let fileContents: string + + if (schemaChoice === 'build') { + const schema = await buildSchema() + if (!schema) throw new CancelledError() + fileContents = generateClientFromSchema(integration, schema) + } else { + fileContents = generatePlaceholderClient(integration) + } + + // Write the file + const dir = dirname(resolvedPath) + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }) + } + + writeFileSync(resolvedPath, fileContents, 'utf-8') + p.log.success(`Encryption client written to ${clientFilePath}`) + + return { ...state, clientFilePath, schemaGenerated: true } + }, +} diff --git a/packages/stack/src/bin/commands/init/steps/detect-database-url.ts b/packages/stack/src/bin/commands/init/steps/detect-database-url.ts deleted file mode 100644 index ce72627f..00000000 --- a/packages/stack/src/bin/commands/init/steps/detect-database-url.ts +++ /dev/null @@ -1,50 +0,0 @@ -import * as p from '@clack/prompts' -import type { InitProvider, InitState, InitStep } from '../types.js' -import { CancelledError } from '../types.js' - -function maskUrl(url: string): string { - return url.replace(/:\/\/([^:]+):([^@]+)@/, '://***@') -} - -export const detectDatabaseUrlStep: InitStep = { - id: 'detect-database-url', - name: 'Detect database URL', - async run(state: InitState, _provider: InitProvider): Promise { - const envUrl = process.env.DATABASE_URL - - if (envUrl) { - p.log.success(`Detected DATABASE_URL in .env\n ${maskUrl(envUrl)}`) - return { ...state, databaseUrl: envUrl } - } - - p.log.warn('No DATABASE_URL found in .env') - - const action = await p.select({ - message: 'How would you like to proceed?', - options: [ - { value: 'enter', label: 'Enter database URL now' }, - { value: 'skip', label: "Skip for now (I'll add it later)" }, - ], - }) - - if (p.isCancel(action)) throw new CancelledError() - - if (action === 'enter') { - const url = await p.text({ - message: 'Enter your database URL:', - placeholder: 'postgresql://user:password@host:5432/database', - validate: (val) => { - if (!val.trim()) return 'Database URL is required' - if (!val.startsWith('postgres')) - return 'Must be a PostgreSQL connection string' - }, - }) - - if (p.isCancel(url)) throw new CancelledError() - - return { ...state, databaseUrl: url } - } - - return state - }, -} diff --git a/packages/stack/src/bin/commands/init/steps/install-eql.ts b/packages/stack/src/bin/commands/init/steps/install-eql.ts deleted file mode 100644 index 414b9fa8..00000000 --- a/packages/stack/src/bin/commands/init/steps/install-eql.ts +++ /dev/null @@ -1,48 +0,0 @@ -import * as p from '@clack/prompts' -import { installEqlExtension } from '../stubs.js' -import type { InitProvider, InitState, InitStep } from '../types.js' -import { CancelledError } from '../types.js' - -export const installEqlStep: InitStep = { - id: 'install-eql', - name: 'Install EQL extension', - async run(state: InitState, _provider: InitProvider): Promise { - if (!state.databaseUrl) { - p.log.warn('Skipping EQL extension installation (no database URL)') - return { ...state, eqlInstalled: false } - } - - const install = await p.confirm({ - message: 'Install the EQL encryption extension in your database?', - }) - - if (p.isCancel(install)) throw new CancelledError() - - if (!install) { - p.log.info('Skipping EQL extension installation') - p.note( - 'You can install it manually later:\n CREATE EXTENSION IF NOT EXISTS eql_v2;\n\nOr re-run this command:\n npx @cipherstash/stack init', - 'Manual Installation', - ) - return { ...state, eqlInstalled: false } - } - - const s = p.spinner() - s.start('Installing EQL extension...') - - try { - await installEqlExtension(state.databaseUrl) - s.stop('EQL extension installed successfully') - return { ...state, eqlInstalled: true } - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - s.stop('EQL extension installation failed') - p.log.error(message) - p.note( - 'You can install it manually:\n CREATE EXTENSION IF NOT EXISTS eql_v2;\n\nOr install via Database.dev:\n https://database.dev/cipherstash/eql', - 'Manual Installation', - ) - return { ...state, eqlInstalled: false } - } - }, -} diff --git a/packages/stack/src/bin/commands/init/steps/install-forge.ts b/packages/stack/src/bin/commands/init/steps/install-forge.ts new file mode 100644 index 00000000..05646807 --- /dev/null +++ b/packages/stack/src/bin/commands/init/steps/install-forge.ts @@ -0,0 +1,55 @@ +import { execSync } from 'node:child_process' +import * as p from '@clack/prompts' +import type { InitProvider, InitState, InitStep } from '../types.js' +import { CancelledError } from '../types.js' +import { + detectPackageManager, + devInstallCommand, + isPackageInstalled, +} from '../utils.js' + +const FORGE_PACKAGE = '@cipherstash/stack-forge' + +export const installForgeStep: InitStep = { + id: 'install-forge', + name: 'Install stack-forge', + async run(state: InitState, _provider: InitProvider): Promise { + if (isPackageInstalled(FORGE_PACKAGE)) { + p.log.success(`${FORGE_PACKAGE} is already installed.`) + return { ...state, forgeInstalled: true } + } + + const pm = detectPackageManager() + const cmd = devInstallCommand(pm, FORGE_PACKAGE) + + const install = await p.confirm({ + message: `Install ${FORGE_PACKAGE} as a dev dependency? (${cmd})`, + }) + + if (p.isCancel(install)) throw new CancelledError() + + if (!install) { + p.log.info(`Skipping ${FORGE_PACKAGE} installation.`) + p.note( + `You can install it manually later:\n ${cmd}`, + 'Manual Installation', + ) + return { ...state, forgeInstalled: false } + } + + const s = p.spinner() + s.start(`Installing ${FORGE_PACKAGE}...`) + + try { + execSync(cmd, { cwd: process.cwd(), stdio: 'pipe' }) + s.stop(`${FORGE_PACKAGE} installed successfully`) + return { ...state, forgeInstalled: true } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + s.stop(`${FORGE_PACKAGE} installation failed`) + p.log.error(message) + p.note(`You can install it manually:\n ${cmd}`, 'Manual Installation') + return { ...state, forgeInstalled: false } + } + }, +} diff --git a/packages/stack/src/bin/commands/init/steps/select-connection.ts b/packages/stack/src/bin/commands/init/steps/select-connection.ts index a8e21837..b0ba880c 100644 --- a/packages/stack/src/bin/commands/init/steps/select-connection.ts +++ b/packages/stack/src/bin/commands/init/steps/select-connection.ts @@ -1,5 +1,10 @@ import * as p from '@clack/prompts' -import type { InitProvider, InitState, InitStep } from '../types.js' +import type { + ConnectionMethod, + InitProvider, + InitState, + InitStep, +} from '../types.js' import { CancelledError } from '../types.js' export const selectConnectionStep: InitStep = { @@ -13,6 +18,6 @@ export const selectConnectionStep: InitStep = { if (p.isCancel(method)) throw new CancelledError() - return { ...state, connectionMethod: method as string } + return { ...state, connectionMethod: method as ConnectionMethod } }, } diff --git a/packages/stack/src/bin/commands/init/steps/select-region.ts b/packages/stack/src/bin/commands/init/steps/select-region.ts deleted file mode 100644 index db29757f..00000000 --- a/packages/stack/src/bin/commands/init/steps/select-region.ts +++ /dev/null @@ -1,25 +0,0 @@ -import * as p from '@clack/prompts' -import type { InitProvider, InitState, InitStep } from '../types.js' -import { CancelledError } from '../types.js' - -const REGIONS = [ - { value: 'ap-southeast-2', label: 'Asia Pacific (Sydney)' }, - { value: 'us-east-1', label: 'US East (N. Virginia)' }, - { value: 'us-west-2', label: 'US West (Oregon)' }, - { value: 'eu-west-1', label: 'Europe (Ireland)' }, -] - -export const selectRegionStep: InitStep = { - id: 'select-region', - name: 'Select region', - async run(state: InitState, _provider: InitProvider): Promise { - const region = await p.select({ - message: 'Where should we create your workspace?', - options: REGIONS, - }) - - if (p.isCancel(region)) throw new CancelledError() - - return { ...state, region: region as string } - }, -} diff --git a/packages/stack/src/bin/commands/init/steps/select-workspace.ts b/packages/stack/src/bin/commands/init/steps/select-workspace.ts deleted file mode 100644 index 866a00f2..00000000 --- a/packages/stack/src/bin/commands/init/steps/select-workspace.ts +++ /dev/null @@ -1,49 +0,0 @@ -import * as p from '@clack/prompts' -import { createWorkspace, fetchWorkspaces } from '../stubs.js' -import type { InitProvider, InitState, InitStep } from '../types.js' -import { CancelledError } from '../types.js' - -export const selectWorkspaceStep: InitStep = { - id: 'select-workspace', - name: 'Select workspace', - async run(state: InitState, _provider: InitProvider): Promise { - const s = p.spinner() - s.start('Loading workspaces...') - const workspaces = await fetchWorkspaces(state.accessToken!) - s.stop('Workspaces loaded') - - const options = [ - ...workspaces.map((ws) => ({ value: ws.id, label: ws.name })), - { value: '__create__', label: 'Create new workspace' }, - ] - - const selected = await p.select({ - message: 'Select a workspace', - options, - }) - - if (p.isCancel(selected)) throw new CancelledError() - - if (selected === '__create__') { - const name = await p.text({ - message: 'What should we call your new workspace?', - placeholder: 'my-project', - validate: (val) => { - if (!val.trim()) return 'Workspace name is required' - }, - }) - - if (p.isCancel(name)) throw new CancelledError() - - const s2 = p.spinner() - s2.start('Creating workspace...') - const ws = await createWorkspace(state.accessToken!, name) - s2.stop(`Workspace created: ${ws.name}`) - - return { ...state, workspaceId: ws.id, workspaceName: ws.name } - } - - const ws = workspaces.find((w) => w.id === selected)! - return { ...state, workspaceId: ws.id, workspaceName: ws.name } - }, -} diff --git a/packages/stack/src/bin/commands/init/stubs.ts b/packages/stack/src/bin/commands/init/stubs.ts deleted file mode 100644 index 121ba487..00000000 --- a/packages/stack/src/bin/commands/init/stubs.ts +++ /dev/null @@ -1,85 +0,0 @@ -// TODO: Replace all stubs with real API calls - -export interface DeviceCodeResponse { - verificationUrl: string - userCode: string - deviceCode: string - expiresIn: number - interval: number -} - -export interface TokenResponse { - accessToken: string - refreshToken: string - expiresIn: number -} - -export interface Workspace { - id: string - name: string -} - -function delay(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)) -} - -/** - * TODO: POST to auth service `/device/code` endpoint - */ -export async function startDeviceCodeAuth(): Promise { - await delay(500) - return { - verificationUrl: 'https://cipherstash.com/activate', - userCode: 'ABCD-1234', - deviceCode: 'device_code_placeholder', - expiresIn: 900, - interval: 5, - } -} - -/** - * TODO: Poll POST `/device/token` until user completes auth - */ -export async function pollForToken(deviceCode: string): Promise { - void deviceCode - await delay(2000) - return { - accessToken: 'stub_access_token', - refreshToken: 'stub_refresh_token', - expiresIn: 3600, - } -} - -/** - * TODO: GET `/workspaces` with Bearer token - */ -export async function fetchWorkspaces( - accessToken: string, -): Promise { - void accessToken - await delay(300) - return [ - { id: 'ws_1', name: 'My First Workspace' }, - { id: 'ws_2', name: 'Production' }, - ] -} - -/** - * TODO: POST `/workspaces` with `{ name }` - */ -export async function createWorkspace( - accessToken: string, - name: string, -): Promise { - void accessToken - await delay(500) - return { id: `ws_${Date.now()}`, name } -} - -/** - * TODO: Connect to database and run `CREATE EXTENSION IF NOT EXISTS eql_v2` - */ -export async function installEqlExtension(databaseUrl: string): Promise { - void databaseUrl - await delay(1500) -} diff --git a/packages/stack/src/bin/commands/init/types.ts b/packages/stack/src/bin/commands/init/types.ts index 0291024b..ff9b00d5 100644 --- a/packages/stack/src/bin/commands/init/types.ts +++ b/packages/stack/src/bin/commands/init/types.ts @@ -1,11 +1,27 @@ +export type ConnectionMethod = 'drizzle' | 'supabase-js' | 'prisma' | 'raw-sql' + +export type Integration = 'drizzle' | 'supabase' | 'postgresql' + +export type DataType = 'string' | 'number' | 'boolean' | 'date' | 'json' + +export type SearchOp = 'equality' | 'orderAndRange' | 'freeTextSearch' + +export interface ColumnDef { + name: string + dataType: DataType + searchOps: SearchOp[] +} + +export interface SchemaDef { + tableName: string + columns: ColumnDef[] +} + export interface InitState { - accessToken?: string - workspaceId?: string - workspaceName?: string - region?: string - connectionMethod?: string - databaseUrl?: string - eqlInstalled?: boolean + connectionMethod?: ConnectionMethod + clientFilePath?: string + schemaGenerated?: boolean + forgeInstalled?: boolean } export interface InitStep { @@ -17,7 +33,11 @@ export interface InitStep { export interface InitProvider { name: string introMessage: string - connectionOptions: Array<{ value: string; label: string; hint?: string }> + connectionOptions: Array<{ + value: ConnectionMethod + label: string + hint?: string + }> getNextSteps(state: InitState): string[] } @@ -27,3 +47,16 @@ export class CancelledError extends Error { this.name = 'CancelledError' } } + +/** Maps a connection method to the code generation integration type. */ +export function toIntegration(method: ConnectionMethod): Integration { + switch (method) { + case 'drizzle': + return 'drizzle' + case 'supabase-js': + return 'supabase' + case 'prisma': + case 'raw-sql': + return 'postgresql' + } +} diff --git a/packages/stack/src/bin/commands/init/utils.ts b/packages/stack/src/bin/commands/init/utils.ts new file mode 100644 index 00000000..edcc885b --- /dev/null +++ b/packages/stack/src/bin/commands/init/utils.ts @@ -0,0 +1,169 @@ +import { existsSync } from 'node:fs' +import { resolve } from 'node:path' +import type { Integration, SchemaDef } from './types.js' + +/** + * Checks if a package is installed in the current project by looking + * for its directory in node_modules. + */ +export function isPackageInstalled(packageName: string): boolean { + const modulePath = resolve(process.cwd(), 'node_modules', packageName) + return existsSync(modulePath) +} + +/** Detects the package manager used in the current project by checking lock files. */ +export function detectPackageManager(): 'npm' | 'pnpm' | 'yarn' | 'bun' { + const cwd = process.cwd() + if ( + existsSync(resolve(cwd, 'bun.lockb')) || + existsSync(resolve(cwd, 'bun.lock')) + ) + return 'bun' + if (existsSync(resolve(cwd, 'pnpm-lock.yaml'))) return 'pnpm' + if (existsSync(resolve(cwd, 'yarn.lock'))) return 'yarn' + return 'npm' +} + +/** Returns the install command for adding a dev dependency with the given package manager. */ +export function devInstallCommand( + pm: ReturnType, + packageName: string, +): string { + switch (pm) { + case 'bun': + return `bun add -D ${packageName}` + case 'pnpm': + return `pnpm add -D ${packageName}` + case 'yarn': + return `yarn add -D ${packageName}` + case 'npm': + return `npm install -D ${packageName}` + } +} + +function toCamelCase(str: string): string { + return str.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase()) +} + +function drizzleTsType(dataType: string): string { + switch (dataType) { + case 'number': + return 'number' + case 'boolean': + return 'boolean' + case 'date': + return 'Date' + case 'json': + return 'Record' + default: + return 'string' + } +} + +function generateDrizzleFromSchema(schema: SchemaDef): string { + const varName = `${toCamelCase(schema.tableName)}Table` + const schemaVarName = `${toCamelCase(schema.tableName)}Schema` + + const columnDefs = schema.columns.map((col) => { + const opts: string[] = [] + if (col.dataType !== 'string') { + opts.push(`dataType: '${col.dataType}'`) + } + if (col.searchOps.includes('equality')) { + opts.push('equality: true') + } + if (col.searchOps.includes('orderAndRange')) { + opts.push('orderAndRange: true') + } + if (col.searchOps.includes('freeTextSearch')) { + opts.push('freeTextSearch: true') + } + + const tsType = drizzleTsType(col.dataType) + const optsStr = + opts.length > 0 ? `, {\n ${opts.join(',\n ')},\n }` : '' + return ` ${col.name}: encryptedType<${tsType}>('${col.name}'${optsStr}),` + }) + + return `import { pgTable, integer, timestamp } from 'drizzle-orm/pg-core' +import { encryptedType, extractEncryptionSchema } from '@cipherstash/stack/drizzle' +import { Encryption } from '@cipherstash/stack' + +export const ${varName} = pgTable('${schema.tableName}', { + id: integer('id').primaryKey().generatedAlwaysAsIdentity(), +${columnDefs.join('\n')} + createdAt: timestamp('created_at').defaultNow(), +}) + +const ${schemaVarName} = extractEncryptionSchema(${varName}) + +export const encryptionClient = await Encryption({ + schemas: [${schemaVarName}], +}) +` +} + +function generateSchemaFromDef(schema: SchemaDef): string { + const varName = `${toCamelCase(schema.tableName)}Table` + + const columnDefs = schema.columns.map((col) => { + const parts: string[] = [` ${col.name}: encryptedColumn('${col.name}')`] + + if (col.dataType !== 'string') { + parts.push(`.dataType('${col.dataType}')`) + } + + for (const op of col.searchOps) { + parts.push(`.${op}()`) + } + + return `${parts.join('\n ')},` + }) + + return `import { encryptedTable, encryptedColumn } from '@cipherstash/stack/schema' +import { Encryption } from '@cipherstash/stack' + +export const ${varName} = encryptedTable('${schema.tableName}', { +${columnDefs.join('\n')} +}) + +export const encryptionClient = await Encryption({ + schemas: [${varName}], +}) +` +} + +/** Generates the encryption client file contents for a given integration and schema. */ +export function generateClientFromSchema( + integration: Integration, + schema: SchemaDef, +): string { + switch (integration) { + case 'drizzle': + return generateDrizzleFromSchema(schema) + case 'supabase': + case 'postgresql': + return generateSchemaFromDef(schema) + } +} + +/** Generates an encryption client file with a placeholder schema for getting started. */ +export function generatePlaceholderClient(integration: Integration): string { + const placeholder: SchemaDef = { + tableName: 'users', + columns: [ + { + name: 'email', + dataType: 'string', + searchOps: ['equality', 'freeTextSearch'], + }, + { + name: 'name', + dataType: 'string', + searchOps: ['equality', 'freeTextSearch'], + }, + ], + } + + return generateClientFromSchema(integration, placeholder) +} diff --git a/skills/stash-forge/SKILL.md b/skills/stash-forge/SKILL.md index b99d9d18..dfec14e6 100644 --- a/skills/stash-forge/SKILL.md +++ b/skills/stash-forge/SKILL.md @@ -1,6 +1,6 @@ --- name: stash-forge -description: Configure and use `@cipherstash/stack-forge` for EQL database setup, encryption schema management, Supabase and Drizzle integrations. Use when adding encryption to a project, defining encrypted schemas, or setting up CipherStash EQL in a database. +description: Configure and use `@cipherstash/stack-forge` for EQL database setup, encryption schema management, and Supabase integration. --- # CipherStash Stack - Stash Forge @@ -35,6 +35,7 @@ import { defineConfig } from '@cipherstash/stack-forge' export default defineConfig({ databaseUrl: process.env.DATABASE_URL!, + client: './src/encryption/index.ts', }) ``` @@ -60,29 +61,39 @@ The primary interface is the `stash-forge` CLI, run via `npx`: npx stash-forge [options] ``` -### `init` — Initialize CipherStash Forge in your project +### `setup` — Configure database and install EQL extensions -Interactive wizard that scaffolds your project for CipherStash encryption. +Interactive wizard that configures your database connection and installs EQL. Run this after `stash init` has set up your encryption schema. ```bash -npx stash-forge init +npx stash-forge setup +npx stash-forge setup --supabase +npx stash-forge setup --force +npx stash-forge setup --drizzle ``` The wizard will: -1. Check if `@cipherstash/stack` is installed and prompt to install it (auto-detects npm/pnpm/yarn/bun) -2. Ask for your database URL (pre-fills from `DATABASE_URL` env var if set) -3. Ask which integration you're using (Drizzle ORM, Supabase, or plain PostgreSQL) -4. Ask where to create the encryption client file (default: `./src/encryption/index.ts`) -5. If the client file already exists, ask whether to keep it or overwrite it -6. Let you choose between: - - **Build a schema now** — interactive wizard: table name, column names, data types (string/number/boolean/date/json), and search operations (exact match, order and range, free-text search) for each column - - **Use a placeholder schema** — generates an example `users` table with `email` and `name` columns -7. Generate `stash.config.ts` and the encryption client file -8. Print next steps with a link to the [CipherStash dashboard](https://dashboard.cipherstash.com/sign-in) for credentials - -The generated client file uses the correct imports for the chosen integration: -- **Drizzle:** `encryptedType`, `extractEncryptionSchema` from `@cipherstash/stack/drizzle` -- **Supabase/PostgreSQL:** `encryptedTable`, `encryptedColumn` from `@cipherstash/stack/schema` +1. Auto-detect the encryption client file by scanning common locations (`./src/encryption/index.ts`, etc.), then confirm or ask for the path +2. Ask for the database URL (pre-fills from `DATABASE_URL` env var if set) +3. Generate `stash.config.ts` with the database URL and client path +4. Ask to install EQL extensions now +5. If installing, ask which Postgres provider is being used to determine the right install flags: + - **Supabase** — uses `--supabase` (no operator families + Supabase role grants) + - **Neon, Vercel Postgres, PlanetScale, Prisma Postgres** — uses `--exclude-operator-family` + - **AWS RDS, Other / Self-hosted** — standard install +6. Install EQL extensions in the database + +If `--supabase` is passed as a flag, the provider selection is skipped. + +**Flags:** +| 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 using the bundled version | ### `install` — Install EQL extension to the database @@ -146,16 +157,9 @@ You then run `npx drizzle-kit migrate` to apply it. Requires `drizzle-kit` as a Upgrade an existing EQL installation to the version bundled with the package (or latest from GitHub). ```bash -# Upgrade using bundled SQL npx stash-forge upgrade - -# Preview what would happen npx stash-forge upgrade --dry-run - -# Upgrade with Supabase-compatible SQL npx stash-forge upgrade --supabase - -# Fetch latest from GitHub npx stash-forge upgrade --latest ``` @@ -174,13 +178,8 @@ The EQL install SQL is idempotent and safe to re-run. The command checks the cur Validate your encryption schema for common misconfigurations. ```bash -# Basic validation npx stash-forge validate - -# Validate with Supabase context npx stash-forge validate --supabase - -# Validate with operator family exclusion context npx stash-forge validate --exclude-operator-family ``` @@ -211,10 +210,7 @@ import { validateEncryptConfig, reportIssues } from '@cipherstash/stack-forge' This command is **only required when using CipherStash Proxy**. If you're using the SDK directly (Drizzle, Supabase, or plain PostgreSQL), this step is not needed — the schema lives in your application code as the source of truth. ```bash -# Push schema to the database npx stash-forge push - -# Preview the schema as JSON without writing to the database npx stash-forge push --dry-run ``` @@ -232,8 +228,6 @@ When pushing, stash-forge: **SDK to EQL type mapping:** -The SDK uses developer-friendly type names (e.g. `'string'`, `'number'`), but EQL expects PostgreSQL-aligned types. The `push` command automatically maps these before writing to the database: - | SDK type (`dataType()`) | EQL `cast_as` | |-------------------------|---------------| | `string` | `text` | @@ -268,10 +262,6 @@ Verifies the database URL in your config is valid and the database is reachable. Useful for debugging connection issues before running `install` or other commands. -### Other commands (planned) - -- `migrate` — Run pending encrypt config migrations - ## Programmatic API ### `defineConfig(config: StashConfig): StashConfig`