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 c818cb2b9..31dbe4ea6 100644 --- a/sources/Engine.ts +++ b/sources/Engine.ts @@ -276,7 +276,15 @@ export class Engine { } case `NoSpec`: { - if (typeof locator.reference === `function`) + 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`) { @@ -307,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 c2c9ea2de..9c7214c08 100644 --- a/sources/commands/Base.ts +++ b/sources/commands/Base.ts @@ -15,11 +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`: - 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`); + 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.range ?? 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 49705b900..02f2864b4 100644 --- a/sources/commands/deprecated/Prepare.ts +++ b/sources/commands/deprecated/Prepare.ts @@ -38,8 +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`: - 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`); + 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 ${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 29f61f59a..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,66 +88,64 @@ 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} is 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 `${name}@${version ?? `*`}`; + 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 range = `range` in lookup && lookup.range; - 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 projectFound = lookup.type !== `NoProject`; + 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); } } - const content = lookup.type !== `NoProject` + const content = projectFound ? await fs.promises.readFile(lookup.target, `utf8`) : ``; 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`); @@ -150,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; - range?: Descriptor & {onFail?: DevEngineDependency[`onFail`]}; + devEnginesValue?: DevEngineDependency; envFilePath?: string; } export type LoadSpecResult = | {type: `NoProject`, target: string, envFilePath?: string} - | {type: `NoSpec`, target: string, envFilePath?: string} + | {type: `NoSpec`, target: string, envFilePath?: string, devEnginesValue?: DevEngineDependency} | FoundSpecResult; async function loadEnvFileIfExists(cwd: string): Promise<{env: LocalEnvFile, path: string} | void> { @@ -234,22 +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`) + const {packageManagerField, devEnginesPackageManager} = parsePackageJSON(selection.data); + + 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`); + + 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}), + }; + } + + if (packageManagerField === undefined) return {type: `NoSpec`, target: selection.manifestPath, envFilePath: localEnv?.path}; - debugUtils.log(`${selection.manifestPath} defines ${rawPmSpec} as local package manager`); + 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, - 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: 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 e6f7d7200..e2ff4a75d 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,28 +363,29 @@ 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), { + await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as Filename), { devEngines: { packageManager: { name: `pnpm`, 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`, }); - await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as PortablePath), { + // No version should also work + await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as Filename), { devEngines: { packageManager: { name: `pnpm`, - version: `6.x`, }, }, packageManager: `pnpm@6.6.2+sha224.eb5c0acad3b0f40ecdaa2db9aa5a73134ad256e17e22d1419a2ab073`, @@ -411,20 +395,169 @@ 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 => { + await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as Filename), { devEngines: { packageManager: { name: `pnpm`, + version: `^1.0.0`, + }, + }, + }); + + // 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.stringContaining(`This project is configured to use pnpm because ${ + npath.fromPortablePath(ppath.join(cwd, `package.json` as Filename)) + } has a "devEngines.packageManager" field`), + stdout: ``, + }); + + // Transparent commands are still allowed to use the fallback version. + await expect(runCli(cwd, [`yarn`, `dlx`, `--help`])).resolves.toMatchObject({ + exitCode: 0, + stderr: ``, + }); + + // 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`, + }); + }); + }); + + 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`; + + await xfs.mktempPromise(async cwd => { + const devEngines = { + packageManager: { + name: `pnpm`, + version: `^1.0.0`, + }, + }; + + await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as Filename), { + 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, + }); + }); + }); +}); + +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`, }, }, - packageManager: `pnpm@6.6.2+sha224.eb5c0acad3b0f40ecdaa2db9aa5a73134ad256e17e22d1419a2ab073`, }); + + 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: ``, - stdout: `6.6.2\n`, }); }); }); @@ -1824,24 +1957,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: ``, - }); - }); -});