From 37fa11c316db2d1f84b8d19d9fe1811d667ebafc Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood Date: Thu, 30 Apr 2026 23:36:22 +1000 Subject: [PATCH 01/12] feat(cli): add runnerCommand helper for package-manager-aware exec --- .../src/commands/init/__tests__/utils.test.ts | 33 +++++++++++++++++++ packages/cli/src/commands/init/utils.ts | 25 ++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/packages/cli/src/commands/init/__tests__/utils.test.ts b/packages/cli/src/commands/init/__tests__/utils.test.ts index 73c6571d..e53d6489 100644 --- a/packages/cli/src/commands/init/__tests__/utils.test.ts +++ b/packages/cli/src/commands/init/__tests__/utils.test.ts @@ -6,6 +6,7 @@ import { detectPackageManager, devInstallCommand, prodInstallCommand, + runnerCommand, } from '../utils.js' describe('detectPackageManager', () => { @@ -133,3 +134,35 @@ describe('devInstallCommand', () => { ) }) }) + +describe('runnerCommand', () => { + it('returns npx for npm', () => { + expect(runnerCommand('npm', '@cipherstash/cli')).toBe( + 'npx @cipherstash/cli', + ) + }) + + it('returns bunx for bun', () => { + expect(runnerCommand('bun', '@cipherstash/cli')).toBe( + 'bunx @cipherstash/cli', + ) + }) + + it('returns pnpm dlx for pnpm', () => { + expect(runnerCommand('pnpm', '@cipherstash/cli')).toBe( + 'pnpm dlx @cipherstash/cli', + ) + }) + + it('returns yarn dlx for yarn', () => { + expect(runnerCommand('yarn', '@cipherstash/cli')).toBe( + 'yarn dlx @cipherstash/cli', + ) + }) + + it('passes the package reference through verbatim (multi-word args allowed)', () => { + expect(runnerCommand('bun', '@cipherstash/cli db install')).toBe( + 'bunx @cipherstash/cli db install', + ) + }) +}) diff --git a/packages/cli/src/commands/init/utils.ts b/packages/cli/src/commands/init/utils.ts index 8d289bc1..40c9d19c 100644 --- a/packages/cli/src/commands/init/utils.ts +++ b/packages/cli/src/commands/init/utils.ts @@ -113,6 +113,31 @@ export function combinedInstallCommands( return commands } +/** + * Returns the one-shot remote-execution command for the given package + * manager, ready to prefix a package reference. We mirror what each tool + * documents: + * npm → `npx` + * bun → `bunx` + * pnpm → `pnpm dlx` + * yarn → `yarn dlx` + * + * `ref` is appended verbatim, so callers may pass `'@cipherstash/cli'` + * or `'@cipherstash/cli db install'`. + */ +export function runnerCommand(pm: PackageManager, ref: string): string { + switch (pm) { + case 'bun': + return `bunx ${ref}` + case 'pnpm': + return `pnpm dlx ${ref}` + case 'yarn': + return `yarn dlx ${ref}` + case 'npm': + return `npx ${ref}` + } +} + function toCamelCase(str: string): string { return str.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase()) } From b8a5bfb6dbc8c0449b5e6509d0c8f529500e41f6 Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood Date: Thu, 30 Apr 2026 23:41:33 +1000 Subject: [PATCH 02/12] fix(cli): show package-manager-correct commands in init Next Steps --- .../init/providers/__tests__/base.test.ts | 36 +++++++++++++++++++ .../init/providers/__tests__/drizzle.test.ts | 36 +++++++++++++++++++ .../init/providers/__tests__/supabase.test.ts | 33 +++++++++++++++++ .../cli/src/commands/init/providers/base.ts | 9 +++-- .../src/commands/init/providers/drizzle.ts | 9 +++-- .../src/commands/init/providers/supabase.ts | 9 +++-- .../cli/src/commands/init/steps/next-steps.ts | 4 ++- packages/cli/src/commands/init/types.ts | 4 ++- 8 files changed, 129 insertions(+), 11 deletions(-) create mode 100644 packages/cli/src/commands/init/providers/__tests__/base.test.ts create mode 100644 packages/cli/src/commands/init/providers/__tests__/drizzle.test.ts create mode 100644 packages/cli/src/commands/init/providers/__tests__/supabase.test.ts diff --git a/packages/cli/src/commands/init/providers/__tests__/base.test.ts b/packages/cli/src/commands/init/providers/__tests__/base.test.ts new file mode 100644 index 00000000..ad192b0f --- /dev/null +++ b/packages/cli/src/commands/init/providers/__tests__/base.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import { createBaseProvider } from '../base.js' + +describe('createBaseProvider getNextSteps', () => { + const provider = createBaseProvider() + + it('uses npx when package manager is npm', () => { + const steps = provider.getNextSteps({}, 'npm') + expect(steps[0]).toBe('Set up your database: npx @cipherstash/cli db install') + expect(steps[1]).toContain('npx @cipherstash/wizard') + }) + + it('uses bunx when package manager is bun', () => { + const steps = provider.getNextSteps({}, 'bun') + expect(steps[0]).toBe('Set up your database: bunx @cipherstash/cli db install') + expect(steps[1]).toContain('bunx @cipherstash/wizard') + // Sanity: the old hardcoded `npx` should be gone. + for (const s of steps) expect(s).not.toMatch(/\bnpx\b/) + }) + + it('uses pnpm dlx when package manager is pnpm', () => { + const steps = provider.getNextSteps({}, 'pnpm') + expect(steps[0]).toBe('Set up your database: pnpm dlx @cipherstash/cli db install') + expect(steps[1]).toContain('pnpm dlx @cipherstash/wizard') + }) + + it('uses yarn dlx when package manager is yarn', () => { + const steps = provider.getNextSteps({}, 'yarn') + expect(steps[0]).toBe('Set up your database: yarn dlx @cipherstash/cli db install') + }) + + it('still includes the manual-edit suffix when clientFilePath is set', () => { + const steps = provider.getNextSteps({ clientFilePath: './src/encryption/index.ts' }, 'bun') + expect(steps[1]).toContain('edit ./src/encryption/index.ts directly') + }) +}) diff --git a/packages/cli/src/commands/init/providers/__tests__/drizzle.test.ts b/packages/cli/src/commands/init/providers/__tests__/drizzle.test.ts new file mode 100644 index 00000000..7d665f7c --- /dev/null +++ b/packages/cli/src/commands/init/providers/__tests__/drizzle.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import { createDrizzleProvider } from '../drizzle.js' + +describe('createDrizzleProvider getNextSteps', () => { + const provider = createDrizzleProvider() + + it('uses npx when package manager is npm', () => { + const steps = provider.getNextSteps({}, 'npm') + expect(steps[0]).toBe( + 'Set up your database: npx @cipherstash/cli db install --drizzle', + ) + }) + + it('uses bunx when package manager is bun', () => { + const steps = provider.getNextSteps({}, 'bun') + expect(steps[0]).toBe( + 'Set up your database: bunx @cipherstash/cli db install --drizzle', + ) + expect(steps[1]).toContain('bunx @cipherstash/wizard') + for (const s of steps) expect(s).not.toMatch(/\bnpx\b/) + }) + + it('uses pnpm dlx when package manager is pnpm', () => { + const steps = provider.getNextSteps({}, 'pnpm') + expect(steps[0]).toBe( + 'Set up your database: pnpm dlx @cipherstash/cli db install --drizzle', + ) + }) + + it('uses yarn dlx when package manager is yarn', () => { + const steps = provider.getNextSteps({}, 'yarn') + expect(steps[0]).toBe( + 'Set up your database: yarn dlx @cipherstash/cli db install --drizzle', + ) + }) +}) diff --git a/packages/cli/src/commands/init/providers/__tests__/supabase.test.ts b/packages/cli/src/commands/init/providers/__tests__/supabase.test.ts new file mode 100644 index 00000000..728ba332 --- /dev/null +++ b/packages/cli/src/commands/init/providers/__tests__/supabase.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { createSupabaseProvider } from '../supabase.js' + +describe('createSupabaseProvider getNextSteps', () => { + const provider = createSupabaseProvider() + + it('uses npx when package manager is npm', () => { + const steps = provider.getNextSteps({}, 'npm') + expect(steps[0]).toBe( + 'Install EQL: npx @cipherstash/cli db install --supabase (prompts for migration vs direct)', + ) + }) + + it('uses bunx when package manager is bun', () => { + const steps = provider.getNextSteps({}, 'bun') + expect(steps[0]).toBe( + 'Install EQL: bunx @cipherstash/cli db install --supabase (prompts for migration vs direct)', + ) + expect(steps[2]).toContain('bunx @cipherstash/wizard') // wizard step is third + for (const s of steps) expect(s).not.toMatch(/\bnpx\b/) + }) + + it('uses pnpm dlx when package manager is pnpm', () => { + const steps = provider.getNextSteps({}, 'pnpm') + expect(steps[0]).toContain('pnpm dlx @cipherstash/cli db install --supabase') + }) + + it('leaves the supabase CLI commands alone (those are not npm packages)', () => { + const steps = provider.getNextSteps({}, 'bun') + expect(steps.join('\n')).toContain('supabase db reset') + expect(steps.join('\n')).toContain('supabase migration up') + }) +}) diff --git a/packages/cli/src/commands/init/providers/base.ts b/packages/cli/src/commands/init/providers/base.ts index ef135a1c..fe3c41fb 100644 --- a/packages/cli/src/commands/init/providers/base.ts +++ b/packages/cli/src/commands/init/providers/base.ts @@ -1,16 +1,19 @@ import type { InitProvider, InitState } from '../types.js' +import { type PackageManager, runnerCommand } from '../utils.js' export function createBaseProvider(): InitProvider { return { name: 'base', introMessage: 'Setting up CipherStash for your project...', - getNextSteps(state: InitState): string[] { + getNextSteps(state: InitState, pm: PackageManager): string[] { + const cli = runnerCommand(pm, '@cipherstash/cli') + const wizard = runnerCommand(pm, '@cipherstash/wizard') const manualEdit = state.clientFilePath ? `edit ${state.clientFilePath} directly` : 'edit your encryption schema directly' return [ - 'Set up your database: npx @cipherstash/cli db install', - `Customize your schema: npx @cipherstash/wizard (AI-guided, automated) — or ${manualEdit}`, + `Set up your database: ${cli} db install`, + `Customize your schema: ${wizard} (AI-guided, automated) — or ${manualEdit}`, 'Quickstart: https://cipherstash.com/docs/stack/quickstart', 'Dashboard: https://dashboard.cipherstash.com/workspaces', ] diff --git a/packages/cli/src/commands/init/providers/drizzle.ts b/packages/cli/src/commands/init/providers/drizzle.ts index 33423a11..c611e767 100644 --- a/packages/cli/src/commands/init/providers/drizzle.ts +++ b/packages/cli/src/commands/init/providers/drizzle.ts @@ -1,17 +1,20 @@ import type { InitProvider, InitState } from '../types.js' +import { type PackageManager, runnerCommand } from '../utils.js' export function createDrizzleProvider(): InitProvider { return { name: 'drizzle', introMessage: 'Setting up CipherStash for your Drizzle project...', - getNextSteps(state: InitState): string[] { - const steps = ['Set up your database: npx @cipherstash/cli db install --drizzle'] + getNextSteps(state: InitState, pm: PackageManager): string[] { + const cli = runnerCommand(pm, '@cipherstash/cli') + const wizard = runnerCommand(pm, '@cipherstash/wizard') + const steps = [`Set up your database: ${cli} db install --drizzle`] const manualEdit = state.clientFilePath ? `edit ${state.clientFilePath} directly` : 'edit your encryption schema directly' steps.push( - `Customize your schema: npx @cipherstash/wizard (AI-guided, automated) — or ${manualEdit}`, + `Customize your schema: ${wizard} (AI-guided, automated) — or ${manualEdit}`, ) steps.push( diff --git a/packages/cli/src/commands/init/providers/supabase.ts b/packages/cli/src/commands/init/providers/supabase.ts index 9f862f48..ab17e66e 100644 --- a/packages/cli/src/commands/init/providers/supabase.ts +++ b/packages/cli/src/commands/init/providers/supabase.ts @@ -1,12 +1,15 @@ import type { InitProvider, InitState } from '../types.js' +import { type PackageManager, runnerCommand } from '../utils.js' export function createSupabaseProvider(): InitProvider { return { name: 'supabase', introMessage: 'Setting up CipherStash for your Supabase project...', - getNextSteps(state: InitState): string[] { + getNextSteps(state: InitState, pm: PackageManager): string[] { + const cli = runnerCommand(pm, '@cipherstash/cli') + const wizard = runnerCommand(pm, '@cipherstash/wizard') const steps = [ - 'Install EQL: npx @cipherstash/cli db install --supabase (prompts for migration vs direct)', + `Install EQL: ${cli} db install --supabase (prompts for migration vs direct)`, 'Apply it: supabase db reset (local) or supabase migration up (remote)', ] @@ -14,7 +17,7 @@ export function createSupabaseProvider(): InitProvider { ? `edit ${state.clientFilePath} directly` : 'edit your encryption schema directly' steps.push( - `Customize your schema: npx @cipherstash/wizard (AI-guided, automated) — or ${manualEdit}`, + `Customize your schema: ${wizard} (AI-guided, automated) — or ${manualEdit}`, ) steps.push( diff --git a/packages/cli/src/commands/init/steps/next-steps.ts b/packages/cli/src/commands/init/steps/next-steps.ts index e4a59b15..bf8e290f 100644 --- a/packages/cli/src/commands/init/steps/next-steps.ts +++ b/packages/cli/src/commands/init/steps/next-steps.ts @@ -1,11 +1,13 @@ import * as p from '@clack/prompts' import type { InitProvider, InitState, InitStep } from '../types.js' +import { detectPackageManager } from '../utils.js' export const nextStepsStep: InitStep = { id: 'next-steps', name: 'Next steps', async run(state: InitState, provider: InitProvider): Promise { - const steps = provider.getNextSteps(state) + const pm = detectPackageManager() + const steps = provider.getNextSteps(state, pm) const numbered = steps.map((s, i) => `${i + 1}. ${s}`).join('\n') p.note(numbered, 'Next Steps') return state diff --git a/packages/cli/src/commands/init/types.ts b/packages/cli/src/commands/init/types.ts index eb703285..75f491c4 100644 --- a/packages/cli/src/commands/init/types.ts +++ b/packages/cli/src/commands/init/types.ts @@ -1,3 +1,5 @@ +import type { PackageManager } from './utils.js' + export type Integration = 'drizzle' | 'supabase' | 'postgresql' export type DataType = 'string' | 'number' | 'boolean' | 'date' | 'json' @@ -32,7 +34,7 @@ export interface InitStep { export interface InitProvider { name: string introMessage: string - getNextSteps(state: InitState): string[] + getNextSteps(state: InitState, pm: PackageManager): string[] } export class CancelledError extends Error { From efaaa5a38a11e3a5d817bddf138cbb336b08fd26 Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood Date: Thu, 30 Apr 2026 23:47:19 +1000 Subject: [PATCH 03/12] feat(wizard): expose execCommand on DetectedPackageManager --- packages/wizard/src/__tests__/detect.test.ts | 5 +++++ packages/wizard/src/lib/detect.ts | 8 ++++---- packages/wizard/src/lib/types.ts | 1 + 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/wizard/src/__tests__/detect.test.ts b/packages/wizard/src/__tests__/detect.test.ts index 9bbad868..91b5b9c2 100644 --- a/packages/wizard/src/__tests__/detect.test.ts +++ b/packages/wizard/src/__tests__/detect.test.ts @@ -136,6 +136,7 @@ describe('detectPackageManager', () => { const pm = detectPackageManager(tmp) expect(pm?.name).toBe('bun') expect(pm?.installCommand).toBe('bun add') + expect(pm?.execCommand).toBe('bunx') }) it('detects bun from bun.lockb', () => { @@ -149,6 +150,7 @@ describe('detectPackageManager', () => { const pm = detectPackageManager(tmp) expect(pm?.name).toBe('pnpm') expect(pm?.installCommand).toBe('pnpm add') + expect(pm?.execCommand).toBe('pnpm dlx') }) it('detects yarn', () => { @@ -156,6 +158,7 @@ describe('detectPackageManager', () => { const pm = detectPackageManager(tmp) expect(pm?.name).toBe('yarn') expect(pm?.installCommand).toBe('yarn add') + expect(pm?.execCommand).toBe('yarn dlx') }) it('detects npm', () => { @@ -163,6 +166,7 @@ describe('detectPackageManager', () => { const pm = detectPackageManager(tmp) expect(pm?.name).toBe('npm') expect(pm?.installCommand).toBe('npm install') + expect(pm?.execCommand).toBe('npx') }) it('honours bunx via npm_config_user_agent with no lockfile', () => { @@ -170,6 +174,7 @@ describe('detectPackageManager', () => { const pm = detectPackageManager(tmp) expect(pm?.name).toBe('bun') expect(pm?.installCommand).toBe('bun add') + expect(pm?.execCommand).toBe('bunx') }) it('honours pnpm dlx via user agent', () => { diff --git a/packages/wizard/src/lib/detect.ts b/packages/wizard/src/lib/detect.ts index 71fd0fc3..088293c3 100644 --- a/packages/wizard/src/lib/detect.ts +++ b/packages/wizard/src/lib/detect.ts @@ -49,10 +49,10 @@ const PACKAGE_MANAGERS: Record< 'bun' | 'pnpm' | 'yarn' | 'npm', DetectedPackageManager > = { - bun: { name: 'bun', installCommand: 'bun add', runCommand: 'bun run' }, - pnpm: { name: 'pnpm', installCommand: 'pnpm add', runCommand: 'pnpm run' }, - yarn: { name: 'yarn', installCommand: 'yarn add', runCommand: 'yarn run' }, - npm: { name: 'npm', installCommand: 'npm install', runCommand: 'npm run' }, + bun: { name: 'bun', installCommand: 'bun add', runCommand: 'bun run', execCommand: 'bunx' }, + pnpm: { name: 'pnpm', installCommand: 'pnpm add', runCommand: 'pnpm run', execCommand: 'pnpm dlx' }, + yarn: { name: 'yarn', installCommand: 'yarn add', runCommand: 'yarn run', execCommand: 'yarn dlx' }, + npm: { name: 'npm', installCommand: 'npm install', runCommand: 'npm run', execCommand: 'npx' }, } /** diff --git a/packages/wizard/src/lib/types.ts b/packages/wizard/src/lib/types.ts index b33e96a3..7644652f 100644 --- a/packages/wizard/src/lib/types.ts +++ b/packages/wizard/src/lib/types.ts @@ -28,6 +28,7 @@ export interface DetectedPackageManager { name: 'npm' | 'pnpm' | 'yarn' | 'bun' installCommand: string runCommand: string + execCommand: string } export interface FrameworkConfig< From d61a71500c2bbd375d1861f4562d12046bc1884b Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood Date: Thu, 30 Apr 2026 23:53:57 +1000 Subject: [PATCH 04/12] fix(wizard): execute and display post-agent steps with detected package manager --- .../wizard/src/__tests__/post-agent.test.ts | 70 +++++++++++++++++++ packages/wizard/src/lib/post-agent.ts | 26 +++---- packages/wizard/src/run.ts | 1 + 3 files changed, 85 insertions(+), 12 deletions(-) create mode 100644 packages/wizard/src/__tests__/post-agent.test.ts diff --git a/packages/wizard/src/__tests__/post-agent.test.ts b/packages/wizard/src/__tests__/post-agent.test.ts new file mode 100644 index 00000000..8214c941 --- /dev/null +++ b/packages/wizard/src/__tests__/post-agent.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { runPostAgentSteps } from '../lib/post-agent.js' +import type { DetectedPackageManager } from '../lib/types.js' + +// Mock the child_process module +vi.mock('node:child_process') + +import * as childProcess from 'node:child_process' + +const bun: DetectedPackageManager = { + name: 'bun', + installCommand: 'bun add', + runCommand: 'bun run', + execCommand: 'bunx', +} + +describe('runPostAgentSteps execution commands', () => { + beforeEach(() => { + vi.mocked(childProcess.execSync).mockClear() + vi.mocked(childProcess.execSync).mockImplementation(() => Buffer.from('')) + }) + + it('executes db install/db push using the detected runner (bun → bunx)', async () => { + await runPostAgentSteps({ + cwd: '/tmp/fake', + integration: 'supabase', + packageManager: bun, + gathered: { + installCommand: 'bun add @cipherstash/stack', + hasStashConfig: false, + // Other GatheredContext fields aren't read in this code path; cast for the test. + } as never, + }) + + const commands = vi.mocked(childProcess.execSync).mock.calls.map((c) => c[0] as string) + expect(commands).toContain('bunx @cipherstash/cli db install') + expect(commands).toContain('bunx @cipherstash/cli db push') + // Sanity: no leftover npx forms for the cipherstash binaries. + for (const cmd of commands) { + expect(cmd).not.toMatch(/^npx @cipherstash/) + } + }) + + it('executes drizzle-kit using the detected runner (bun → bunx drizzle-kit generate)', async () => { + // Confirm prompts for the migrate step would pause execution; the test + // skips that by using non-drizzle integration above. Here we only + // assert the generate step. + await runPostAgentSteps({ + cwd: '/tmp/fake', + integration: 'supabase', // avoid the interactive p.confirm in drizzle path + packageManager: bun, + gathered: { installCommand: 'bun add @cipherstash/stack', hasStashConfig: true } as never, + }) + // db push runs with no install + const commands = vi.mocked(childProcess.execSync).mock.calls.map((c) => c[0] as string) + expect(commands).toContain('bunx @cipherstash/cli db push') + }) + + it('falls back to npx when packageManager is undefined', async () => { + await runPostAgentSteps({ + cwd: '/tmp/fake', + integration: 'supabase', + packageManager: undefined, + gathered: { installCommand: 'npm install @cipherstash/stack', hasStashConfig: false } as never, + }) + const commands = vi.mocked(childProcess.execSync).mock.calls.map((c) => c[0] as string) + expect(commands).toContain('npx @cipherstash/cli db install') + expect(commands).toContain('npx @cipherstash/cli db push') + }) +}) diff --git a/packages/wizard/src/lib/post-agent.ts b/packages/wizard/src/lib/post-agent.ts index c53bc2c9..fcfd29c6 100644 --- a/packages/wizard/src/lib/post-agent.ts +++ b/packages/wizard/src/lib/post-agent.ts @@ -11,12 +11,13 @@ import { resolve } from 'node:path' import * as p from '@clack/prompts' import { rewriteEncryptedAlterColumns } from './rewrite-migrations.js' import type { GatheredContext } from './gather.js' -import type { Integration } from './types.js' +import type { DetectedPackageManager, Integration } from './types.js' interface PostAgentOptions { cwd: string integration: Integration gathered: GatheredContext + packageManager: DetectedPackageManager | undefined } /** @@ -29,7 +30,8 @@ const DRIZZLE_OUT_DIRS = ['drizzle', 'migrations', 'src/db/migrations'] * Run all post-agent steps: install packages, push config, run migrations. */ export async function runPostAgentSteps(opts: PostAgentOptions): Promise { - const { cwd, integration, gathered } = opts + const { cwd, integration, gathered, packageManager } = opts + const runner = packageManager?.execCommand ?? 'npx' // Step 1: Install @cipherstash/stack await runStep( @@ -39,14 +41,14 @@ export async function runPostAgentSteps(opts: PostAgentOptions): Promise { cwd, ) - // Step 2: Run npx @cipherstash/cli db install if the project doesn't yet + // Step 2: Run runner @cipherstash/cli db install if the project doesn't yet // have a stash.config.ts. `db install` scaffolds the config and installs // EQL in a single step (CIP-2986). if (!gathered.hasStashConfig) { await runStep( - 'Running npx @cipherstash/cli db install...', - 'npx @cipherstash/cli db install complete', - 'npx @cipherstash/cli db install', + `Running ${runner} @cipherstash/cli db install...`, + `${runner} @cipherstash/cli db install complete`, + `${runner} @cipherstash/cli db install`, cwd, ) } @@ -55,7 +57,7 @@ export async function runPostAgentSteps(opts: PostAgentOptions): Promise { await runStep( 'Pushing encryption config to database...', 'Encryption config pushed', - 'npx @cipherstash/cli db push', + `${runner} @cipherstash/cli db push`, cwd, ) @@ -64,7 +66,7 @@ export async function runPostAgentSteps(opts: PostAgentOptions): Promise { await runStep( 'Generating Drizzle migration...', 'Migration generated', - 'npx drizzle-kit generate', + `${runner} drizzle-kit generate`, cwd, ) @@ -73,7 +75,7 @@ export async function runPostAgentSteps(opts: PostAgentOptions): Promise { await rewriteEncryptedMigrations(cwd) const shouldMigrate = await p.confirm({ - message: 'Run the migration now? (npx drizzle-kit migrate)', + message: `Run the migration now? (${runner} drizzle-kit migrate)`, initialValue: true, }) @@ -81,7 +83,7 @@ export async function runPostAgentSteps(opts: PostAgentOptions): Promise { await runStep( 'Running migration...', 'Migration complete', - 'npx drizzle-kit migrate', + `${runner} drizzle-kit migrate`, cwd, ) } @@ -90,7 +92,7 @@ export async function runPostAgentSteps(opts: PostAgentOptions): Promise { if (integration === 'prisma') { const shouldMigrate = await p.confirm({ message: - 'Run Prisma migration now? (npx prisma migrate dev --name add-encryption)', + `Run Prisma migration now? (${runner} prisma migrate dev --name add-encryption)`, initialValue: true, }) @@ -98,7 +100,7 @@ export async function runPostAgentSteps(opts: PostAgentOptions): Promise { await runStep( 'Running Prisma migration...', 'Migration complete', - 'npx prisma migrate dev --name add-encryption', + `${runner} prisma migrate dev --name add-encryption`, cwd, ) } diff --git a/packages/wizard/src/run.ts b/packages/wizard/src/run.ts index 3c52a6a5..d7d06e17 100644 --- a/packages/wizard/src/run.ts +++ b/packages/wizard/src/run.ts @@ -182,6 +182,7 @@ export async function run(options: RunOptions) { cwd: options.cwd, integration: selectedIntegration, gathered, + packageManager, }) changelog.phase( 'Post-agent steps complete', From a99fa24cbd01588985d14840da0046d567a4745e Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood Date: Thu, 30 Apr 2026 23:59:32 +1000 Subject: [PATCH 05/12] style(wizard): biome formatting for post-agent files --- .../wizard/src/__tests__/post-agent.test.ts | 24 ++++++++++++++----- packages/wizard/src/lib/post-agent.ts | 5 ++-- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/packages/wizard/src/__tests__/post-agent.test.ts b/packages/wizard/src/__tests__/post-agent.test.ts index 8214c941..d29343fc 100644 --- a/packages/wizard/src/__tests__/post-agent.test.ts +++ b/packages/wizard/src/__tests__/post-agent.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi, beforeEach } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { runPostAgentSteps } from '../lib/post-agent.js' import type { DetectedPackageManager } from '../lib/types.js' @@ -32,7 +32,9 @@ describe('runPostAgentSteps execution commands', () => { } as never, }) - const commands = vi.mocked(childProcess.execSync).mock.calls.map((c) => c[0] as string) + const commands = vi + .mocked(childProcess.execSync) + .mock.calls.map((c) => c[0] as string) expect(commands).toContain('bunx @cipherstash/cli db install') expect(commands).toContain('bunx @cipherstash/cli db push') // Sanity: no leftover npx forms for the cipherstash binaries. @@ -49,10 +51,15 @@ describe('runPostAgentSteps execution commands', () => { cwd: '/tmp/fake', integration: 'supabase', // avoid the interactive p.confirm in drizzle path packageManager: bun, - gathered: { installCommand: 'bun add @cipherstash/stack', hasStashConfig: true } as never, + gathered: { + installCommand: 'bun add @cipherstash/stack', + hasStashConfig: true, + } as never, }) // db push runs with no install - const commands = vi.mocked(childProcess.execSync).mock.calls.map((c) => c[0] as string) + const commands = vi + .mocked(childProcess.execSync) + .mock.calls.map((c) => c[0] as string) expect(commands).toContain('bunx @cipherstash/cli db push') }) @@ -61,9 +68,14 @@ describe('runPostAgentSteps execution commands', () => { cwd: '/tmp/fake', integration: 'supabase', packageManager: undefined, - gathered: { installCommand: 'npm install @cipherstash/stack', hasStashConfig: false } as never, + gathered: { + installCommand: 'npm install @cipherstash/stack', + hasStashConfig: false, + } as never, }) - const commands = vi.mocked(childProcess.execSync).mock.calls.map((c) => c[0] as string) + const commands = vi + .mocked(childProcess.execSync) + .mock.calls.map((c) => c[0] as string) expect(commands).toContain('npx @cipherstash/cli db install') expect(commands).toContain('npx @cipherstash/cli db push') }) diff --git a/packages/wizard/src/lib/post-agent.ts b/packages/wizard/src/lib/post-agent.ts index fcfd29c6..f0c0b9ca 100644 --- a/packages/wizard/src/lib/post-agent.ts +++ b/packages/wizard/src/lib/post-agent.ts @@ -9,8 +9,8 @@ import { execSync } from 'node:child_process' import { existsSync } from 'node:fs' import { resolve } from 'node:path' import * as p from '@clack/prompts' -import { rewriteEncryptedAlterColumns } from './rewrite-migrations.js' import type { GatheredContext } from './gather.js' +import { rewriteEncryptedAlterColumns } from './rewrite-migrations.js' import type { DetectedPackageManager, Integration } from './types.js' interface PostAgentOptions { @@ -91,8 +91,7 @@ export async function runPostAgentSteps(opts: PostAgentOptions): Promise { if (integration === 'prisma') { const shouldMigrate = await p.confirm({ - message: - `Run Prisma migration now? (${runner} prisma migrate dev --name add-encryption)`, + message: `Run Prisma migration now? (${runner} prisma migrate dev --name add-encryption)`, initialValue: true, }) From 64e0c386634b916a768d7ad745c52bece1bcd2ac Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood Date: Fri, 1 May 2026 00:01:07 +1000 Subject: [PATCH 06/12] fix(wizard): use detected package manager in prerequisite messages --- .../src/__tests__/prerequisites.test.ts | 65 +++++++++++++++++++ packages/wizard/src/lib/prerequisites.ts | 6 +- 2 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 packages/wizard/src/__tests__/prerequisites.test.ts diff --git a/packages/wizard/src/__tests__/prerequisites.test.ts b/packages/wizard/src/__tests__/prerequisites.test.ts new file mode 100644 index 00000000..c26b802a --- /dev/null +++ b/packages/wizard/src/__tests__/prerequisites.test.ts @@ -0,0 +1,65 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { checkPrerequisites } from '../lib/prerequisites.js' + +// Force the auth check to fail so we exercise the missing-list copy. +vi.mock('@cipherstash/auth', () => ({ + default: { + AutoStrategy: { + detect: () => ({ + getToken: async () => { + const err = new Error('not authed') as Error & { code: string } + err.code = 'NOT_AUTHENTICATED' + throw err + }, + }), + }, + }, +})) + +describe('checkPrerequisites missing-list copy', () => { + let tmp: string + let originalUA: string | undefined + + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), 'wiz-prereq-')) + originalUA = process.env.npm_config_user_agent + process.env.npm_config_user_agent = undefined + }) + + afterEach(() => { + rmSync(tmp, { recursive: true, force: true }) + if (originalUA === undefined) process.env.npm_config_user_agent = undefined + else process.env.npm_config_user_agent = originalUA + }) + + it('uses bunx when bun.lock is present', async () => { + writeFileSync(join(tmp, 'bun.lock'), '') + const r = await checkPrerequisites(tmp) + expect(r.ok).toBe(false) + expect(r.missing.join('\n')).toContain( + 'Run: bunx @cipherstash/cli auth login', + ) + expect(r.missing.join('\n')).toContain( + 'Run: bunx @cipherstash/cli db install', + ) + expect(r.missing.join('\n')).not.toMatch(/\bnpx\b/) + }) + + it('uses pnpm dlx when pnpm-lock.yaml is present', async () => { + writeFileSync(join(tmp, 'pnpm-lock.yaml'), '') + const r = await checkPrerequisites(tmp) + expect(r.missing.join('\n')).toContain( + 'Run: pnpm dlx @cipherstash/cli auth login', + ) + }) + + it('falls back to npx when no package manager can be detected', async () => { + const r = await checkPrerequisites(tmp) + expect(r.missing.join('\n')).toContain( + 'Run: npx @cipherstash/cli auth login', + ) + }) +}) diff --git a/packages/wizard/src/lib/prerequisites.ts b/packages/wizard/src/lib/prerequisites.ts index 17742b7b..0c815fb3 100644 --- a/packages/wizard/src/lib/prerequisites.ts +++ b/packages/wizard/src/lib/prerequisites.ts @@ -1,6 +1,7 @@ import { existsSync } from 'node:fs' import { resolve } from 'node:path' import auth from '@cipherstash/auth' +import { detectPackageManager } from './detect.js' interface PrerequisiteResult { ok: boolean @@ -16,16 +17,17 @@ export async function checkPrerequisites( cwd: string, ): Promise { const missing: string[] = [] + const runner = detectPackageManager(cwd)?.execCommand ?? 'npx' if (!(await hasCredentials())) { missing.push( - 'Not authenticated with CipherStash. Run: npx @cipherstash/cli auth login', + `Not authenticated with CipherStash. Run: ${runner} @cipherstash/cli auth login`, ) } if (!findStashConfig(cwd)) { missing.push( - 'No stash.config.ts found. Run: npx @cipherstash/cli db install', + `No stash.config.ts found. Run: ${runner} @cipherstash/cli db install`, ) } From eb7e3541d07ab7936bf9d5247e631c477e30b846 Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood Date: Fri, 1 May 2026 00:05:14 +1000 Subject: [PATCH 07/12] test(wizard): use delete to unset env var in prerequisites test --- packages/wizard/src/__tests__/prerequisites.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/wizard/src/__tests__/prerequisites.test.ts b/packages/wizard/src/__tests__/prerequisites.test.ts index c26b802a..2634aaea 100644 --- a/packages/wizard/src/__tests__/prerequisites.test.ts +++ b/packages/wizard/src/__tests__/prerequisites.test.ts @@ -26,12 +26,12 @@ describe('checkPrerequisites missing-list copy', () => { beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'wiz-prereq-')) originalUA = process.env.npm_config_user_agent - process.env.npm_config_user_agent = undefined + delete process.env.npm_config_user_agent }) afterEach(() => { rmSync(tmp, { recursive: true, force: true }) - if (originalUA === undefined) process.env.npm_config_user_agent = undefined + if (originalUA === undefined) delete process.env.npm_config_user_agent else process.env.npm_config_user_agent = originalUA }) From 6e0673f8e646b8b5d6d92a3e144a1442b5c099fe Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood Date: Fri, 1 May 2026 00:07:58 +1000 Subject: [PATCH 08/12] fix(wizard): use detected package manager in agent error messages Thread runner command through classifyError and classifyHttpError to replace hardcoded 'npx @cipherstash/cli auth login' strings with detected package manager (bunx, pnpm dlx, yarn dlx, npx). Update call sites: - agent/interface.ts: derive runner from session.detectedPackageManager (3 calls) - agent/fetch-prompt.ts: accept optional runner param, default 'npx' (1 call) - run.ts: pass packageManager.execCommand to fetchIntegrationPrompt All callers now get runner-aware auth failure messages per detected PM. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/__tests__/errors-runner.test.ts | 36 +++ packages/wizard/src/agent/errors.ts | 22 +- packages/wizard/src/agent/fetch-prompt.ts | 13 +- packages/wizard/src/agent/interface.ts | 259 ++++++++++-------- packages/wizard/src/run.ts | 6 +- 5 files changed, 207 insertions(+), 129 deletions(-) create mode 100644 packages/wizard/src/__tests__/errors-runner.test.ts diff --git a/packages/wizard/src/__tests__/errors-runner.test.ts b/packages/wizard/src/__tests__/errors-runner.test.ts new file mode 100644 index 00000000..c211aa80 --- /dev/null +++ b/packages/wizard/src/__tests__/errors-runner.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import { classifyError, classifyHttpError } from '../agent/errors.js' + +describe('classifyError runner', () => { + it('uses npx by default for auth failure', () => { + expect(classifyError('authentication_failed', '')).toContain( + 'Run: npx @cipherstash/cli auth login', + ) + }) + + it('uses bunx when runner=bunx', () => { + expect(classifyError('authentication_failed', '', 'bunx')).toContain( + 'Run: bunx @cipherstash/cli auth login', + ) + }) + + it('uses pnpm dlx when runner=pnpm dlx', () => { + expect(classifyError('authentication_failed', '', 'pnpm dlx')).toContain( + 'Run: pnpm dlx @cipherstash/cli auth login', + ) + }) +}) + +describe('classifyHttpError runner', () => { + it('uses npx by default for 401', () => { + expect(classifyHttpError(401, '')).toContain( + 'Run: npx @cipherstash/cli auth login', + ) + }) + + it('uses bunx when runner=bunx for 401', () => { + expect(classifyHttpError(401, '', 'bunx')).toContain( + 'Run: bunx @cipherstash/cli auth login', + ) + }) +}) diff --git a/packages/wizard/src/agent/errors.ts b/packages/wizard/src/agent/errors.ts index 24e53d2c..eee58cc1 100644 --- a/packages/wizard/src/agent/errors.ts +++ b/packages/wizard/src/agent/errors.ts @@ -26,11 +26,12 @@ export function formatWizardError(summary: string, detail?: string): string { export function classifyError( errorCode: string | undefined, rawMessage: string, + runner = 'npx', ): string { if (errorCode === 'authentication_failed') { return formatWizardError( 'Authentication failed.', - 'Your CipherStash token may be expired or invalid. Run: npx @cipherstash/cli auth login', + `Your CipherStash token may be expired or invalid. Run: ${runner} @cipherstash/cli auth login`, ) } if (errorCode === 'rate_limit') { @@ -57,10 +58,13 @@ export function classifyError( } catch { apiMessage = body } - return classifyHttpError(status, apiMessage || rawMessage) + return classifyHttpError(status, apiMessage || rawMessage, runner) } - if (rawMessage.includes('ECONNREFUSED') || rawMessage.includes('fetch failed')) { + if ( + rawMessage.includes('ECONNREFUSED') || + rawMessage.includes('fetch failed') + ) { return formatWizardError( 'Could not reach the CipherStash AI gateway.', 'The gateway may be temporarily unavailable. Check the status pages below.', @@ -84,7 +88,11 @@ export function classifyError( * Classify an HTTP error from a direct gateway fetch into the same * user-friendly format the agent SDK errors use. */ -export function classifyHttpError(status: number, apiMessage: string): string { +export function classifyHttpError( + status: number, + apiMessage: string, + runner = 'npx', +): string { if (status === 400) { return formatWizardError( `The AI gateway rejected the request (HTTP ${status}).`, @@ -94,7 +102,7 @@ export function classifyHttpError(status: number, apiMessage: string): string { if (status === 401) { return formatWizardError( 'Authentication failed (HTTP 401).', - 'Your CipherStash token may be expired. Run: npx @cipherstash/cli auth login', + `Your CipherStash token may be expired. Run: ${runner} @cipherstash/cli auth login`, ) } if (status === 429) { @@ -106,7 +114,9 @@ export function classifyHttpError(status: number, apiMessage: string): string { if (status >= 500) { return formatWizardError( `The AI service returned an error (HTTP ${status}).`, - apiMessage ? `Reason: ${apiMessage}` : 'This is likely a temporary issue.', + apiMessage + ? `Reason: ${apiMessage}` + : 'This is likely a temporary issue.', ) } return formatWizardError( diff --git a/packages/wizard/src/agent/fetch-prompt.ts b/packages/wizard/src/agent/fetch-prompt.ts index 12847eb6..c8616c33 100644 --- a/packages/wizard/src/agent/fetch-prompt.ts +++ b/packages/wizard/src/agent/fetch-prompt.ts @@ -19,6 +19,7 @@ interface GatewayErrorBody { export async function fetchIntegrationPrompt( ctx: GatheredContext, cliVersion: string, + runner = 'npx', ): Promise { const strategy = AutoStrategy.detect() const { token } = await strategy.getToken() @@ -48,10 +49,7 @@ export async function fetchIntegrationPrompt( // Network failures, DNS errors, AbortSignal.timeout — classifyError // recognizes "fetch failed" / ECONNREFUSED and renders the gateway-status footer. throw new Error( - formatWizardError( - 'Could not reach the CipherStash AI gateway.', - message, - ), + formatWizardError('Could not reach the CipherStash AI gateway.', message), ) } @@ -63,11 +61,14 @@ export async function fetchIntegrationPrompt( } catch { // fall back to status code only } - throw new Error(classifyHttpError(res.status, apiMessage)) + throw new Error(classifyHttpError(res.status, apiMessage, runner)) } const body = (await res.json()) as Partial - if (typeof body.prompt !== 'string' || typeof body.promptVersion !== 'string') { + if ( + typeof body.prompt !== 'string' || + typeof body.promptVersion !== 'string' + ) { throw new Error( formatWizardError( 'The wizard gateway returned an invalid prompt response.', diff --git a/packages/wizard/src/agent/interface.ts b/packages/wizard/src/agent/interface.ts index 86f5b295..cae06f97 100644 --- a/packages/wizard/src/agent/interface.ts +++ b/packages/wizard/src/agent/interface.ts @@ -9,14 +9,14 @@ * - Interactive conversation loop (user can reply to agent questions) */ +import type { PermissionResult } from '@anthropic-ai/claude-agent-sdk' +import auth from '@cipherstash/auth' import * as p from '@clack/prompts' import { GATEWAY_URL } from '../lib/constants.js' import { formatAgentOutput } from '../lib/format.js' +import type { WizardSession } from '../lib/types.js' import { classifyError, formatWizardError } from './errors.js' import { scanPreToolUse } from './hooks.js' -import type { WizardSession } from '../lib/types.js' -import type { PermissionResult } from '@anthropic-ai/claude-agent-sdk' -import auth from '@cipherstash/auth' const { AutoStrategy } = auth @@ -81,10 +81,10 @@ const ALLOWED_WRITE_PATHS = [ /** Sensitive file patterns the agent must not read directly. */ const SENSITIVE_FILE_PATTERNS = [ - /\.env($|\.)/, // .env, .env.local, .env.production, etc. - /auth\.json$/, // ~/.cipherstash/auth.json - /secretkey\.json$/, // ~/.cipherstash/secretkey.json - /credentials/i, // Various credential files + /\.env($|\.)/, // .env, .env.local, .env.production, etc. + /auth\.json$/, // ~/.cipherstash/auth.json + /secretkey\.json$/, // ~/.cipherstash/secretkey.json + /credentials/i, // Various credential files ] function isSensitivePath(filePath: string): boolean { @@ -105,7 +105,10 @@ export function wizardCanUseTool( input: Record, ): true | string { // Layer 1: Run YARA-style pre-execution scan - const hookResult = scanPreToolUse(toolName, String(input.command ?? input.file_path ?? '')) + const hookResult = scanPreToolUse( + toolName, + String(input.command ?? input.file_path ?? ''), + ) if (hookResult.blocked) { return hookResult.reason ?? 'Blocked by security scan' } @@ -179,7 +182,10 @@ async function getAccessToken(): Promise { /** * Friendly tool name for spinner messages. */ -function describeToolUse(toolName: string, input: Record): string { +function describeToolUse( + toolName: string, + input: Record, +): string { switch (toolName) { case 'Read': return `Reading ${shortenPath(String(input.file_path ?? ''))}` @@ -214,7 +220,12 @@ function looksLikeQuestion(text: string): boolean { const trimmed = text.trim() // Ends with a question mark or contains common question patterns if (trimmed.endsWith('?')) return true - if (/let me know|which .*(do you|would you|should)|please (choose|select|confirm|tell)/i.test(trimmed)) return true + if ( + /let me know|which .*(do you|would you|should)|please (choose|select|confirm|tell)/i.test( + trimmed, + ) + ) + return true return false } @@ -337,7 +348,9 @@ export async function initializeAgent( }, }, stderr: session.debug - ? (data: string) => { p.log.warn(`[agent stderr] ${data.trim()}`) } + ? (data: string) => { + p.log.warn(`[agent stderr] ${data.trim()}`) + } : undefined, } @@ -353,141 +366,154 @@ export async function initializeAgent( if (spinnerActive) { spinner.stop('Failed to start agent') } + const runner = session.detectedPackageManager?.execCommand ?? 'npx' return { success: false, output: '', durationMs: Date.now() - start, - error: classifyError(undefined, msg), + error: classifyError(undefined, msg, runner), } } try { - for await (const message of response) { - // First message from the agent — update spinner - if (!receivedFirstMessage && message.type === 'assistant') { - receivedFirstMessage = true - spinner.message('Agent is analyzing your project...') - } + for await (const message of response) { + // First message from the agent — update spinner + if (!receivedFirstMessage && message.type === 'assistant') { + receivedFirstMessage = true + spinner.message('Agent is analyzing your project...') + } - if (message.type === 'assistant') { - lastAssistantHadToolUse = false - const content = message.message?.content - if (Array.isArray(content)) { - for (const block of content) { - if (block.type === 'text' && typeof block.text === 'string') { - currentTurnText.push(block.text) - allCollectedText.push(block.text) - } + if (message.type === 'assistant') { + lastAssistantHadToolUse = false + const content = message.message?.content + if (Array.isArray(content)) { + for (const block of content) { + if (block.type === 'text' && typeof block.text === 'string') { + currentTurnText.push(block.text) + allCollectedText.push(block.text) + } - if (block.type === 'tool_use') { - lastAssistantHadToolUse = true - if (spinnerActive) { - const desc = describeToolUse( - block.name ?? 'unknown', - (block.input as Record) ?? {}, - ) - spinner.message(desc) + if (block.type === 'tool_use') { + lastAssistantHadToolUse = true + if (spinnerActive) { + const desc = describeToolUse( + block.name ?? 'unknown', + (block.input as Record) ?? {}, + ) + spinner.message(desc) + } } } } } - } - if (message.type === 'system' && message.subtype === 'init') { - if (spinnerActive) { - spinner.message('Agent initialized, starting work...') + if (message.type === 'system' && message.subtype === 'init') { + if (spinnerActive) { + spinner.message('Agent initialized, starting work...') + } } - } - if (message.type === 'result') { - turnCount++ - - const isSuccess = message.subtype === 'success' && !message.is_error - if (isSuccess) { - const turnText = currentTurnText.join('\n').trim() - - // Check if the agent is asking the user a question - // (text-only response, no tool calls, and looks like a question) - if ( - turnText.length > 0 && - !lastAssistantHadToolUse && - looksLikeQuestion(turnText) && - turnCount < MAX_CONVERSATION_TURNS - ) { - // Stop spinner, show agent output, prompt user - if (spinnerActive) { - spinner.stop('Agent needs your input') - spinnerActive = false - } - - console.log('') - console.log(formatAgentOutput(turnText)) - console.log('') + if (message.type === 'result') { + turnCount++ + + const isSuccess = message.subtype === 'success' && !message.is_error + if (isSuccess) { + const turnText = currentTurnText.join('\n').trim() + + // Check if the agent is asking the user a question + // (text-only response, no tool calls, and looks like a question) + if ( + turnText.length > 0 && + !lastAssistantHadToolUse && + looksLikeQuestion(turnText) && + turnCount < MAX_CONVERSATION_TURNS + ) { + // Stop spinner, show agent output, prompt user + if (spinnerActive) { + spinner.stop('Agent needs your input') + spinnerActive = false + } - const userReply = await p.text({ - message: 'Your reply (or "done" to finish):', - placeholder: 'Type your answer...', - }) + console.log('') + console.log(formatAgentOutput(turnText)) + console.log('') - if (p.isCancel(userReply) || userReply.toLowerCase().trim() === 'done') { - // User wants to stop + const userReply = await p.text({ + message: 'Your reply (or "done" to finish):', + placeholder: 'Type your answer...', + }) + + if ( + p.isCancel(userReply) || + userReply.toLowerCase().trim() === 'done' + ) { + // User wants to stop + success = true + signalDone() + } else { + // Send reply to the agent, restart spinner + currentTurnText = [] + spinner.start('Agent is working...') + spinnerActive = true + pushMessage(userReply) + } + } else { + // Agent is done (made changes, gave final instructions, etc.) success = true + const durationSec = ((Date.now() - start) / 1000).toFixed(1) + if (spinnerActive) { + spinner.stop(`Agent completed in ${durationSec}s`) + spinnerActive = false + } + + if (turnText.length > 0) { + console.log('') + console.log(formatAgentOutput(turnText)) + console.log('') + } + signalDone() - } else { - // Send reply to the agent, restart spinner - currentTurnText = [] - spinner.start('Agent is working...') - spinnerActive = true - pushMessage(userReply) } } else { - // Agent is done (made changes, gave final instructions, etc.) - success = true - const durationSec = ((Date.now() - start) / 1000).toFixed(1) - if (spinnerActive) { - spinner.stop(`Agent completed in ${durationSec}s`) - spinnerActive = false + // Extract as much detail as possible from the result message + const errorDetail = + message.error_details ?? + message.result ?? + message.last_assistant_message ?? + 'Agent execution failed' + + if (session.debug) { + p.log.warn( + `[debug] Result message: ${JSON.stringify( + { + subtype: message.subtype, + is_error: message.is_error, + error: message.error, + error_details: message.error_details, + result: message.result?.slice(0, 500), + last_assistant_message: + message.last_assistant_message?.slice(0, 500), + stop_reason: message.stop_reason, + }, + null, + 2, + )}`, + ) } - if (turnText.length > 0) { - console.log('') - console.log(formatAgentOutput(turnText)) - console.log('') + const runner = + session.detectedPackageManager?.execCommand ?? 'npx' + errorMessage = classifyError(message.error, errorDetail, runner) + + if (spinnerActive) { + spinner.stop('Agent encountered an error') + spinnerActive = false } signalDone() } - } else { - // Extract as much detail as possible from the result message - const errorDetail = message.error_details - ?? message.result - ?? message.last_assistant_message - ?? 'Agent execution failed' - - if (session.debug) { - p.log.warn(`[debug] Result message: ${JSON.stringify({ - subtype: message.subtype, - is_error: message.is_error, - error: message.error, - error_details: message.error_details, - result: message.result?.slice(0, 500), - last_assistant_message: message.last_assistant_message?.slice(0, 500), - stop_reason: message.stop_reason, - }, null, 2)}`) - } - - errorMessage = classifyError(message.error, errorDetail) - - if (spinnerActive) { - spinner.stop('Agent encountered an error') - spinnerActive = false - } - - signalDone() } } - } - } catch (err) { const msg = err instanceof Error ? err.message : 'Unknown error' if (spinnerActive) { @@ -495,7 +521,8 @@ export async function initializeAgent( spinnerActive = false } - errorMessage = classifyError(undefined, msg) + const runner = session.detectedPackageManager?.execCommand ?? 'npx' + errorMessage = classifyError(undefined, msg, runner) signalDone() } diff --git a/packages/wizard/src/run.ts b/packages/wizard/src/run.ts index d7d06e17..c27f076b 100644 --- a/packages/wizard/src/run.ts +++ b/packages/wizard/src/run.ts @@ -161,7 +161,11 @@ export async function run(options: RunOptions) { // and they don't depend on each other. const [agent, fetched] = await Promise.all([ initializeAgent(session), - fetchIntegrationPrompt(gathered, options.cliVersion), + fetchIntegrationPrompt( + gathered, + options.cliVersion, + packageManager?.execCommand ?? 'npx', + ), ]) if (session.debug) { From c210cfdc4822f4a066e180ca827d890c70157f97 Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood Date: Fri, 1 May 2026 00:13:36 +1000 Subject: [PATCH 09/12] style(cli): biome formatting for new init provider tests --- .../init/providers/__tests__/base.test.ts | 21 ++++++++++++++----- .../init/providers/__tests__/supabase.test.ts | 4 +++- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/commands/init/providers/__tests__/base.test.ts b/packages/cli/src/commands/init/providers/__tests__/base.test.ts index ad192b0f..ddfec294 100644 --- a/packages/cli/src/commands/init/providers/__tests__/base.test.ts +++ b/packages/cli/src/commands/init/providers/__tests__/base.test.ts @@ -6,13 +6,17 @@ describe('createBaseProvider getNextSteps', () => { it('uses npx when package manager is npm', () => { const steps = provider.getNextSteps({}, 'npm') - expect(steps[0]).toBe('Set up your database: npx @cipherstash/cli db install') + expect(steps[0]).toBe( + 'Set up your database: npx @cipherstash/cli db install', + ) expect(steps[1]).toContain('npx @cipherstash/wizard') }) it('uses bunx when package manager is bun', () => { const steps = provider.getNextSteps({}, 'bun') - expect(steps[0]).toBe('Set up your database: bunx @cipherstash/cli db install') + expect(steps[0]).toBe( + 'Set up your database: bunx @cipherstash/cli db install', + ) expect(steps[1]).toContain('bunx @cipherstash/wizard') // Sanity: the old hardcoded `npx` should be gone. for (const s of steps) expect(s).not.toMatch(/\bnpx\b/) @@ -20,17 +24,24 @@ describe('createBaseProvider getNextSteps', () => { it('uses pnpm dlx when package manager is pnpm', () => { const steps = provider.getNextSteps({}, 'pnpm') - expect(steps[0]).toBe('Set up your database: pnpm dlx @cipherstash/cli db install') + expect(steps[0]).toBe( + 'Set up your database: pnpm dlx @cipherstash/cli db install', + ) expect(steps[1]).toContain('pnpm dlx @cipherstash/wizard') }) it('uses yarn dlx when package manager is yarn', () => { const steps = provider.getNextSteps({}, 'yarn') - expect(steps[0]).toBe('Set up your database: yarn dlx @cipherstash/cli db install') + expect(steps[0]).toBe( + 'Set up your database: yarn dlx @cipherstash/cli db install', + ) }) it('still includes the manual-edit suffix when clientFilePath is set', () => { - const steps = provider.getNextSteps({ clientFilePath: './src/encryption/index.ts' }, 'bun') + const steps = provider.getNextSteps( + { clientFilePath: './src/encryption/index.ts' }, + 'bun', + ) expect(steps[1]).toContain('edit ./src/encryption/index.ts directly') }) }) diff --git a/packages/cli/src/commands/init/providers/__tests__/supabase.test.ts b/packages/cli/src/commands/init/providers/__tests__/supabase.test.ts index 728ba332..4b905f37 100644 --- a/packages/cli/src/commands/init/providers/__tests__/supabase.test.ts +++ b/packages/cli/src/commands/init/providers/__tests__/supabase.test.ts @@ -22,7 +22,9 @@ describe('createSupabaseProvider getNextSteps', () => { it('uses pnpm dlx when package manager is pnpm', () => { const steps = provider.getNextSteps({}, 'pnpm') - expect(steps[0]).toContain('pnpm dlx @cipherstash/cli db install --supabase') + expect(steps[0]).toContain( + 'pnpm dlx @cipherstash/cli db install --supabase', + ) }) it('leaves the supabase CLI commands alone (those are not npm packages)', () => { From 9bcb998328a0f4cf04d1c28353affd8b2c575216 Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood Date: Fri, 1 May 2026 01:02:56 +1000 Subject: [PATCH 10/12] test(e2e): add root e2e workspace with package-manager test suite Bootstraps a top-level `e2e/` workspace and ports the verification logic from the `verify-package-managers.sh` proof-of-concept into a vitest suite. Two suites: - CLI init providers: imports the production source and asserts each PM yields the right runner (`npx`/`bunx`/`pnpm dlx`/`yarn dlx`) in the rendered "Next Steps" copy. 12 cases. - Wizard binary: spawns the BUILT wizard binary in throwaway sandbox dirs (lockfile + npm_config_user_agent variations) and asserts the prerequisite "Run: ..." line uses the detected runner. 9 cases. Skipped when no auth is configured locally and no `CS_*` env vars are present in the runner environment. Adds a `test:e2e` script at the repo root that delegates to turbo, and a standalone `e2e-tests` job in `.github/workflows/tests.yml` that exposes the auth secrets so the wizard suite stays live in CI. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/tests.yml | 40 ++++++ e2e/README.md | 41 ++++++ e2e/package.json | 17 +++ e2e/tests/package-managers.e2e.test.ts | 182 +++++++++++++++++++++++++ e2e/tsconfig.json | 4 + e2e/vitest.config.ts | 14 ++ package.json | 3 +- pnpm-lock.yaml | 29 ++-- pnpm-workspace.yaml | 1 + 9 files changed, 322 insertions(+), 9 deletions(-) create mode 100644 e2e/README.md create mode 100644 e2e/package.json create mode 100644 e2e/tests/package-managers.e2e.test.ts create mode 100644 e2e/tsconfig.json create mode 100644 e2e/vitest.config.ts diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e09d57b5..7ef66e8f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -87,3 +87,43 @@ jobs: # deps declared on the `test:e2e` task are honored. - name: Run CLI E2E tests run: pnpm exec turbo run test:e2e --filter @cipherstash/cli + + e2e-tests: + name: Run E2E Tests + runs-on: blacksmith-4vcpu-ubuntu-2404 + + # Auth-dependent suites in `e2e/` skip themselves unless these env vars + # are set. We expose them at the job level so the wizard subprocess + # picks them up via `process.env`. + env: + CS_WORKSPACE_CRN: ${{ secrets.CS_WORKSPACE_CRN }} + CS_CLIENT_ID: ${{ secrets.CS_CLIENT_ID }} + CS_CLIENT_KEY: ${{ secrets.CS_CLIENT_KEY }} + CS_CLIENT_ACCESS_KEY: ${{ secrets.CS_CLIENT_ACCESS_KEY }} + CS_ZEROKMS_HOST: https://ap-southeast-2.aws.zerokms.cipherstashmanaged.net + CS_CTS_HOST: https://ap-southeast-2.aws.cts.cipherstashmanaged.net + + steps: + - name: Checkout Repo + uses: actions/checkout@v3 + + - uses: pnpm/action-setup@v4 + name: Install pnpm + with: + run_install: false + + - name: Install Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install + + # Run the standalone `e2e/` workspace via turbo so the `^build` + # dep on the `test:e2e` task builds cli + wizard first. CLI's own + # E2E (`packages/cli/tests/e2e/**`) is covered by the `run-tests` + # job above; we filter to the new workspace here to avoid duplication. + - name: Run E2E tests + run: pnpm exec turbo run test:e2e --filter @cipherstash/e2e diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 00000000..260eab93 --- /dev/null +++ b/e2e/README.md @@ -0,0 +1,41 @@ +# `@cipherstash/e2e` + +End-to-end tests that exercise built CipherStash binaries and cross-package behaviour. Lives outside `packages/` because these tests are not tied to a single package — they verify how the published artefacts behave when a user actually runs them. + +## Running + +From the repo root: + +```bash +pnpm run test:e2e +``` + +This delegates to turbo, which builds dependent packages first and then runs `vitest run` inside this workspace. + +To run a single test file: + +```bash +pnpm --filter @cipherstash/e2e exec vitest run tests/package-managers.e2e.test.ts +``` + +## What's covered + +| Test file | Scope | +| --- | --- | +| `tests/package-managers.e2e.test.ts` | The `init` providers and the wizard binary render `bunx`/`pnpm dlx`/`yarn dlx`/`npx` based on detected package manager. | + +## Auth-dependent suites + +Some tests spawn the wizard binary, which runs an auth check before reaching the prerequisite path under test. These are wrapped in `describe.skipIf(!authConfigured)` and only run when: + +- `~/.cipherstash/auth.json` exists (typical local dev), **or** +- `CS_CLIENT_ID` and `CS_CLIENT_KEY` are set in the environment (CI with secrets wired) + +The CI job for this workspace exposes those env vars from repo secrets. Without them the wizard suite is skipped (the provider suite still runs). + +## Adding a new e2e test + +- File name must end in `.e2e.test.ts` to be picked up by `vitest.config.ts`. +- Prefer spawning the **built** binary (`packages//dist/bin/...`) over importing source — that's the value e2e gives over unit tests. If the binary isn't built when your test runs, fail fast with a clear message; turbo's `test:e2e` task declares `^build` + `build` deps so a top-level `pnpm run test:e2e` will build first. +- For tests that need a clean cwd, use `mkdtempSync(join(tmpdir(), 'stash-...-e2e-'))` and clean up in `afterEach`. +- Mock nothing. If you find yourself wanting to mock, the test belongs in a unit suite. diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 00000000..cb612264 --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,17 @@ +{ + "name": "@cipherstash/e2e", + "version": "0.0.0", + "private": true, + "description": "End-to-end tests that exercise built CipherStash binaries and cross-package behaviour.", + "type": "module", + "scripts": { + "test:e2e": "vitest run" + }, + "dependencies": { + "@cipherstash/cli": "workspace:*", + "@cipherstash/wizard": "workspace:*" + }, + "devDependencies": { + "vitest": "catalog:repo" + } +} diff --git a/e2e/tests/package-managers.e2e.test.ts b/e2e/tests/package-managers.e2e.test.ts new file mode 100644 index 00000000..a25d5860 --- /dev/null +++ b/e2e/tests/package-managers.e2e.test.ts @@ -0,0 +1,182 @@ +import { execFileSync } from 'node:child_process' +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' + +import { createBaseProvider } from '../../packages/cli/src/commands/init/providers/base.js' +import { createDrizzleProvider } from '../../packages/cli/src/commands/init/providers/drizzle.js' +import { createSupabaseProvider } from '../../packages/cli/src/commands/init/providers/supabase.js' +import type { PackageManager } from '../../packages/cli/src/commands/init/utils.js' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const REPO_ROOT = resolve(__dirname, '../..') +const WIZARD_BIN = resolve(REPO_ROOT, 'packages/wizard/dist/bin/wizard.js') + +const PMS: PackageManager[] = ['npm', 'bun', 'pnpm', 'yarn'] +const RUNNER: Record = { + npm: 'npx', + bun: 'bunx', + pnpm: 'pnpm dlx', + yarn: 'yarn dlx', +} + +// Suite A — pure-function rendering of "Next Steps" via the CLI's init +// providers. Imports source so we exercise the production code path +// without needing the binary to be built. +describe('CLI init providers — package-manager-aware Next Steps', () => { + const cases: Array<{ + label: string + create: () => ReturnType + firstStep: (runner: string) => string + }> = [ + { + label: 'base', + create: createBaseProvider, + firstStep: (r) => + `Set up your database: ${r} @cipherstash/cli db install`, + }, + { + label: 'drizzle', + create: createDrizzleProvider, + firstStep: (r) => + `Set up your database: ${r} @cipherstash/cli db install --drizzle`, + }, + { + label: 'supabase', + create: createSupabaseProvider, + firstStep: (r) => + `Install EQL: ${r} @cipherstash/cli db install --supabase (prompts for migration vs direct)`, + }, + ] + + for (const { label, create, firstStep } of cases) { + for (const pm of PMS) { + it(`${label} provider renders ${RUNNER[pm]} for pm=${pm}`, () => { + const steps = create().getNextSteps({}, pm) + expect(steps[0]).toBe(firstStep(RUNNER[pm])) + // The wizard hint should also use the right runner. + expect(steps.find((s) => s.includes('@cipherstash/wizard'))).toContain( + `${RUNNER[pm]} @cipherstash/wizard`, + ) + // No accidental npx leakage when the runner isn't npx. + if (RUNNER[pm] !== 'npx') { + for (const s of steps) expect(s).not.toMatch(/\bnpx\b/) + } + }) + } + } +}) + +// Suite B — runs the BUILT wizard binary in throwaway sandbox dirs and +// asserts the runner-aware "Run: ..." line in the prerequisites output. +// +// Requires the user to be authenticated (the wizard's auth check runs +// before the prereq check). Skipped when no auth is configured locally +// and no auth env vars are present in the runner environment. The CI +// job exposes auth secrets explicitly to keep this assertion live. +const authConfigured = (() => { + if (process.env.CS_CLIENT_ID && process.env.CS_CLIENT_KEY) return true + const home = process.env.HOME + if (!home) return false + return existsSync(join(home, '.cipherstash', 'auth.json')) +})() + +describe.skipIf(!authConfigured)( + 'wizard binary — package-manager-aware prerequisites', + () => { + let sandbox: string + + beforeAll(() => { + // The binary must be built — wizard's build is fast (~16ms tsup esbuild). + // Caller is expected to run it; surface a clear error if absent. + if (!existsSync(WIZARD_BIN)) { + throw new Error( + `Wizard binary not found at ${WIZARD_BIN}. Run \`pnpm --filter @cipherstash/wizard build\` first (turbo's test:e2e task does this automatically).`, + ) + } + }) + + beforeEach(() => { + sandbox = mkdtempSync(join(tmpdir(), 'stash-pm-e2e-')) + }) + + afterEach(() => { + rmSync(sandbox, { recursive: true, force: true }) + }) + + function runWizard(opts: { + lockfile?: string + userAgent?: string + }): string { + if (opts.lockfile) writeFileSync(join(sandbox, opts.lockfile), '') + try { + return execFileSync(process.execPath, [WIZARD_BIN], { + cwd: sandbox, + env: { + ...process.env, + npm_config_user_agent: opts.userAgent ?? '', + }, + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'pipe'], + }) + } catch (err) { + // Wizard exits non-zero when prereqs are missing — that's the path + // we're testing. Surface the captured stdout/stderr from the error. + const e = err as NodeJS.ErrnoException & { + stdout?: Buffer | string + stderr?: Buffer | string + } + return [e.stdout?.toString() ?? '', e.stderr?.toString() ?? ''].join( + '\n', + ) + } + } + + describe('lockfile-driven detection', () => { + it.each([ + { pm: 'bun' as const, lockfile: 'bun.lock' }, + { pm: 'pnpm' as const, lockfile: 'pnpm-lock.yaml' }, + { pm: 'yarn' as const, lockfile: 'yarn.lock' }, + ])('uses $pm runner when $lockfile is present', ({ pm, lockfile }) => { + const out = runWizard({ lockfile }) + expect(out).toContain(`Run: ${RUNNER[pm]} @cipherstash/cli db install`) + }) + + it('falls back to npx when no lockfile and no user agent', () => { + const out = runWizard({}) + expect(out).toContain('Run: npx @cipherstash/cli db install') + }) + }) + + describe('user-agent driven detection', () => { + it.each([ + { pm: 'bun' as const, userAgent: 'bun/1.1.40 npm/? node/v22.3.0' }, + { pm: 'pnpm' as const, userAgent: 'pnpm/9.0.0 npm/? node/v20.0.0' }, + { pm: 'yarn' as const, userAgent: 'yarn/4.0.0 npm/? node/v20.0.0' }, + ])('uses $pm runner when UA is $userAgent', ({ pm, userAgent }) => { + const out = runWizard({ userAgent }) + expect(out).toContain(`Run: ${RUNNER[pm]} @cipherstash/cli db install`) + }) + }) + + describe('precedence', () => { + it('non-npm user agent wins over a mismatched lockfile', () => { + const out = runWizard({ + lockfile: 'pnpm-lock.yaml', + userAgent: 'bun/1.1.40 npm/? node/v22.3.0', + }) + expect(out).toContain('Run: bunx @cipherstash/cli db install') + }) + + it('npm user agent is ignored in favour of a lockfile', () => { + const out = runWizard({ + lockfile: 'bun.lock', + userAgent: 'npm/10.2.4 node/v20.0.0', + }) + expect(out).toContain('Run: bunx @cipherstash/cli db install') + }) + }) + }, +) diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json new file mode 100644 index 00000000..bc6d3ebf --- /dev/null +++ b/e2e/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.json", + "include": ["tests/**/*.ts", "vitest.config.ts"] +} diff --git a/e2e/vitest.config.ts b/e2e/vitest.config.ts new file mode 100644 index 00000000..17926341 --- /dev/null +++ b/e2e/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vitest/config' + +// E2E tests spawn child processes (built binaries) and may hit the network. +// Use the forks pool so each test gets a clean process; longer timeouts to +// accommodate subprocess startup + I/O. +export default defineConfig({ + test: { + globals: true, + include: ['tests/**/*.e2e.test.ts'], + pool: 'forks', + testTimeout: 30_000, + hookTimeout: 30_000, + }, +}) diff --git a/package.json b/package.json index d323190b..aa944bd7 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,8 @@ "clean": "rimraf --glob **/.next **/.turbo **/dist **/node_modules", "code:fix": "biome check --write", "release": "pnpm run build && changeset publish", - "test": "turbo test --filter './packages/*'" + "test": "turbo test --filter './packages/*'", + "test:e2e": "turbo run test:e2e" }, "devDependencies": { "@biomejs/biome": "^1.9.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1a4ca45a..8f228940 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -46,6 +46,19 @@ importers: specifier: 2.1.1 version: 2.1.1 + e2e: + dependencies: + '@cipherstash/cli': + specifier: workspace:* + version: link:../packages/cli + '@cipherstash/wizard': + specifier: workspace:* + version: link:../packages/wizard + devDependencies: + vitest: + specifier: catalog:repo + version: 3.1.3(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.19.3) + examples/basic: dependencies: '@cipherstash/stack': @@ -168,10 +181,6 @@ importers: next: specifier: ^14 || ^15 version: 15.5.10(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - optionalDependencies: - '@rollup/rollup-linux-x64-gnu': - specifier: 4.24.0 - version: 4.24.0 devDependencies: '@clerk/nextjs': specifier: catalog:security @@ -188,6 +197,10 @@ importers: vitest: specifier: catalog:repo version: 3.1.3(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.19.3) + optionalDependencies: + '@rollup/rollup-linux-x64-gnu': + specifier: 4.24.0 + version: 4.24.0 packages/protect: dependencies: @@ -209,10 +222,6 @@ importers: zod: specifier: ^3.24.2 version: 3.25.76 - optionalDependencies: - '@rollup/rollup-linux-x64-gnu': - specifier: 4.24.0 - version: 4.24.0 devDependencies: '@supabase/supabase-js': specifier: ^2.47.10 @@ -238,6 +247,10 @@ importers: vitest: specifier: catalog:repo version: 3.1.3(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.19.3) + optionalDependencies: + '@rollup/rollup-linux-x64-gnu': + specifier: 4.24.0 + version: 4.24.0 packages/protect-dynamodb: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0b9a503c..8a460bd9 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,7 @@ packages: - packages/* - examples/* + - e2e catalogs: repo: From f63210039944f2567e82f8e84ccb3e4b4e3cc503 Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood Date: Fri, 1 May 2026 01:10:37 +1000 Subject: [PATCH 11/12] test: address review feedback on init + post-agent tests - Add the missing yarn case to supabase provider tests (mirrors the existing pnpm/bun cases that drizzle/base already had). - Rename the misleading post-agent test that claimed to verify drizzle-kit but actually used integration='supabase'. The test does exercise a useful path (hasStashConfig=true short-circuits db install while db push still runs with bunx), so the body is preserved with an honest name and an extra negative assertion that db install does NOT run. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../init/providers/__tests__/supabase.test.ts | 11 +++++++++++ packages/wizard/src/__tests__/post-agent.test.ts | 9 +++------ 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/commands/init/providers/__tests__/supabase.test.ts b/packages/cli/src/commands/init/providers/__tests__/supabase.test.ts index 4b905f37..7e8030f6 100644 --- a/packages/cli/src/commands/init/providers/__tests__/supabase.test.ts +++ b/packages/cli/src/commands/init/providers/__tests__/supabase.test.ts @@ -27,6 +27,17 @@ describe('createSupabaseProvider getNextSteps', () => { ) }) + it('uses yarn dlx when package manager is yarn', () => { + const steps = provider.getNextSteps({}, 'yarn') + expect(steps[0]).toBe( + 'Install EQL: yarn dlx @cipherstash/cli db install --supabase (prompts for migration vs direct)', + ) + expect(steps[2]).toContain('yarn dlx @cipherstash/wizard') + // Sanity: the supabase CLI commands stay untouched. + expect(steps.join('\n')).toContain('supabase db reset') + expect(steps.join('\n')).toContain('supabase migration up') + }) + it('leaves the supabase CLI commands alone (those are not npm packages)', () => { const steps = provider.getNextSteps({}, 'bun') expect(steps.join('\n')).toContain('supabase db reset') diff --git a/packages/wizard/src/__tests__/post-agent.test.ts b/packages/wizard/src/__tests__/post-agent.test.ts index d29343fc..83faee12 100644 --- a/packages/wizard/src/__tests__/post-agent.test.ts +++ b/packages/wizard/src/__tests__/post-agent.test.ts @@ -43,24 +43,21 @@ describe('runPostAgentSteps execution commands', () => { } }) - it('executes drizzle-kit using the detected runner (bun → bunx drizzle-kit generate)', async () => { - // Confirm prompts for the migrate step would pause execution; the test - // skips that by using non-drizzle integration above. Here we only - // assert the generate step. + it('skips db install when hasStashConfig=true and still uses bunx for db push', async () => { await runPostAgentSteps({ cwd: '/tmp/fake', - integration: 'supabase', // avoid the interactive p.confirm in drizzle path + integration: 'supabase', packageManager: bun, gathered: { installCommand: 'bun add @cipherstash/stack', hasStashConfig: true, } as never, }) - // db push runs with no install const commands = vi .mocked(childProcess.execSync) .mock.calls.map((c) => c[0] as string) expect(commands).toContain('bunx @cipherstash/cli db push') + expect(commands).not.toContain('bunx @cipherstash/cli db install') }) it('falls back to npx when packageManager is undefined', async () => { From f34fe9dc81ba4af8d6d20af499e8f171d65c63ae Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood Date: Fri, 1 May 2026 01:26:41 +1000 Subject: [PATCH 12/12] chore: changeset for package-manager-aware command output Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/package-manager-aware-runner.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/package-manager-aware-runner.md diff --git a/.changeset/package-manager-aware-runner.md b/.changeset/package-manager-aware-runner.md new file mode 100644 index 00000000..50af0c6e --- /dev/null +++ b/.changeset/package-manager-aware-runner.md @@ -0,0 +1,6 @@ +--- +"@cipherstash/cli": patch +"@cipherstash/wizard": patch +--- + +Show and execute commands using the detected package manager's runner (`npx` / `bunx` / `pnpm dlx` / `yarn dlx`) instead of always emitting `npx`. A user who runs `bunx @cipherstash/cli init` now sees a "Next Steps" panel that suggests `bunx @cipherstash/cli db install` and `bunx @cipherstash/wizard`, and the wizard's post-agent step both displays and shells out to `bunx @cipherstash/cli db push` (was: `Failed: npx @cipherstash/cli db push`). Wizard prerequisite messages and AI-agent error hints (e.g. on a 401, `Run: bunx @cipherstash/cli auth login`) follow the same rule. Detection sources are unchanged: `npm_config_user_agent` first, then lockfile, then `npx` fallback.