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. 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/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/providers/__tests__/base.test.ts b/packages/cli/src/commands/init/providers/__tests__/base.test.ts new file mode 100644 index 00000000..ddfec294 --- /dev/null +++ b/packages/cli/src/commands/init/providers/__tests__/base.test.ts @@ -0,0 +1,47 @@ +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..7e8030f6 --- /dev/null +++ b/packages/cli/src/commands/init/providers/__tests__/supabase.test.ts @@ -0,0 +1,46 @@ +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('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') + 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 { 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()) } 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/__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/__tests__/post-agent.test.ts b/packages/wizard/src/__tests__/post-agent.test.ts new file mode 100644 index 00000000..83faee12 --- /dev/null +++ b/packages/wizard/src/__tests__/post-agent.test.ts @@ -0,0 +1,79 @@ +import { beforeEach, describe, expect, it, vi } 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('skips db install when hasStashConfig=true and still uses bunx for db push', async () => { + await runPostAgentSteps({ + cwd: '/tmp/fake', + integration: 'supabase', + packageManager: bun, + gathered: { + installCommand: 'bun add @cipherstash/stack', + hasStashConfig: true, + } as never, + }) + 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 () => { + 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/__tests__/prerequisites.test.ts b/packages/wizard/src/__tests__/prerequisites.test.ts new file mode 100644 index 00000000..2634aaea --- /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 + delete process.env.npm_config_user_agent + }) + + afterEach(() => { + rmSync(tmp, { recursive: true, force: true }) + if (originalUA === undefined) delete process.env.npm_config_user_agent + 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/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/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/post-agent.ts b/packages/wizard/src/lib/post-agent.ts index c53bc2c9..f0c0b9ca 100644 --- a/packages/wizard/src/lib/post-agent.ts +++ b/packages/wizard/src/lib/post-agent.ts @@ -9,14 +9,15 @@ 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 type { Integration } from './types.js' +import { rewriteEncryptedAlterColumns } from './rewrite-migrations.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, ) } @@ -89,8 +91,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)', + message: `Run Prisma migration now? (${runner} prisma migrate dev --name add-encryption)`, initialValue: true, }) @@ -98,7 +99,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/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`, ) } 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< diff --git a/packages/wizard/src/run.ts b/packages/wizard/src/run.ts index 3c52a6a5..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) { @@ -182,6 +186,7 @@ export async function run(options: RunOptions) { cwd: options.cwd, integration: selectedIntegration, gathered, + packageManager, }) changelog.phase( 'Post-agent steps complete', 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: