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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/cli-pm-user-agent.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,5 @@ mise.local.toml
cipherstash.toml
cipherstash.secret.toml
sql/cipherstash-*.sql

notes/
135 changes: 135 additions & 0 deletions packages/cli/src/commands/init/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.spyOn> | 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',
)
})
})
37 changes: 35 additions & 2 deletions packages/cli/src/commands/init/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `"<tool>/<version> ..."`.
*
* 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')) ||
Expand Down
45 changes: 44 additions & 1 deletion packages/cli/src/commands/wizard/__tests__/detect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})

Expand Down Expand Up @@ -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')
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
53 changes: 38 additions & 15 deletions packages/cli/src/commands/wizard/lib/detect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Loading