Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be the first sentence of this section.

Because it sounds like this devEngines.packageManager is ignored when there is a top level packageManager, right?

Copy link
Contributor Author

@aduh95 aduh95 Feb 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No it's never ignored. As said in the first paragraph of this section, if it is defined to something Corepack recognises, it will throw/warn/do nothing (depending of the value of onFail) if the user is trying to use an incompatible package manager (i.e. if the one defined in package.json#packageManager does not match the one defined in package.json#devEngines.packageManager for when the user is using a Corepack shim)

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
Expand Down Expand Up @@ -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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about top-level packageManager?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's what or fallback to the same major line is for

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}`).

Expand Down
4 changes: 2 additions & 2 deletions sources/commands/Base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ export abstract class BaseCommand extends Command<Context> {
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()];
}
}
}
Expand Down
86 changes: 83 additions & 3 deletions sources/specUtils.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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`);
Expand All @@ -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<LoadSpecResult> {
let nextCwd = initialCwd;
Expand Down Expand Up @@ -117,13 +190,20 @@ export async function loadSpec(initialCwd: string): Promise<LoadSpecResult> {
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)),
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

devEngines allow to specify version ranges - this now causes this to throw an exception if you pass a version like ^10.
IMHO here the enforceExactVersion option of parseSpec needs to be set to false if devEngines is used.

};
Expand Down
119 changes: 107 additions & 12 deletions tests/Up.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: `[email protected]`,
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: `[email protected]`,
});

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: `[email protected]+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: `[email protected]`,
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: `[email protected]+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: `[email protected]+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: `[email protected]`,
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: `[email protected]+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: `[email protected]+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: ``,
});
});
});
});
Expand Down
Loading