diff --git a/.changeset/pr-1587.md b/.changeset/pr-1587.md new file mode 100644 index 0000000000..c21931efa2 --- /dev/null +++ b/.changeset/pr-1587.md @@ -0,0 +1,6 @@ + +--- +'@sanity/cli': patch +--- + +fix(cli): honor packageManager field and check package-lock.json last when detecting package manager \ No newline at end of file diff --git a/packages/@sanity/cli/src/util/packageManager/preferredPm.ts b/packages/@sanity/cli/src/util/packageManager/preferredPm.ts index 0128981639..7a1013b4e3 100644 --- a/packages/@sanity/cli/src/util/packageManager/preferredPm.ts +++ b/packages/@sanity/cli/src/util/packageManager/preferredPm.ts @@ -11,10 +11,14 @@ import {toForwardSlashes} from '../toForwardSlashes.js' type DetectablePackageManager = 'bun' | 'npm' | 'pnpm' | 'yarn' /** - * Detects the preferred package manager for a project by examining lock files, + * Detects the preferred package manager for a project by examining the corepack + * `packageManager` field, the `devEngines.packageManager` declaration, lock files, * workspace configurations, and node_modules markers. */ export function preferredPm(pkgPath: string): DetectablePackageManager | null { + const fromPackageManagerField = detectFromPackageManagerField(pkgPath) + if (fromPackageManagerField) return fromPackageManagerField + const fromLockFile = detectFromLockFile(pkgPath) if (fromLockFile) return fromLockFile @@ -27,13 +31,55 @@ export function preferredPm(pkgPath: string): DetectablePackageManager | null { return detectFromNodeModules(pkgPath) } +/** + * Detects the package manager from the corepack `packageManager` field in the + * package.json at `dir`, e.g. `"pnpm@9.1.2"` or `"yarn@4.1.0+sha224.…"`, falling + * back to the `devEngines.packageManager` declaration. An explicit declaration + * is the strongest signal of intent, so it takes precedence over lock files. + * Malformed values and unknown names are ignored. + */ +function detectFromPackageManagerField(dir: string): DetectablePackageManager | null { + try { + const manifest = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')) + const packageManager = manifest?.packageManager + if (typeof packageManager === 'string') { + const match = packageManager.match(/^(npm|pnpm|yarn|bun)@\d/) + if (match) return match[1] as DetectablePackageManager + } + return detectFromDevEngines(manifest?.devEngines) + } catch { + // missing or malformed package.json — fall through to other detection strategies + return null + } +} + +/** + * Detects the package manager from a `devEngines.packageManager` declaration + * (https://docs.npmjs.com/cli/configuring-npm/package-json#devengines). + * The field is an object (`{name: 'pnpm', …}`) or an array of such objects, + * in which case the first entry wins. Malformed shapes and unknown names are ignored. + */ +function detectFromDevEngines(devEngines: unknown): DetectablePackageManager | null { + if (!devEngines || typeof devEngines !== 'object') return null + const packageManager = (devEngines as Record).packageManager + const entry = Array.isArray(packageManager) ? packageManager[0] : packageManager + if (!entry || typeof entry !== 'object') return null + const name = (entry as Record).name + if (name === 'npm' || name === 'pnpm' || name === 'yarn' || name === 'bun') return name + return null +} + function detectFromLockFile(dir: string): DetectablePackageManager | null { - if (fs.existsSync(path.join(dir, 'package-lock.json'))) return 'npm' if (fs.existsSync(path.join(dir, 'yarn.lock'))) return 'yarn' if (fs.existsSync(path.join(dir, 'pnpm-lock.yaml'))) return 'pnpm' if (fs.existsSync(path.join(dir, 'shrinkwrap.yaml'))) return 'pnpm' if (fs.existsSync(path.join(dir, 'bun.lockb')) || fs.existsSync(path.join(dir, 'bun.lock'))) return 'bun' + // npm is checked last: it is already the ecosystem-wide fallback, so when multiple + // lockfiles coexist (e.g. a stray package-lock.json left behind by a one-off + // `npm install`), the non-npm lockfile is the stronger signal of intent. + if (fs.existsSync(path.join(dir, 'package-lock.json'))) return 'npm' + if (fs.existsSync(path.join(dir, 'npm-shrinkwrap.json'))) return 'npm' return null } @@ -80,7 +126,7 @@ function detectFromWorkspaceRoot(pkgPath: string): DetectablePackageManager | nu const matchDir = packageDir === dir ? resolvedPkgPath : packageDir const relativePath = toForwardSlashes(path.relative(dir, matchDir)) if (relativePath === '' || picomatch.isMatch(relativePath, workspaces)) { - return detectFromLockFile(dir) ?? 'yarn' + return detectFromPackageManagerField(dir) ?? detectFromLockFile(dir) ?? 'yarn' } } } catch { diff --git a/packages/@sanity/cli/test/integration/util/packageManager/preferredPm.test.ts b/packages/@sanity/cli/test/integration/util/packageManager/preferredPm.test.ts index 2053070b70..326ae70456 100644 --- a/packages/@sanity/cli/test/integration/util/packageManager/preferredPm.test.ts +++ b/packages/@sanity/cli/test/integration/util/packageManager/preferredPm.test.ts @@ -48,11 +48,143 @@ describe('preferredPm', () => { expect(preferredPm(tmpDir)).toBe('bun') }) - it('prioritizes package-lock.json over yarn.lock', () => { + it('prioritizes yarn.lock over a stray package-lock.json', () => { fs.writeFileSync(path.join(tmpDir, 'package-lock.json'), '{}') fs.writeFileSync(path.join(tmpDir, 'yarn.lock'), '') + expect(preferredPm(tmpDir)).toBe('yarn') + }) + + it('prioritizes pnpm-lock.yaml over a stray package-lock.json', () => { + fs.writeFileSync(path.join(tmpDir, 'package-lock.json'), '{}') + fs.writeFileSync(path.join(tmpDir, 'pnpm-lock.yaml'), '') + expect(preferredPm(tmpDir)).toBe('pnpm') + }) + + it('returns npm when package-lock.json is the only lock file', () => { + fs.writeFileSync(path.join(tmpDir, 'package-lock.json'), '{}') + expect(preferredPm(tmpDir)).toBe('npm') + }) + + it('returns npm when npm-shrinkwrap.json is the only lock file', () => { + fs.writeFileSync(path.join(tmpDir, 'npm-shrinkwrap.json'), '{}') expect(preferredPm(tmpDir)).toBe('npm') }) + + it('prioritizes pnpm-lock.yaml over a stray npm-shrinkwrap.json', () => { + fs.writeFileSync(path.join(tmpDir, 'npm-shrinkwrap.json'), '{}') + fs.writeFileSync(path.join(tmpDir, 'pnpm-lock.yaml'), '') + expect(preferredPm(tmpDir)).toBe('pnpm') + }) + }) + + describe('packageManager field detection', () => { + it('prioritizes the packageManager field over package-lock.json', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({packageManager: 'pnpm@9.1.2'}), + ) + fs.writeFileSync(path.join(tmpDir, 'package-lock.json'), '{}') + expect(preferredPm(tmpDir)).toBe('pnpm') + }) + + it('returns yarn for a packageManager field with a hash suffix', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + packageManager: 'yarn@4.1.0+sha224.fd21d9eb5fba020083811af1d4953acc21eeb9f6', + }), + ) + expect(preferredPm(tmpDir)).toBe('yarn') + }) + + it('returns bun for a bun packageManager field', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({packageManager: 'bun@1.1.0'}), + ) + expect(preferredPm(tmpDir)).toBe('bun') + }) + + it('ignores a malformed packageManager field and falls back to lock files', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({packageManager: 'not-a-real-pm@1.0.0'}), + ) + fs.writeFileSync(path.join(tmpDir, 'pnpm-lock.yaml'), '') + expect(preferredPm(tmpDir)).toBe('pnpm') + }) + + it('ignores a packageManager field without a version', () => { + fs.writeFileSync(path.join(tmpDir, 'package.json'), JSON.stringify({packageManager: 'pnpm'})) + fs.writeFileSync(path.join(tmpDir, 'yarn.lock'), '') + expect(preferredPm(tmpDir)).toBe('yarn') + }) + + it('returns the package manager declared in devEngines.packageManager', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({devEngines: {packageManager: {name: 'pnpm'}}}), + ) + expect(preferredPm(tmpDir)).toBe('pnpm') + }) + + it('prioritizes the packageManager field over devEngines when both are present', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + devEngines: {packageManager: {name: 'yarn'}}, + packageManager: 'pnpm@9.1.2', + }), + ) + expect(preferredPm(tmpDir)).toBe('pnpm') + }) + + it('prioritizes devEngines.packageManager over package-lock.json', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({devEngines: {packageManager: {name: 'yarn'}}}), + ) + fs.writeFileSync(path.join(tmpDir, 'package-lock.json'), '{}') + expect(preferredPm(tmpDir)).toBe('yarn') + }) + + it('uses the first entry when devEngines.packageManager is an array', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({devEngines: {packageManager: [{name: 'bun'}, {name: 'yarn'}]}}), + ) + expect(preferredPm(tmpDir)).toBe('bun') + }) + + it('ignores malformed devEngines shapes and falls back to lock files', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({devEngines: {packageManager: 'pnpm'}}), + ) + fs.writeFileSync(path.join(tmpDir, 'yarn.lock'), '') + expect(preferredPm(tmpDir)).toBe('yarn') + }) + + it('ignores unknown devEngines package manager names', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({devEngines: {packageManager: {name: 'not-a-real-pm'}}}), + ) + fs.writeFileSync(path.join(tmpDir, 'pnpm-lock.yaml'), '') + expect(preferredPm(tmpDir)).toBe('pnpm') + }) + + it('prioritizes the workspace root packageManager field over its lock file', () => { + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({packageManager: 'pnpm@9.1.2', workspaces: ['packages/*']}), + ) + fs.writeFileSync(path.join(tmpDir, 'package-lock.json'), '{}') + const childDir = path.join(tmpDir, 'packages', 'child') + fs.mkdirSync(childDir, {recursive: true}) + fs.writeFileSync(path.join(childDir, 'package.json'), JSON.stringify({name: 'child'})) + expect(preferredPm(childDir)).toBe('pnpm') + }) }) describe('parent directory pnpm walk-up', () => {