From eb94ac8b052ac3aa4ae38b29344555aea6859b54 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 17 Jun 2026 13:07:47 +1000 Subject: [PATCH 1/3] feat(cli): guard against missing native binaries stash loads native Rust addons (protect-ffi via @cipherstash/stack, and @cipherstash/auth) distributed as per-platform optional npm packages. npm intermittently skips installing these optional deps (npm/cli#4828), leaving the base package present but the platform binary missing. The failure was a raw MODULE_NOT_FOUND stack trace with no guidance. - Split the bin into a thin launcher (stash.ts) that dynamically imports the command graph (main.ts), so a native-load failure during module evaluation is caught and rendered as actionable fix guidance instead of a stack trace. - Add src/native.ts: isNativeBinaryMissing() detection + reportNativeBinaryMissing() guidance, with unit tests. - Add 'stash doctor' to diagnose runtime + native modules. Dispatched before the command graph loads so it runs even when a binary is missing. Interim hardening ahead of the planned Rust/Homebrew CLI. --- .changeset/native-binary-guards.md | 9 + packages/cli/src/__tests__/native.test.ts | 75 ++++ packages/cli/src/bin/main.ts | 467 +++++++++++++++++++++ packages/cli/src/bin/stash.ts | 472 ++-------------------- packages/cli/src/commands/doctor/index.ts | 82 ++++ packages/cli/src/native.ts | 85 ++++ 6 files changed, 747 insertions(+), 443 deletions(-) create mode 100644 .changeset/native-binary-guards.md create mode 100644 packages/cli/src/__tests__/native.test.ts create mode 100644 packages/cli/src/bin/main.ts create mode 100644 packages/cli/src/commands/doctor/index.ts create mode 100644 packages/cli/src/native.ts diff --git a/.changeset/native-binary-guards.md b/.changeset/native-binary-guards.md new file mode 100644 index 00000000..cd321d04 --- /dev/null +++ b/.changeset/native-binary-guards.md @@ -0,0 +1,9 @@ +--- +"stash": minor +--- + +Add guards for missing native binaries. When npm skips the platform-specific +optional dependency (a known npm bug), stash now prints actionable fix +guidance instead of a raw `MODULE_NOT_FOUND` stack trace. Adds a new +`stash doctor` command that diagnoses the runtime and native modules and works +even when a binary is missing. diff --git a/packages/cli/src/__tests__/native.test.ts b/packages/cli/src/__tests__/native.test.ts new file mode 100644 index 00000000..e7ff6a41 --- /dev/null +++ b/packages/cli/src/__tests__/native.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest' +import { isNativeBinaryMissing } from '../native.js' + +interface ModuleError extends Error { + code?: string + requireStack?: string[] +} + +function moduleError(message: string, requireStack: string[] = []): ModuleError { + const err = new Error(message) as ModuleError + err.code = 'MODULE_NOT_FOUND' + err.requireStack = requireStack + return err +} + +describe('isNativeBinaryMissing', () => { + it('matches a missing platform-specific protect-ffi binary', () => { + // The real-world failure: npm skipped the optional native dependency. + const err = moduleError( + "Cannot find module '@cipherstash/protect-ffi-darwin-arm64'", + [ + '/x/node_modules/@cipherstash/protect-ffi/lib/load.cjs', + '/x/node_modules/@cipherstash/protect-ffi/lib/index.cjs', + ], + ) + expect(isNativeBinaryMissing(err)).toBe(true) + }) + + it('matches the auth native binary on linux/windows targets', () => { + expect( + isNativeBinaryMissing( + moduleError("Cannot find module '@cipherstash/auth-linux-x64-gnu'"), + ), + ).toBe(true) + expect( + isNativeBinaryMissing( + moduleError("Cannot find module '@cipherstash/auth-win32-x64-msvc'"), + ), + ).toBe(true) + }) + + it('matches when only the neon loader appears in the require stack', () => { + const err = moduleError('Cannot find module somewhere', [ + '/x/node_modules/@neon-rs/load/dist/index.js', + ]) + expect(isNativeBinaryMissing(err)).toBe(true) + }) + + it('does not match a missing top-level package', () => { + expect( + isNativeBinaryMissing( + moduleError("Cannot find module '@cipherstash/stack'"), + ), + ).toBe(false) + }) + + it('does not match unrelated module errors', () => { + expect( + isNativeBinaryMissing(moduleError("Cannot find module 'left-pad'")), + ).toBe(false) + }) + + it('does not match errors without a module-not-found code', () => { + const err = new Error( + 'Cannot find module @cipherstash/protect-ffi-darwin-arm64', + ) as ModuleError + err.code = 'EACCES' + expect(isNativeBinaryMissing(err)).toBe(false) + }) + + it('ignores non-Error values', () => { + expect(isNativeBinaryMissing(undefined)).toBe(false) + expect(isNativeBinaryMissing('boom')).toBe(false) + }) +}) diff --git a/packages/cli/src/bin/main.ts b/packages/cli/src/bin/main.ts new file mode 100644 index 00000000..a880950d --- /dev/null +++ b/packages/cli/src/bin/main.ts @@ -0,0 +1,467 @@ +import { config } from 'dotenv' + +// Load env files in Next.js precedence order. dotenv's default behavior is to +// not overwrite vars that are already set, so loading .env.local first means +// its values win over .env for the same keys. Users can still set anything in +// the real environment to override both. +config({ path: '.env.local' }) +config({ path: '.env.development.local' }) +config({ path: '.env.development' }) +config({ path: '.env' }) + +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import * as p from '@clack/prompts' +// Commands that depend on @cipherstash/stack are lazy-loaded in the switch below. +import { + authCommand, + dbStatusCommand, + envCommand, + implCommand, + initCommand, + installCommand, + planCommand, + statusCommand, + testConnectionCommand, + upgradeCommand, + wizardCommand, +} from '../commands/index.js' +import { messages } from '../messages.js' + +function isModuleNotFound(err: unknown): boolean { + return ( + err instanceof Error && + 'code' in err && + (err as { code: string }).code === 'ERR_MODULE_NOT_FOUND' + ) +} + +import { + detectPackageManager, + prodInstallCommand, + runnerCommand, +} from '../commands/init/utils.js' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const pkg = JSON.parse( + readFileSync(join(__dirname, '../../package.json'), 'utf-8'), +) + +// Detect once, share across help rendering and the requireStack hint. +// Detection reads `npm_config_user_agent` (when the user invoked via +// `bunx`/`pnpm dlx`/`yarn dlx`) and falls back to the lockfile in cwd. +const PM = detectPackageManager() +const STASH = runnerCommand(PM, 'stash') + +async function requireStack(importFn: () => Promise): Promise { + try { + return await importFn() + } catch (err: unknown) { + if (isModuleNotFound(err)) { + p.log.error( + `@cipherstash/stack is required for this command. + Install it with: ${prodInstallCommand(PM, '@cipherstash/stack')} + Or run: ${STASH} init`, + ) + process.exit(1) as never + } + throw err + } +} + +const HELP = ` +${messages.cli.versionBannerPrefix}${pkg.version} + +${messages.cli.usagePrefix}${STASH} [options] + +Commands: + init Initialize CipherStash for your project + plan Draft a reviewable encryption plan at .cipherstash/plan.md + impl Execute the plan with a local agent + status Displays implementation status + auth Authenticate with CipherStash + wizard AI-guided encryption setup (reads your codebase) + doctor Diagnose install problems (native binaries, runtime) + + db install Scaffold stash.config.ts (if missing) and install EQL extensions + db upgrade Upgrade EQL extensions to the latest version + db push Push encryption schema (writes pending if active config already exists) + db activate Promote pending → active without renames (use after additive db push) + db validate Validate encryption schema + db migrate Run pending encrypt config migrations + db status Show EQL installation status + db test-connection Test database connectivity + + schema build Build an encryption schema from your database + + encrypt status Show per-column migration status (phase, progress, drift) + encrypt plan Diff intent (.cipherstash/migrations.json) vs observed state + encrypt backfill Resumably encrypt plaintext into the encrypted column + encrypt cutover Rename swap encrypted → primary column + encrypt drop Generate a migration to drop the plaintext column + + env (experimental) Print production env vars for deployment + +Options: + --help, -h Show help + --version, -v Show version + +Init Flags: + --supabase Use Supabase-specific setup flow + --drizzle Use Drizzle-specific setup flow + --prisma-next Use Prisma Next-specific setup flow (EQL bundle installed via prisma-next migration apply) + --proxy Query encrypted data via CipherStash Proxy + --no-proxy Query encrypted data directly via the SDK (default) + +Plan Flags: + --complete-rollout Plan the entire encryption lifecycle (schema-add through drop) + in one document. Skips the production-deploy gate that + normally separates rollout from cutover. Only safe when this + database is not backing a deployed application (local dev, + sandbox, freshly seeded test environment). + --target Skip the agent-target picker and hand off directly to one of + claude-code | codex | agents-md | wizard. Safe to call from + non-TTY contexts (CI, pipes). Without --target in non-TTY, + the command prints a hint and exits cleanly instead of hanging. + +Status Flags: + --quest Force the quest-log output (emoji + progress bars) + even in non-TTY contexts. Default is auto: fancy + in a terminal, plain in CI / pipes / agents. + --plain Force the plain-text output even in TTY contexts. + --json Emit a structured JSON document instead. + +Impl Flags: + --continue-without-plan Skip planning and go straight to implementation + (interactively confirms before proceeding) + --target Skip the agent-target picker and hand off directly to one of + claude-code | codex | agents-md | wizard. Safe to call from + non-TTY contexts (CI, pipes). Without --target in non-TTY, + the command prints a hint and exits cleanly instead of hanging. + +DB Flags: + --force (install) Reinstall / overwrite even if already installed + --dry-run (install, push, upgrade) Show what would happen without making changes + --supabase (install, upgrade, validate) Use Supabase-compatible mode (auto-detected from DATABASE_URL) + --drizzle (install) Generate a Drizzle migration instead of direct install (auto-detected from project) + --migration (install, requires --supabase) Write a Supabase migration file instead of running SQL directly + --direct (install, requires --supabase) Run the SQL directly against the database (mutually exclusive with --migration) + --migrations-dir (install, requires --supabase) Override the Supabase migrations directory (default: supabase/migrations) + --exclude-operator-family (install, upgrade, validate) Skip operator family creation + --latest (install, upgrade) Fetch the latest EQL from GitHub + --database-url (all db / schema commands) Override DATABASE_URL for this run only — never written to disk + +Examples: + ${STASH} init + ${STASH} init --supabase + ${STASH} init --prisma-next + ${STASH} plan + ${STASH} impl + ${STASH} impl --continue-without-plan + ${STASH} impl --target claude-code + ${STASH} status + ${STASH} auth login + ${STASH} wizard + ${STASH} db install + ${STASH} db push + ${STASH} schema build + ${STASH} doctor +`.trim() + +interface ParsedArgs { + command: string | undefined + subcommand: string | undefined + commandArgs: string[] + flags: Record + values: Record +} + +function parseArgs(argv: string[]): ParsedArgs { + const args = argv.slice(2) + const command = args[0] + const subcommand = args[1] && !args[1].startsWith('-') ? args[1] : undefined + const rest = args.slice(subcommand ? 2 : 1) + + const flags: Record = {} + const values: Record = {} + const commandArgs: string[] = [] + + for (let i = 0; i < rest.length; i++) { + const arg = rest[i] + if (arg.startsWith('--')) { + const key = arg.slice(2) + const nextArg = rest[i + 1] + if (nextArg !== undefined && !nextArg.startsWith('-')) { + values[key] = nextArg + i++ + } else { + flags[key] = true + } + } else { + commandArgs.push(arg) + } + } + + return { command, subcommand, commandArgs, flags, values } +} + +async function runDbCommand( + sub: string | undefined, + flags: Record, + values: Record, +) { + // Plumbed through every db subcommand so the URL resolver can use it as + // an explicit override. See packages/cli/src/config/database-url.ts. + const databaseUrl = values['database-url'] + + switch (sub) { + case 'install': + await installCommand({ + force: flags.force, + dryRun: flags['dry-run'], + supabase: flags.supabase, + excludeOperatorFamily: flags['exclude-operator-family'], + drizzle: flags.drizzle, + latest: flags.latest, + name: values.name, + out: values.out, + migration: flags.migration, + direct: flags.direct, + migrationsDir: values['migrations-dir'], + databaseUrl, + }) + break + case 'upgrade': + await upgradeCommand({ + dryRun: flags['dry-run'], + supabase: flags.supabase, + excludeOperatorFamily: flags['exclude-operator-family'], + latest: flags.latest, + databaseUrl, + }) + break + case 'push': { + const { pushCommand } = await requireStack( + () => import('../commands/db/push.js'), + ) + await pushCommand({ dryRun: flags['dry-run'], databaseUrl }) + break + } + case 'activate': { + const { activateCommand } = await requireStack( + () => import('../commands/db/activate.js'), + ) + await activateCommand({ databaseUrl }) + break + } + case 'validate': { + const { validateCommand } = await requireStack( + () => import('../commands/db/validate.js'), + ) + await validateCommand({ + supabase: flags.supabase, + excludeOperatorFamily: flags['exclude-operator-family'], + databaseUrl, + }) + break + } + case 'status': + await dbStatusCommand({ databaseUrl }) + break + case 'test-connection': + await testConnectionCommand({ databaseUrl }) + break + case 'migrate': + p.log.warn(messages.db.migrateNotImplemented(STASH)) + break + default: + p.log.error(`${messages.db.unknownSubcommand}: ${sub ?? '(none)'}`) + console.log() + console.log(HELP) + process.exit(1) + } +} + +async function runEncryptCommand( + sub: string | undefined, + flags: Record, + values: Record, +) { + switch (sub) { + case 'status': { + const { statusCommand } = await requireStack( + () => import('../commands/encrypt/status.js'), + ) + await statusCommand() + break + } + case 'plan': { + const { planCommand } = await requireStack( + () => import('../commands/encrypt/plan.js'), + ) + await planCommand() + break + } + case 'backfill': { + const table = requireValue(values, 'table') + const column = requireValue(values, 'column') + const { backfillCommand } = await requireStack( + () => import('../commands/encrypt/backfill.js'), + ) + await backfillCommand({ + table, + column, + pkColumn: values['pk-column'], + chunkSize: values['chunk-size'] + ? Number(values['chunk-size']) + : undefined, + encryptedColumn: values['encrypted-column'], + schemaColumnKey: values['schema-column-key'], + confirmDualWritesDeployed: flags['confirm-dual-writes-deployed'], + force: flags.force, + }) + break + } + case 'cutover': { + const table = requireValue(values, 'table') + const column = requireValue(values, 'column') + const { cutoverCommand } = await requireStack( + () => import('../commands/encrypt/cutover.js'), + ) + await cutoverCommand({ + table, + column, + proxyUrl: values['proxy-url'], + migrationsDir: values['migrations-dir'], + }) + break + } + case 'drop': { + const table = requireValue(values, 'table') + const column = requireValue(values, 'column') + const { dropCommand } = await requireStack( + () => import('../commands/encrypt/drop.js'), + ) + await dropCommand({ + table, + column, + migrationsDir: values['migrations-dir'], + }) + break + } + default: + p.log.error(`Unknown encrypt subcommand: ${sub ?? '(none)'}`) + console.log() + console.log(HELP) + process.exit(1) + } +} + +function requireValue(values: Record, key: string): string { + const v = values[key] + if (!v) { + p.log.error(`Missing required --${key} value.`) + process.exit(1) + } + return v +} + +async function runSchemaCommand( + sub: string | undefined, + flags: Record, + values: Record, +) { + switch (sub) { + case 'build': { + const { builderCommand } = await requireStack( + () => import('../commands/schema/build.js'), + ) + await builderCommand({ + supabase: flags.supabase, + databaseUrl: values['database-url'], + }) + break + } + default: + p.log.error(`Unknown schema subcommand: ${sub ?? '(none)'}`) + console.log() + console.log(HELP) + process.exit(1) + } +} + +// The CLI body. Loaded by the thin launcher in stash.ts via dynamic import so +// that a missing native binary (evaluated when this module's command graph +// loads) surfaces as friendly guidance rather than a raw stack trace. +export async function run() { + const { command, subcommand, commandArgs, flags, values } = parseArgs( + process.argv, + ) + + if (!command || command === '--help' || command === '-h' || flags.help) { + console.log(HELP) + return + } + + if (command === '--version' || command === '-v' || flags.version) { + console.log(pkg.version) + return + } + + switch (command) { + case 'init': + await initCommand(flags) + break + case 'plan': + await planCommand(flags, values) + break + case 'impl': + await implCommand(flags, values) + break + case 'status': + await statusCommand({ + quest: flags.quest, + plain: flags.plain, + json: flags.json, + }) + break + case 'auth': { + const authArgs = subcommand ? [subcommand, ...commandArgs] : commandArgs + await authCommand(authArgs, flags) + break + } + case 'db': + await runDbCommand(subcommand, flags, values) + break + case 'encrypt': + await runEncryptCommand(subcommand, flags, values) + break + case 'schema': + await runSchemaCommand(subcommand, flags, values) + break + 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 + } + case 'doctor': { + // Normally intercepted by the launcher before this module loads (so it + // works even when the native binary is missing); handled here too so the + // command still runs if run() is invoked directly. + const { doctorCommand } = await import('../commands/doctor/index.js') + await doctorCommand() + break + } + default: + console.error(`${messages.cli.unknownCommand}: ${command}\n`) + console.log(HELP) + process.exit(1) + } +} diff --git a/packages/cli/src/bin/stash.ts b/packages/cli/src/bin/stash.ts index a9d2f838..6d37a8ec 100644 --- a/packages/cli/src/bin/stash.ts +++ b/packages/cli/src/bin/stash.ts @@ -1,459 +1,45 @@ -import { config } from 'dotenv' +// Thin launcher. +// +// The CLI body lives in main.ts. It is loaded via dynamic import so the native +// addons it pulls in (e.g. @cipherstash/protect-ffi through @cipherstash/stack) +// are evaluated *inside* a try/catch. When npm has skipped the platform- +// specific optional dependency, that evaluation throws MODULE_NOT_FOUND; we +// catch it here and print actionable guidance instead of a raw stack trace. +// +// `doctor` is dispatched before the body loads so it remains runnable even when +// the native binary is missing — that's precisely when you want to diagnose. -// Load env files in Next.js precedence order. dotenv's default behavior is to -// not overwrite vars that are already set, so loading .env.local first means -// its values win over .env for the same keys. Users can still set anything in -// the real environment to override both. -config({ path: '.env.local' }) -config({ path: '.env.development.local' }) -config({ path: '.env.development' }) -config({ path: '.env' }) - -import { readFileSync } from 'node:fs' -import { dirname, join } from 'node:path' -import { fileURLToPath } from 'node:url' import * as p from '@clack/prompts' -// Commands that depend on @cipherstash/stack are lazy-loaded in the switch below. -import { - authCommand, - dbStatusCommand, - envCommand, - implCommand, - initCommand, - installCommand, - planCommand, - statusCommand, - testConnectionCommand, - upgradeCommand, - wizardCommand, -} from '../commands/index.js' -import { messages } from '../messages.js' - -function isModuleNotFound(err: unknown): boolean { - return ( - err instanceof Error && - 'code' in err && - (err as { code: string }).code === 'ERR_MODULE_NOT_FOUND' - ) -} - -import { - detectPackageManager, - prodInstallCommand, - runnerCommand, -} from '../commands/init/utils.js' - -const __dirname = dirname(fileURLToPath(import.meta.url)) -const pkg = JSON.parse( - readFileSync(join(__dirname, '../../package.json'), 'utf-8'), -) +import { isNativeBinaryMissing, reportNativeBinaryMissing } from '../native.js' -// Detect once, share across help rendering and the requireStack hint. -// Detection reads `npm_config_user_agent` (when the user invoked via -// `bunx`/`pnpm dlx`/`yarn dlx`) and falls back to the lockfile in cwd. -const PM = detectPackageManager() -const STASH = runnerCommand(PM, 'stash') +async function bootstrap() { + if (process.argv[2] === 'doctor') { + const { doctorCommand } = await import('../commands/doctor/index.js') + await doctorCommand() + return + } -async function requireStack(importFn: () => Promise): Promise { + let run: () => Promise try { - return await importFn() - } catch (err: unknown) { - if (isModuleNotFound(err)) { - p.log.error( - `@cipherstash/stack is required for this command. - Install it with: ${prodInstallCommand(PM, '@cipherstash/stack')} - Or run: ${STASH} init`, - ) - process.exit(1) as never + ;({ run } = await import('./main.js')) + } catch (err) { + if (isNativeBinaryMissing(err)) { + reportNativeBinaryMissing(err) + process.exit(1) } throw err } -} - -const HELP = ` -${messages.cli.versionBannerPrefix}${pkg.version} - -${messages.cli.usagePrefix}${STASH} [options] - -Commands: - init Initialize CipherStash for your project - plan Draft a reviewable encryption plan at .cipherstash/plan.md - impl Execute the plan with a local agent - status Displays implementation status - 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 - db push Push encryption schema (writes pending if active config already exists) - db activate Promote pending → active without renames (use after additive db push) - db validate Validate encryption schema - db migrate Run pending encrypt config migrations - db status Show EQL installation status - db test-connection Test database connectivity - - schema build Build an encryption schema from your database - - encrypt status Show per-column migration status (phase, progress, drift) - encrypt plan Diff intent (.cipherstash/migrations.json) vs observed state - encrypt backfill Resumably encrypt plaintext into the encrypted column - encrypt cutover Rename swap encrypted → primary column - encrypt drop Generate a migration to drop the plaintext column - - env (experimental) Print production env vars for deployment - -Options: - --help, -h Show help - --version, -v Show version - -Init Flags: - --supabase Use Supabase-specific setup flow - --drizzle Use Drizzle-specific setup flow - --prisma-next Use Prisma Next-specific setup flow (EQL bundle installed via prisma-next migration apply) - --proxy Query encrypted data via CipherStash Proxy - --no-proxy Query encrypted data directly via the SDK (default) - -Plan Flags: - --complete-rollout Plan the entire encryption lifecycle (schema-add through drop) - in one document. Skips the production-deploy gate that - normally separates rollout from cutover. Only safe when this - database is not backing a deployed application (local dev, - sandbox, freshly seeded test environment). - --target Skip the agent-target picker and hand off directly to one of - claude-code | codex | agents-md | wizard. Safe to call from - non-TTY contexts (CI, pipes). Without --target in non-TTY, - the command prints a hint and exits cleanly instead of hanging. - -Status Flags: - --quest Force the quest-log output (emoji + progress bars) - even in non-TTY contexts. Default is auto: fancy - in a terminal, plain in CI / pipes / agents. - --plain Force the plain-text output even in TTY contexts. - --json Emit a structured JSON document instead. - -Impl Flags: - --continue-without-plan Skip planning and go straight to implementation - (interactively confirms before proceeding) - --target Skip the agent-target picker and hand off directly to one of - claude-code | codex | agents-md | wizard. Safe to call from - non-TTY contexts (CI, pipes). Without --target in non-TTY, - the command prints a hint and exits cleanly instead of hanging. - -DB Flags: - --force (install) Reinstall / overwrite even if already installed - --dry-run (install, push, upgrade) Show what would happen without making changes - --supabase (install, upgrade, validate) Use Supabase-compatible mode (auto-detected from DATABASE_URL) - --drizzle (install) Generate a Drizzle migration instead of direct install (auto-detected from project) - --migration (install, requires --supabase) Write a Supabase migration file instead of running SQL directly - --direct (install, requires --supabase) Run the SQL directly against the database (mutually exclusive with --migration) - --migrations-dir (install, requires --supabase) Override the Supabase migrations directory (default: supabase/migrations) - --exclude-operator-family (install, upgrade, validate) Skip operator family creation - --latest (install, upgrade) Fetch the latest EQL from GitHub - --database-url (all db / schema commands) Override DATABASE_URL for this run only — never written to disk - -Examples: - ${STASH} init - ${STASH} init --supabase - ${STASH} init --prisma-next - ${STASH} plan - ${STASH} impl - ${STASH} impl --continue-without-plan - ${STASH} impl --target claude-code - ${STASH} status - ${STASH} auth login - ${STASH} wizard - ${STASH} db install - ${STASH} db push - ${STASH} schema build -`.trim() - -interface ParsedArgs { - command: string | undefined - subcommand: string | undefined - commandArgs: string[] - flags: Record - values: Record -} - -function parseArgs(argv: string[]): ParsedArgs { - const args = argv.slice(2) - const command = args[0] - const subcommand = args[1] && !args[1].startsWith('-') ? args[1] : undefined - const rest = args.slice(subcommand ? 2 : 1) - - const flags: Record = {} - const values: Record = {} - const commandArgs: string[] = [] - - for (let i = 0; i < rest.length; i++) { - const arg = rest[i] - if (arg.startsWith('--')) { - const key = arg.slice(2) - const nextArg = rest[i + 1] - if (nextArg !== undefined && !nextArg.startsWith('-')) { - values[key] = nextArg - i++ - } else { - flags[key] = true - } - } else { - commandArgs.push(arg) - } - } - return { command, subcommand, commandArgs, flags, values } + await run() } -async function runDbCommand( - sub: string | undefined, - flags: Record, - values: Record, -) { - // Plumbed through every db subcommand so the URL resolver can use it as - // an explicit override. See packages/cli/src/config/database-url.ts. - const databaseUrl = values['database-url'] - - switch (sub) { - case 'install': - await installCommand({ - force: flags.force, - dryRun: flags['dry-run'], - supabase: flags.supabase, - excludeOperatorFamily: flags['exclude-operator-family'], - drizzle: flags.drizzle, - latest: flags.latest, - name: values.name, - out: values.out, - migration: flags.migration, - direct: flags.direct, - migrationsDir: values['migrations-dir'], - databaseUrl, - }) - break - case 'upgrade': - await upgradeCommand({ - dryRun: flags['dry-run'], - supabase: flags.supabase, - excludeOperatorFamily: flags['exclude-operator-family'], - latest: flags.latest, - databaseUrl, - }) - break - case 'push': { - const { pushCommand } = await requireStack( - () => import('../commands/db/push.js'), - ) - await pushCommand({ dryRun: flags['dry-run'], databaseUrl }) - break - } - case 'activate': { - const { activateCommand } = await requireStack( - () => import('../commands/db/activate.js'), - ) - await activateCommand({ databaseUrl }) - break - } - case 'validate': { - const { validateCommand } = await requireStack( - () => import('../commands/db/validate.js'), - ) - await validateCommand({ - supabase: flags.supabase, - excludeOperatorFamily: flags['exclude-operator-family'], - databaseUrl, - }) - break - } - case 'status': - await dbStatusCommand({ databaseUrl }) - break - case 'test-connection': - await testConnectionCommand({ databaseUrl }) - break - case 'migrate': - p.log.warn(messages.db.migrateNotImplemented(STASH)) - break - default: - p.log.error(`${messages.db.unknownSubcommand}: ${sub ?? '(none)'}`) - console.log() - console.log(HELP) - process.exit(1) - } -} - -async function runEncryptCommand( - sub: string | undefined, - flags: Record, - values: Record, -) { - switch (sub) { - case 'status': { - const { statusCommand } = await requireStack( - () => import('../commands/encrypt/status.js'), - ) - await statusCommand() - break - } - case 'plan': { - const { planCommand } = await requireStack( - () => import('../commands/encrypt/plan.js'), - ) - await planCommand() - break - } - case 'backfill': { - const table = requireValue(values, 'table') - const column = requireValue(values, 'column') - const { backfillCommand } = await requireStack( - () => import('../commands/encrypt/backfill.js'), - ) - await backfillCommand({ - table, - column, - pkColumn: values['pk-column'], - chunkSize: values['chunk-size'] - ? Number(values['chunk-size']) - : undefined, - encryptedColumn: values['encrypted-column'], - schemaColumnKey: values['schema-column-key'], - confirmDualWritesDeployed: flags['confirm-dual-writes-deployed'], - force: flags.force, - }) - break - } - case 'cutover': { - const table = requireValue(values, 'table') - const column = requireValue(values, 'column') - const { cutoverCommand } = await requireStack( - () => import('../commands/encrypt/cutover.js'), - ) - await cutoverCommand({ - table, - column, - proxyUrl: values['proxy-url'], - migrationsDir: values['migrations-dir'], - }) - break - } - case 'drop': { - const table = requireValue(values, 'table') - const column = requireValue(values, 'column') - const { dropCommand } = await requireStack( - () => import('../commands/encrypt/drop.js'), - ) - await dropCommand({ - table, - column, - migrationsDir: values['migrations-dir'], - }) - break - } - default: - p.log.error(`Unknown encrypt subcommand: ${sub ?? '(none)'}`) - console.log() - console.log(HELP) - process.exit(1) - } -} - -function requireValue(values: Record, key: string): string { - const v = values[key] - if (!v) { - p.log.error(`Missing required --${key} value.`) +bootstrap().catch((err: unknown) => { + // Also caught here in case a native addon loads lazily (at call time) rather + // than during module evaluation. + if (isNativeBinaryMissing(err)) { + reportNativeBinaryMissing(err) process.exit(1) } - return v -} - -async function runSchemaCommand( - sub: string | undefined, - flags: Record, - values: Record, -) { - switch (sub) { - case 'build': { - const { builderCommand } = await requireStack( - () => import('../commands/schema/build.js'), - ) - await builderCommand({ - supabase: flags.supabase, - databaseUrl: values['database-url'], - }) - break - } - default: - p.log.error(`Unknown schema subcommand: ${sub ?? '(none)'}`) - console.log() - console.log(HELP) - process.exit(1) - } -} - -async function main() { - const { command, subcommand, commandArgs, flags, values } = parseArgs( - process.argv, - ) - - if (!command || command === '--help' || command === '-h' || flags.help) { - console.log(HELP) - return - } - - if (command === '--version' || command === '-v' || flags.version) { - console.log(pkg.version) - return - } - - switch (command) { - case 'init': - await initCommand(flags) - break - case 'plan': - await planCommand(flags, values) - break - case 'impl': - await implCommand(flags, values) - break - case 'status': - await statusCommand({ - quest: flags.quest, - plain: flags.plain, - json: flags.json, - }) - break - case 'auth': { - const authArgs = subcommand ? [subcommand, ...commandArgs] : commandArgs - await authCommand(authArgs, flags) - break - } - case 'db': - await runDbCommand(subcommand, flags, values) - break - case 'encrypt': - await runEncryptCommand(subcommand, flags, values) - break - case 'schema': - await runSchemaCommand(subcommand, flags, values) - break - 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) - process.exit(1) - } -} - -main().catch((err: unknown) => { const message = err instanceof Error ? err.message : String(err) p.log.error(`Fatal error: ${message}`) process.exit(1) diff --git a/packages/cli/src/commands/doctor/index.ts b/packages/cli/src/commands/doctor/index.ts new file mode 100644 index 00000000..7db0c264 --- /dev/null +++ b/packages/cli/src/commands/doctor/index.ts @@ -0,0 +1,82 @@ +import * as p from '@clack/prompts' +import { + currentTarget, + isNativeBinaryMissing, + reportNativeBinaryMissing, +} from '../../native.js' + +// Native-bearing packages the CLI loads at runtime. Importing each forces its +// @neon-rs/load proxy to resolve the platform binary — the same load that fails +// when npm skips the optional dependency. @cipherstash/stack is the peer that +// pulls protect-ffi; it may legitimately be absent until `stash init`. +const PROBES: { label: string; pkg: string; optional?: boolean }[] = [ + { + label: 'Encryption engine (@cipherstash/stack → protect-ffi)', + pkg: '@cipherstash/stack', + optional: true, + }, + { label: 'Auth (@cipherstash/auth)', pkg: '@cipherstash/auth' }, +] + +function report(ok: boolean, label: string, detail?: string) { + const text = detail ? `${label} — ${detail}` : label + if (ok) p.log.success(text) + else p.log.error(text) +} + +function isPackageMissing(err: unknown, pkg: string): boolean { + if (!(err instanceof Error)) return false + const code = (err as { code?: string }).code + if (code !== 'ERR_MODULE_NOT_FOUND' && code !== 'MODULE_NOT_FOUND') { + return false + } + return err.message.includes(pkg) +} + +export async function doctorCommand(): Promise { + p.intro('stash doctor') + + let failed = false + let nativeError: unknown + + const nodeMajor = Number(process.versions.node.split('.')[0]) + const nodeOk = Number.isFinite(nodeMajor) && nodeMajor >= 22 + report(nodeOk, `Node.js ${process.versions.node}`, nodeOk ? '' : 'requires >= 22') + if (!nodeOk) failed = true + + report(true, `Platform ${currentTarget()}`) + + for (const probe of PROBES) { + try { + await import(probe.pkg) + report(true, probe.label) + } catch (err) { + if (isNativeBinaryMissing(err)) { + report(false, probe.label, 'native binary missing') + failed = true + nativeError = err + } else if (isPackageMissing(err, probe.pkg)) { + // A missing top-level package is a different problem from a missing + // native binary; only the latter is what these guards exist for. + report( + Boolean(probe.optional), + probe.label, + probe.optional ? 'not installed (run `stash init`)' : 'not installed', + ) + if (!probe.optional) failed = true + } else { + throw err + } + } + } + + if (nativeError) { + reportNativeBinaryMissing(nativeError) + } + + if (failed) { + p.outro('stash doctor found problems.') + process.exit(1) + } + p.outro('All checks passed.') +} diff --git a/packages/cli/src/native.ts b/packages/cli/src/native.ts new file mode 100644 index 00000000..7d7b149a --- /dev/null +++ b/packages/cli/src/native.ts @@ -0,0 +1,85 @@ +// Guards for the prebuilt native addons stash depends on. +// +// stash loads native Rust addons (e.g. @cipherstash/protect-ffi via +// @cipherstash/stack, and @cipherstash/auth) that are distributed as +// per-platform optional npm packages named `--` and +// selected at runtime by @neon-rs/load. npm intermittently skips installing +// these optional dependencies (https://github.com/npm/cli/issues/4828), +// leaving the base package present but the platform binary missing. The raw +// failure is an unhelpful MODULE_NOT_FOUND stack trace; these helpers detect +// that case and turn it into actionable guidance. + +import * as p from '@clack/prompts' + +interface ModuleError extends Error { + code?: string + requireStack?: string[] +} + +/** `-` for the current process, e.g. `darwin-arm64`. */ +export function currentTarget(): string { + return `${process.platform}-${process.arch}` +} + +/** + * True when `err` is a failure to load one of our prebuilt native addons (a + * missing `@cipherstash/--` optional package), as opposed + * to a missing top-level package or any other module error. + */ +export function isNativeBinaryMissing(err: unknown): err is ModuleError { + if (!(err instanceof Error)) return false + const e = err as ModuleError + // CJS require throws `MODULE_NOT_FOUND`; ESM throws `ERR_MODULE_NOT_FOUND`. + if (e.code !== 'MODULE_NOT_FOUND' && e.code !== 'ERR_MODULE_NOT_FOUND') { + return false + } + const haystack = `${e.message}\n${(e.requireStack ?? []).join('\n')}` + // A platform-suffixed @cipherstash package, or a failure surfaced from the + // neon loader, both mean the optional native binary wasn't installed. + return ( + /@cipherstash\/[a-z0-9-]+-(?:darwin|linux|win32)-[a-z0-9-]+/i.test( + haystack, + ) || /[\\/]@neon-rs[\\/]load[\\/]/.test(haystack) + ) +} + +function missingModuleName(err: ModuleError): string | undefined { + return /Cannot find module '([^']+)'/.exec(err.message)?.[1] +} + +/** + * Print actionable guidance for a missing native binary. Does not exit — the + * caller decides the exit code so this can be reused by `stash doctor`. + */ +export function reportNativeBinaryMissing(err: unknown): void { + const e = err instanceof Error ? (err as ModuleError) : undefined + const missing = e ? missingModuleName(e) : undefined + const target = currentTarget() + + p.log.error("stash couldn't load its native module for this platform.") + p.note( + [ + missing + ? `Missing package: ${missing}` + : `Missing the @cipherstash/*-${target} native binary.`, + `Platform: ${target}`, + '', + 'stash ships prebuilt binaries as optional npm packages. npm sometimes', + 'skips them due to a known bug (https://github.com/npm/cli/issues/4828).', + '', + 'Fix it with one of:', + '', + ' # ran via npx', + ' rm -rf "$(npm config get cache)/_npx" && npx stash@latest ', + '', + ' # stash is a project dependency', + ' rm -rf node_modules package-lock.json && npm install', + '', + ' # installed globally', + ' npm install -g stash@latest --force', + '', + 'Then run `stash doctor` to confirm.', + ].join('\n'), + 'Native module not found', + ) +} From 571e55fb11eb2accc2c9567204b81bcf95020d12 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 17 Jun 2026 14:22:52 +1000 Subject: [PATCH 2/3] fix(cli): runner-aware native guidance + ESM-aware module name; doctor E2E - native.ts: build the re-run command via runnerCommand(detectPackageManager()) instead of a hardcoded `npx`, fixing the lint:runners CI failure and making the guidance correct for bun/pnpm/yarn. The npx cache-clear hint is now shown only for npm. - missingModuleName(): extract the real platform package from the message/ requireStack (so Linux's libc suffix like -linux-x64-gnu is preserved) and parse the ESM 'Cannot find package' form, not just CJS 'Cannot find module'. Fallback wording no longer implies a specific package suffix. (Copilot review) - Add tests/e2e/doctor.e2e.test.ts covering the launcher's doctor dispatch and exit code, per the src/bin/stash.ts coding guideline. --- packages/cli/src/native.ts | 66 +++++++++++++++++------ packages/cli/tests/e2e/doctor.e2e.test.ts | 26 +++++++++ 2 files changed, 76 insertions(+), 16 deletions(-) create mode 100644 packages/cli/tests/e2e/doctor.e2e.test.ts diff --git a/packages/cli/src/native.ts b/packages/cli/src/native.ts index 7d7b149a..6f918705 100644 --- a/packages/cli/src/native.ts +++ b/packages/cli/src/native.ts @@ -10,12 +10,21 @@ // that case and turn it into actionable guidance. import * as p from '@clack/prompts' +import { + detectPackageManager, + type PackageManager, + runnerCommand, +} from './commands/init/utils.js' interface ModuleError extends Error { code?: string requireStack?: string[] } +// Matches the platform-suffixed optional package, e.g. +// `@cipherstash/protect-ffi-darwin-arm64` or `@cipherstash/auth-linux-x64-gnu`. +const PLATFORM_PKG = /@cipherstash\/[a-z0-9-]+-(?:darwin|linux|win32)-[a-z0-9-]+/i + /** `-` for the current process, e.g. `darwin-arm64`. */ export function currentTarget(): string { return `${process.platform}-${process.arch}` @@ -36,15 +45,33 @@ export function isNativeBinaryMissing(err: unknown): err is ModuleError { const haystack = `${e.message}\n${(e.requireStack ?? []).join('\n')}` // A platform-suffixed @cipherstash package, or a failure surfaced from the // neon loader, both mean the optional native binary wasn't installed. - return ( - /@cipherstash\/[a-z0-9-]+-(?:darwin|linux|win32)-[a-z0-9-]+/i.test( - haystack, - ) || /[\\/]@neon-rs[\\/]load[\\/]/.test(haystack) - ) + return PLATFORM_PKG.test(haystack) || /[\\/]@neon-rs[\\/]load[\\/]/.test(haystack) } function missingModuleName(err: ModuleError): string | undefined { - return /Cannot find module '([^']+)'/.exec(err.message)?.[1] + const haystack = `${err.message}\n${(err.requireStack ?? []).join('\n')}` + // Prefer the real platform package name wherever it appears — on Linux it + // carries a libc/toolchain suffix (e.g. `-linux-x64-gnu`) that a generic + // `-` guess would miss. + const pkg = PLATFORM_PKG.exec(haystack)?.[0] + if (pkg) return pkg + // Fall back to the quoted name. CJS says "Cannot find module 'X'"; ESM + // (ERR_MODULE_NOT_FOUND) says "Cannot find package 'X'". + return /Cannot find (?:module|package) '([^']+)'/.exec(err.message)?.[1] +} + +// Recovery command to reinstall a project's dependencies from scratch. +function reinstallCommand(pm: PackageManager): string { + switch (pm) { + case 'bun': + return 'rm -rf node_modules bun.lock && bun install' + case 'pnpm': + return 'rm -rf node_modules pnpm-lock.yaml && pnpm install' + case 'yarn': + return 'rm -rf node_modules yarn.lock && yarn install' + case 'npm': + return 'rm -rf node_modules package-lock.json && npm install' + } } /** @@ -55,28 +82,35 @@ export function reportNativeBinaryMissing(err: unknown): void { const e = err instanceof Error ? (err as ModuleError) : undefined const missing = e ? missingModuleName(e) : undefined const target = currentTarget() + const pm = detectPackageManager() + // Runner-aware so we don't hardcode `npx` (see scripts/lint-no-hardcoded-runners.mjs): + // npm → `npx`, bun → `bunx`, pnpm/yarn → `… dlx`. + const rerun = `${runnerCommand(pm, 'stash@latest')} ` + // The one-shot runner cache is npm-specific (`_npx`); for other package + // managers a clean re-run is the equivalent first step. + const rerunStep = + pm === 'npm' + ? ` rm -rf "$(npm config get cache)/_npx" && ${rerun}` + : ` ${rerun}` p.log.error("stash couldn't load its native module for this platform.") p.note( [ missing ? `Missing package: ${missing}` - : `Missing the @cipherstash/*-${target} native binary.`, + : `No native binary was loaded for ${target}.`, `Platform: ${target}`, '', - 'stash ships prebuilt binaries as optional npm packages. npm sometimes', - 'skips them due to a known bug (https://github.com/npm/cli/issues/4828).', + 'stash ships prebuilt binaries as optional packages. Package managers', + 'sometimes skip them — a known npm bug: https://github.com/npm/cli/issues/4828', '', 'Fix it with one of:', '', - ' # ran via npx', - ' rm -rf "$(npm config get cache)/_npx" && npx stash@latest ', - '', - ' # stash is a project dependency', - ' rm -rf node_modules package-lock.json && npm install', + ' # re-run, clearing a stale runner cache', + rerunStep, '', - ' # installed globally', - ' npm install -g stash@latest --force', + ' # if stash is a project dependency, reinstall', + ` ${reinstallCommand(pm)}`, '', 'Then run `stash doctor` to confirm.', ].join('\n'), diff --git a/packages/cli/tests/e2e/doctor.e2e.test.ts b/packages/cli/tests/e2e/doctor.e2e.test.ts new file mode 100644 index 00000000..fe1998c8 --- /dev/null +++ b/packages/cli/tests/e2e/doctor.e2e.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import { render } from '../helpers/pty.js' + +// `doctor` is dispatched by the thin launcher (src/bin/stash.ts) *before* the +// command graph loads, so these also exercise the launcher's top-level path +// and exit codes — the behavior the bin coding guideline asks us to cover. +describe('stash doctor — E2E', () => { + it('runs the diagnostics and exits 0 on a healthy install', async () => { + const r = render(['doctor']) + const { exitCode } = await r.exit + expect(exitCode).toBe(0) + expect(r.output).toContain('stash doctor') + // Platform line is always emitted regardless of which probes are present. + expect(r.output).toContain(`Platform ${process.platform}-${process.arch}`) + expect(r.output).toContain('All checks passed') + }) + + it('is dispatched even though it is not registered in the help command list', async () => { + // Guards the launcher path: `doctor` must not fall through to the unknown- + // command handler (which would exit 1 and print the usage banner). + const r = render(['doctor']) + const { exitCode } = await r.exit + expect(exitCode).toBe(0) + expect(r.output).not.toContain('Unknown command') + }) +}) From 646354b4671f0e2f663746761e810307a17fdef2 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 17 Jun 2026 14:40:18 +1000 Subject: [PATCH 3/3] fix(cli): windows-aware native recovery commands; extract doctor messages - native.ts: reinstall/cache-clear guidance now emits PowerShell on win32 (Remove-Item) instead of non-runnable POSIX (rm -rf/$(...)), since win32 is a supported native target. (CodeRabbit) - Extract doctor's asserted user-facing strings into messages.doctor (title, platformLabel, allChecksPassed); doctor and its E2E test reference the constants so copy changes stay in one place. (CodeRabbit) --- packages/cli/src/commands/doctor/index.ts | 7 +++-- packages/cli/src/messages.ts | 6 ++++ packages/cli/src/native.ts | 35 +++++++++++++---------- packages/cli/tests/e2e/doctor.e2e.test.ts | 11 ++++--- 4 files changed, 37 insertions(+), 22 deletions(-) diff --git a/packages/cli/src/commands/doctor/index.ts b/packages/cli/src/commands/doctor/index.ts index 7db0c264..36fec757 100644 --- a/packages/cli/src/commands/doctor/index.ts +++ b/packages/cli/src/commands/doctor/index.ts @@ -1,4 +1,5 @@ import * as p from '@clack/prompts' +import { messages } from '../../messages.js' import { currentTarget, isNativeBinaryMissing, @@ -34,7 +35,7 @@ function isPackageMissing(err: unknown, pkg: string): boolean { } export async function doctorCommand(): Promise { - p.intro('stash doctor') + p.intro(messages.doctor.title) let failed = false let nativeError: unknown @@ -44,7 +45,7 @@ export async function doctorCommand(): Promise { report(nodeOk, `Node.js ${process.versions.node}`, nodeOk ? '' : 'requires >= 22') if (!nodeOk) failed = true - report(true, `Platform ${currentTarget()}`) + report(true, `${messages.doctor.platformLabel} ${currentTarget()}`) for (const probe of PROBES) { try { @@ -78,5 +79,5 @@ export async function doctorCommand(): Promise { p.outro('stash doctor found problems.') process.exit(1) } - p.outro('All checks passed.') + p.outro(messages.doctor.allChecksPassed) } diff --git a/packages/cli/src/messages.ts b/packages/cli/src/messages.ts index 88cd46a6..822f87e2 100644 --- a/packages/cli/src/messages.ts +++ b/packages/cli/src/messages.ts @@ -22,6 +22,12 @@ export const messages = { usagePrefix: 'Usage: ', unknownCommand: 'Unknown command', }, + doctor: { + title: 'stash doctor', + /** Leader of the platform check line; the `-` is appended. */ + platformLabel: 'Platform', + allChecksPassed: 'All checks passed.', + }, auth: { /** Same shape as `cli.usagePrefix` — leader only. */ usagePrefix: 'Usage: ', diff --git a/packages/cli/src/native.ts b/packages/cli/src/native.ts index 6f918705..c98f7e86 100644 --- a/packages/cli/src/native.ts +++ b/packages/cli/src/native.ts @@ -60,18 +60,21 @@ function missingModuleName(err: ModuleError): string | undefined { return /Cannot find (?:module|package) '([^']+)'/.exec(err.message)?.[1] } -// Recovery command to reinstall a project's dependencies from scratch. +const LOCKFILE: Record = { + bun: 'bun.lock', + pnpm: 'pnpm-lock.yaml', + yarn: 'yarn.lock', + npm: 'package-lock.json', +} + +// Recovery command to reinstall a project's dependencies from scratch. win32 is +// a supported target, so emit PowerShell there rather than non-runnable POSIX. function reinstallCommand(pm: PackageManager): string { - switch (pm) { - case 'bun': - return 'rm -rf node_modules bun.lock && bun install' - case 'pnpm': - return 'rm -rf node_modules pnpm-lock.yaml && pnpm install' - case 'yarn': - return 'rm -rf node_modules yarn.lock && yarn install' - case 'npm': - return 'rm -rf node_modules package-lock.json && npm install' + const lock = LOCKFILE[pm] + if (process.platform === 'win32') { + return `Remove-Item -Recurse -Force node_modules, ${lock}; ${pm} install` } + return `rm -rf node_modules ${lock} && ${pm} install` } /** @@ -87,11 +90,13 @@ export function reportNativeBinaryMissing(err: unknown): void { // npm → `npx`, bun → `bunx`, pnpm/yarn → `… dlx`. const rerun = `${runnerCommand(pm, 'stash@latest')} ` // The one-shot runner cache is npm-specific (`_npx`); for other package - // managers a clean re-run is the equivalent first step. - const rerunStep = - pm === 'npm' - ? ` rm -rf "$(npm config get cache)/_npx" && ${rerun}` - : ` ${rerun}` + // managers a clean re-run is the equivalent first step. Shell syntax differs + // on Windows (PowerShell), which is a supported target. + const clearNpxCache = + process.platform === 'win32' + ? `Remove-Item -Recurse -Force "$(npm config get cache)\\_npx"; ${rerun}` + : `rm -rf "$(npm config get cache)/_npx" && ${rerun}` + const rerunStep = pm === 'npm' ? ` ${clearNpxCache}` : ` ${rerun}` p.log.error("stash couldn't load its native module for this platform.") p.note( diff --git a/packages/cli/tests/e2e/doctor.e2e.test.ts b/packages/cli/tests/e2e/doctor.e2e.test.ts index fe1998c8..4b3a7586 100644 --- a/packages/cli/tests/e2e/doctor.e2e.test.ts +++ b/packages/cli/tests/e2e/doctor.e2e.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { messages } from '../../src/messages.js' import { render } from '../helpers/pty.js' // `doctor` is dispatched by the thin launcher (src/bin/stash.ts) *before* the @@ -9,10 +10,12 @@ describe('stash doctor — E2E', () => { const r = render(['doctor']) const { exitCode } = await r.exit expect(exitCode).toBe(0) - expect(r.output).toContain('stash doctor') + expect(r.output).toContain(messages.doctor.title) // Platform line is always emitted regardless of which probes are present. - expect(r.output).toContain(`Platform ${process.platform}-${process.arch}`) - expect(r.output).toContain('All checks passed') + expect(r.output).toContain( + `${messages.doctor.platformLabel} ${process.platform}-${process.arch}`, + ) + expect(r.output).toContain(messages.doctor.allChecksPassed) }) it('is dispatched even though it is not registered in the help command list', async () => { @@ -21,6 +24,6 @@ describe('stash doctor — E2E', () => { const r = render(['doctor']) const { exitCode } = await r.exit expect(exitCode).toBe(0) - expect(r.output).not.toContain('Unknown command') + expect(r.output).not.toContain(messages.cli.unknownCommand) }) })