diff --git a/sources/Engine.ts b/sources/Engine.ts index d93501596..0cde06934 100644 --- a/sources/Engine.ts +++ b/sources/Engine.ts @@ -229,20 +229,20 @@ export class Engine { } /** - * Locates the active project's package manager specification. + * Locates the package manager specification from nearest package.json having one. * * If the specification exists but doesn't match the active package manager, * an error is thrown to prevent users from using the wrong package manager, * which would lead to inconsistent project layouts. * - * If the project doesn't include a specification file, we just assume that - * whatever the user uses is exactly what they want to use. Since the version - * isn't specified, we fallback on known good versions. + * If none of the package.json files have specification, + * or a package.json had specification which was invalid but allowed failure, + * we just assume that whatever the user uses is exactly what they want to use. + * Since the version isn't specified, we fallback on known good versions. * - * Finally, if the project doesn't exist at all, we ask the user whether they - * want to create one in the current project. If they do, we initialize a new - * project using the default package managers, and configure it so that we - * don't need to ask again in the future. + * Finally, if the package.json doesn't exist at all, + * we just assume that whatever the user uses is exactly what they want to use. + * Since the version isn't specified, we fallback on known good versions. */ async findProjectSpec(initialCwd: string, locator: Locator | LazyLocator, {transparent = false, binaryVersion}: {transparent?: boolean, binaryVersion?: string | null} = {}): Promise { // A locator is a valid descriptor (but not the other way around) @@ -258,59 +258,66 @@ export class Engine { if (process.env.COREPACK_ENABLE_STRICT === `0`) transparent = true; - while (true) { - const result = await specUtils.loadSpec(initialCwd); + const result = await specUtils.loadSpec(initialCwd); - switch (result.type) { - case `NoProject`: { - if (typeof locator.reference === `function`) - fallbackDescriptor.range = await locator.reference(); + switch (result.type) { + case `NoProject`: { + if (typeof locator.reference === `function`) + fallbackDescriptor.range = await locator.reference(); - debugUtils.log(`Falling back to ${fallbackDescriptor.name}@${fallbackDescriptor.range} as no project manifest were found`); - return fallbackDescriptor; - } + debugUtils.log(`Falling back to ${fallbackDescriptor.name}@${fallbackDescriptor.range} as no project manifest were found`); + return fallbackDescriptor; + } - case `NoSpec`: { - if (typeof locator.reference === `function`) - fallbackDescriptor.range = await locator.reference(); + case `NoSpec`: { + 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) - throw new UsageError(`Failed to successfully resolve '${fallbackDescriptor.range}' to a valid ${fallbackDescriptor.name} release`); + if (process.env.COREPACK_ENABLE_AUTO_PIN === `1`) { + const resolved = await this.resolveDescriptor(fallbackDescriptor, {allowTags: true}); + if (resolved === null) + throw new UsageError(`Failed to successfully resolve '${fallbackDescriptor.range}' to a valid ${fallbackDescriptor.name} release`); - const installSpec = await this.ensurePackageManager(resolved); + const installSpec = await this.ensurePackageManager(resolved); - console.error(`! The local project doesn't define a 'packageManager' field. Corepack will now add one referencing ${installSpec.locator.name}@${installSpec.locator.reference}.`); - console.error(`! For more details about this field, consult the documentation at https://nodejs.org/api/packages.html#packagemanager`); - console.error(); + console.error(`! The local project doesn't define a 'packageManager' field. Corepack will now add one referencing ${installSpec.locator.name}@${installSpec.locator.reference}.`); + console.error(`! For more details about this field, consult the documentation at https://nodejs.org/api/packages.html#packagemanager`); + console.error(); - await specUtils.setLocalPackageManager(path.dirname(result.target), installSpec); - } - - debugUtils.log(`Falling back to ${fallbackDescriptor.name}@${fallbackDescriptor.range} in the absence of "packageManager" field in ${result.target}`); - return fallbackDescriptor; + await specUtils.setLocalPackageManager(path.dirname(result.target), installSpec); } - case `Found`: { - const spec = result.getSpec({enforceExactVersion: !binaryVersion}); - if (spec.name !== locator.name) { - if (transparent) { - if (typeof locator.reference === `function`) - fallbackDescriptor.range = await locator.reference(); - - 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`); - } + debugUtils.log(`Falling back to ${fallbackDescriptor.name}@${fallbackDescriptor.range} in the absence of "packageManager" field in ${result.target}`); + return fallbackDescriptor; + } + + case `Found`: { + const spec = result.getSpec({enforceExactVersion: !binaryVersion}); + if (spec.name !== locator.name) { + const devEnginesSayMismatchIsNotError = result.sourceField === `devEngines.packageManager` + && result.devEnginesRange !== undefined + && result.devEnginesRange.onFail !== `error`; + + if (transparent || devEnginesSayMismatchIsNotError) { + if (typeof locator.reference === `function`) + fallbackDescriptor.range = await locator.reference(); + + if (devEnginesSayMismatchIsNotError && result.devEnginesRange!.onFail === `warn`) + console.warn(`! Corepack validation warning: Using ${fallbackDescriptor.name} as requested (@${fallbackDescriptor.range}) because ${result.target} defines "devEngines.packageManager" with mismatched ${spec.name}@${spec.range} and onFail: warn.`); + + debugUtils.log(`Falling back to ${fallbackDescriptor.name}@${fallbackDescriptor.range} in a ${spec.name}@${spec.range} project`); + return fallbackDescriptor; } else { - debugUtils.log(`Using ${spec.name}@${spec.range} as defined in project manifest ${result.target}`); - return spec; + throw new UsageError(`This project is configured to use ${spec.name} because ${result.target} has a "${result.sourceField}" field`); } + } else { + debugUtils.log(`Using ${spec.name}@${spec.range} as defined in project manifest ${result.target}`); + return spec; } } } + + throw new Error(`Assertion failed: Unsupported loadSpec result type`); } async executePackageManagerRequest({packageManager, binaryName, binaryVersion}: PackageManagerRequest, {cwd, args}: {cwd: string, args: Array}): Promise { diff --git a/sources/commands/Base.ts b/sources/commands/Base.ts index 950195427..e943449b0 100644 --- a/sources/commands/Base.ts +++ b/sources/commands/Base.ts @@ -19,7 +19,7 @@ export abstract class BaseCommand extends Command { 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.devEnginesRange ?? lookup.getSpec()]; } } } diff --git a/sources/specUtils.ts b/sources/specUtils.ts index 8b73aacd2..ec0427af1 100644 --- a/sources/specUtils.ts +++ b/sources/specUtils.ts @@ -15,19 +15,32 @@ import type {LocalEnvFile} from './types'; const nodeModulesRegExp = /[\\/]node_modules[\\/](@[^\\/]*[\\/])?([^@\\/][^\\/]*)$/; -export function parseSpec(raw: unknown, source: string, {enforceExactVersion = true} = {}): Descriptor { +export function parseSpec(arg: string | ParsedPackageManager, source: string, {enforceExactVersion = true} = {}): Descriptor { + let raw: string; + let sourceField: PackageManagerSourceField | undefined; + + if (typeof arg === `object` && arg.sourceField !== undefined) { + raw = arg.rawPmSpec; + sourceField = arg.sourceField; + } else { + raw = arg as string; + sourceField = undefined; + } + + const maybeSourceFieldOf = sourceField ? `"${sourceField}" of ` : ``; + if (typeof raw !== `string`) - throw new UsageError(`Invalid package manager specification in ${source}; expected a string`); + throw new UsageError(`Invalid package manager specification in ${maybeSourceFieldOf}${source}; expected a string`); const atIndex = raw.indexOf(`@`); if (atIndex === -1 || atIndex === raw.length - 1) { if (enforceExactVersion) - throw new UsageError(`No version specified for ${raw} in "packageManager" of ${source}`); + throw new UsageError(`No version specified for ${raw} in ${maybeSourceFieldOf}${source}`); const name = atIndex === -1 ? raw : raw.slice(0, -1); if (!isSupportedPackageManager(name)) - throw new UsageError(`Unsupported package manager specification (${name})`); + throw new UsageError(`Unsupported package manager specification (${name}) in ${maybeSourceFieldOf}${source}`); return { name, range: `*`, @@ -40,13 +53,13 @@ export function parseSpec(raw: unknown, source: string, {enforceExactVersion = t const isURL = URL.canParse(range); if (!isURL) { if (enforceExactVersion && !semverValid(range)) - throw new UsageError(`Invalid package manager specification in ${source} (${raw}); expected a semver version${enforceExactVersion ? `` : `, range, or tag`}`); + throw new UsageError(`Invalid package manager specification in ${maybeSourceFieldOf}${source} (${raw}); expected a semver version${enforceExactVersion ? `` : `, range, or tag`}`); if (!isSupportedPackageManager(name)) { - throw new UsageError(`Unsupported package manager specification (${raw})`); + throw new UsageError(`Unsupported package manager specification (${raw}) in ${maybeSourceFieldOf}${source}`); } } else if (isSupportedPackageManager(name) && process.env.COREPACK_ENABLE_UNSAFE_CUSTOM_URLS !== `1`) { - throw new UsageError(`Illegal use of URL for known package manager. Instead, select a specific version, or set COREPACK_ENABLE_UNSAFE_CUSTOM_URLS=1 in your environment (${raw})`); + throw new UsageError(`Illegal use of URL for known package manager. Instead, select a specific version, or set COREPACK_ENABLE_UNSAFE_CUSTOM_URLS=1 in your environment (${raw}) in ${maybeSourceFieldOf}${source}`); } @@ -61,69 +74,106 @@ type CorepackPackageJSON = { devEngines?: {packageManager?: DevEngineDependency}; }; +type PackageManagerSourceField = `packageManager` | `devEngines.packageManager`; + +type ParsedPackageManager = { + sourceField: PackageManagerSourceField; + rawPmSpec: string; + devEnginesValues?: DevEngineDependency; +} & ({ + sourceField: `packageManager`; +} | { + sourceField: `devEngines.packageManager`; + devEnginesValues: DevEngineDependency; +} +); + interface DevEngineDependency { name: string; version: string; - onFail?: `ignore` | `warn` | `error`; + onFail?: `ignore` | `warn` | `error` | `download`; } -function warnOrThrow(errorMessage: string, onFail?: DevEngineDependency[`onFail`]) { + +function normalizeOnFail(onFail?: DevEngineDependency[`onFail`]) { switch (onFail) { + case undefined: + case `download`: + return `error`; + default: + return onFail; + } +} + +function warnOrThrow(errorMessage: string, onFail?: DevEngineDependency[`onFail`]) { + switch (normalizeOnFail(onFail)) { case `ignore`: break; case `error`: - case undefined: throw new UsageError(errorMessage); default: console.warn(`! Corepack validation warning: ${errorMessage}`); } } -function parsePackageJSON(packageJSONContent: CorepackPackageJSON) { +function parsePackageJSON(packageJSONContent: CorepackPackageJSON): ParsedPackageManager | undefined { 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 resultFromPackageManager = pm !== undefined + ? {sourceField: `packageManager`, rawPmSpec: pm} satisfies ParsedPackageManager + : undefined; - 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; - } + if (pm === `` || pm === null) + return resultFromPackageManager; // short-circuit with defined, but invalid "packageManager" values - debugUtils.log(`devEngines.packageManager defines that ${name}@${version} is the local package manager`); + if (packageJSONContent.devEngines?.packageManager === undefined) + return resultFromPackageManager; - 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); + const {packageManager: devEnginesPackageManager} = packageJSONContent.devEngines; + + if (typeof devEnginesPackageManager !== `object`) { + console.warn(`! Corepack only supports objects as valid value for devEngines.packageManager. The current value (${JSON.stringify(devEnginesPackageManager)}) will be ignored.`); + return resultFromPackageManager; + } + if (Array.isArray(devEnginesPackageManager)) { + console.warn(`! Corepack does not currently support array values for devEngines.packageManager`); + return resultFromPackageManager; + } - 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); + const {name, version, onFail} = devEnginesPackageManager; + if (typeof name !== `string` || name.includes(`@`)) { + warnOrThrow(`The value of devEngines.packageManager.name ${JSON.stringify(name)} is not a supported string value`, onFail); + return resultFromPackageManager; + } + 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 resultFromPackageManager; + } - return pm; - } + debugUtils.log(`devEngines.packageManager defines that ${name}@${version} is the local package manager`); + if (pm !== undefined) { + 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); - return `${name}@${version ?? `*`}`; + else if (version != null && !semverSatisfies(pm.slice(devEnginesPackageManager.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 { + ...resultFromPackageManager!, + devEnginesValues: devEnginesPackageManager, + }; } - return pm; + return { + sourceField: `devEngines.packageManager`, + rawPmSpec: `${name}@${version ?? `*`}`, + devEnginesValues: devEnginesPackageManager, + }; } export async function setLocalPackageManager(cwd: string, info: PreparedPackageManagerInfo) { const lookup = await loadSpec(cwd); - const range = `range` in lookup && lookup.range; + const range = `devEnginesRange` in lookup && lookup.devEnginesRange; 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); @@ -151,14 +201,22 @@ interface FoundSpecResult { type: `Found`; target: string; getSpec: (options?: {enforceExactVersion?: boolean}) => Descriptor; - range?: Descriptor & {onFail?: DevEngineDependency[`onFail`]}; envFilePath?: string; + sourceField: PackageManagerSourceField; // source of the spec + devEnginesRange?: Descriptor & {onFail: `ignore` | `warn` | `error`}; } export type LoadSpecResult = | {type: `NoProject`, target: string} | {type: `NoSpec`, target: string} | FoundSpecResult; +// We walk up the directory tree to support workspace-style projects whose +// package manager may be declared in a higher-level manifest. The search +// stops at the nearest package.json that defines either package manager +// field, even if that value is invalid. The tradeoff is that if the nearest +// package.json does not define any package manager field, then an upper-level +// package.json can dictate which package manager gets used even when that +// higher-level manifest is unrelated to the current project. export async function loadSpec(initialCwd: string): Promise { let nextCwd = initialCwd; let currCwd = ``; @@ -170,7 +228,11 @@ export async function loadSpec(initialCwd: string): Promise { localEnv: LocalEnvFile; } | null = null; - while (nextCwd !== currCwd && (!selection || !selection.data.packageManager)) { + const selectionHasPmSpecified = (selection: {data: CorepackPackageJSON} | null) => { + return selection !== null && (selection.data.packageManager !== undefined || selection.data.devEngines?.packageManager !== undefined); + }; + + while (nextCwd !== currCwd && !selectionHasPmSpecified(selection)) { currCwd = nextCwd; nextCwd = path.dirname(currCwd); @@ -193,7 +255,7 @@ export async function loadSpec(initialCwd: string): Promise { } catch {} if (typeof data !== `object` || data === null) - throw new UsageError(`Invalid package.json in ${path.relative(initialCwd, manifestPath)}`); + throw new UsageError(`Could not parse ${path.relative(initialCwd, manifestPath)} as JSON.`); let localEnv: LocalEnvFile; const envFilePath = path.resolve(currCwd, process.env.COREPACK_ENV_FILE ?? `.corepack.env`); @@ -233,22 +295,23 @@ export async function loadSpec(initialCwd: string): Promise { process.env = selection.localEnv; } - const rawPmSpec = parsePackageJSON(selection.data); - if (typeof rawPmSpec === `undefined`) + const parsedPackageManager = parsePackageJSON(selection.data); + if (typeof parsedPackageManager === `undefined`) return {type: `NoSpec`, target: selection.manifestPath}; - debugUtils.log(`${selection.manifestPath} defines ${rawPmSpec} as local package manager`); + debugUtils.log(`${selection.manifestPath} defines ${parsedPackageManager.rawPmSpec} as local package manager via ${parsedPackageManager.sourceField}`); return { type: `Found`, target: selection.manifestPath, + sourceField: parsedPackageManager.sourceField, envFilePath, - range: selection.data.devEngines?.packageManager?.version && { - name: selection.data.devEngines.packageManager.name, - range: selection.data.devEngines.packageManager.version, - onFail: selection.data.devEngines.packageManager.onFail, + devEnginesRange: parsedPackageManager.devEnginesValues && { + name: parsedPackageManager.devEnginesValues.name, + range: parsedPackageManager.devEnginesValues.version, + onFail: normalizeOnFail(parsedPackageManager.devEnginesValues.onFail), }, // 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(parsedPackageManager, path.relative(initialCwd, selection.manifestPath), {enforceExactVersion}), }; } diff --git a/tests/main.test.ts b/tests/main.test.ts index c629f002f..12f05f1bd 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -1,13 +1,14 @@ -import {Filename, ppath, xfs, npath, PortablePath} from '@yarnpkg/fslib'; -import os from 'node:os'; -import process from 'node:process'; -import {afterEach, beforeEach, describe, expect, it} from 'vitest'; +import {Filename, ppath, xfs, npath, PortablePath} from '@yarnpkg/fslib'; +import os from 'node:os'; +import process from 'node:process'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; -import config from '../config.json'; -import * as folderUtils from '../sources/folderUtils'; -import {SupportedPackageManagerSet} from '../sources/types'; +import config from '../config.json'; +import {Engine} from '../sources/Engine'; +import * as folderUtils from '../sources/folderUtils'; +import {SupportedPackageManagerSet} from '../sources/types'; -import {runCli} from './_runCli'; +import {runCli} from './_runCli'; beforeEach(async () => { @@ -582,6 +583,320 @@ it(`should use the closest matching packageManager field`, async () => { }); }); +it(`should use the closest matching devEngines.packageManager field over a parent packageManager field`, async () => { + await xfs.mktempPromise(async cwd => { + const projectCwd = ppath.join(cwd, `foo` as PortablePath); + + await xfs.mkdirPromise(projectCwd, {recursive: true}); + + await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as PortablePath), { + packageManager: `yarn@1.22.4`, + }); + + await xfs.writeJsonPromise(ppath.join(projectCwd, `package.json` as PortablePath), { + devEngines: { + packageManager: { + name: `npm`, + version: `6.14.2`, + }, + }, + }); + + await expect(runCli(projectCwd, [`npm`, `--version`])).resolves.toMatchObject({ + exitCode: 0, + stderr: ``, + stdout: `6.14.2\n`, + }); + }); +}); + +it(`should use the closest devEngines.packageManager field when parent has no spec in Engine.findProjectSpec`, async () => { + await xfs.mktempPromise(async cwd => { + const projectCwd = ppath.join(cwd, `foo` as PortablePath); + + await xfs.mkdirPromise(projectCwd, {recursive: true}); + + await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as Filename), { + // empty package.json file + }); + await xfs.writeJsonPromise(ppath.join(projectCwd, `package.json` as Filename), { + devEngines: { + packageManager: { + name: `npm`, + version: `6.14.2`, + }, + }, + }); + + const engine = new Engine(); + await expect(engine.findProjectSpec(npath.fromPortablePath(projectCwd), { + name: `npm`, + reference: `9.9.9`, + })).resolves.toMatchObject({ + name: `npm`, + range: `6.14.2`, + }); + }); +}); + +it(`should use the closest devEngines.packageManager field over a parent packageManager in Engine.findProjectSpec`, async () => { + await xfs.mktempPromise(async cwd => { + const projectCwd = ppath.join(cwd, `foo` as PortablePath); + + await xfs.mkdirPromise(projectCwd, {recursive: true}); + + await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as Filename), { + packageManager: `yarn@1.22.4`, + }); + await xfs.writeJsonPromise(ppath.join(projectCwd, `package.json` as Filename), { + devEngines: { + packageManager: { + name: `npm`, + version: `6.14.2`, + }, + }, + }); + + const engine = new Engine(); + await expect(engine.findProjectSpec(npath.fromPortablePath(projectCwd), { + name: `npm`, + reference: `9.9.9`, + })).resolves.toMatchObject({ + name: `npm`, + range: `6.14.2`, + }); + }); +}); + +it(`should ignore a parent packageManager when a closer devEngines.packageManager is invalid with onFail set to "warn" in Engine.findProjectSpec`, async () => { + await xfs.mktempPromise(async cwd => { + const projectCwd = ppath.join(cwd, `foo` as PortablePath); + + await xfs.mkdirPromise(projectCwd, {recursive: true}); + + await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as Filename), { + packageManager: `yarn@1.22.4`, + }); + await xfs.writeJsonPromise(ppath.join(projectCwd, `package.json` as Filename), { + devEngines: { + packageManager: { + name: `npm`, + version: `bad`, + onFail: `warn`, + }, + }, + }); + + const engine = new Engine(); + await expect(engine.findProjectSpec(npath.fromPortablePath(projectCwd), { + name: `npm`, + reference: `9.9.9`, + })).resolves.toMatchObject({ + name: `npm`, + range: `9.9.9`, + }); + }); +}); + +it(`should mention devEngines.packageManager when Engine.findProjectSpec rejects a mismatched package manager`, async () => { + await xfs.mktempPromise(async cwd => { + const projectCwd = ppath.join(cwd, `foo` as PortablePath); + + await xfs.mkdirPromise(projectCwd, {recursive: true}); + + await xfs.writeJsonPromise(ppath.join(projectCwd, `package.json` as Filename), { + devEngines: { + packageManager: { + name: `npm`, + version: `6.14.2`, + }, + }, + }); + + const engine = new Engine(); + await expect(engine.findProjectSpec(npath.fromPortablePath(projectCwd), { + name: `yarn`, + reference: `1.22.4`, + })).rejects.toThrow(`This project is configured to use npm because ${npath.fromPortablePath(ppath.join(projectCwd, `package.json` as Filename))} has a "devEngines.packageManager" field`); + }); +}); + +it(`should fall back to the requested package manager when only devEngines.packageManager mismatches with onFail set to "ignore" in Engine.findProjectSpec`, async () => { + await xfs.mktempPromise(async cwd => { + const projectCwd = ppath.join(cwd, `foo` as PortablePath); + + await xfs.mkdirPromise(projectCwd, {recursive: true}); + + await xfs.writeJsonPromise(ppath.join(projectCwd, `package.json` as Filename), { + devEngines: { + packageManager: { + name: `npm`, + version: `6.14.2`, + onFail: `ignore`, + }, + }, + }); + + const engine = new Engine(); + await expect(engine.findProjectSpec(npath.fromPortablePath(projectCwd), { + name: `yarn`, + reference: `1.22.4`, + })).resolves.toMatchObject({ + name: `yarn`, + range: `1.22.4`, + }); + }); +}); + +it(`should fall back to the requested package manager and warn when only devEngines.packageManager mismatches with onFail set to "warn" in Engine.findProjectSpec`, async () => { + await xfs.mktempPromise(async cwd => { + const projectCwd = ppath.join(cwd, `foo` as PortablePath); + + await xfs.mkdirPromise(projectCwd, {recursive: true}); + + await xfs.writeJsonPromise(ppath.join(projectCwd, `package.json` as Filename), { + devEngines: { + packageManager: { + name: `npm`, + version: `6.14.2`, + onFail: `warn`, + }, + }, + }); + + const warn = vi.spyOn(console, `warn`).mockImplementation(() => {}); + + try { + const engine = new Engine(); + await expect(engine.findProjectSpec(npath.fromPortablePath(projectCwd), { + name: `yarn`, + reference: `1.22.4`, + })).resolves.toMatchObject({ + name: `yarn`, + range: `1.22.4`, + }); + + expect(warn).toHaveBeenCalledWith(`! Corepack validation warning: Using yarn as requested (@1.22.4) because ${npath.fromPortablePath(ppath.join(projectCwd, `package.json` as Filename))} defines "devEngines.packageManager" with mismatched npm@6.14.2 and onFail: warn.`); + } finally { + warn.mockRestore(); + } + }); +}); + +it(`should reject when only devEngines.packageManager mismatches with onFail set to "download" in Engine.findProjectSpec`, async () => { + await xfs.mktempPromise(async cwd => { + const projectCwd = ppath.join(cwd, `foo` as PortablePath); + + await xfs.mkdirPromise(projectCwd, {recursive: true}); + + await xfs.writeJsonPromise(ppath.join(projectCwd, `package.json` as Filename), { + devEngines: { + packageManager: { + name: `npm`, + version: `6.14.2`, + onFail: `download`, + }, + }, + }); + + const engine = new Engine(); + await expect(engine.findProjectSpec(npath.fromPortablePath(projectCwd), { + name: `yarn`, + reference: `1.22.4`, + })).rejects.toThrow(`This project is configured to use npm because ${npath.fromPortablePath(ppath.join(projectCwd, `package.json` as Filename))} has a "devEngines.packageManager" field`); + }); +}); + +it(`should treat packageManager empty string as an invalid selected value in Engine.findProjectSpec`, async () => { + await xfs.mktempPromise(async cwd => { + const projectCwd = ppath.join(cwd, `foo` as PortablePath); + + await xfs.mkdirPromise(projectCwd, {recursive: true}); + + await xfs.writeJsonPromise(ppath.join(projectCwd, `package.json` as Filename), { + packageManager: ``, + devEngines: { + packageManager: { + name: `npm`, + version: `6.14.2`, + }, + }, + }); + + const engine = new Engine(); + await expect(engine.findProjectSpec(npath.fromPortablePath(projectCwd), { + name: `npm`, + reference: `9.9.9`, + })).rejects.toThrow(`No version specified for in "packageManager" of package.json`); + }); +}); + +it(`should stop at a child packageManager empty string instead of falling back to a parent manifest in Engine.findProjectSpec`, async () => { + await xfs.mktempPromise(async cwd => { + const projectCwd = ppath.join(cwd, `foo` as PortablePath); + + await xfs.mkdirPromise(projectCwd, {recursive: true}); + + await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as Filename), { + packageManager: `yarn@1.22.4`, + }); + await xfs.writeJsonPromise(ppath.join(projectCwd, `package.json` as Filename), { + packageManager: ``, + }); + + const engine = new Engine(); + await expect(engine.findProjectSpec(npath.fromPortablePath(projectCwd), { + name: `yarn`, + reference: `1.22.4`, + })).rejects.toThrow(`No version specified for in "packageManager" of package.json`); + }); +}); + +it(`should treat packageManager null as an invalid selected value in Engine.findProjectSpec`, async () => { + await xfs.mktempPromise(async cwd => { + const projectCwd = ppath.join(cwd, `foo` as PortablePath); + + await xfs.mkdirPromise(projectCwd, {recursive: true}); + + await xfs.writeJsonPromise(ppath.join(projectCwd, `package.json` as Filename), { + packageManager: null, + devEngines: { + packageManager: { + name: `npm`, + version: `6.14.2`, + }, + }, + }); + + const engine = new Engine(); + await expect(engine.findProjectSpec(npath.fromPortablePath(projectCwd), { + name: `npm`, + reference: `9.9.9`, + })).rejects.toThrow(`Invalid package manager specification in "packageManager" of package.json; expected a string`); + }); +}); + +it(`should stop at a child packageManager null instead of falling back to a parent manifest in Engine.findProjectSpec`, async () => { + await xfs.mktempPromise(async cwd => { + const projectCwd = ppath.join(cwd, `foo` as PortablePath); + + await xfs.mkdirPromise(projectCwd, {recursive: true}); + + await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as Filename), { + packageManager: `yarn@1.22.4`, + }); + await xfs.writeJsonPromise(ppath.join(projectCwd, `package.json` as Filename), { + packageManager: null, + }); + + const engine = new Engine(); + await expect(engine.findProjectSpec(npath.fromPortablePath(projectCwd), { + name: `yarn`, + reference: `1.22.4`, + })).rejects.toThrow(`Invalid package manager specification in "packageManager" of package.json; expected a string`); + }); +}); + it(`should expose its root to spawned processes`, async () => { await xfs.mktempPromise(async cwd => { await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as Filename), {