diff --git a/.changeset/cli-pm-user-agent.md b/.changeset/cli-pm-user-agent.md new file mode 100644 index 00000000..cfe0943f --- /dev/null +++ b/.changeset/cli-pm-user-agent.md @@ -0,0 +1,5 @@ +--- +"@cipherstash/cli": patch +--- + +Detect the package manager from `npm_config_user_agent` when running `stash init`. Running `bunx @cipherstash/cli init`, `pnpm dlx @cipherstash/cli init`, or `yarn dlx @cipherstash/cli init` now uses the invoking tool for dependency installation (`bun add`, `pnpm add`, `yarn add`) instead of falling back to `npm install`. Lockfile detection is still preferred when present, so projects with an existing convention are unaffected. Fixes `EUNSUPPORTEDPROTOCOL` failures on `workspace:*` deps in Bun-managed projects. diff --git a/.gitignore b/.gitignore index fc7ee438..c8717622 100644 --- a/.gitignore +++ b/.gitignore @@ -66,3 +66,5 @@ mise.local.toml cipherstash.toml cipherstash.secret.toml sql/cipherstash-*.sql + +notes/ diff --git a/packages/cli/src/commands/init/__tests__/utils.test.ts b/packages/cli/src/commands/init/__tests__/utils.test.ts new file mode 100644 index 00000000..73c6571d --- /dev/null +++ b/packages/cli/src/commands/init/__tests__/utils.test.ts @@ -0,0 +1,135 @@ +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 { + detectPackageManager, + devInstallCommand, + prodInstallCommand, +} from '../utils.js' + +describe('detectPackageManager', () => { + let tmp: string + let originalUserAgent: string | undefined + let cwdSpy: ReturnType | undefined + + beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), 'init-utils-test-')) + originalUserAgent = process.env.npm_config_user_agent + delete process.env.npm_config_user_agent + cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(tmp) + }) + + afterEach(() => { + cwdSpy?.mockRestore() + rmSync(tmp, { recursive: true, force: true }) + if (originalUserAgent === undefined) { + delete process.env.npm_config_user_agent + } else { + process.env.npm_config_user_agent = originalUserAgent + } + }) + + it('defaults to npm when no lockfile and no user agent', () => { + expect(detectPackageManager()).toBe('npm') + }) + + it('detects bun from bun.lock', () => { + writeFileSync(join(tmp, 'bun.lock'), '') + expect(detectPackageManager()).toBe('bun') + }) + + it('detects bun from bun.lockb', () => { + writeFileSync(join(tmp, 'bun.lockb'), '') + expect(detectPackageManager()).toBe('bun') + }) + + it('detects pnpm from pnpm-lock.yaml', () => { + writeFileSync(join(tmp, 'pnpm-lock.yaml'), '') + expect(detectPackageManager()).toBe('pnpm') + }) + + it('detects yarn from yarn.lock', () => { + writeFileSync(join(tmp, 'yarn.lock'), '') + expect(detectPackageManager()).toBe('yarn') + }) + + it('honours bunx via npm_config_user_agent without a lockfile', () => { + process.env.npm_config_user_agent = 'bun/1.1.40 npm/? node/v22.3.0' + expect(detectPackageManager()).toBe('bun') + }) + + it('honours pnpm dlx via user agent', () => { + process.env.npm_config_user_agent = 'pnpm/9.0.0 npm/? node/v20.0.0' + expect(detectPackageManager()).toBe('pnpm') + }) + + it('honours yarn dlx via user agent', () => { + process.env.npm_config_user_agent = 'yarn/4.0.0 npm/? node/v20.0.0' + expect(detectPackageManager()).toBe('yarn') + }) + + it('lets non-npm user agent win over a mismatched lockfile', () => { + writeFileSync(join(tmp, 'pnpm-lock.yaml'), '') + process.env.npm_config_user_agent = 'bun/1.1.40 npm/? node/v22.3.0' + expect(detectPackageManager()).toBe('bun') + }) + + it('ignores npm/npx user agent in favour of lockfile', () => { + writeFileSync(join(tmp, 'bun.lock'), '') + process.env.npm_config_user_agent = 'npm/10.2.4 node/v20.0.0' + expect(detectPackageManager()).toBe('bun') + }) +}) + +describe('prodInstallCommand', () => { + it('returns bun add for bun', () => { + expect(prodInstallCommand('bun', '@cipherstash/stack')).toBe( + 'bun add @cipherstash/stack', + ) + }) + + it('returns pnpm add for pnpm', () => { + expect(prodInstallCommand('pnpm', '@cipherstash/stack')).toBe( + 'pnpm add @cipherstash/stack', + ) + }) + + it('returns yarn add for yarn', () => { + expect(prodInstallCommand('yarn', '@cipherstash/stack')).toBe( + 'yarn add @cipherstash/stack', + ) + }) + + it('returns npm install for npm', () => { + expect(prodInstallCommand('npm', '@cipherstash/stack')).toBe( + 'npm install @cipherstash/stack', + ) + }) +}) + +describe('devInstallCommand', () => { + it('returns bun add -D for bun', () => { + expect(devInstallCommand('bun', '@cipherstash/cli')).toBe( + 'bun add -D @cipherstash/cli', + ) + }) + + it('returns pnpm add -D for pnpm', () => { + expect(devInstallCommand('pnpm', '@cipherstash/cli')).toBe( + 'pnpm add -D @cipherstash/cli', + ) + }) + + it('returns yarn add -D for yarn', () => { + expect(devInstallCommand('yarn', '@cipherstash/cli')).toBe( + 'yarn add -D @cipherstash/cli', + ) + }) + + it('returns npm install -D for npm', () => { + expect(devInstallCommand('npm', '@cipherstash/cli')).toBe( + 'npm install -D @cipherstash/cli', + ) + }) +}) diff --git a/packages/cli/src/commands/init/utils.ts b/packages/cli/src/commands/init/utils.ts index dd69500b..6c7dda3e 100644 --- a/packages/cli/src/commands/init/utils.ts +++ b/packages/cli/src/commands/init/utils.ts @@ -11,8 +11,41 @@ export function isPackageInstalled(packageName: string): boolean { return existsSync(modulePath) } -/** Detects the package manager used in the current project by checking lock files. */ -export function detectPackageManager(): 'npm' | 'pnpm' | 'yarn' | 'bun' { +export type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun' + +/** + * Parse `npm_config_user_agent` to identify a non-npm runner. + * + * npm, pnpm, yarn, and bun all set this env var when they invoke a package + * script or a `*x`/`dlx`-style runner. It starts with `"/ ..."`. + * + * We only trust non-npm values. `bunx`, `pnpm dlx`, and `yarn dlx` are + * deliberate choices by the user. `npm`/`npx` is the default and often a + * reflex invocation, so we don't let it override lockfile detection. + */ +function packageManagerFromUserAgent(): PackageManager | undefined { + const ua = process.env.npm_config_user_agent + if (!ua) return undefined + if (ua.startsWith('bun/')) return 'bun' + if (ua.startsWith('pnpm/')) return 'pnpm' + if (ua.startsWith('yarn/')) return 'yarn' + return undefined +} + +/** + * Detect the package manager used for the current project. + * + * Priority: + * 1. `npm_config_user_agent` — when the user explicitly invokes via + * `bunx`/`pnpm dlx`/`yarn dlx`, honour that choice even in projects + * without a matching lockfile (e.g. fresh projects). + * 2. Lockfile in cwd — respects the existing project convention. + * 3. Default to `npm`. + */ +export function detectPackageManager(): PackageManager { + const fromUserAgent = packageManagerFromUserAgent() + if (fromUserAgent) return fromUserAgent + const cwd = process.cwd() if ( existsSync(resolve(cwd, 'bun.lockb')) || diff --git a/packages/cli/src/commands/wizard/__tests__/detect.test.ts b/packages/cli/src/commands/wizard/__tests__/detect.test.ts index d0cfeed7..9bbad868 100644 --- a/packages/cli/src/commands/wizard/__tests__/detect.test.ts +++ b/packages/cli/src/commands/wizard/__tests__/detect.test.ts @@ -108,16 +108,26 @@ describe('detectTypeScript', () => { describe('detectPackageManager', () => { let tmp: string + let originalUserAgent: string | undefined beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'wizard-test-')) + originalUserAgent = process.env.npm_config_user_agent + // Tests run under a package manager, so the env leaks in and would + // short-circuit the lockfile branches we want to cover. + delete process.env.npm_config_user_agent }) afterEach(() => { rmSync(tmp, { recursive: true, force: true }) + if (originalUserAgent === undefined) { + delete process.env.npm_config_user_agent + } else { + process.env.npm_config_user_agent = originalUserAgent + } }) - it('returns undefined when no lockfile exists', () => { + it('returns undefined when no lockfile or user agent', () => { expect(detectPackageManager(tmp)).toBeUndefined() }) @@ -154,4 +164,37 @@ describe('detectPackageManager', () => { expect(pm?.name).toBe('npm') expect(pm?.installCommand).toBe('npm install') }) + + it('honours bunx via npm_config_user_agent with no lockfile', () => { + process.env.npm_config_user_agent = 'bun/1.1.40 npm/? node/v22.3.0' + const pm = detectPackageManager(tmp) + expect(pm?.name).toBe('bun') + expect(pm?.installCommand).toBe('bun add') + }) + + it('honours pnpm dlx via user agent', () => { + process.env.npm_config_user_agent = 'pnpm/9.0.0 npm/? node/v20.0.0' + const pm = detectPackageManager(tmp) + expect(pm?.name).toBe('pnpm') + }) + + it('honours yarn dlx via user agent', () => { + process.env.npm_config_user_agent = 'yarn/4.0.0 npm/? node/v20.0.0' + const pm = detectPackageManager(tmp) + expect(pm?.name).toBe('yarn') + }) + + it('user agent wins over a mismatched lockfile', () => { + writeFileSync(join(tmp, 'pnpm-lock.yaml'), '') + process.env.npm_config_user_agent = 'bun/1.1.40 npm/? node/v22.3.0' + const pm = detectPackageManager(tmp) + expect(pm?.name).toBe('bun') + }) + + it('ignores npm/npx user agent and falls through to lockfile', () => { + writeFileSync(join(tmp, 'bun.lock'), '') + process.env.npm_config_user_agent = 'npm/10.2.4 node/v20.0.0' + const pm = detectPackageManager(tmp) + expect(pm?.name).toBe('bun') + }) }) diff --git a/packages/cli/src/commands/wizard/__tests__/wizard-tools.test.ts b/packages/cli/src/commands/wizard/__tests__/wizard-tools.test.ts index ff4aa1da..9bb039f9 100644 --- a/packages/cli/src/commands/wizard/__tests__/wizard-tools.test.ts +++ b/packages/cli/src/commands/wizard/__tests__/wizard-tools.test.ts @@ -186,13 +186,21 @@ describe('security: regex injection', () => { describe('detectPackageManagerTool', () => { let tmp: string + let originalUserAgent: string | undefined beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'wizard-test-')) + originalUserAgent = process.env.npm_config_user_agent + delete process.env.npm_config_user_agent }) afterEach(() => { rmSync(tmp, { recursive: true, force: true }) + if (originalUserAgent === undefined) { + delete process.env.npm_config_user_agent + } else { + process.env.npm_config_user_agent = originalUserAgent + } }) it('returns detected: false when no lockfile', () => { diff --git a/packages/cli/src/commands/wizard/lib/detect.ts b/packages/cli/src/commands/wizard/lib/detect.ts index ea9f9cc8..71fd0fc3 100644 --- a/packages/cli/src/commands/wizard/lib/detect.ts +++ b/packages/cli/src/commands/wizard/lib/detect.ts @@ -45,28 +45,51 @@ export function detectTypeScript(cwd: string): boolean { return existsSync(resolve(cwd, 'tsconfig.json')) } -/** Detect the package manager used in the project. */ +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' }, +} + +/** + * Identify a non-npm runner from `npm_config_user_agent`. + * + * `bunx`, `pnpm dlx`, and `yarn dlx` set this env var. We only trust non-npm + * values: `npx` is frequently a reflex invocation and shouldn't override + * lockfile detection, but `bunx` is a deliberate choice that should win. + */ +function packageManagerFromUserAgent(): DetectedPackageManager | undefined { + const ua = process.env.npm_config_user_agent + if (!ua) return undefined + if (ua.startsWith('bun/')) return PACKAGE_MANAGERS.bun + if (ua.startsWith('pnpm/')) return PACKAGE_MANAGERS.pnpm + if (ua.startsWith('yarn/')) return PACKAGE_MANAGERS.yarn + return undefined +} + +/** + * Detect the package manager used in the project. + * + * Priority: runtime user agent (bunx/pnpm dlx/yarn dlx) → lockfile → undefined. + */ export function detectPackageManager( cwd: string, ): DetectedPackageManager | undefined { + const fromUserAgent = packageManagerFromUserAgent() + if (fromUserAgent) return fromUserAgent + if ( existsSync(resolve(cwd, 'bun.lockb')) || existsSync(resolve(cwd, 'bun.lock')) ) { - return { name: 'bun', installCommand: 'bun add', runCommand: 'bun run' } - } - if (existsSync(resolve(cwd, 'pnpm-lock.yaml'))) { - return { - name: 'pnpm', - installCommand: 'pnpm add', - runCommand: 'pnpm run', - } - } - if (existsSync(resolve(cwd, 'yarn.lock'))) { - return { name: 'yarn', installCommand: 'yarn add', runCommand: 'yarn run' } - } - if (existsSync(resolve(cwd, 'package-lock.json'))) { - return { name: 'npm', installCommand: 'npm install', runCommand: 'npm run' } + return PACKAGE_MANAGERS.bun } + if (existsSync(resolve(cwd, 'pnpm-lock.yaml'))) return PACKAGE_MANAGERS.pnpm + if (existsSync(resolve(cwd, 'yarn.lock'))) return PACKAGE_MANAGERS.yarn + if (existsSync(resolve(cwd, 'package-lock.json'))) return PACKAGE_MANAGERS.npm return undefined }