diff --git a/.changeset/cli-runner-aware-help-banners.md b/.changeset/cli-runner-aware-help-banners.md new file mode 100644 index 00000000..65d7ad4d --- /dev/null +++ b/.changeset/cli-runner-aware-help-banners.md @@ -0,0 +1,27 @@ +--- +'@cipherstash/cli': patch +--- + +Make `--help` banners and the post-install "Next steps" panel show commands using the package manager the user actually invoked the CLI with, instead of always emitting `npx`. + +A user who runs `bunx @cipherstash/cli --help` now sees: + +``` +Usage: bunx @cipherstash/cli [options] +… +Examples: + bunx @cipherstash/cli init + bunx @cipherstash/cli auth login + bunx @cipherstash/cli db install +``` + +instead of `npx @cipherstash/cli …` regardless of how they invoked it. Same for `pnpm dlx`, `yarn dlx`, and the default `npx` path. + +Concretely: + +- `--help` (top-level) — usage line and all six examples in `bin/stash.ts`. +- `--help` (auth) — usage line and the two `auth login` examples in `commands/auth/index.ts`. +- `db install`'s "Next steps" note — the `wizard` invocation now matches the user's runner. +- The `@cipherstash/stack is required for this command` hint shown by `requireStack` (when `db push`/`validate`/`schema build` are run before the runtime SDK is installed) now suggests the package manager's install command and the user's runner for the follow-up `init` invocation. + +No public-API change. Detection sources unchanged from #379: `npm_config_user_agent` first, then lockfile, then `npx` fallback. diff --git a/packages/cli/src/bin/stash.ts b/packages/cli/src/bin/stash.ts index 7770d9c3..23ae7e6f 100644 --- a/packages/cli/src/bin/stash.ts +++ b/packages/cli/src/bin/stash.ts @@ -34,15 +34,32 @@ function isModuleNotFound(err: unknown): boolean { ) } +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.\n' + - ' Install it with: npm install @cipherstash/stack\n' + - ' Or run: npx stash init', + `@cipherstash/stack is required for this command. + Install it with: ${prodInstallCommand(PM, '@cipherstash/stack')} + Or run: ${STASH} init`, ) process.exit(1) as never } @@ -50,15 +67,10 @@ async function requireStack(importFn: () => Promise): Promise { } } -const __dirname = dirname(fileURLToPath(import.meta.url)) -const pkg = JSON.parse( - readFileSync(join(__dirname, '../../package.json'), 'utf-8'), -) - const HELP = ` ${messages.cli.versionBannerPrefix}${pkg.version} -${messages.cli.usagePrefix} [options] +${messages.cli.usagePrefix}${STASH} [options] Commands: init Initialize CipherStash for your project @@ -98,13 +110,13 @@ DB Flags: --database-url (all db / schema commands) Override DATABASE_URL for this run only — never written to disk Examples: - npx stash init - npx stash init --supabase - npx stash auth login - npx stash wizard - npx stash db install - npx stash db push - npx stash schema build + ${STASH} init + ${STASH} init --supabase + ${STASH} auth login + ${STASH} wizard + ${STASH} db install + ${STASH} db push + ${STASH} schema build `.trim() interface ParsedArgs { diff --git a/packages/cli/src/commands/auth/index.ts b/packages/cli/src/commands/auth/index.ts index e76c1ad7..efe3faae 100644 --- a/packages/cli/src/commands/auth/index.ts +++ b/packages/cli/src/commands/auth/index.ts @@ -1,8 +1,11 @@ import { messages } from '../../messages.js' +import { detectPackageManager, runnerCommand } from '../init/utils.js' import { bindDevice, login, selectRegion } from './login.js' +const STASH_AUTH = runnerCommand(detectPackageManager(), 'stash auth') + const HELP = ` -${messages.auth.usagePrefix} [options] +${messages.auth.usagePrefix}${STASH_AUTH} [options] Commands: login Authenticate with CipherStash @@ -12,8 +15,8 @@ Options: --drizzle Track Drizzle as the referrer Examples: - npx stash auth login - npx stash auth login --supabase + ${STASH_AUTH} login + ${STASH_AUTH} login --supabase `.trim() function referrerFromFlags(flags: Record): string | undefined { diff --git a/packages/cli/src/commands/db/install.ts b/packages/cli/src/commands/db/install.ts index 3c6fe064..9711408e 100644 --- a/packages/cli/src/commands/db/install.ts +++ b/packages/cli/src/commands/db/install.ts @@ -269,12 +269,13 @@ function resolveProviderOptions( } function printNextSteps(): void { + const pm = detectPackageManager() p.note( [ 'Next steps:', '', ' 1. Wire up encrypt/decrypt with the wizard (AI-guided, automated):', - ' stash wizard', + ` ${runnerCommand(pm, 'stash')} wizard`, '', ' 2. Or use the client directly from @cipherstash/stack:', " import { Encryption } from '@cipherstash/stack'", diff --git a/packages/cli/src/messages.ts b/packages/cli/src/messages.ts index 62f335e8..d55e7de7 100644 --- a/packages/cli/src/messages.ts +++ b/packages/cli/src/messages.ts @@ -12,11 +12,19 @@ export const messages = { cli: { versionBannerPrefix: 'CipherStash CLI v', - usagePrefix: 'Usage: npx stash', + /** + * Stable leader of the usage line. The runner-and-package portion + * (e.g. `npx stash` or `bunx stash`) is appended at render time by + * the bin so the help text matches how the user invoked the CLI. + * Tests assert on this leader plus `'stash'` separately to stay + * runner-agnostic. + */ + usagePrefix: 'Usage: ', unknownCommand: 'Unknown command', }, auth: { - usagePrefix: 'Usage: npx stash auth', + /** Same shape as `cli.usagePrefix` — leader only. */ + usagePrefix: 'Usage: ', unknownSubcommand: 'Unknown auth command', selectRegion: 'Select a region', cancelled: 'Cancelled.', diff --git a/packages/cli/tests/e2e/runner-aware-help.e2e.test.ts b/packages/cli/tests/e2e/runner-aware-help.e2e.test.ts new file mode 100644 index 00000000..4386504d --- /dev/null +++ b/packages/cli/tests/e2e/runner-aware-help.e2e.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest' +import { render } from '../helpers/pty.js' + +/** + * E2E coverage for the runner-aware help rendering. The smoke suite + * already verifies the help is rendered; this file specifically asserts + * that the correct package-manager runner (`npx` / `bunx` / `pnpm dlx` / + * `yarn dlx`) flows from `npm_config_user_agent` through + * `detectPackageManager` → `runnerCommand` into the Usage line and + * Examples. + * + * Detection itself is unit-tested in + * `src/commands/init/__tests__/utils.test.ts`. These E2E tests close the + * gap between "the helper works in isolation" and "the rendered HELP + * actually surfaces what the helper produces". + */ + +const cases = [ + { ua: '', label: 'npx' }, // empty UA — falls back to lockfile/npm + { ua: 'bun/1.0.0', label: 'bunx' }, + { ua: 'pnpm/9.0.0', label: 'pnpm dlx' }, + { ua: 'yarn/4.0.0', label: 'yarn dlx' }, +] as const + +describe('--help — runner-aware Usage + Examples', () => { + it.each(cases)( + 'with npm_config_user_agent=$ua, renders "$label stash"', + async ({ ua, label }) => { + const r = render(['--help'], { env: { npm_config_user_agent: ua } }) + const { exitCode } = await r.exit + expect(exitCode).toBe(0) + // Usage line must use the right runner. The leader is stable + // (`messages.cli.usagePrefix === 'Usage: '`) so we assert on the + // suffix the renderer composes at runtime. + expect(r.output).toContain(`Usage: ${label} stash`) + // At least one of the Examples lines must surface the same runner. + expect(r.output).toContain(`${label} stash init`) + expect(r.output).toContain(`${label} stash db install`) + }, + ) +}) + +describe('auth — runner-aware Usage + Examples', () => { + it.each(cases)( + 'with npm_config_user_agent=$ua, renders "$label stash auth"', + async ({ ua, label }) => { + // `auth` with no subcommand prints the auth HELP and exits 0. + const r = render(['auth'], { env: { npm_config_user_agent: ua } }) + const { exitCode } = await r.exit + expect(exitCode).toBe(0) + expect(r.output).toContain(`Usage: ${label} stash auth`) + expect(r.output).toContain(`${label} stash auth login`) + }, + ) +})