From ce70b4dbb837f9ea74d412945c7300bb37643423 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 2 May 2026 15:47:33 +1000 Subject: [PATCH 1/2] feat(cli): stash wizard thin-wrapper subcommand The wizard ships as @cipherstash/wizard so the agent SDK stays out of the CLI bundle. Re-add a `stash wizard` subcommand that spawns the wizard via the project's package manager (npx / pnpm dlx / yarn dlx / bunx) so users only have to think about one CLI surface. Flags after `wizard` are forwarded verbatim. Cold-cache runs print an explicit "first run downloads ~5s" line so the CLI doesn't appear hung while the package manager resolves the package; warm-cache runs print a single "Launching..." line and hand the terminal over to the wizard. Existing copy that pointed at `npx @cipherstash/wizard` (init's next-steps for base/Drizzle/Supabase, db install's post-install note, the help banner) now uses `stash wizard`. --- .changeset/cli-stash-wizard-subcommand.md | 13 ++++ packages/cli/src/bin/stash.ts | 11 +++ packages/cli/src/commands/db/install.ts | 2 +- packages/cli/src/commands/index.ts | 1 + .../init/providers/__tests__/base.test.ts | 6 +- .../init/providers/__tests__/drizzle.test.ts | 2 +- .../init/providers/__tests__/supabase.test.ts | 4 +- .../cli/src/commands/init/providers/base.ts | 3 +- .../src/commands/init/providers/drizzle.ts | 3 +- .../src/commands/init/providers/supabase.ts | 3 +- .../commands/wizard/__tests__/index.test.ts | 43 +++++++++++ packages/cli/src/commands/wizard/index.ts | 74 +++++++++++++++++++ 12 files changed, 152 insertions(+), 13 deletions(-) create mode 100644 .changeset/cli-stash-wizard-subcommand.md create mode 100644 packages/cli/src/commands/wizard/__tests__/index.test.ts create mode 100644 packages/cli/src/commands/wizard/index.ts diff --git a/.changeset/cli-stash-wizard-subcommand.md b/.changeset/cli-stash-wizard-subcommand.md new file mode 100644 index 00000000..a98eea2e --- /dev/null +++ b/.changeset/cli-stash-wizard-subcommand.md @@ -0,0 +1,13 @@ +--- +'stash': minor +--- + +Add `stash wizard` as a thin wrapper subcommand around `@cipherstash/wizard`. + +The wizard ships as a separate npm package so the heavy agent SDK stays out of the `stash` CLI bundle. Until now, users had to remember a second tool name (`npx @cipherstash/wizard`); the wrapper exposes the same capability under the existing `stash` surface so the user only has to think about one CLI. + +`stash wizard` detects the project's package manager and spawns the wizard via the matching one-shot runner — `npx`, `pnpm dlx`, `yarn dlx`, or `bunx` — with `stdio: 'inherit'` so the wizard owns the terminal cleanly. Any flags after `wizard` are forwarded verbatim, so `stash wizard --debug` works. + +On a cold cache (the wizard package isn't installed in the project) the runner downloads it before launching — a few seconds. The wrapper prints an explicit "first run downloads ~5s" line in that case so the CLI doesn't appear hung. On a warm cache, just a "Launching the CipherStash wizard…" line, then the wizard takes over. + +Existing copy that pointed at `npx @cipherstash/wizard` (init's next-steps for base / Drizzle / Supabase, `db install`'s post-install note) now uses `stash wizard`. diff --git a/packages/cli/src/bin/stash.ts b/packages/cli/src/bin/stash.ts index 725a2bc1..7770d9c3 100644 --- a/packages/cli/src/bin/stash.ts +++ b/packages/cli/src/bin/stash.ts @@ -22,6 +22,7 @@ import { statusCommand, testConnectionCommand, upgradeCommand, + wizardCommand, } from '../commands/index.js' import { messages } from '../messages.js' @@ -62,6 +63,7 @@ ${messages.cli.usagePrefix} [options] Commands: init Initialize CipherStash for your project auth Authenticate with CipherStash + wizard AI-guided encryption setup (reads your codebase) db install Scaffold stash.config.ts (if missing) and install EQL extensions db upgrade Upgrade EQL extensions to the latest version @@ -99,6 +101,7 @@ Examples: npx stash init npx stash init --supabase npx stash auth login + npx stash wizard npx stash db install npx stash db push npx stash schema build @@ -268,6 +271,14 @@ async function main() { case 'env': await envCommand({ write: flags.write }) break + case 'wizard': { + // Forward everything after `stash wizard` verbatim. The wizard package + // owns its own flag parsing; we don't try to interpret its surface + // here so it can evolve independently. + const wizardArgs = process.argv.slice(3) + await wizardCommand(wizardArgs) + break + } default: console.error(`${messages.cli.unknownCommand}: ${command}\n`) console.log(HELP) diff --git a/packages/cli/src/commands/db/install.ts b/packages/cli/src/commands/db/install.ts index 4aef5e30..3c6fe064 100644 --- a/packages/cli/src/commands/db/install.ts +++ b/packages/cli/src/commands/db/install.ts @@ -274,7 +274,7 @@ function printNextSteps(): void { 'Next steps:', '', ' 1. Wire up encrypt/decrypt with the wizard (AI-guided, automated):', - ' npx @cipherstash/wizard', + ' stash wizard', '', ' 2. Or use the client directly from @cipherstash/stack:', " import { Encryption } from '@cipherstash/stack'", diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index 8b3e236b..17154eec 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -5,3 +5,4 @@ export { upgradeCommand } from './db/upgrade.js' export { authCommand } from './auth/index.js' export { initCommand } from './init/index.js' export { envCommand } from './env/index.js' +export { wizardCommand } from './wizard/index.js' 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 602546a8..d1d5e2ca 100644 --- a/packages/cli/src/commands/init/providers/__tests__/base.test.ts +++ b/packages/cli/src/commands/init/providers/__tests__/base.test.ts @@ -9,7 +9,7 @@ describe('createBaseProvider getNextSteps', () => { expect(steps[0]).toBe( 'Set up your database: npx stash db install', ) - expect(steps[1]).toContain('npx @cipherstash/wizard') + expect(steps[1]).toContain('npx stash wizard') }) it('uses bunx when package manager is bun', () => { @@ -17,7 +17,7 @@ describe('createBaseProvider getNextSteps', () => { expect(steps[0]).toBe( 'Set up your database: bunx stash db install', ) - expect(steps[1]).toContain('bunx @cipherstash/wizard') + expect(steps[1]).toContain('bunx stash wizard') // Sanity: the old hardcoded `npx` should be gone. for (const s of steps) expect(s).not.toMatch(/\bnpx\b/) }) @@ -27,7 +27,7 @@ describe('createBaseProvider getNextSteps', () => { expect(steps[0]).toBe( 'Set up your database: pnpm dlx stash db install', ) - expect(steps[1]).toContain('pnpm dlx @cipherstash/wizard') + expect(steps[1]).toContain('pnpm dlx stash wizard') }) it('uses yarn dlx when package manager is yarn', () => { diff --git a/packages/cli/src/commands/init/providers/__tests__/drizzle.test.ts b/packages/cli/src/commands/init/providers/__tests__/drizzle.test.ts index 1f247670..43a417a6 100644 --- a/packages/cli/src/commands/init/providers/__tests__/drizzle.test.ts +++ b/packages/cli/src/commands/init/providers/__tests__/drizzle.test.ts @@ -16,7 +16,7 @@ describe('createDrizzleProvider getNextSteps', () => { expect(steps[0]).toBe( 'Set up your database: bunx stash db install --drizzle', ) - expect(steps[1]).toContain('bunx @cipherstash/wizard') + expect(steps[1]).toContain('bunx stash wizard') for (const s of steps) expect(s).not.toMatch(/\bnpx\b/) }) 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 e23049a8..38a4dccc 100644 --- a/packages/cli/src/commands/init/providers/__tests__/supabase.test.ts +++ b/packages/cli/src/commands/init/providers/__tests__/supabase.test.ts @@ -16,7 +16,7 @@ describe('createSupabaseProvider getNextSteps', () => { expect(steps[0]).toBe( 'Install EQL: bunx stash db install --supabase (prompts for migration vs direct)', ) - expect(steps[2]).toContain('bunx @cipherstash/wizard') // wizard step is third + expect(steps[2]).toContain('bunx stash wizard') // wizard step is third for (const s of steps) expect(s).not.toMatch(/\bnpx\b/) }) @@ -32,7 +32,7 @@ describe('createSupabaseProvider getNextSteps', () => { expect(steps[0]).toBe( 'Install EQL: yarn dlx stash db install --supabase (prompts for migration vs direct)', ) - expect(steps[2]).toContain('yarn dlx @cipherstash/wizard') + expect(steps[2]).toContain('yarn dlx stash wizard') // Sanity: the supabase CLI commands stay untouched. 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 4f8ba279..1b74d117 100644 --- a/packages/cli/src/commands/init/providers/base.ts +++ b/packages/cli/src/commands/init/providers/base.ts @@ -7,13 +7,12 @@ export function createBaseProvider(): InitProvider { introMessage: 'Setting up CipherStash for your project...', getNextSteps(state: InitState, pm: PackageManager): string[] { const cli = runnerCommand(pm, 'stash') - const wizard = runnerCommand(pm, '@cipherstash/wizard') const manualEdit = state.clientFilePath ? `edit ${state.clientFilePath} directly` : 'edit your encryption schema directly' return [ `Set up your database: ${cli} db install`, - `Customize your schema: ${wizard} (AI-guided, automated) — or ${manualEdit}`, + `Customize your schema: ${cli} 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 8b443882..16b5c73f 100644 --- a/packages/cli/src/commands/init/providers/drizzle.ts +++ b/packages/cli/src/commands/init/providers/drizzle.ts @@ -7,14 +7,13 @@ export function createDrizzleProvider(): InitProvider { introMessage: 'Setting up CipherStash for your Drizzle project...', getNextSteps(state: InitState, pm: PackageManager): string[] { const cli = runnerCommand(pm, 'stash') - 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: ${wizard} (AI-guided, automated) — or ${manualEdit}`, + `Customize your schema: ${cli} 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 b9cbcf2b..5c669fc5 100644 --- a/packages/cli/src/commands/init/providers/supabase.ts +++ b/packages/cli/src/commands/init/providers/supabase.ts @@ -7,7 +7,6 @@ export function createSupabaseProvider(): InitProvider { introMessage: 'Setting up CipherStash for your Supabase project...', getNextSteps(state: InitState, pm: PackageManager): string[] { const cli = runnerCommand(pm, 'stash') - const wizard = runnerCommand(pm, '@cipherstash/wizard') const steps = [ `Install EQL: ${cli} db install --supabase (prompts for migration vs direct)`, 'Apply it: supabase db reset (local) or supabase migration up (remote)', @@ -17,7 +16,7 @@ export function createSupabaseProvider(): InitProvider { ? `edit ${state.clientFilePath} directly` : 'edit your encryption schema directly' steps.push( - `Customize your schema: ${wizard} (AI-guided, automated) — or ${manualEdit}`, + `Customize your schema: ${cli} wizard (AI-guided, automated) — or ${manualEdit}`, ) steps.push( diff --git a/packages/cli/src/commands/wizard/__tests__/index.test.ts b/packages/cli/src/commands/wizard/__tests__/index.test.ts new file mode 100644 index 00000000..a86c0cd3 --- /dev/null +++ b/packages/cli/src/commands/wizard/__tests__/index.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { splitRunner } from '../index.js' + +describe('splitRunner', () => { + it('splits a single-token runner like bunx', () => { + expect(splitRunner('bunx @cipherstash/wizard')).toEqual({ + bin: 'bunx', + preArgs: ['@cipherstash/wizard'], + }) + }) + + it('splits a multi-token runner like pnpm dlx', () => { + expect(splitRunner('pnpm dlx @cipherstash/wizard')).toEqual({ + bin: 'pnpm', + preArgs: ['dlx', '@cipherstash/wizard'], + }) + }) + + it('splits yarn dlx', () => { + expect(splitRunner('yarn dlx @cipherstash/wizard')).toEqual({ + bin: 'yarn', + preArgs: ['dlx', '@cipherstash/wizard'], + }) + }) + + it('splits npx', () => { + expect(splitRunner('npx @cipherstash/wizard')).toEqual({ + bin: 'npx', + preArgs: ['@cipherstash/wizard'], + }) + }) + + it('collapses consecutive whitespace', () => { + expect(splitRunner('pnpm dlx @cipherstash/wizard')).toEqual({ + bin: 'pnpm', + preArgs: ['dlx', '@cipherstash/wizard'], + }) + }) + + it('throws on an empty runner', () => { + expect(() => splitRunner('')).toThrow(/Empty runner command/i) + }) +}) diff --git a/packages/cli/src/commands/wizard/index.ts b/packages/cli/src/commands/wizard/index.ts new file mode 100644 index 00000000..0b663b99 --- /dev/null +++ b/packages/cli/src/commands/wizard/index.ts @@ -0,0 +1,74 @@ +import { spawn } from 'node:child_process' +import * as p from '@clack/prompts' +import { + detectPackageManager, + isPackageInstalled, + runnerCommand, +} from '../init/utils.js' + +const WIZARD_PACKAGE = '@cipherstash/wizard' + +/** + * Resolve the runner invocation into argv-style tokens for `spawn`. + * + * `runnerCommand` returns strings like `'pnpm dlx @cipherstash/wizard'` or + * `'npx @cipherstash/wizard'`. Splitting on whitespace is safe here because + * every token is constructed from a closed enum (the package manager name + * and the literal package). We avoid `shell: true` so we don't have to + * worry about quoting user-passed flags downstream. + */ +function splitRunner(cmd: string): { bin: string; preArgs: string[] } { + const tokens = cmd.split(/\s+/).filter(Boolean) + const [bin, ...preArgs] = tokens + if (!bin) { + // Should be unreachable — runnerCommand always returns at least one token. + throw new Error(`Empty runner command: "${cmd}"`) + } + return { bin, preArgs } +} + +/** + * Thin wrapper around `@cipherstash/wizard`. + * + * The wizard ships as its own package so the heavy agent SDK stays out of the + * `stash` CLI bundle. This wrapper exists so users see one CLI surface + * (`stash wizard`) instead of being told to remember a second tool name. + * + * On a cold cache (the wizard package isn't installed in the project) the + * package manager will download it before running — that can take a few + * seconds. We surface that explicitly so the user doesn't think the CLI is + * hung. We don't show a spinner because the wizard itself uses clack and + * needs an inherited TTY; intercepting child stdout would break the wizard's + * own UI. + */ +export async function wizardCommand(passthroughArgs: string[]): Promise { + const pm = detectPackageManager() + const runner = runnerCommand(pm, WIZARD_PACKAGE) + const cached = isPackageInstalled(WIZARD_PACKAGE) + + if (cached) { + p.log.info('Launching the CipherStash wizard...') + } else { + p.log.info( + `Launching the CipherStash wizard... first run downloads ${WIZARD_PACKAGE} (~5s).`, + ) + } + + const { bin, preArgs } = splitRunner(runner) + const args = [...preArgs, ...passthroughArgs] + + const exitCode = await new Promise((resolvePromise) => { + const child = spawn(bin, args, { stdio: 'inherit', shell: false }) + child.on('close', (code) => resolvePromise(code ?? 0)) + child.on('error', (err) => { + p.log.error(`Failed to launch wizard: ${err.message}`) + resolvePromise(127) + }) + }) + + if (exitCode !== 0) { + process.exit(exitCode) + } +} + +export { splitRunner } From e999857a347a5dd938d9198a4770046c2569d2f3 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 2 May 2026 16:22:02 +1000 Subject: [PATCH 2/2] test(e2e): match `stash wizard` instead of `@cipherstash/wizard` The wrapper changes the runner-aware string the providers emit from ` @cipherstash/wizard` to ` stash wizard`. The provider unit tests were updated; the e2e suite checked the wizard hint via a `steps.find` and was missed. Updates the expectation so the find lands. --- e2e/tests/package-managers.e2e.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/e2e/tests/package-managers.e2e.test.ts b/e2e/tests/package-managers.e2e.test.ts index 0aee50b0..a399fd12 100644 --- a/e2e/tests/package-managers.e2e.test.ts +++ b/e2e/tests/package-managers.e2e.test.ts @@ -56,9 +56,11 @@ describe('CLI init providers — package-manager-aware Next Steps', () => { 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`, + // The wizard hint should also use the right runner. The wrapper + // subcommand is `stash wizard`, so the rendered runner-aware string + // looks like e.g. `bunx stash wizard`. + expect(steps.find((s) => s.includes('stash wizard'))).toContain( + `${RUNNER[pm]} stash wizard`, ) // No accidental npx leakage when the runner isn't npx. if (RUNNER[pm] !== 'npx') {