diff --git a/README.md b/README.md index fea1e2c8f..308b80632 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,35 @@ use in the archive). } ``` +#### `devEngines.packageManager` + +When a `devEngines.packageManager` field is defined, and is an object containing +a `"name"` field (can also optionally contain `version` and `onFail` fields), +Corepack will use it to validate you're using a compatible package manager. + +Depending on the value of `devEngines.packageManager.onFail`: + +- if set to `ignore`, Corepack won't print any warning or error. +- if unset or set to `error`, Corepack will throw an error in case of a mismatch. +- if set to `warn` or some other value, Corepack will print a warning in case + 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: + +```json +{ + "devEngines":{ + "packageManager": { + "name": "yarn", + "version": "3.2.3+sha224.953c8233f7a92884eee2de69a1b92d1f2ec1655e66d08071ba9a02fa" + } + } +} +``` + ## Known Good Releases When running Corepack within projects that don't list a supported package @@ -246,6 +275,7 @@ it. Unlike `corepack use` this command doesn't take a package manager name nor a version range, as it will always select the latest available version from the +range specified in `devEngines.packageManager.version`, or fallback to the same major line. Should you need to upgrade to a new major, use an explicit `corepack use {name}@latest` call (or simply `corepack use {name}`). diff --git a/sources/commands/Base.ts b/sources/commands/Base.ts index 7f918f0a5..950195427 100644 --- a/sources/commands/Base.ts +++ b/sources/commands/Base.ts @@ -16,10 +16,10 @@ 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`: - 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 nor a 'devEngines.packageManager' field - please specify the package manager to pack, or update the manifest to reference it`); default: { - return [lookup.getSpec()]; + return [lookup.range ?? lookup.getSpec()]; } } } diff --git a/sources/specUtils.ts b/sources/specUtils.ts index eb7a3db48..36af0372a 100644 --- a/sources/specUtils.ts +++ b/sources/specUtils.ts @@ -1,7 +1,9 @@ import {UsageError} from 'clipanion'; import fs from 'fs'; import path from 'path'; +import semverSatisfies from 'semver/functions/satisfies'; import semverValid from 'semver/functions/valid'; +import semverValidRange from 'semver/ranges/valid'; import {PreparedPackageManagerInfo} from './Engine'; import * as debugUtils from './debugUtils'; @@ -52,16 +54,87 @@ export function parseSpec(raw: unknown, source: string, {enforceExactVersion = t }; } +type CorepackPackageJSON = { + packageManager?: string; + devEngines?: { packageManager?: DevEngineDependency }; +}; + +interface DevEngineDependency { + name: string; + version: string; + onFail?: 'ignore' | 'warn' | 'error'; +} +function warnOrThrow(errorMessage: string, onFail?: DevEngineDependency['onFail']) { + switch (onFail) { + case `ignore`: + break; + case `error`: + case undefined: + throw new UsageError(errorMessage); + default: + console.warn(`! Corepack validation warning: ${errorMessage}`); + } +} +function parsePackageJSON(packageJSONContent: CorepackPackageJSON) { + 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; + } + if (Array.isArray(packageManager)) { + console.warn(`! Corepack does not currently support array values for devEngines.packageManager`); + return 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; + } + 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; + } + + debugUtils.log(`devEngines.packageManager defines that ${name}@${version} is the local package manager`); + + if (pm) { + 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)) + 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 pm; +} + export async function setLocalPackageManager(cwd: string, info: PreparedPackageManagerInfo) { const lookup = await loadSpec(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 content = lookup.type !== `NoProject` ? await fs.promises.readFile(lookup.target, `utf8`) : ``; const {data, indent} = nodeUtils.readPackageJson(content); - const previousPackageManager = data.packageManager ?? `unknown`; + const previousPackageManager = data.packageManager ?? (range ? `${range.name}@${range.range}` : `unknown`); data.packageManager = `${info.locator.name}@${info.locator.reference}`; const newContent = nodeUtils.normalizeLineEndings(content, `${JSON.stringify(data, null, indent)}\n`); @@ -75,7 +148,7 @@ export async function setLocalPackageManager(cwd: string, info: PreparedPackageM export type LoadSpecResult = | {type: `NoProject`, target: string} | {type: `NoSpec`, target: string} - | {type: `Found`, target: string, getSpec: () => Descriptor}; + | {type: `Found`, target: string, getSpec: () => Descriptor, range?: Descriptor & {onFail?: DevEngineDependency['onFail']}}; export async function loadSpec(initialCwd: string): Promise { let nextCwd = initialCwd; @@ -117,13 +190,20 @@ export async function loadSpec(initialCwd: string): Promise { if (selection === null) return {type: `NoProject`, target: path.join(initialCwd, `package.json`)}; - const rawPmSpec = selection.data.packageManager; + const rawPmSpec = parsePackageJSON(selection.data); if (typeof rawPmSpec === `undefined`) return {type: `NoSpec`, target: selection.manifestPath}; + debugUtils.log(`${selection.manifestPath} defines ${rawPmSpec} as local package manager`); + return { type: `Found`, target: selection.manifestPath, + range: selection.data.devEngines?.packageManager?.version && { + name: selection.data.devEngines.packageManager.name, + range: selection.data.devEngines.packageManager.version, + onFail: selection.data.devEngines.packageManager.onFail, + }, // Lazy-loading it so we do not throw errors on commands that do not need valid spec. getSpec: () => parseSpec(rawPmSpec, path.relative(initialCwd, selection.manifestPath)), }; diff --git a/tests/Up.test.ts b/tests/Up.test.ts index bf41154d6..dac6e9d96 100644 --- a/tests/Up.test.ts +++ b/tests/Up.test.ts @@ -17,24 +17,119 @@ beforeEach(async () => { }); describe(`UpCommand`, () => { - it(`should upgrade the package manager from the current project`, async () => { - await xfs.mktempPromise(async cwd => { - await xfs.writeJsonPromise(ppath.join(cwd, `package.json`), { - packageManager: `yarn@2.1.0`, + describe(`should update the "packageManager" field from the current project`, () => { + it(`to the same major if no devEngines range`, async () => { + await xfs.mktempPromise(async cwd => { + await xfs.writeJsonPromise(ppath.join(cwd, `package.json`), { + packageManager: `yarn@2.1.0`, + }); + + await expect(runCli(cwd, [`up`])).resolves.toMatchObject({ + exitCode: 0, + stderr: ``, + stdout: expect.stringMatching(/^Installing yarn@2\.4\.3 in the project\.\.\.\n\n/), + }); + + await expect(xfs.readJsonPromise(ppath.join(cwd, `package.json`))).resolves.toMatchObject({ + packageManager: `yarn@2.4.3+sha512.8dd9fedc5451829619e526c56f42609ad88ae4776d9d3f9456d578ac085115c0c2f0fb02bb7d57fd2e1b6e1ac96efba35e80a20a056668f61c96934f67694fd0`, + }); + + await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({ + exitCode: 0, + stdout: `2.4.3\n`, + stderr: ``, + }); }); + }); - await expect(runCli(cwd, [`up`])).resolves.toMatchObject({ - exitCode: 0, - stderr: ``, + it(`to whichever range devEngines defines`, async () => { + await xfs.mktempPromise(async cwd => { + await xfs.writeJsonPromise(ppath.join(cwd, `package.json`), { + packageManager: `yarn@1.1.0`, + devEngines: { + packageManager: { + name: `yarn`, + version: `1.x || 2.x`, + }, + }, + }); + + await expect(runCli(cwd, [`up`])).resolves.toMatchObject({ + exitCode: 0, + stderr: ``, + stdout: expect.stringMatching(/^Installing yarn@2\.4\.3 in the project\.\.\.\n\n/), + }); + + await expect(xfs.readJsonPromise(ppath.join(cwd, `package.json`))).resolves.toMatchObject({ + packageManager: `yarn@2.4.3+sha512.8dd9fedc5451829619e526c56f42609ad88ae4776d9d3f9456d578ac085115c0c2f0fb02bb7d57fd2e1b6e1ac96efba35e80a20a056668f61c96934f67694fd0`, + }); + + await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({ + exitCode: 0, + stdout: `2.4.3\n`, + stderr: ``, + }); }); + }); - await expect(xfs.readJsonPromise(ppath.join(cwd, `package.json`))).resolves.toMatchObject({ - packageManager: `yarn@2.4.3+sha512.8dd9fedc5451829619e526c56f42609ad88ae4776d9d3f9456d578ac085115c0c2f0fb02bb7d57fd2e1b6e1ac96efba35e80a20a056668f61c96934f67694fd0`, + it(`to whichever range devEngines defines even if onFail is set to ignore`, async () => { + await xfs.mktempPromise(async cwd => { + await xfs.writeJsonPromise(ppath.join(cwd, `package.json`), { + packageManager: `pnpm@10.1.0`, + devEngines: { + packageManager: { + name: `yarn`, + version: `1.x || 2.x`, + onFail: `ignore`, + }, + }, + }); + + await expect(runCli(cwd, [`up`])).resolves.toMatchObject({ + exitCode: 0, + stderr: ``, + stdout: expect.stringMatching(/^Installing yarn@2\.4\.3 in the project\.\.\.\n\n/), + }); + + await expect(xfs.readJsonPromise(ppath.join(cwd, `package.json`))).resolves.toMatchObject({ + packageManager: `yarn@2.4.3+sha512.8dd9fedc5451829619e526c56f42609ad88ae4776d9d3f9456d578ac085115c0c2f0fb02bb7d57fd2e1b6e1ac96efba35e80a20a056668f61c96934f67694fd0`, + }); + + await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({ + exitCode: 0, + stdout: `2.4.3\n`, + stderr: ``, + }); }); + }); + + it(`should succeed even if no 'packageManager' field`, async () => { + await xfs.mktempPromise(async cwd => { + process.env.NO_COLOR = `1`; + await xfs.writeJsonPromise(ppath.join(cwd, `package.json`), { + devEngines: { + packageManager: { + name: `yarn`, + version: `1.x || 2.x`, + }, + }, + }); + + await expect(runCli(cwd, [`up`])).resolves.toMatchObject({ + exitCode: 0, + stderr: ``, + stdout: expect.stringMatching(/^Installing yarn@2\.4\.3 in the project\.\.\.\n\n/), + }); + + await expect(xfs.readJsonPromise(ppath.join(cwd, `package.json`))).resolves.toMatchObject({ + packageManager: `yarn@2.4.3+sha512.8dd9fedc5451829619e526c56f42609ad88ae4776d9d3f9456d578ac085115c0c2f0fb02bb7d57fd2e1b6e1ac96efba35e80a20a056668f61c96934f67694fd0`, + }); - await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({ - exitCode: 0, - stdout: `2.4.3\n`, + await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({ + exitCode: 0, + stdout: `2.4.3\n`, + stderr: ``, + }); }); }); }); diff --git a/tests/Use.test.ts b/tests/Use.test.ts index d4f9a3ef5..978c2e3a8 100644 --- a/tests/Use.test.ts +++ b/tests/Use.test.ts @@ -17,23 +17,100 @@ beforeEach(async () => { }); describe(`UseCommand`, () => { - it(`should set the package manager in the current project`, async () => { - await xfs.mktempPromise(async cwd => { - await xfs.writeJsonPromise(ppath.join(cwd, `package.json`), { - packageManager: `yarn@1.0.0`, - }); + describe(`should set the package manager in the current project`, () => { + it(`With an existing 'packageManager' field`, async () => { + await xfs.mktempPromise(async cwd => { + await xfs.writeJsonPromise(ppath.join(cwd, `package.json`), { + packageManager: `yarn@1.0.0`, + license: `MIT`, + }); - await expect(runCli(cwd, [`use`, `yarn@1.22.4`])).resolves.toMatchObject({ - exitCode: 0, + await expect(runCli(cwd, [`use`, `yarn@1.22.4`])).resolves.toMatchObject({ + exitCode: 0, + stdout: expect.stringMatching(/^Installing yarn@1\.22\.4 in the project\.\.\.\n\n/), + stderr: ``, + }); + + await expect(xfs.readJsonPromise(ppath.join(cwd, `package.json`))).resolves.toMatchObject({ + packageManager: `yarn@1.22.4+sha512.a1833b862fe52169bd6c2a033045a07df5bc6a23595c259e675fed1b2d035ab37abe6ce309720abb6636d68f03615054b6292dc0a70da31c8697fda228b50d18`, + }); + + await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({ + exitCode: 0, + stdout: `1.22.4\n`, + stderr: ``, + }); }); + }); + it(`with 'devEngines.packageManager' field`, async () => { + await xfs.mktempPromise(async cwd => { + process.env.NO_COLOR = `1`; + const devEngines = {packageManager: {name: `yarn`, version: `2.x`}}; + await xfs.writeJsonPromise(ppath.join(cwd, `package.json`), { + devEngines, + }); - await expect(xfs.readJsonPromise(ppath.join(cwd, `package.json`))).resolves.toMatchObject({ - packageManager: `yarn@1.22.4+sha512.a1833b862fe52169bd6c2a033045a07df5bc6a23595c259e675fed1b2d035ab37abe6ce309720abb6636d68f03615054b6292dc0a70da31c8697fda228b50d18`, + // Should refuse to install an incompatible version: + await expect(runCli(cwd, [`use`, `yarn@1.22.4`])).resolves.toMatchObject({ + exitCode: 1, + stderr: ``, + stdout: `Installing yarn@1.22.4 in the project...\nUsage Error: The requested version of yarn@1.22.4+sha512.a1833b862fe52169bd6c2a033045a07df5bc6a23595c259e675fed1b2d035ab37abe6ce309720abb6636d68f03615054b6292dc0a70da31c8697fda228b50d18 does not match the devEngines specification (yarn@2.x)\n\n$ corepack use \n`, + }); + + // Should accept setting to a compatible version: + await expect(runCli(cwd, [`use`, `yarn@2.4.3`])).resolves.toMatchObject({ + exitCode: 0, + stderr: ``, + stdout: expect.stringMatching(/^Installing yarn@2\.4\.3 in the project\.\.\.\n\n/), + }); + + await expect(xfs.readJsonPromise(ppath.join(cwd, `package.json`))).resolves.toMatchObject({ + devEngines, + packageManager: `yarn@2.4.3+sha512.8dd9fedc5451829619e526c56f42609ad88ae4776d9d3f9456d578ac085115c0c2f0fb02bb7d57fd2e1b6e1ac96efba35e80a20a056668f61c96934f67694fd0`, + }); + + await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({ + exitCode: 0, + stdout: `2.4.3\n`, + stderr: ``, + }); }); + }); - await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({ - exitCode: 0, - stdout: `1.22.4\n`, + it(`with 'devEngines.packageManager' and 'packageManager' fields`, async () => { + await xfs.mktempPromise(async cwd => { + process.env.NO_COLOR = `1`; + const devEngines = {packageManager: {name: `yarn`, version: `1.x || 2.x`}}; + await xfs.writeJsonPromise(ppath.join(cwd, `package.json`), { + devEngines, + packageManager: `yarn@1.1.0`, + license: `MIT`, + }); + + // Should refuse to install an incompatible version: + await expect(runCli(cwd, [`use`, `yarn@1.22.4`])).resolves.toMatchObject({ + exitCode: 0, + stderr: ``, + stdout: expect.stringMatching(/^Installing yarn@1\.22\.4 in the project\.\.\.\n\n/), + }); + + // Should accept setting to a compatible version: + await expect(runCli(cwd, [`use`, `yarn@2.4.3`])).resolves.toMatchObject({ + exitCode: 0, + stderr: ``, + stdout: expect.stringMatching(/^Installing yarn@2\.4\.3 in the project\.\.\.\n\n/), + }); + + await expect(xfs.readJsonPromise(ppath.join(cwd, `package.json`))).resolves.toMatchObject({ + devEngines, + packageManager: `yarn@2.4.3+sha512.8dd9fedc5451829619e526c56f42609ad88ae4776d9d3f9456d578ac085115c0c2f0fb02bb7d57fd2e1b6e1ac96efba35e80a20a056668f61c96934f67694fd0`, + }); + + await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({ + exitCode: 0, + stdout: `2.4.3\n`, + stderr: ``, + }); }); }); }); @@ -100,7 +177,7 @@ describe(`UseCommand`, () => { await expect(runCli(cwd, [`use`, `yarn@1.22.4`])).resolves.toMatchObject({ exitCode: 0, stderr: ``, - stdout: expect.stringMatching(/^Installing yarn@1\.22\.4 in the project\.\.\.\n\nyarn install v1\.22\.4\ninfo No lockfile found\.\n(.*\n)+Done in \d+\.\d+s\.\n$/), + stdout: expect.stringMatching(/^Installing yarn@1\.22\.4 in the project\.\.\.\n\n/), }); await expect(xfs.readJsonPromise(ppath.join(cwd, `package.json`))).resolves.toMatchObject({ diff --git a/tests/main.test.ts b/tests/main.test.ts index 9e878d8de..ed0771a52 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -22,16 +22,33 @@ beforeEach(async () => { }; }); -it(`should refuse to download a package manager if the hash doesn't match`, async () => { - await xfs.mktempPromise(async cwd => { - await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as Filename), { - packageManager: `yarn@1.22.4+sha1.deadbeef`, +describe(`should refuse to download a package manager if the hash doesn't match`, () => { + it(`the one defined in "devEngines.packageManager" field`, async () => { + await xfs.mktempPromise(async cwd => { + await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as Filename), { + devEngines: { + packageManager: {name: `yarn`, version: `1.22.4+sha1.deadbeef`}, + }, + }); + + await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({ + exitCode: 1, + stderr: expect.stringContaining(`Mismatch hashes`), + stdout: ``, + }); }); + }); + it(`the one defined in "packageManager" field`, async () => { + await xfs.mktempPromise(async cwd => { + await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as Filename), { + packageManager: `yarn@1.22.4+sha1.deadbeef`, + }); - await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({ - exitCode: 1, - stderr: expect.stringContaining(`Mismatch hashes`), - stdout: ``, + await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({ + exitCode: 1, + stderr: expect.stringContaining(`Mismatch hashes`), + stdout: ``, + }); }); }); }); @@ -156,6 +173,20 @@ for (const [name, version, expectedVersion = version.split(`+`, 1)[0]] of tested stderr: ``, stdout: `${expectedVersion}\n`, }); + + await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as Filename), { + devEngines: {packageManager: {name, version}}, + }); + + await expect(runCli(cwd, [name, `--version`])).resolves.toMatchObject(URL.canParse(version) ? { + exitCode: 1, + stderr: expect.stringMatching(/^The value of devEngines\.packageManager\.version ".+" is not a valid semver range\n$/), + stdout: ``, + } : { + exitCode: 0, + stderr: ``, + stdout: `${expectedVersion}\n`, + }); }); }); } @@ -237,6 +268,298 @@ 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), { + devEngines: { + packageManager: { + name: `yarn`, + version: `yarn@1.x`, + }, + }, + }); + + await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({ + exitCode: 1, + stderr: `The value of devEngines.packageManager.version "yarn@1.x" is not a valid semver range\n`, + stdout: ``, + }); + }); + }); + it(`warn on array values`, async () => { + await xfs.mktempPromise(async cwd => { + await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as PortablePath), { + packageManager: `yarn@1.22.4+sha1.01c1197ca5b27f21edc8bc472cd4c8ce0e5a470e`, + devEngines: { + packageManager: [{ + name: `pnpm`, + version: `10.x`, + }, + ]}, + }); + + await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({ + exitCode: 0, + stderr: `! Corepack does not currently support array values for devEngines.packageManager\n`, + stdout: `1.22.4\n`, + }); + }); + }); + it(`warn on string values`, async () => { + await xfs.mktempPromise(async cwd => { + await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as PortablePath), { + packageManager: `yarn@1.22.4+sha1.01c1197ca5b27f21edc8bc472cd4c8ce0e5a470e`, + devEngines: { + packageManager: `pnpm@10.x`, + }, + }); + + await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({ + exitCode: 0, + stderr: `! Corepack only supports objects as valid value for devEngines.packageManager. The current value ("pnpm@10.x") will be ignored.\n`, + stdout: `1.22.4\n`, + }); + }); + }); + it(`warn on number values`, async () => { + await xfs.mktempPromise(async cwd => { + await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as PortablePath), { + packageManager: `yarn@1.22.4+sha1.01c1197ca5b27f21edc8bc472cd4c8ce0e5a470e`, + devEngines: { + packageManager: 10, + }, + }); + + await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({ + exitCode: 0, + stderr: `! Corepack only supports objects as valid value for devEngines.packageManager. The current value (10) will be ignored.\n`, + stdout: `1.22.4\n`, + }); + }); + }); +}); + +it(`should use hash from "packageManager" even when "devEngines" defines a different one`, async () => { + await xfs.mktempPromise(async cwd => { + await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as PortablePath), { + packageManager: `yarn@3.0.0-rc.2+sha1.11111`, + devEngines: { + packageManager: { + name: `yarn`, + version: `3.0.0-rc.2+sha1.22222`, + }, + }, + }); + + await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({ + exitCode: 1, + stderr: expect.stringContaining(`Mismatch hashes. Expected 11111, got`), + stdout: ``, + }); + }); +}); + +describe(`should accept range in devEngines only if a specific version is provided`, () => { + it(`either in package.json#packageManager field`, async () => { + await xfs.mktempPromise(async cwd => { + await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as PortablePath), { + devEngines: { + packageManager: { + name: `pnpm`, + version: `6.x`, + }, + }, + }); + 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: ``, + }); + + await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as PortablePath), { + devEngines: { + packageManager: { + name: `pnpm`, + version: `6.x`, + }, + }, + packageManager: `pnpm@6.6.2+sha224.eb5c0acad3b0f40ecdaa2db9aa5a73134ad256e17e22d1419a2ab073`, + }); + await expect(runCli(cwd, [`pnpm`, `--version`])).resolves.toMatchObject({ + 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`, + }, + }, + packageManager: `pnpm@6.6.2+sha224.eb5c0acad3b0f40ecdaa2db9aa5a73134ad256e17e22d1419a2ab073`, + }); + await expect(runCli(cwd, [`pnpm`, `--version`])).resolves.toMatchObject({ + exitCode: 0, + stderr: ``, + stdout: `6.6.2\n`, + }); + }); + }); +}); + +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 => { + await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as PortablePath), { + devEngines: { + packageManager: { + name: `yarn`, + onFail: `ignore`, + }, + }, + packageManager: `pnpm@6.6.2+sha1.7b4d6b176c1b93b5670ed94c24babb7d80c13854`, + }); + await expect(runCli(cwd, [`pnpm`, `--version`])).resolves.toMatchObject({ + exitCode: 0, + stderr: ``, + stdout: `6.6.2\n`, + }); + }); + }); + it(`should warn if devEngines.packageManager.onFail is set to "warn"`, async () => { + await xfs.mktempPromise(async cwd => { + await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as PortablePath), { + devEngines: { + packageManager: { + name: `yarn`, + onFail: `warn`, + }, + }, + packageManager: `pnpm@6.6.2+sha1.7b4d6b176c1b93b5670ed94c24babb7d80c13854`, + }); + await expect(runCli(cwd, [`pnpm`, `--version`])).resolves.toMatchObject({ + exitCode: 0, + stderr: `! Corepack validation warning: "packageManager" field is set to "pnpm@6.6.2+sha1.7b4d6b176c1b93b5670ed94c24babb7d80c13854" which does not match the "devEngines.packageManager" field set to "yarn"\n`, + stdout: `6.6.2\n`, + }); + }); + }); + it(`should throw if devEngines.packageManager.onFail is set to "error"`, async () => { + await xfs.mktempPromise(async cwd => { + await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as PortablePath), { + devEngines: { + packageManager: { + name: `yarn`, + onFail: `error`, + }, + }, + packageManager: `pnpm@6.6.2+sha1.7b4d6b176c1b93b5670ed94c24babb7d80c13854`, + }); + await expect(runCli(cwd, [`pnpm`, `--version`])).resolves.toMatchObject({ + exitCode: 1, + stderr: `"packageManager" field is set to "pnpm@6.6.2+sha1.7b4d6b176c1b93b5670ed94c24babb7d80c13854" which does not match the "devEngines.packageManager" field set to "yarn"\n`, + stdout: ``, + }); + }); + }); + it(`should throw by default`, async () => { + await xfs.mktempPromise(async cwd => { + await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as PortablePath), { + devEngines: { + packageManager: { + name: `yarn`, + }, + }, + packageManager: `pnpm@6.6.2+sha1.7b4d6b176c1b93b5670ed94c24babb7d80c13854`, + }); + await expect(runCli(cwd, [`pnpm`, `--version`])).resolves.toMatchObject({ + exitCode: 1, + stderr: `"packageManager" field is set to "pnpm@6.6.2+sha1.7b4d6b176c1b93b5670ed94c24babb7d80c13854" which does not match the "devEngines.packageManager" field set to "yarn"\n`, + stdout: ``, + }); + }); + }); +}); + +describe(`should reject if range in devEngines does not match version provided`, () => { + it(`unless onFail is set to "ignore"`, async () => { + await xfs.mktempPromise(async cwd => { + await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as PortablePath), { + devEngines: { + packageManager: { + name: `pnpm`, + version: `10.x`, + onFail: `ignore`, + }, + }, + packageManager: `pnpm@6.6.2+sha1.7b4d6b176c1b93b5670ed94c24babb7d80c13854`, + }); + await expect(runCli(cwd, [`pnpm`, `--version`])).resolves.toMatchObject({ + exitCode: 0, + stderr: ``, + stdout: `6.6.2\n`, + }); + }); + }); + it(`unless onFail is set to "warn"`, async () => { + await xfs.mktempPromise(async cwd => { + await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as PortablePath), { + devEngines: { + packageManager: { + name: `pnpm`, + version: `10.x`, + onFail: `warn`, + }, + }, + packageManager: `pnpm@6.6.2+sha1.7b4d6b176c1b93b5670ed94c24babb7d80c13854`, + }); + await expect(runCli(cwd, [`pnpm`, `--version`])).resolves.toMatchObject({ + exitCode: 0, + stderr: `! Corepack validation warning: "packageManager" field is set to "pnpm@6.6.2+sha1.7b4d6b176c1b93b5670ed94c24babb7d80c13854" which does not match the value defined in "devEngines.packageManager" for "pnpm" of "10.x"\n`, + stdout: `6.6.2\n`, + }); + }); + }); + it(`in package.json#packageManager field`, async () => { + await xfs.mktempPromise(async cwd => { + await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as PortablePath), { + devEngines: { + packageManager: { + name: `pnpm`, + version: `10.x`, + }, + }, + packageManager: `pnpm@6.6.2+sha1.7b4d6b176c1b93b5670ed94c24babb7d80c13854`, + }); + await expect(runCli(cwd, [`pnpm`, `--version`])).resolves.toMatchObject({ + exitCode: 1, + stderr: `"packageManager" field is set to "pnpm@6.6.2+sha1.7b4d6b176c1b93b5670ed94c24babb7d80c13854" which does not match the value defined in "devEngines.packageManager" for "pnpm" of "10.x"\n`, + stdout: ``, + }); + }); + }); +}); + it(`should use the closest matching packageManager field`, async () => { await xfs.mktempPromise(async cwd => { await xfs.mkdirPromise(ppath.join(cwd, `foo` as PortablePath), {recursive: true});