Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/cli-stash-wizard-subcommand.md
Original file line number Diff line number Diff line change
@@ -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`.
8 changes: 5 additions & 3 deletions e2e/tests/package-managers.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
11 changes: 11 additions & 0 deletions packages/cli/src/bin/stash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
statusCommand,
testConnectionCommand,
upgradeCommand,
wizardCommand,
} from '../commands/index.js'
import { messages } from '../messages.js'

Expand Down Expand Up @@ -62,6 +63,7 @@ ${messages.cli.usagePrefix} <command> [options]
Commands:
init Initialize CipherStash for your project
auth <subcommand> 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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Comment on lines +274 to +281
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

E2E test required by coding guidelines for this routing change.

The wizard case introduces a new exit-code propagation path (wizardCommand calls process.exit(exitCode)) routed through stash.ts. The coding guidelines explicitly require an E2E test when touching src/bin/stash.ts "argv parsing, exit codes, or top-level error handling". The PR's test plan covers only unit tests for splitRunner; no E2E test for stash wizard routing is listed.

At minimum, an E2E test should verify that stash wizard --help (or equivalent) exits 0 and that an unknown/failing wizard invocation propagates the expected non-zero exit code.

As per coding guidelines: "Add an E2E test when touching src/bin/stash.ts argv parsing, exit codes, or top-level error handling."

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

In `@packages/cli/src/bin/stash.ts` around lines 274 - 281, Add an E2E test that
exercises the new `wizard` routing in `stash.ts` by spawning the CLI and
asserting exit codes: call the binary with arguments ["wizard","--help"] and
assert it exits with code 0, and call a failing/unknown wizard invocation (e.g.,
["wizard","--invalid-flag"] or a wizard mode that you know returns non-zero) and
assert the process exit code is the expected non-zero value; use Node's
child_process.spawn/execFile in a Jest/Mocha test to capture exit events and
stdout/stderr, and reference the `wizardCommand` behavior so the test ensures
exit-code propagation through the `case 'wizard'` branch.

default:
console.error(`${messages.cli.unknownCommand}: ${command}\n`)
console.log(HELP)
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/db/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'",
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,15 @@ 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', () => {
const steps = provider.getNextSteps({}, 'bun')
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/)
})
Expand All @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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/)
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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/)
})

Expand All @@ -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')
Expand Down
3 changes: 1 addition & 2 deletions packages/cli/src/commands/init/providers/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
]
Expand Down
3 changes: 1 addition & 2 deletions packages/cli/src/commands/init/providers/drizzle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
3 changes: 1 addition & 2 deletions packages/cli/src/commands/init/providers/supabase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)',
Expand All @@ -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(
Expand Down
43 changes: 43 additions & 0 deletions packages/cli/src/commands/wizard/__tests__/index.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
74 changes: 74 additions & 0 deletions packages/cli/src/commands/wizard/index.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<number>((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)
})
})
Comment on lines +60 to +67
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check package.json engines field and any CI matrix for Windows runners
rg -n '"engines"' packages/cli/package.json
fd -e yml -e yaml . .github/workflows --exec grep -l 'windows'

Repository: cipherstash/stack

Length of output: 78


🏁 Script executed:

cat packages/cli/package.json | head -100

Repository: cipherstash/stack

Length of output: 1741


🏁 Script executed:

rg -A 5 '"engines"' packages/cli/package.json

Repository: cipherstash/stack

Length of output: 97


🏁 Script executed:

cat -n packages/cli/src/commands/wizard/index.ts | sed -n '50,75p'

Repository: cipherstash/stack

Length of output: 925


🏁 Script executed:

rg -B 5 -A 15 'function splitRunner|export.*splitRunner|const splitRunner' packages/cli/src/commands/wizard/

Repository: cipherstash/stack

Length of output: 2181


🏁 Script executed:

rg -n "splitRunner" packages/cli/src/commands/wizard/index.ts

Repository: cipherstash/stack

Length of output: 212


🏁 Script executed:

rg -B 10 "const runner = " packages/cli/src/commands/wizard/index.ts

Repository: cipherstash/stack

Length of output: 622


🏁 Script executed:

rg -B 20 "function splitRunner" packages/cli/src/commands/wizard/index.ts | head -40

Repository: cipherstash/stack

Length of output: 790


🏁 Script executed:

rg -A 30 "function runnerCommand|export.*runnerCommand" packages/cli/src/commands/init/utils.ts

Repository: cipherstash/stack

Length of output: 745


🏁 Script executed:

rg -A 20 "function detectPackageManager|export.*detectPackageManager" packages/cli/src/commands/init/utils.ts

Repository: cipherstash/stack

Length of output: 734


🏁 Script executed:

rg -n "windows|Windows|WIN32|win32" packages/cli/README.md packages/cli/CHANGELOG.md 2>/dev/null

Repository: cipherstash/stack

Length of output: 43


🏁 Script executed:

find packages/cli -name "*.md" -type f | xargs grep -l "Windows\|windows\|platform" 2>/dev/null

Repository: cipherstash/stack

Length of output: 45


🏁 Script executed:

rg "spawn.*bin.*shell.*false" packages/cli/src/ -A 5 -B 5

Repository: cipherstash/stack

Length of output: 944


spawn with shell: false will fail on Windows for npm, pnpm, and yarn.

These package managers are distributed as .cmd batch scripts on Windows, and spawn with shell: false cannot execute them directly — the same ENOENT behavior is confirmed in the Node.js ecosystem. Only bunx (a native .exe) works as-is.

While Windows support is not explicitly declared in the engines field, the code will fail on Windows regardless. The standard fix in the Node.js ecosystem is to replace spawn with cross-spawn, which transparently appends .cmd on Windows:

🐛 Proposed fix using cross-spawn
-import { spawn } from 'node:child_process'
+import spawn from 'cross-spawn'

cross-spawn is a drop-in replacement with identical call signature and is already a common dependency in CLI tooling. Alternatively, if adding a new dependency is undesirable, a minimal inline shim works:

-    const child = spawn(bin, args, { stdio: 'inherit', shell: false })
+    const winBin = process.platform === 'win32' ? `${bin}.cmd` : bin
+    const child = spawn(winBin, args, { stdio: 'inherit', shell: false })

Note: shell: true is not a safe workaround because passthroughArgs are user-supplied and would be subject to shell injection.

📝 Committable suggestion

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

Suggested change
const exitCode = await new Promise<number>((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)
})
})
const exitCode = await new Promise<number>((resolvePromise) => {
const winBin = process.platform === 'win32' ? `${bin}.cmd` : bin
const child = spawn(winBin, 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)
})
})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/cli/src/commands/wizard/index.ts` around lines 60 - 67, The Windows
failure is caused by using node's child_process.spawn (spawn) with shell: false
which cannot execute package manager .cmd shims (npm/pnpm/yarn); replace the use
of spawn in this block with cross-spawn's default export (import spawn from
'cross-spawn') and call it with the same signature (spawn(bin, args, { stdio:
'inherit' })) so .cmd is appended on Windows; keep the child.on('close', ...)
and child.on('error', ...) handlers and the exitCode Promise logic unchanged and
ensure TypeScript types/imports are updated to reference cross-spawn instead of
child_process.spawn.


if (exitCode !== 0) {
process.exit(exitCode)
}
}

export { splitRunner }
Loading