From 3c664c516b54b138947a1a93959af47b350861c7 Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Sat, 15 Aug 2026 12:23:48 +0200 Subject: [PATCH 1/4] feat: use range from `devEngines` when no `packageManager` is set Using Corepack without setting a `packageManager` is not recommended, but given that no errors is thrown when no `devEngines` is set, the behavior should be the same when one is set to a parsable range. --- sources/Engine.ts | 5 +- sources/commands/Base.ts | 3 +- sources/commands/deprecated/Prepare.ts | 4 + sources/specUtils.ts | 38 +++++---- tests/main.test.ts | 105 +++++++++++++------------ 5 files changed, 90 insertions(+), 65 deletions(-) diff --git a/sources/Engine.ts b/sources/Engine.ts index c818cb2b9..890d6e6a7 100644 --- a/sources/Engine.ts +++ b/sources/Engine.ts @@ -276,9 +276,12 @@ export class Engine { } case `NoSpec`: { - if (typeof locator.reference === `function`) + if (result.devEnginesValue) + fallbackDescriptor.range = result.devEnginesValue.range; + else if (typeof locator.reference === `function`) fallbackDescriptor.range = await locator.reference(); + if (process.env.COREPACK_ENABLE_AUTO_PIN === `1`) { const resolved = await this.resolveDescriptor(fallbackDescriptor, {allowTags: true}); if (resolved === null) diff --git a/sources/commands/Base.ts b/sources/commands/Base.ts index c2c9ea2de..5a8b3088b 100644 --- a/sources/commands/Base.ts +++ b/sources/commands/Base.ts @@ -16,10 +16,11 @@ export abstract class BaseCommand extends Command { throw new UsageError(`Couldn't find a project in the local directory - please specify the package manager to pack, or run this command from a valid project`); case `NoSpec`: + if (lookup.devEnginesValue) return [lookup.devEnginesValue]; throw new UsageError(`The local project doesn't feature a 'packageManager' field nor a 'devEngines.packageManager' field - please specify the package manager to pack, or update the manifest to reference it`); default: { - return [lookup.range ?? lookup.getSpec()]; + return [lookup.devEnginesValue ?? lookup.getSpec()]; } } } else { diff --git a/sources/commands/deprecated/Prepare.ts b/sources/commands/deprecated/Prepare.ts index 49705b900..2b73cd28d 100644 --- a/sources/commands/deprecated/Prepare.ts +++ b/sources/commands/deprecated/Prepare.ts @@ -39,6 +39,10 @@ export class PrepareCommand extends Command { throw new UsageError(`Couldn't find a project in the local directory - please specify the package manager to pack, or run this command from a valid project`); case `NoSpec`: + if (lookup.devEnginesValue) { + specs.push(lookup.devEnginesValue); + break; + } throw new UsageError(`The local project doesn't feature a 'packageManager' field - please specify the package manager to pack, or update the manifest to reference it`); default: { diff --git a/sources/specUtils.ts b/sources/specUtils.ts index 29f61f59a..d29b619fd 100644 --- a/sources/specUtils.ts +++ b/sources/specUtils.ts @@ -101,7 +101,7 @@ function parsePackageJSON(packageJSONContent: CorepackPackageJSON) { return pm; } - debugUtils.log(`devEngines.packageManager defines that ${name}@${version} is the local package manager`); + debugUtils.log(`devEngines.packageManager defines that ${name}${version ? `@${version}` : ``} should the local package manager`); if (pm) { if (!pm.startsWith?.(`${name}@`)) @@ -113,8 +113,9 @@ function parsePackageJSON(packageJSONContent: CorepackPackageJSON) { return pm; } - - return `${name}@${version ?? `*`}`; + return {spec: `${name}@${version ?? `*`}`, name, version, toString() { + return this.spec; + }}; } return pm; @@ -123,14 +124,15 @@ function parsePackageJSON(packageJSONContent: CorepackPackageJSON) { export async function setLocalPackageManager(cwd: string, info: PreparedPackageManagerInfo) { const lookup = await loadSpecAndEnv(cwd); - const range = `range` in lookup && lookup.range; + const projectFound = lookup.type !== `NoProject`; + const range = projectFound && lookup.devEnginesValue; if (range) { if (info.locator.name !== range.name || !semverSatisfies(info.locator.reference, range.range)) { warnOrThrow(`The requested version of ${info.locator.name}@${info.locator.reference} does not match the devEngines specification (${range.name}@${range.range})`, range.onFail); } } - const content = lookup.type !== `NoProject` + const content = projectFound ? await fs.promises.readFile(lookup.target, `utf8`) : ``; @@ -151,12 +153,12 @@ interface FoundSpecResult { type: `Found`; target: string; getSpec: (options?: {enforceExactVersion?: boolean}) => Descriptor; - range?: Descriptor & {onFail?: DevEngineDependency[`onFail`]}; + devEnginesValue?: Descriptor & {onFail?: DevEngineDependency[`onFail`]}; envFilePath?: string; } export type LoadSpecResult = | {type: `NoProject`, target: string, envFilePath?: string} - | {type: `NoSpec`, target: string, envFilePath?: string} + | {type: `NoSpec`, target: string, envFilePath?: string, devEnginesValue?: FoundSpecResult[`devEnginesValue`]} | FoundSpecResult; async function loadEnvFileIfExists(cwd: string): Promise<{env: LocalEnvFile, path: string} | void> { @@ -238,18 +240,26 @@ export async function loadSpecAndEnv(initialCwd: string, {envOnly} = {envOnly: f if (typeof rawPmSpec === `undefined`) return {type: `NoSpec`, target: selection.manifestPath, envFilePath: localEnv?.path}; - debugUtils.log(`${selection.manifestPath} defines ${rawPmSpec} as local package manager`); + const devEnginesValue = selection.data.devEngines?.packageManager?.version && { + name: selection.data.devEngines.packageManager.name, + range: selection.data.devEngines.packageManager.version, + onFail: selection.data.devEngines.packageManager.onFail, + }; + + if (typeof rawPmSpec === `object` && !semverValid(rawPmSpec.version)) { + debugUtils.log(`${selection.manifestPath} devEngines does not specify a specific version`); + return {type: `NoSpec`, target: selection.manifestPath, envFilePath: localEnv?.path, devEnginesValue}; + } + + const hasPackageManagerField = typeof rawPmSpec === `string`; + debugUtils.log(`${selection.manifestPath} defines ${rawPmSpec} as local package manager${hasPackageManagerField ? `using packageManager field` : ``}`); return { type: `Found`, target: selection.manifestPath, envFilePath: localEnv?.path, - range: selection.data.devEngines?.packageManager?.version && { - name: selection.data.devEngines.packageManager.name, - range: selection.data.devEngines.packageManager.version, - onFail: selection.data.devEngines.packageManager.onFail, - }, + devEnginesValue, // Lazy-loading it so we do not throw errors on commands that do not need valid spec. - getSpec: ({enforceExactVersion = true} = {}) => parseSpec(rawPmSpec, path.relative(initialCwd, selection.manifestPath), {enforceExactVersion}), + getSpec: ({enforceExactVersion = true} = {}) => parseSpec(`${rawPmSpec}`, path.relative(initialCwd, selection.manifestPath), {enforceExactVersion}), }; } diff --git a/tests/main.test.ts b/tests/main.test.ts index e6f7d7200..a098af04c 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -272,23 +272,6 @@ it(`should ignore the packageManager field when found within a node_modules vend }); describe(`should handle invalid devEngines values`, () => { - it(`throw on missing version`, async () => { - await xfs.mktempPromise(async cwd => { - await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as PortablePath), { - devEngines: { - packageManager: { - name: `yarn`, - }, - }, - }); - - await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({ - exitCode: 1, - stderr: `Invalid package manager specification in package.json (yarn@*); expected a semver version\n`, - stdout: ``, - }); - }); - }); it(`throw on invalid version`, async () => { await xfs.mktempPromise(async cwd => { await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as PortablePath), { @@ -380,8 +363,8 @@ it(`should use hash from "packageManager" even when "devEngines" defines a diffe }); }); -describe(`should accept range in devEngines only if a specific version is provided`, () => { - it(`either in package.json#packageManager field`, async () => { +describe(`should accept range in devEngines`, () => { + it(`should accept if package.json#packageManager field matches`, async () => { await xfs.mktempPromise(async cwd => { await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as PortablePath), { devEngines: { @@ -390,18 +373,19 @@ describe(`should accept range in devEngines only if a specific version is provid version: `6.x`, }, }, + packageManager: `pnpm@6.6.2+sha224.eb5c0acad3b0f40ecdaa2db9aa5a73134ad256e17e22d1419a2ab073`, }); await expect(runCli(cwd, [`pnpm`, `--version`])).resolves.toMatchObject({ - exitCode: 1, - stderr: `Invalid package manager specification in package.json (pnpm@6.x); expected a semver version\n`, - stdout: ``, + exitCode: 0, + stderr: ``, + stdout: `6.6.2\n`, }); + // No version should also work await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as PortablePath), { devEngines: { packageManager: { name: `pnpm`, - version: `6.x`, }, }, packageManager: `pnpm@6.6.2+sha224.eb5c0acad3b0f40ecdaa2db9aa5a73134ad256e17e22d1419a2ab073`, @@ -411,20 +395,64 @@ describe(`should accept range in devEngines only if a specific version is provid stderr: ``, stdout: `6.6.2\n`, }); + }); + }); - // No version should also work - await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as PortablePath), { + it(`should accept without a package.json#packageManager field`, async () => { + process.env.AUTH_TYPE = `COREPACK_NPM_TOKEN`; + process.env.TEST_INTEGRITY = `valid`; + + await xfs.mktempPromise(async cwd => { + // When no user version is specified, range versions in devEngines should still cause error + await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as Filename), { devEngines: { packageManager: { name: `pnpm`, + version: `^1.0.0`, }, }, - packageManager: `pnpm@6.6.2+sha224.eb5c0acad3b0f40ecdaa2db9aa5a73134ad256e17e22d1419a2ab073`, }); - await expect(runCli(cwd, [`pnpm`, `--version`])).resolves.toMatchObject({ + + // Without user-specified version, should still fail due to range version in devEngines + await expect(runCli(cwd, [`pnpm`, `--version`], true)).resolves.toMatchObject({ exitCode: 0, stderr: ``, - stdout: `6.6.2\n`, + stdout: `pnpm: Hello from custom registry\n`, + }); + }); + }); + + it(`should pin a specific if COREPACK_ENABLE_AUTO_PIN is set`, async () => { + process.env.AUTH_TYPE = `COREPACK_NPM_TOKEN`; + process.env.TEST_INTEGRITY = `valid`; + process.env.COREPACK_ENABLE_AUTO_PIN = `1`; + + await xfs.mktempPromise(async cwd => { + // When no user version is specified, range versions in devEngines should still cause error + await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as Filename), { + devEngines: { + packageManager: { + name: `pnpm`, + version: `^1.0.0`, + }, + }, + }); + + // Without user-specified version, should still fail due to range version in devEngines + await expect(runCli(cwd, [`pnpm`, `--version`], true)).resolves.toMatchObject({ + exitCode: 0, + stderr: expect.stringContaining(`local project doesn't define a 'packageManager' field`), + stdout: `pnpm: Hello from custom registry\n`, + }); + + await expect(xfs.readJsonPromise(ppath.join(cwd, `package.json` as Filename))).resolves.toMatchObject({ + packageManager: `pnpm@1.9998.9999+sha512.14fba45289c972afe6d52036e6cf3c03901fecfe0c0b1231b4b4a65e19ded0bc5810405bebebcffc22334df23939e01a7c5b9da6a3e6ad5b8ffa91f49883c593`, + devEngines: { + packageManager: { + name: `pnpm`, + version: `^1.0.0`, + }, + }, }); }); }); @@ -1824,24 +1852,3 @@ describe(`allow range versions in devEngines.packageManager.version when user sp }); } }); - -it(`should still validate devEngines.packageManager.version format when no user version specified`, async () => { - await xfs.mktempPromise(async cwd => { - // When no user version is specified, range versions in devEngines should still cause error - await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as Filename), { - devEngines: { - packageManager: { - name: `npm`, - version: `^6.14.2`, - }, - }, - }); - - // Without user-specified version, should still fail due to range version in devEngines - await expect(runCli(cwd, [`npm`, `--version`])).resolves.toMatchObject({ - exitCode: 1, - stderr: expect.stringContaining(`Invalid package manager specification in package.json (npm@^6.14.2); expected a semver version`), - stdout: ``, - }); - }); -}); From aee2ad1f8bf7f011a2deb828ae4203c858e6ab94 Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Sat, 15 Aug 2026 15:19:06 +0200 Subject: [PATCH 2/4] fixup! feat: use range from `devEngines` when no `packageManager` is set --- tests/main.test.ts | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/tests/main.test.ts b/tests/main.test.ts index a098af04c..a4662b0f1 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -428,14 +428,15 @@ describe(`should accept range in devEngines`, () => { process.env.COREPACK_ENABLE_AUTO_PIN = `1`; await xfs.mktempPromise(async cwd => { - // When no user version is specified, range versions in devEngines should still cause error - await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as Filename), { - devEngines: { - packageManager: { - name: `pnpm`, - version: `^1.0.0`, - }, + const devEngines = { + packageManager: { + name: `pnpm`, + version: `^1.0.0`, }, + }; + + await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as Filename), { + devEngines, }); // Without user-specified version, should still fail due to range version in devEngines @@ -446,13 +447,8 @@ describe(`should accept range in devEngines`, () => { }); await expect(xfs.readJsonPromise(ppath.join(cwd, `package.json` as Filename))).resolves.toMatchObject({ - packageManager: `pnpm@1.9998.9999+sha512.14fba45289c972afe6d52036e6cf3c03901fecfe0c0b1231b4b4a65e19ded0bc5810405bebebcffc22334df23939e01a7c5b9da6a3e6ad5b8ffa91f49883c593`, - devEngines: { - packageManager: { - name: `pnpm`, - version: `^1.0.0`, - }, - }, + packageManager: expect.stringMatching(/^pnpm@1\.9998\.9999\+sha512\.[0-9a-z]{128}$/), + devEngines, }); }); }); From 36656c928c5aea2ee5f252f68a4afebc378e65ce Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Sun, 16 Aug 2026 01:13:37 +0200 Subject: [PATCH 3/4] fixup! feat: use range from `devEngines` when no `packageManager` is set --- sources/Engine.ts | 15 +++++-- sources/commands/Base.ts | 4 +- sources/commands/deprecated/Prepare.ts | 4 +- sources/specUtils.ts | 6 +-- tests/main.test.ts | 56 +++++++++++++++++++++++++- 5 files changed, 74 insertions(+), 11 deletions(-) diff --git a/sources/Engine.ts b/sources/Engine.ts index 890d6e6a7..15ba53079 100644 --- a/sources/Engine.ts +++ b/sources/Engine.ts @@ -276,9 +276,18 @@ export class Engine { } case `NoSpec`: { - if (result.devEnginesValue) - fallbackDescriptor.range = result.devEnginesValue.range; - else if (typeof locator.reference === `function`) + let rangeWasSet = false; + if (result.devEnginesValue) { + const {name, range} = result.devEnginesValue; + if (name !== fallbackDescriptor.name) + throw new UsageError(`This project is configured to use ${name} because ${result.target} has a "packageManager" field`); + + if (range) { + fallbackDescriptor.range = range; + rangeWasSet = true; + } + } + if (!rangeWasSet && typeof locator.reference === `function`) fallbackDescriptor.range = await locator.reference(); diff --git a/sources/commands/Base.ts b/sources/commands/Base.ts index 5a8b3088b..5e06fddec 100644 --- a/sources/commands/Base.ts +++ b/sources/commands/Base.ts @@ -16,8 +16,8 @@ export abstract class BaseCommand extends Command { throw new UsageError(`Couldn't find a project in the local directory - please specify the package manager to pack, or run this command from a valid project`); case `NoSpec`: - if (lookup.devEnginesValue) return [lookup.devEnginesValue]; - throw new UsageError(`The local project doesn't feature a 'packageManager' field nor a 'devEngines.packageManager' field - please specify the package manager to pack, or update the manifest to reference it`); + if (lookup.devEnginesValue?.range) return [lookup.devEnginesValue]; + throw new UsageError(`The local project doesn't feature a 'packageManager' field ${lookup.devEnginesValue ? `` : `nor a 'devEngines.packageManager' field `}- please specify the package manager to pack, or update the manifest to reference it`); default: { return [lookup.devEnginesValue ?? lookup.getSpec()]; diff --git a/sources/commands/deprecated/Prepare.ts b/sources/commands/deprecated/Prepare.ts index 2b73cd28d..493479009 100644 --- a/sources/commands/deprecated/Prepare.ts +++ b/sources/commands/deprecated/Prepare.ts @@ -39,11 +39,11 @@ export class PrepareCommand extends Command { throw new UsageError(`Couldn't find a project in the local directory - please specify the package manager to pack, or run this command from a valid project`); case `NoSpec`: - if (lookup.devEnginesValue) { + if (lookup.devEnginesValue?.range) { specs.push(lookup.devEnginesValue); break; } - throw new UsageError(`The local project doesn't feature a 'packageManager' field - please specify the package manager to pack, or update the manifest to reference it`); + throw new UsageError(`The local project doesn't feature a 'packageManager' field ${lookup.devEnginesValue ? `` : `nor a 'devEngines.packageManager' field `}- please specify the package manager to pack, or update the manifest to reference it`); default: { specs.push(lookup.getSpec()); diff --git a/sources/specUtils.ts b/sources/specUtils.ts index d29b619fd..38ea66130 100644 --- a/sources/specUtils.ts +++ b/sources/specUtils.ts @@ -240,7 +240,7 @@ export async function loadSpecAndEnv(initialCwd: string, {envOnly} = {envOnly: f if (typeof rawPmSpec === `undefined`) return {type: `NoSpec`, target: selection.manifestPath, envFilePath: localEnv?.path}; - const devEnginesValue = selection.data.devEngines?.packageManager?.version && { + const devEnginesValue = selection.data.devEngines?.packageManager?.name && { name: selection.data.devEngines.packageManager.name, range: selection.data.devEngines.packageManager.version, onFail: selection.data.devEngines.packageManager.onFail, @@ -252,13 +252,13 @@ export async function loadSpecAndEnv(initialCwd: string, {envOnly} = {envOnly: f } const hasPackageManagerField = typeof rawPmSpec === `string`; - debugUtils.log(`${selection.manifestPath} defines ${rawPmSpec} as local package manager${hasPackageManagerField ? `using packageManager field` : ``}`); + debugUtils.log(`${selection.manifestPath} defines ${rawPmSpec} as local package manager${hasPackageManagerField ? ` using packageManager field` : ``}`); return { type: `Found`, target: selection.manifestPath, envFilePath: localEnv?.path, - devEnginesValue, + devEnginesValue: devEnginesValue?.range && devEnginesValue, // Lazy-loading it so we do not throw errors on commands that do not need valid spec. getSpec: ({enforceExactVersion = true} = {}) => parseSpec(`${rawPmSpec}`, path.relative(initialCwd, selection.manifestPath), {enforceExactVersion}), }; diff --git a/tests/main.test.ts b/tests/main.test.ts index a4662b0f1..f0396336a 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -413,7 +413,14 @@ describe(`should accept range in devEngines`, () => { }, }); - // Without user-specified version, should still fail due to range version in devEngines + // Should fail if trying to use a different package manager than the one defined in devEngines + await expect(runCli(cwd, [`yarn`, `install`], true)).resolves.toMatchObject({ + exitCode: 1, + stderr: expect.stringMatching(/This project is configured to use pnpm because .+\/package\.json has a "packageManager" field/), + stdout: ``, + }); + + // Without user-specified version, should resolve to the range in devEngines await expect(runCli(cwd, [`pnpm`, `--version`], true)).resolves.toMatchObject({ exitCode: 0, stderr: ``, @@ -454,6 +461,53 @@ describe(`should accept range in devEngines`, () => { }); }); +describe(`devEngines.packageManager without a version`, () => { + it(`should still enforce the package manager name`, async () => { + await xfs.mktempPromise(async cwd => { + await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as Filename), { + devEngines: { + packageManager: { + name: `yarn`, + }, + }, + }); + + process.env.FORCE_COLOR = `0`; + + await expect(runCli(cwd, [`pnpm`, `--version`])).resolves.toMatchObject({ + stdout: ``, + stderr: expect.stringContaining(`This project is configured to use yarn`), + exitCode: 1, + }); + + // The matching package manager runs, using the default version as no range is given. + await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({ + stdout: `${config.definitions.yarn.default.split(`+`, 1)[0]}\n`, + stderr: ``, + exitCode: 0, + }); + }); + }); + + it(`should not claim the devEngines.packageManager field is missing`, async () => { + await xfs.mktempPromise(async cwd => { + await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as Filename), { + devEngines: { + packageManager: { + name: `yarn`, + }, + }, + }); + + await expect(runCli(cwd, [`pack`])).resolves.toMatchObject({ + exitCode: 1, + stdout: expect.stringContaining(`The local project doesn't feature a 'packageManager' field - please specify the package manager to pack, or update the manifest to reference it`), + stderr: ``, + }); + }); + }); +}); + describe(`when devEngines.packageManager.name does not match packageManager`, () => { it(`should ignore if devEngines.packageManager.onFail is set to "ignore"`, async () => { await xfs.mktempPromise(async cwd => { From fcb48ce2601d95e0f3358dd883bfa45ff647f741 Mon Sep 17 00:00:00 2001 From: Antoine du Hamel Date: Sun, 16 Aug 2026 19:47:37 +0200 Subject: [PATCH 4/4] fixup! feat: use range from `devEngines` when no `packageManager` is set --- README.md | 16 +++- sources/Engine.ts | 24 +++--- sources/commands/Base.ts | 13 +++- sources/commands/deprecated/Prepare.ts | 10 ++- sources/specUtils.ts | 102 +++++++++++++++---------- tests/main.test.ts | 77 ++++++++++++++++--- 6 files changed, 166 insertions(+), 76 deletions(-) diff --git a/README.md b/README.md index dd32b4ad0..f9cf009e1 100644 --- a/README.md +++ b/README.md @@ -127,9 +127,9 @@ Depending on the value of `devEngines.packageManager.onFail`: of mismatch. If the top-level `packageManager` field is missing, Corepack will use the -package manager defined in `devEngines.packageManager` – in which case you must -provide a specific version in `devEngines.packageManager.version`, ideally with -a hash, as explained in the previous section: +package manager defined in `devEngines.packageManager`. You should provide a +specific version in `devEngines.packageManager.version`, ideally with a hash, as +explained in the previous section: ```json { @@ -142,6 +142,16 @@ a hash, as explained in the previous section: } ``` +When `devEngines.packageManager.version` is a range rather than a specific +version, Corepack resolves it the same way as when a range is given on the +command line: the latest version matching the range is looked up on the npm +registry, which means the resolution requires network access (or a cache +containing a matching version, see [Offline Workflow](#offline-workflow)), and +may change over time. Set `COREPACK_ENABLE_AUTO_PIN=1` to have Corepack add the +resolved version to the `packageManager` field. When +`devEngines.packageManager.version` is missing, Corepack falls back to its +[Known Good Release](#known-good-releases) for that package manager. + ## Known Good Releases When running Corepack within projects that don't list a supported package diff --git a/sources/Engine.ts b/sources/Engine.ts index 15ba53079..31dbe4ea6 100644 --- a/sources/Engine.ts +++ b/sources/Engine.ts @@ -276,20 +276,16 @@ export class Engine { } case `NoSpec`: { - let rangeWasSet = false; - if (result.devEnginesValue) { - const {name, range} = result.devEnginesValue; - if (name !== fallbackDescriptor.name) - throw new UsageError(`This project is configured to use ${name} because ${result.target} has a "packageManager" field`); - - if (range) { - fallbackDescriptor.range = range; - rangeWasSet = true; - } - } - if (!rangeWasSet && typeof locator.reference === `function`) - fallbackDescriptor.range = await locator.reference(); + const {devEnginesValue} = result; + const nameMatches = devEnginesValue != null && devEnginesValue.name === fallbackDescriptor.name; + if (devEnginesValue != null && !nameMatches && !transparent) + specUtils.warnOrThrow(`This project is configured to use ${devEnginesValue.name} because ${result.target} has a "devEngines.packageManager" field`, devEnginesValue.onFail); + + if (nameMatches && devEnginesValue.version) + fallbackDescriptor.range = devEnginesValue.version; + else if (typeof locator.reference === `function`) + fallbackDescriptor.range = await locator.reference(); if (process.env.COREPACK_ENABLE_AUTO_PIN === `1`) { const resolved = await this.resolveDescriptor(fallbackDescriptor, {allowTags: true}); @@ -319,7 +315,7 @@ export class Engine { debugUtils.log(`Falling back to ${fallbackDescriptor.name}@${fallbackDescriptor.range} in a ${spec.name}@${spec.range} project`); return fallbackDescriptor; } else { - throw new UsageError(`This project is configured to use ${spec.name} because ${result.target} has a "packageManager" field`); + throw new UsageError(`This project is configured to use ${spec.name} because ${result.target} has a "${result.field}" field`); } } else { debugUtils.log(`Using ${spec.name}@${spec.range} as defined in project manifest ${result.target}`); diff --git a/sources/commands/Base.ts b/sources/commands/Base.ts index 5e06fddec..9c7214c08 100644 --- a/sources/commands/Base.ts +++ b/sources/commands/Base.ts @@ -15,12 +15,17 @@ export abstract class BaseCommand extends Command { case `NoProject`: throw new UsageError(`Couldn't find a project in the local directory - please specify the package manager to pack, or run this command from a valid project`); - case `NoSpec`: - if (lookup.devEnginesValue?.range) return [lookup.devEnginesValue]; - throw new UsageError(`The local project doesn't feature a 'packageManager' field ${lookup.devEnginesValue ? `` : `nor a 'devEngines.packageManager' field `}- please specify the package manager to pack, or update the manifest to reference it`); + case `NoSpec`: { + const {devEnginesValue} = lookup; + if (devEnginesValue?.version) + return [specUtils.devEnginesToDescriptor(devEnginesValue)]; + + throw new UsageError(`The local project doesn't feature a 'packageManager' field ${devEnginesValue ? `` : `nor a 'devEngines.packageManager' field `}- please specify the package manager to pack, or update the manifest to reference it`); + } default: { - return [lookup.devEnginesValue ?? lookup.getSpec()]; + const {devEnginesValue} = lookup; + return [devEnginesValue?.version ? specUtils.devEnginesToDescriptor(devEnginesValue) : lookup.getSpec()]; } } } else { diff --git a/sources/commands/deprecated/Prepare.ts b/sources/commands/deprecated/Prepare.ts index 493479009..02f2864b4 100644 --- a/sources/commands/deprecated/Prepare.ts +++ b/sources/commands/deprecated/Prepare.ts @@ -38,12 +38,14 @@ export class PrepareCommand extends Command { case `NoProject`: throw new UsageError(`Couldn't find a project in the local directory - please specify the package manager to pack, or run this command from a valid project`); - case `NoSpec`: - if (lookup.devEnginesValue?.range) { - specs.push(lookup.devEnginesValue); + case `NoSpec`: { + const {devEnginesValue} = lookup; + if (devEnginesValue?.version) { + specs.push(specUtils.devEnginesToDescriptor(devEnginesValue)); break; } - throw new UsageError(`The local project doesn't feature a 'packageManager' field ${lookup.devEnginesValue ? `` : `nor a 'devEngines.packageManager' field `}- please specify the package manager to pack, or update the manifest to reference it`); + throw new UsageError(`The local project doesn't feature a 'packageManager' field ${devEnginesValue ? `` : `nor a 'devEngines.packageManager' field `}- please specify the package manager to pack, or update the manifest to reference it`); + } default: { specs.push(lookup.getSpec()); diff --git a/sources/specUtils.ts b/sources/specUtils.ts index 38ea66130..275c49058 100644 --- a/sources/specUtils.ts +++ b/sources/specUtils.ts @@ -61,12 +61,23 @@ type CorepackPackageJSON = { devEngines?: {packageManager?: DevEngineDependency}; }; -interface DevEngineDependency { +export interface DevEngineDependency { name: string; - version: string; + /** Semver version or range, as found in the manifest. */ + version?: string; onFail?: `ignore` | `warn` | `error`; } -function warnOrThrow(errorMessage: string, onFail?: DevEngineDependency[`onFail`]) { + +export function devEnginesToDescriptor({name, version}: DevEngineDependency): Descriptor { + return {name, range: version ?? `*`}; +} + +interface ParsedPackageJSON { + packageManagerField?: string; + devEnginesPackageManager?: DevEngineDependency; +} + +export function warnOrThrow(errorMessage: string, onFail?: DevEngineDependency[`onFail`]) { switch (onFail) { case `ignore`: break; @@ -77,58 +88,54 @@ function warnOrThrow(errorMessage: string, onFail?: DevEngineDependency[`onFail` console.warn(`! Corepack validation warning: ${errorMessage}`); } } -function parsePackageJSON(packageJSONContent: CorepackPackageJSON) { +function parsePackageJSON(packageJSONContent: CorepackPackageJSON): ParsedPackageJSON { const {packageManager: pm} = packageJSONContent; if (packageJSONContent.devEngines?.packageManager != null) { const {packageManager} = packageJSONContent.devEngines; if (typeof packageManager !== `object`) { console.warn(`! Corepack only supports objects as valid value for devEngines.packageManager. The current value (${JSON.stringify(packageManager)}) will be ignored.`); - return pm; + return {packageManagerField: pm}; } if (Array.isArray(packageManager)) { console.warn(`! Corepack does not currently support array values for devEngines.packageManager`); - return pm; + return {packageManagerField: pm}; } const {name, version, onFail} = packageManager; if (typeof name !== `string` || name.includes(`@`)) { warnOrThrow(`The value of devEngines.packageManager.name ${JSON.stringify(name)} is not a supported string value`, onFail); - return pm; + return {packageManagerField: pm}; } if (version != null && (typeof version !== `string` || !semverValidRange(version))) { warnOrThrow(`The value of devEngines.packageManager.version ${JSON.stringify(version)} is not a valid semver range`, onFail); - return pm; + return {packageManagerField: pm}; } - debugUtils.log(`devEngines.packageManager defines that ${name}${version ? `@${version}` : ``} should the local package manager`); + debugUtils.log(`devEngines.packageManager defines that ${name}${version ? `@${version}` : ``} should be the local package manager`); if (pm) { - if (!pm.startsWith?.(`${name}@`)) + if (!pm.startsWith?.(`${name}@`)) { warnOrThrow(`"packageManager" field is set to ${JSON.stringify(pm)} which does not match the "devEngines.packageManager" field set to ${JSON.stringify(name)}`, onFail); - - else if (version != null && !semverSatisfies(pm.slice(packageManager.name.length + 1), version)) + } else if (version != null && !semverSatisfies(pm.slice(name.length + 1), version)) { warnOrThrow(`"packageManager" field is set to ${JSON.stringify(pm)} which does not match the value defined in "devEngines.packageManager" for ${JSON.stringify(name)} of ${JSON.stringify(version)}`, onFail); - - return pm; + } } - return {spec: `${name}@${version ?? `*`}`, name, version, toString() { - return this.spec; - }}; + return {packageManagerField: pm, devEnginesPackageManager: {name, version, onFail}}; } - return pm; + return {packageManagerField: pm}; } export async function setLocalPackageManager(cwd: string, info: PreparedPackageManagerInfo) { const lookup = await loadSpecAndEnv(cwd); const projectFound = lookup.type !== `NoProject`; - const range = projectFound && lookup.devEnginesValue; - if (range) { - if (info.locator.name !== range.name || !semverSatisfies(info.locator.reference, range.range)) { - warnOrThrow(`The requested version of ${info.locator.name}@${info.locator.reference} does not match the devEngines specification (${range.name}@${range.range})`, range.onFail); + const devEnginesValue = projectFound ? lookup.devEnginesValue : undefined; + if (devEnginesValue) { + if (info.locator.name !== devEnginesValue.name || (devEnginesValue.version != null && !semverSatisfies(info.locator.reference, devEnginesValue.version))) { + warnOrThrow(`The requested version of ${info.locator.name}@${info.locator.reference} does not match the devEngines specification (${devEnginesValue.name}@${devEnginesValue.version ?? `*`})`, devEnginesValue.onFail); } } @@ -138,7 +145,7 @@ export async function setLocalPackageManager(cwd: string, info: PreparedPackageM const {data, indent} = nodeUtils.readPackageJson(content); - const previousPackageManager = data.packageManager ?? (range ? `${range.name}@${range.range}` : `unknown`); + const previousPackageManager = data.packageManager ?? (devEnginesValue ? `${devEnginesValue.name}@${devEnginesValue.version ?? `*`}` : `unknown`); data.packageManager = `${info.locator.name}@${info.locator.reference}`; const newContent = nodeUtils.normalizeLineEndings(content, `${JSON.stringify(data, null, indent)}\n`); @@ -152,13 +159,15 @@ export async function setLocalPackageManager(cwd: string, info: PreparedPackageM interface FoundSpecResult { type: `Found`; target: string; + /** Name of the `package.json` field the spec was read from. */ + field: `packageManager` | `devEngines.packageManager`; getSpec: (options?: {enforceExactVersion?: boolean}) => Descriptor; - devEnginesValue?: Descriptor & {onFail?: DevEngineDependency[`onFail`]}; + devEnginesValue?: DevEngineDependency; envFilePath?: string; } export type LoadSpecResult = | {type: `NoProject`, target: string, envFilePath?: string} - | {type: `NoSpec`, target: string, envFilePath?: string, devEnginesValue?: FoundSpecResult[`devEnginesValue`]} + | {type: `NoSpec`, target: string, envFilePath?: string, devEnginesValue?: DevEngineDependency} | FoundSpecResult; async function loadEnvFileIfExists(cwd: string): Promise<{env: LocalEnvFile, path: string} | void> { @@ -236,30 +245,43 @@ export async function loadSpecAndEnv(initialCwd: string, {envOnly} = {envOnly: f if (selection === null) return {type: `NoProject`, target: path.join(initialCwd, `package.json`), envFilePath: localEnv?.path}; - const rawPmSpec = parsePackageJSON(selection.data); - if (typeof rawPmSpec === `undefined`) - return {type: `NoSpec`, target: selection.manifestPath, envFilePath: localEnv?.path}; + const {packageManagerField, devEnginesPackageManager} = parsePackageJSON(selection.data); - const devEnginesValue = selection.data.devEngines?.packageManager?.name && { - name: selection.data.devEngines.packageManager.name, - range: selection.data.devEngines.packageManager.version, - onFail: selection.data.devEngines.packageManager.onFail, - }; + if (devEnginesPackageManager != null && !packageManagerField) { + const {name, version} = devEnginesPackageManager; + + // Without an exact version, there is nothing to install yet – the range (if + // any) is resolved by the caller, as it would for a project without spec. + if (!version || !semverValid(version)) { + debugUtils.log(`${selection.manifestPath} defines ${name} as local package manager using devEngines.packageManager, without an exact version`); + return {type: `NoSpec`, target: selection.manifestPath, envFilePath: localEnv?.path, devEnginesValue: devEnginesPackageManager}; + } + + debugUtils.log(`${selection.manifestPath} defines ${name}@${version} as local package manager using devEngines.packageManager`); - if (typeof rawPmSpec === `object` && !semverValid(rawPmSpec.version)) { - debugUtils.log(`${selection.manifestPath} devEngines does not specify a specific version`); - return {type: `NoSpec`, target: selection.manifestPath, envFilePath: localEnv?.path, devEnginesValue}; + return { + type: `Found`, + target: selection.manifestPath, + field: `devEngines.packageManager`, + envFilePath: localEnv?.path, + devEnginesValue: devEnginesPackageManager, + // Lazy-loading it so we do not throw errors on commands that do not need valid spec. + getSpec: ({enforceExactVersion = true} = {}) => parseSpec(`${name}@${version}`, path.relative(initialCwd, selection.manifestPath), {enforceExactVersion}), + }; } - const hasPackageManagerField = typeof rawPmSpec === `string`; - debugUtils.log(`${selection.manifestPath} defines ${rawPmSpec} as local package manager${hasPackageManagerField ? ` using packageManager field` : ``}`); + if (packageManagerField === undefined) + return {type: `NoSpec`, target: selection.manifestPath, envFilePath: localEnv?.path}; + + debugUtils.log(`${selection.manifestPath} defines ${packageManagerField} as local package manager using the packageManager field`); return { type: `Found`, target: selection.manifestPath, + field: `packageManager`, envFilePath: localEnv?.path, - devEnginesValue: devEnginesValue?.range && devEnginesValue, + devEnginesValue: devEnginesPackageManager, // Lazy-loading it so we do not throw errors on commands that do not need valid spec. - getSpec: ({enforceExactVersion = true} = {}) => parseSpec(`${rawPmSpec}`, path.relative(initialCwd, selection.manifestPath), {enforceExactVersion}), + getSpec: ({enforceExactVersion = true} = {}) => parseSpec(packageManagerField, path.relative(initialCwd, selection.manifestPath), {enforceExactVersion}), }; } diff --git a/tests/main.test.ts b/tests/main.test.ts index f0396336a..e2ff4a75d 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -366,7 +366,7 @@ it(`should use hash from "packageManager" even when "devEngines" defines a diffe describe(`should accept range in devEngines`, () => { it(`should accept if package.json#packageManager field matches`, async () => { await xfs.mktempPromise(async cwd => { - await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as PortablePath), { + await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as Filename), { devEngines: { packageManager: { name: `pnpm`, @@ -382,7 +382,7 @@ describe(`should accept range in devEngines`, () => { }); // No version should also work - await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as PortablePath), { + await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as Filename), { devEngines: { packageManager: { name: `pnpm`, @@ -403,7 +403,6 @@ describe(`should accept range in devEngines`, () => { process.env.TEST_INTEGRITY = `valid`; await xfs.mktempPromise(async cwd => { - // When no user version is specified, range versions in devEngines should still cause error await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as Filename), { devEngines: { packageManager: { @@ -413,23 +412,79 @@ describe(`should accept range in devEngines`, () => { }, }); - // Should fail if trying to use a different package manager than the one defined in devEngines - await expect(runCli(cwd, [`yarn`, `install`], true)).resolves.toMatchObject({ + // The range is resolved as if it had been provided on the command line. + await expect(runCli(cwd, [`pnpm`, `--version`], true)).resolves.toMatchObject({ + exitCode: 0, + stderr: ``, + stdout: `pnpm: Hello from custom registry\n`, + }); + }); + }); + + it(`should refuse to run another package manager`, async () => { + process.env.FORCE_COLOR = `0`; + + await xfs.mktempPromise(async cwd => { + await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as Filename), { + devEngines: { + packageManager: { + name: `pnpm`, + version: `^1.0.0`, + }, + }, + }); + + await expect(runCli(cwd, [`yarn`, `install`])).resolves.toMatchObject({ exitCode: 1, - stderr: expect.stringMatching(/This project is configured to use pnpm because .+\/package\.json has a "packageManager" field/), + stderr: expect.stringContaining(`This project is configured to use pnpm because ${ + npath.fromPortablePath(ppath.join(cwd, `package.json` as Filename)) + } has a "devEngines.packageManager" field`), stdout: ``, }); - // Without user-specified version, should resolve to the range in devEngines - await expect(runCli(cwd, [`pnpm`, `--version`], true)).resolves.toMatchObject({ + // Transparent commands are still allowed to use the fallback version. + await expect(runCli(cwd, [`yarn`, `dlx`, `--help`])).resolves.toMatchObject({ exitCode: 0, stderr: ``, - stdout: `pnpm: Hello from custom registry\n`, + }); + + // Disable strict checking to workaround the UsageError. + process.env.COREPACK_ENABLE_STRICT = `0`; + + await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({ + exitCode: 0, + stderr: ``, + stdout: `${config.definitions.yarn.default.split(`+`, 1)[0]}\n`, }); }); }); - it(`should pin a specific if COREPACK_ENABLE_AUTO_PIN is set`, async () => { + for (const onFail of [`ignore`, `warn`] as const) { + it(`should not refuse to run another package manager when onFail is set to "${onFail}"`, async () => { + process.env.FORCE_COLOR = `0`; + + await xfs.mktempPromise(async cwd => { + await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as Filename), { + devEngines: { + packageManager: { + name: `pnpm`, + version: `^1.0.0`, + onFail, + }, + }, + }); + + await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({ + exitCode: 0, + stderr: onFail === `warn` ? expect.stringContaining(`! Corepack validation warning: This project is configured to use pnpm`) : ``, + stdout: `${config.definitions.yarn.default.split(`+`, 1)[0]}\n`, + }); + }); + }); + } + + + it(`should pin a specific version if COREPACK_ENABLE_AUTO_PIN is set`, async () => { process.env.AUTH_TYPE = `COREPACK_NPM_TOKEN`; process.env.TEST_INTEGRITY = `valid`; process.env.COREPACK_ENABLE_AUTO_PIN = `1`; @@ -446,13 +501,13 @@ describe(`should accept range in devEngines`, () => { devEngines, }); - // Without user-specified version, should still fail due to range version in devEngines await expect(runCli(cwd, [`pnpm`, `--version`], true)).resolves.toMatchObject({ exitCode: 0, stderr: expect.stringContaining(`local project doesn't define a 'packageManager' field`), stdout: `pnpm: Hello from custom registry\n`, }); + // The range from devEngines is left untouched. await expect(xfs.readJsonPromise(ppath.join(cwd, `package.json` as Filename))).resolves.toMatchObject({ packageManager: expect.stringMatching(/^pnpm@1\.9998\.9999\+sha512\.[0-9a-z]{128}$/), devEngines,