Skip to content

Commit b456268

Browse files
authored
feat: add limited support for devEngines (#643)
1 parent 53b1fe7 commit b456268

File tree

6 files changed

+643
-38
lines changed

6 files changed

+643
-38
lines changed

README.md

+30
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,35 @@ use in the archive).
113113
}
114114
```
115115

116+
#### `devEngines.packageManager`
117+
118+
When a `devEngines.packageManager` field is defined, and is an object containing
119+
a `"name"` field (can also optionally contain `version` and `onFail` fields),
120+
Corepack will use it to validate you're using a compatible package manager.
121+
122+
Depending on the value of `devEngines.packageManager.onFail`:
123+
124+
- if set to `ignore`, Corepack won't print any warning or error.
125+
- if unset or set to `error`, Corepack will throw an error in case of a mismatch.
126+
- if set to `warn` or some other value, Corepack will print a warning in case
127+
of mismatch.
128+
129+
If the top-level `packageManager` field is missing, Corepack will use the
130+
package manager defined in `devEngines.packageManager` – in which case you must
131+
provide a specific version in `devEngines.packageManager.version`, ideally with
132+
a hash, as explained in the previous section:
133+
134+
```json
135+
{
136+
"devEngines":{
137+
"packageManager": {
138+
"name": "yarn",
139+
"version": "3.2.3+sha224.953c8233f7a92884eee2de69a1b92d1f2ec1655e66d08071ba9a02fa"
140+
}
141+
}
142+
}
143+
```
144+
116145
## Known Good Releases
117146

118147
When running Corepack within projects that don't list a supported package
@@ -246,6 +275,7 @@ it.
246275

247276
Unlike `corepack use` this command doesn't take a package manager name nor a
248277
version range, as it will always select the latest available version from the
278+
range specified in `devEngines.packageManager.version`, or fallback to the
249279
same major line. Should you need to upgrade to a new major, use an explicit
250280
`corepack use {name}@latest` call (or simply `corepack use {name}`).
251281

sources/commands/Base.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,10 @@ export abstract class BaseCommand extends Command<Context> {
1616
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`);
1717

1818
case `NoSpec`:
19-
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`);
19+
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`);
2020

2121
default: {
22-
return [lookup.getSpec()];
22+
return [lookup.range ?? lookup.getSpec()];
2323
}
2424
}
2525
}

sources/specUtils.ts

+83-3
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import {UsageError} from 'clipanion';
22
import fs from 'fs';
33
import path from 'path';
4+
import semverSatisfies from 'semver/functions/satisfies';
45
import semverValid from 'semver/functions/valid';
6+
import semverValidRange from 'semver/ranges/valid';
57

68
import {PreparedPackageManagerInfo} from './Engine';
79
import * as debugUtils from './debugUtils';
@@ -52,16 +54,87 @@ export function parseSpec(raw: unknown, source: string, {enforceExactVersion = t
5254
};
5355
}
5456

