From af657175e3560bba12c71369d9dccdafa0c55af0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 22:14:15 +0000 Subject: [PATCH 1/3] fix(cli): honor packageManager field and check package-lock.json last Package manager detection now treats the corepack packageManager field in package.json as the strongest signal of intent, checking it before any lock files. Among lock files, package-lock.json is now checked last: npm is already the ecosystem-wide fallback, so a stray package-lock.json (left by a one-off npm install) no longer shadows a deliberate pnpm-lock.yaml or yarn.lock in the same directory. The same precedence applies to workspace root detection. --- .../src/util/packageManager/preferredPm.ts | 33 +++++++-- .../util/packageManager/preferredPm.test.ts | 69 ++++++++++++++++++- 2 files changed, 97 insertions(+), 5 deletions(-) diff --git a/packages/@sanity/cli/src/util/packageManager/preferredPm.ts b/packages/@sanity/cli/src/util/packageManager/preferredPm.ts index 0128981639..bc55b178ff 100644 --- a/packages/@sanity/cli/src/util/packageManager/preferredPm.ts +++ b/packages/@sanity/cli/src/util/packageManager/preferredPm.ts @@ -11,10 +11,13 @@ import {toForwardSlashes} from '../toForwardSlashes.js' type DetectablePackageManager = 'bun' | 'npm' | 'pnpm' | 'yarn' /** - * Detects the preferred package manager for a project by examining lock files, - * workspace configurations, and node_modules markers. + * Detects the preferred package manager for a project by examining the corepack + * `packageManager` field, 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 +30,35 @@ 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.…"`. + * 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') return null + const match = packageManager.match(/^(npm|pnpm|yarn|bun)@\d/) + return match ? (match[1] as DetectablePackageManager) : null + } catch { + // missing or malformed package.json — fall through to other detection strategies + 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' return null } @@ -80,7 +105,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..d741f66dad 100644 --- a/packages/@sanity/cli/test/integration/util/packageManager/preferredPm.test.ts +++ b/packages/@sanity/cli/test/integration/util/packageManager/preferredPm.test.ts @@ -48,13 +48,80 @@ 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') }) }) + 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('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', () => { it('finds pnpm-lock.yaml in a parent directory', () => { fs.writeFileSync(path.join(tmpDir, 'pnpm-lock.yaml'), '') From 7540547ef22544f384b33f3324c7593715756c7c Mon Sep 17 00:00:00 2001 From: "squiggler-app[bot]" <265501495+squiggler-app[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:15:33 +0000 Subject: [PATCH 2/3] chore: update auto-generated changeset for PR #1587 --- .changeset/pr-1587.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/pr-1587.md 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 From 0d4e0765324d38f044da0ddd3068e802697c5bd2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 22:51:58 +0000 Subject: [PATCH 3/3] fix(cli): detect package manager from devEngines and npm-shrinkwrap.json Extends detection with two additions: the devEngines.packageManager declaration (object or array form) is honored after the corepack packageManager field and before lock files, and npm-shrinkwrap.json now maps to npm, checked last alongside package-lock.json so it cannot shadow a pnpm, yarn, or bun lock file. --- .../src/util/packageManager/preferredPm.ts | 35 ++++++++-- .../util/packageManager/preferredPm.test.ts | 65 +++++++++++++++++++ 2 files changed, 93 insertions(+), 7 deletions(-) diff --git a/packages/@sanity/cli/src/util/packageManager/preferredPm.ts b/packages/@sanity/cli/src/util/packageManager/preferredPm.ts index bc55b178ff..7a1013b4e3 100644 --- a/packages/@sanity/cli/src/util/packageManager/preferredPm.ts +++ b/packages/@sanity/cli/src/util/packageManager/preferredPm.ts @@ -12,7 +12,8 @@ type DetectablePackageManager = 'bun' | 'npm' | 'pnpm' | 'yarn' /** * Detects the preferred package manager for a project by examining the corepack - * `packageManager` field, lock files, workspace configurations, and node_modules markers. + * `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) @@ -32,23 +33,42 @@ export function preferredPm(pkgPath: string): DetectablePackageManager | null { /** * 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.…"`. - * An explicit declaration is the strongest signal of intent, so it takes - * precedence over lock files. Malformed values and unknown names are ignored. + * 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') return null - const match = packageManager.match(/^(npm|pnpm|yarn|bun)@\d/) - return match ? (match[1] as DetectablePackageManager) : null + 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, 'yarn.lock'))) return 'yarn' if (fs.existsSync(path.join(dir, 'pnpm-lock.yaml'))) return 'pnpm' @@ -59,6 +79,7 @@ function detectFromLockFile(dir: string): DetectablePackageManager | null { // 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 } 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 d741f66dad..326ae70456 100644 --- a/packages/@sanity/cli/test/integration/util/packageManager/preferredPm.test.ts +++ b/packages/@sanity/cli/test/integration/util/packageManager/preferredPm.test.ts @@ -64,6 +64,17 @@ describe('preferredPm', () => { 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', () => { @@ -109,6 +120,60 @@ describe('preferredPm', () => { 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'),