Skip to content

Commit b1facaf

Browse files
committed
devEngines
1 parent 48ce898 commit b1facaf

File tree

5 files changed

+510
-25
lines changed

5 files changed

+510
-25
lines changed

README.md

Lines changed: 30 additions & 0 deletions
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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export abstract class BaseCommand extends Command<Context> {
1919
throw new UsageError(`The local project doesn't feature a 'packageManager' field - please explicit the package manager to pack, or update the manifest to reference it`);
2020

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

sources/specUtils.ts

Lines changed: 76 additions & 3 deletions
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,6 +54,70 @@ 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+
if (packageJSONContent.devEngines?.packageManager != null) {
80+
const {packageManager} = packageJSONContent.devEngines;
81+
82+
if (typeof packageManager !== `object`) {
83+
console.warn(`! Corepack only supports objects as valid value for devEngines.packageManager. The current value (${JSON.stringify(packageManager)}) will be ignored.`);
84+
return packageJSONContent.packageManager;
85+
}
86+
if (Array.isArray(packageManager)) {
87+
console.warn(`! Corepack does not currently support array values for devEngines.packageManager`);
88+
return packageJSONContent.packageManager;
89+
}
90+
91+
const {name, version, onFail} = packageManager;
92+
if (typeof name !== `string` || name.includes(`@`)) {
93+
warnOrThrow(`The value of devEngines.packageManager.name ${JSON.stringify(name)} is not a supported string value`, onFail);
94+
return packageJSONContent.packageManager;
95+
}
96+
if (version != null && (typeof version !== `string` || !semverValidRange(version))) {
97+
warnOrThrow(`The value of devEngines.packageManager.version ${JSON.stringify(version)} is not a valid semver range`, onFail);
98+
return packageJSONContent.packageManager;
99+
}
100+
101+
debugUtils.log(`devEngines.packageManager defines that ${name}@${version} is the local package manager`);
102+
103+
const {packageManager: pm} = packageJSONContent;
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 packageJSONContent.packageManager;
119+
}
120+
55121
export async function setLocalPackageManager(cwd: string, info: PreparedPackageManagerInfo) {
56122
const lookup = await loadSpec(cwd);
57123

@@ -75,7 +141,7 @@ export async function setLocalPackageManager(cwd: string, info: PreparedPackageM
75141
export type LoadSpecResult =
76142
| {type: `NoProject`, target: string}
77143
| {type: `NoSpec`, target: string}
78-
| {type: `Found`, target: string, spec: Descriptor};
144+
| {type: `Found`, target: string, spec: Descriptor, range?: Descriptor};
79145

80146
export async function loadSpec(initialCwd: string): Promise<LoadSpecResult> {
81147
let nextCwd = initialCwd;
@@ -117,13 +183,20 @@ export async function loadSpec(initialCwd: string): Promise<LoadSpecResult> {
117183
if (selection === null)
118184
return {type: `NoProject`, target: path.join(initialCwd, `package.json`)};
119185

120-
const rawPmSpec = selection.data.packageManager;
186+
const rawPmSpec = parsePackageJSON(selection.data);
121187
if (typeof rawPmSpec === `undefined`)
122188
return {type: `NoSpec`, target: selection.manifestPath};
123189

190+
debugUtils.log(`${selection.manifestPath} defines ${rawPmSpec} as local package manager`);
191+
192+
const spec = parseSpec(rawPmSpec, path.relative(initialCwd, selection.manifestPath));
124193
return {
125194
type: `Found`,
126195
target: selection.manifestPath,
127-
spec: parseSpec(rawPmSpec, path.relative(initialCwd, selection.manifestPath)),
196+
spec,
197+
range: selection.data.devEngines?.packageManager?.version && {
198+
name: selection.data.devEngines.packageManager.name,
199+
range: selection.data.devEngines.packageManager.version,
200+
},
128201
};
129202
}

tests/Up.test.ts

Lines changed: 72 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,24 +11,83 @@ beforeEach(async () => {
1111
});
1212

1313
describe(`UpCommand`, () => {
14-
it(`should upgrade the package manager from the current project`, async () => {
15-
await xfs.mktempPromise(async cwd => {
16-
await xfs.writeJsonPromise(ppath.join(cwd, `package.json`), {
17-
packageManager: `yarn@2.1.0`,
18-
});
14+
describe(`should update the "packageManager" field from the current project`, () => {
15+
it(`to the same major if no devEngines range`, async () => {
16+
await xfs.mktempPromise(async cwd => {
17+
await xfs.writeJsonPromise(ppath.join(cwd, `package.json`), {
18+
packageManager: `yarn@2.1.0`,
19+
});
20+
21+
await expect(runCli(cwd, [`up`])).resolves.toMatchObject({
22+
exitCode: 0,
23+
stderr: ``,
24+
});
25+
26+
await expect(xfs.readJsonPromise(ppath.join(cwd, `package.json`))).resolves.toMatchObject({
27+
packageManager: `yarn@2.4.3+sha512.8dd9fedc5451829619e526c56f42609ad88ae4776d9d3f9456d578ac085115c0c2f0fb02bb7d57fd2e1b6e1ac96efba35e80a20a056668f61c96934f67694fd0`,
28+
});
1929

20-
await expect(runCli(cwd, [`up`])).resolves.toMatchObject({
21-
exitCode: 0,
22-
stderr: ``,
30+
await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({
31+
exitCode: 0,
32+
stdout: `2.4.3\n`,
33+
});
2334
});
35+
});
36+
37+
it(`to whichever range devEngines defines`, async () => {
38+
await xfs.mktempPromise(async cwd => {
39+
await xfs.writeJsonPromise(ppath.join(cwd, `package.json`), {
40+
packageManager: `yarn@1.1.0`,
41+
devEngines: {
42+
packageManager: {
43+
name: `yarn`,
44+
version: `1.x || 2.x`,
45+
},
46+
},
47+
});
48+
49+
await expect(runCli(cwd, [`up`])).resolves.toMatchObject({
50+
exitCode: 0,
51+
stderr: ``,
52+
});
2453

25-
await expect(xfs.readJsonPromise(ppath.join(cwd, `package.json`))).resolves.toMatchObject({
26-
packageManager: `yarn@2.4.3+sha512.8dd9fedc5451829619e526c56f42609ad88ae4776d9d3f9456d578ac085115c0c2f0fb02bb7d57fd2e1b6e1ac96efba35e80a20a056668f61c96934f67694fd0`,
54+
await expect(xfs.readJsonPromise(ppath.join(cwd, `package.json`))).resolves.toMatchObject({
55+
packageManager: `yarn@2.4.3+sha512.8dd9fedc5451829619e526c56f42609ad88ae4776d9d3f9456d578ac085115c0c2f0fb02bb7d57fd2e1b6e1ac96efba35e80a20a056668f61c96934f67694fd0`,
56+
});
57+
58+
await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({
59+
exitCode: 0,
60+
stdout: `2.4.3\n`,
61+
});
2762
});
63+
});
64+
65+
it(`to whichever range devEngines defines even if onFail is set to ignore`, async () => {
66+
await xfs.mktempPromise(async cwd => {
67+
await xfs.writeJsonPromise(ppath.join(cwd, `package.json`), {
68+
packageManager: `pnpm@10.1.0`,
69+
devEngines: {
70+
packageManager: {
71+
name: `yarn`,
72+
version: `1.x || 2.x`,
73+
onFail: `ignore`,
74+
},
75+
},
76+
});
77+
78+
await expect(runCli(cwd, [`up`])).resolves.toMatchObject({
79+
exitCode: 0,
80+
stderr: ``,
81+
});
82+
83+
await expect(xfs.readJsonPromise(ppath.join(cwd, `package.json`))).resolves.toMatchObject({
84+
packageManager: `yarn@2.4.3+sha512.8dd9fedc5451829619e526c56f42609ad88ae4776d9d3f9456d578ac085115c0c2f0fb02bb7d57fd2e1b6e1ac96efba35e80a20a056668f61c96934f67694fd0`,
85+
});
2886

29-
await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({
30-
exitCode: 0,
31-
stdout: `2.4.3\n`,
87+
await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({
88+
exitCode: 0,
89+
stdout: `2.4.3\n`,
90+
});
3291
});
3392
});
3493
});

0 commit comments

Comments
 (0)