57+
type CorepackPackageJSON = {
58+
packageManager?: string;
59+
devEngines?: { packageManager?: DevEngineDependency };
60+
};
61+
62+
interface DevEngineDependency {
63+
name: string;
64+
version: string;
65+
onFail?: 'ignore' | 'warn' | 'error';
66+
}
67+
function warnOrThrow(errorMessage: string, onFail?: DevEngineDependency['onFail']) {
68+
switch (onFail) {
69+
case `ignore`:
70+
break;
71+
case `error`:
72+
case undefined:
73+
throw new UsageError(errorMessage);
74+
default:
75+
console.warn(`! Corepack validation warning: ${errorMessage}`);
76+
}
77+
}
78+
function parsePackageJSON(packageJSONContent: CorepackPackageJSON) {
79+
const {packageManager: pm} = packageJSONContent;
80+
if (packageJSONContent.devEngines?.packageManager != null) {
81+
const {packageManager} = packageJSONContent.devEngines;
82+
83+
if (typeof packageManager !== `object`) {
84+
console.warn(`! Corepack only supports objects as valid value for devEngines.packageManager. The current value (${JSON.stringify(packageManager)}) will be ignored.`);
85+
return pm;
86+
}
87+
if (Array.isArray(packageManager)) {
88+
console.warn(`! Corepack does not currently support array values for devEngines.packageManager`);
89+
return pm;
90+
}
91+
92+
const {name, version, onFail} = packageManager;
93+
if (typeof name !== `string` || name.includes(`@`)) {
94+
warnOrThrow(`The value of devEngines.packageManager.name ${JSON.stringify(name)} is not a supported string value`, onFail);
95+
return pm;
96+
}
97+
if (version != null && (typeof version !== `string` || !semverValidRange(version))) {
98+
warnOrThrow(`The value of devEngines.packageManager.version ${JSON.stringify(version)} is not a valid semver range`, onFail);
99+
return pm;
100+
}
101+
102+
debugUtils.log(`devEngines.packageManager defines that ${name}@${version} is the local package manager`);
103+
104+
if (pm) {
105+
if (!pm.startsWith?.(`${name}@`))
106+
warnOrThrow(`"packageManager" field is set to ${JSON.stringify(pm)} which does not match the "devEngines.packageManager" field set to ${JSON.stringify(name)}`, onFail);
107+
108+
else if (version != null && !semverSatisfies(pm.slice(packageManager.name.length + 1), version))
109+
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);
110+
111+
return pm;
112+
}
113+
114+
115+
return `${name}@${version ?? `*`}`;
116+
}
117+
118+
return pm;
119+
}
120+
55121
export async function setLocalPackageManager(cwd: string, info: PreparedPackageManagerInfo) {
56122
const lookup = await loadSpec(cwd);
57123

124+
const range = `range` in lookup && lookup.range;
125+
if (range) {
126+
if (info.locator.name !== range.name || !semverSatisfies(info.locator.reference, range.range)) {
127+
warnOrThrow(`The requested version of ${info.locator.name}@${info.locator.reference} does not match the devEngines specification (${range.name}@${range.range})`, range.onFail);
128+
}
129+
}
130+
58131
const content = lookup.type !== `NoProject`
59132
? await fs.promises.readFile(lookup.target, `utf8`)
60133
: ``;
61134

62135
const {data, indent} = nodeUtils.readPackageJson(content);
63136

64-
const previousPackageManager = data.packageManager ?? `unknown`;
137+
const previousPackageManager = data.packageManager ?? (range ? `${range.name}@${range.range}` : `unknown`);
65138
data.packageManager = `${info.locator.name}@${info.locator.reference}`;
66139

67140
const newContent = nodeUtils.normalizeLineEndings(content, `${JSON.stringify(data, null, indent)}\n`);
@@ -75,7 +148,7 @@ export async function setLocalPackageManager(cwd: string, info: PreparedPackageM
75148
export type LoadSpecResult =
76149
| {type: `NoProject`, target: string}
77150
| {type: `NoSpec`, target: string}
78-
| {type: `Found`, target: string, getSpec: () => Descriptor};
151+
| {type: `Found`, target: string, getSpec: () => Descriptor, range?: Descriptor & {onFail?: DevEngineDependency['onFail']}};
79152

80153
export async function loadSpec(initialCwd: string): Promise<LoadSpecResult> {
81154
let nextCwd = initialCwd;
@@ -117,13 +190,20 @@ export async function loadSpec(initialCwd: string): Promise<LoadSpecResult> {
117190
if (selection === null)
118191
return {type: `NoProject`, target: path.join(initialCwd, `package.json`)};
119192

120-
const rawPmSpec = selection.data.packageManager;
193+
const rawPmSpec = parsePackageJSON(selection.data);
121194
if (typeof rawPmSpec === `undefined`)
122195
return {type: `NoSpec`, target: selection.manifestPath};
123196

197+
debugUtils.log(`${selection.manifestPath} defines ${rawPmSpec} as local package manager`);
198+
124199
return {
125200
type: `Found`,
126201
target: selection.manifestPath,
202+
range: selection.data.devEngines?.packageManager?.version && {
203+
name: selection.data.devEngines.packageManager.name,
204+
range: selection.data.devEngines.packageManager.version,
205+
onFail: selection.data.devEngines.packageManager.onFail,
206+
},
127207
// Lazy-loading it so we do not throw errors on commands that do not need valid spec.
128208
getSpec: () => parseSpec(rawPmSpec, path.relative(initialCwd, selection.manifestPath)),
129209
};

tests/Up.test.ts

+107-12
Original file line numberDiff line numberDiff line change
@@ -17,24 +17,119 @@ beforeEach(async () => {
1717
});
1818

1919
describe(`UpCommand`, () => {
20-
it(`should upgrade the package manager from the current project`, async () => {
21-
await xfs.mktempPromise(async cwd => {
22-
await xfs.writeJsonPromise(ppath.join(cwd, `package.json`), {
23-
packageManager: `[email protected]`,
20+
describe(`should update the "packageManager" field from the current project`, () => {
21+
it(`to the same major if no devEngines range`, async () => {
22+
await xfs.mktempPromise(async cwd => {
23+
await xfs.writeJsonPromise(ppath.join(cwd, `package.json`), {
24+
packageManager: `[email protected]`,
25+
});
26+
27+
await expect(runCli(cwd, [`up`])).resolves.toMatchObject({
28+
exitCode: 0,
29+
stderr: ``,
30+
stdout: expect.stringMatching(/^Installing yarn@2\.4\.3 in the project\.\.\.\n\n/),
31+
});
32+
33+
await expect(xfs.readJsonPromise(ppath.join(cwd, `package.json`))).resolves.toMatchObject({
34+
packageManager: `[email protected]+sha512.8dd9fedc5451829619e526c56f42609ad88ae4776d9d3f9456d578ac085115c0c2f0fb02bb7d57fd2e1b6e1ac96efba35e80a20a056668f61c96934f67694fd0`,
35+
});
36+
37+
await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({
38+
exitCode: 0,
39+
stdout: `2.4.3\n`,
40+
stderr: ``,
41+
});
2442
});
43+
});
2544

26-
await expect(runCli(cwd, [`up`])).resolves.toMatchObject({
27-
exitCode: 0,
28-
stderr: ``,
45+
it(`to whichever range devEngines defines`, async () => {
46+
await xfs.mktempPromise(async cwd => {
47+
await xfs.writeJsonPromise(ppath.join(cwd, `package.json`), {
48+
packageManager: `[email protected]`,
49+
devEngines: {
50+
packageManager: {
51+
name: `yarn`,
52+
version: `1.x || 2.x`,
53+
},
54+
},
55+
});
56+
57+
await expect(runCli(cwd, [`up`])).resolves.toMatchObject({
58+
exitCode: 0,
59+
stderr: ``,
60+
stdout: expect.stringMatching(/^Installing yarn@2\.4\.3 in the project\.\.\.\n\n/),
61+
});
62+
63+
await expect(xfs.readJsonPromise(ppath.join(cwd, `package.json`))).resolves.toMatchObject({
64+
packageManager: `[email protected]+sha512.8dd9fedc5451829619e526c56f42609ad88ae4776d9d3f9456d578ac085115c0c2f0fb02bb7d57fd2e1b6e1ac96efba35e80a20a056668f61c96934f67694fd0`,
65+
});
66+
67+
await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({
68+
exitCode: 0,
69+
stdout: `2.4.3\n`,
70+
stderr: ``,
71+
});
2972
});
73+
});
3074

31-
await expect(xfs.readJsonPromise(ppath.join(cwd, `package.json`))).resolves.toMatchObject({
32-
packageManager: `[email protected]+sha512.8dd9fedc5451829619e526c56f42609ad88ae4776d9d3f9456d578ac085115c0c2f0fb02bb7d57fd2e1b6e1ac96efba35e80a20a056668f61c96934f67694fd0`,
75+
it(`to whichever range devEngines defines even if onFail is set to ignore`, async () => {
76+
await xfs.mktempPromise(async cwd => {
77+
await xfs.writeJsonPromise(ppath.join(cwd, `package.json`), {
78+
packageManager: `[email protected]`,
79+
devEngines: {
80+
packageManager: {
81+
name: `yarn`,
82+
version: `1.x || 2.x`,
83+
onFail: `ignore`,
84+
},
85+
},
86+
});
87+
88+
await expect(runCli(cwd, [`up`])).resolves.toMatchObject({
89+
exitCode: 0,
90+
stderr: ``,
91+
stdout: expect.stringMatching(/^Installing yarn@2\.4\.3 in the project\.\.\.\n\n/),
92+
});
93+
94+
await expect(xfs.readJsonPromise(ppath.join(cwd, `package.json`))).resolves.toMatchObject({
95+
packageManager: `[email protected]+sha512.8dd9fedc5451829619e526c56f42609ad88ae4776d9d3f9456d578ac085115c0c2f0fb02bb7d57fd2e1b6e1ac96efba35e80a20a056668f61c96934f67694fd0`,
96+
});
97+
98+
await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({
99+
exitCode: 0,
100+
stdout: `2.4.3\n`,
101+
stderr: ``,
102+
});
33103
});
104+
});
105+
106+
it(`should succeed even if no 'packageManager' field`, async () => {
107+
await xfs.mktempPromise(async cwd => {
108+
process.env.NO_COLOR = `1`;
109+
await xfs.writeJsonPromise(ppath.join(cwd, `package.json`), {
110+
devEngines: {
111+
packageManager: {
112+
name: `yarn`,
113+
version: `1.x || 2.x`,
114+
},
115+
},
116+
});
117+
118+
await expect(runCli(cwd, [`up`])).resolves.toMatchObject({
119+
exitCode: 0,
120+
stderr: ``,
121+
stdout: expect.stringMatching(/^Installing yarn@2\.4\.3 in the project\.\.\.\n\n/),
122+
});
123+
124+
await expect(xfs.readJsonPromise(ppath.join(cwd, `package.json`))).resolves.toMatchObject({
125+
packageManager: `[email protected]+sha512.8dd9fedc5451829619e526c56f42609ad88ae4776d9d3f9456d578ac085115c0c2f0fb02bb7d57fd2e1b6e1ac96efba35e80a20a056668f61c96934f67694fd0`,
126+
});
34127

35-
await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({
36-
exitCode: 0,
37-
stdout: `2.4.3\n`,
128+
await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({
129+
exitCode: 0,
130+
stdout: `2.4.3\n`,
131+
stderr: ``,
132+
});
38133
});
39134
});
40135
});

0 commit comments

Comments
 (0)