Skip to content
Closed
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
6 changes: 6 additions & 0 deletions .changeset/pr-1587.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<!-- auto-generated -->
---
'@sanity/cli': patch
---

fix(cli): honor packageManager field and check package-lock.json last when detecting package manager
52 changes: 49 additions & 3 deletions packages/@sanity/cli/src/util/packageManager/preferredPm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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<string, unknown>).packageManager
const entry = Array.isArray(packageManager) ? packageManager[0] : packageManager
if (!entry || typeof entry !== 'object') return null
const name = (entry as Record<string, unknown>).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
}

Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading