Skip to content

Commit dec830b

Browse files
authored
feat: use range from devEngines when no packageManager is set (#892)
Using Corepack without setting a `packageManager` is not recommended, but given that no errors is thrown when no `devEngines` is set, the behavior should be the same when one is set to a parsable range. Disable this behavior with `COREPACK_ON_UNVERIFIED_DOWNLOAD` environment variable.
1 parent 0125d89 commit dec830b

6 files changed

Lines changed: 268 additions & 94 deletions

File tree

‎README.md‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -127,9 +127,9 @@ Depending on the value of `devEngines.packageManager.onFail`:
127127
of mismatch.
128128

129129
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:
130+
package manager defined in `devEngines.packageManager`. You should provide a
131+
specific version in `devEngines.packageManager.version`, ideally with a hash, as
132+
explained in the previous section:
133133

134134
```json
135135
{
@@ -142,6 +142,16 @@ a hash, as explained in the previous section:
142142
}
143143
```
144144

145+
When `devEngines.packageManager.version` is a range rather than a specific
146+
version, Corepack resolves it the same way as when a range is given on the
147+
command line: the latest version matching the range is looked up on the npm
148+
registry, which means the resolution requires network access (or a cache
149+
containing a matching version, see [Offline Workflow](#offline-workflow)), and
150+
may change over time. Set `COREPACK_ENABLE_AUTO_PIN=1` to have Corepack add the
151+
resolved version to the `packageManager` field. When
152+
`devEngines.packageManager.version` is missing, Corepack falls back to its
153+
[Known Good Release](#known-good-releases) for that package manager.
154+
145155
## Known Good Releases
146156

147157
When running Corepack within projects that don't list a supported package

‎sources/Engine.ts‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,15 @@ export class Engine {
276276
}
277277

278278
case `NoSpec`: {
279-
if (typeof locator.reference === `function`)
279+
const {devEnginesValue} = result;
280+
const nameMatches = devEnginesValue != null && devEnginesValue.name === fallbackDescriptor.name;
281+
282+
if (devEnginesValue != null && !nameMatches && !transparent)
283+
specUtils.warnOrThrow(`This project is configured to use ${devEnginesValue.name} because ${result.target} has a "devEngines.packageManager" field`, devEnginesValue.onFail);
284+
285+
if (nameMatches && devEnginesValue.version)
286+
fallbackDescriptor.range = devEnginesValue.version;
287+
else if (typeof locator.reference === `function`)
280288
fallbackDescriptor.range = await locator.reference();
281289

282290
if (process.env.COREPACK_ENABLE_AUTO_PIN === `1`) {
@@ -307,7 +315,7 @@ export class Engine {
307315
debugUtils.log(`Falling back to ${fallbackDescriptor.name}@${fallbackDescriptor.range} in a ${spec.name}@${spec.range} project`);
308316
return fallbackDescriptor;
309317
} else {
310-
throw new UsageError(`This project is configured to use ${spec.name} because ${result.target} has a "packageManager" field`);
318+
throw new UsageError(`This project is configured to use ${spec.name} because ${result.target} has a "${result.field}" field`);
311319
}
312320
} else {
313321
debugUtils.log(`Using ${spec.name}@${spec.range} as defined in project manifest ${result.target}`);

‎sources/commands/Base.ts‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,17 @@ export abstract class BaseCommand extends Command<Context> {
1515
case `NoProject`:
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

18-
case `NoSpec`:
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`);
18+
case `NoSpec`: {
19+
const {devEnginesValue} = lookup;
20+
if (devEnginesValue?.version)
21+
return [specUtils.devEnginesToDescriptor(devEnginesValue)];
22+
23+
throw new UsageError(`The local project doesn't feature a 'packageManager' field ${devEnginesValue ? `` : `nor a 'devEngines.packageManager' field `}- please specify the package manager to pack, or update the manifest to reference it`);
24+
}
2025

2126
default: {
22-
return [lookup.range ?? lookup.getSpec()];
27+
const {devEnginesValue} = lookup;
28+
return [devEnginesValue?.version ? specUtils.devEnginesToDescriptor(devEnginesValue) : lookup.getSpec()];
2329
}
2430
}
2531
} else {

‎sources/commands/deprecated/Prepare.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,14 @@ export class PrepareCommand extends Command<Context> {
3838
case `NoProject`:
3939
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`);
4040

41-
case `NoSpec`:
42-
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`);
41+
case `NoSpec`: {
42+
const {devEnginesValue} = lookup;
43+
if (devEnginesValue?.version) {
44+
specs.push(specUtils.devEnginesToDescriptor(devEnginesValue));
45+
break;
46+
}
47+
throw new UsageError(`The local project doesn't feature a 'packageManager' field ${devEnginesValue ? `` : `nor a 'devEngines.packageManager' field `}- please specify the package manager to pack, or update the manifest to reference it`);
48+
}
4349

4450
default: {
4551
specs.push(lookup.getSpec());

‎sources/specUtils.ts‎

Lines changed: 66 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -61,12 +61,23 @@ type CorepackPackageJSON = {
6161
devEngines?: {packageManager?: DevEngineDependency};
6262
};
6363

64-
interface DevEngineDependency {
64+
export interface DevEngineDependency {
6565
name: string;
66-
version: string;
66+
/** Semver version or range, as found in the manifest. */
67+
version?: string;
6768
onFail?: `ignore` | `warn` | `error`;
6869
}
69-
function warnOrThrow(errorMessage: string, onFail?: DevEngineDependency[`onFail`]) {
70+
71+
export function devEnginesToDescriptor({name, version}: DevEngineDependency): Descriptor {
72+
return {name, range: version ?? `*`};
73+
}
74+
75+
interface ParsedPackageJSON {
76+
packageManagerField?: string;
77+
devEnginesPackageManager?: DevEngineDependency;
78+
}
79+
80+
export function warnOrThrow(errorMessage: string, onFail?: DevEngineDependency[`onFail`]) {
7081
switch (onFail) {
7182
case `ignore`:
7283
break;
@@ -77,66 +88,64 @@ function warnOrThrow(errorMessage: string, onFail?: DevEngineDependency[`onFail`
7788
console.warn(`! Corepack validation warning: ${errorMessage}`);
7889
}
7990
}
80-
function parsePackageJSON(packageJSONContent: CorepackPackageJSON) {
91+
function parsePackageJSON(packageJSONContent: CorepackPackageJSON): ParsedPackageJSON {
8192
const {packageManager: pm} = packageJSONContent;
8293
if (packageJSONContent.devEngines?.packageManager != null) {
8394
const {packageManager} = packageJSONContent.devEngines;
8495

8596
if (typeof packageManager !== `object`) {
8697
console.warn(`! Corepack only supports objects as valid value for devEngines.packageManager. The current value (${JSON.stringify(packageManager)}) will be ignored.`);
87-
return pm;
98+
return {packageManagerField: pm};
8899
}
89100
if (Array.isArray(packageManager)) {
90101
console.warn(`! Corepack does not currently support array values for devEngines.packageManager`);
91-
return pm;
102+
return {packageManagerField: pm};
92103
}
93104

94105
const {name, version, onFail} = packageManager;
95106
if (typeof name !== `string` || name.includes(`@`)) {
96107
warnOrThrow(`The value of devEngines.packageManager.name ${JSON.stringify(name)} is not a supported string value`, onFail);
97-
return pm;
108+
return {packageManagerField: pm};
98109
}
99110
if (version != null && (typeof version !== `string` || !semverValidRange(version))) {
100111
warnOrThrow(`The value of devEngines.packageManager.version ${JSON.stringify(version)} is not a valid semver range`, onFail);
101-
return pm;
112+
return {packageManagerField: pm};
102113
}
103114

104-
debugUtils.log(`devEngines.packageManager defines that ${name}@${version} is the local package manager`);
115+
debugUtils.log(`devEngines.packageManager defines that ${name}${version ? `@${version}` : ``} should be the local package manager`);
105116

106117
if (pm) {
107-
if (!pm.startsWith?.(`${name}@`))
118+
if (!pm.startsWith?.(`${name}@`)) {
108119
warnOrThrow(`"packageManager" field is set to ${JSON.stringify(pm)} which does not match the "devEngines.packageManager" field set to ${JSON.stringify(name)}`, onFail);
109-
110-
else if (version != null && !semverSatisfies(pm.slice(packageManager.name.length + 1), version))
120+
} else if (version != null && !semverSatisfies(pm.slice(name.length + 1), version)) {
111121
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);
112-
113-
return pm;
122+
}
114123
}
115124

116-
117-
return `${name}@${version ?? `*`}`;
125+
return {packageManagerField: pm, devEnginesPackageManager: {name, version, onFail}};
118126
}
119127

120-
return pm;
128+
return {packageManagerField: pm};
121129
}
122130

123131
export async function setLocalPackageManager(cwd: string, info: PreparedPackageManagerInfo) {
124132
const lookup = await loadSpecAndEnv(cwd);
125133

126-
const range = `range` in lookup && lookup.range;
127-
if (range) {
128-
if (info.locator.name !== range.name || !semverSatisfies(info.locator.reference, range.range)) {
129-
warnOrThrow(`The requested version of ${info.locator.name}@${info.locator.reference} does not match the devEngines specification (${range.name}@${range.range})`, range.onFail);
134+
const projectFound = lookup.type !== `NoProject`;
135+
const devEnginesValue = projectFound ? lookup.devEnginesValue : undefined;
136+
if (devEnginesValue) {
137+
if (info.locator.name !== devEnginesValue.name || (devEnginesValue.version != null && !semverSatisfies(info.locator.reference, devEnginesValue.version))) {
138+
warnOrThrow(`The requested version of ${info.locator.name}@${info.locator.reference} does not match the devEngines specification (${devEnginesValue.name}@${devEnginesValue.version ?? `*`})`, devEnginesValue.onFail);
130139
}
131140
}
132141

133-
const content = lookup.type !== `NoProject`
142+
const content = projectFound
134143
? await fs.promises.readFile(lookup.target, `utf8`)
135144
: ``;
136145

137146
const {data, indent} = nodeUtils.readPackageJson(content);
138147

139-
const previousPackageManager = data.packageManager ?? (range ? `${range.name}@${range.range}` : `unknown`);
148+
const previousPackageManager = data.packageManager ?? (devEnginesValue ? `${devEnginesValue.name}@${devEnginesValue.version ?? `*`}` : `unknown`);
140149
data.packageManager = `${info.locator.name}@${info.locator.reference}`;
141150

142151
const newContent = nodeUtils.normalizeLineEndings(content, `${JSON.stringify(data, null, indent)}\n`);
@@ -150,13 +159,15 @@ export async function setLocalPackageManager(cwd: string, info: PreparedPackageM
150159
interface FoundSpecResult {
151160
type: `Found`;
152161
target: string;
162+
/** Name of the `package.json` field the spec was read from. */
163+
field: `packageManager` | `devEngines.packageManager`;
153164
getSpec: (options?: {enforceExactVersion?: boolean}) => Descriptor;
154-
range?: Descriptor & {onFail?: DevEngineDependency[`onFail`]};
165+
devEnginesValue?: DevEngineDependency;
155166
envFilePath?: string;
156167
}
157168
export type LoadSpecResult =
158169
| {type: `NoProject`, target: string, envFilePath?: string}
159-
| {type: `NoSpec`, target: string, envFilePath?: string}
170+
| {type: `NoSpec`, target: string, envFilePath?: string, devEnginesValue?: DevEngineDependency}
160171
| FoundSpecResult;
161172

162173
async function loadEnvFileIfExists(cwd: string): Promise<{env: LocalEnvFile, path: string} | void> {
@@ -234,22 +245,43 @@ export async function loadSpecAndEnv(initialCwd: string, {envOnly} = {envOnly: f
234245
if (selection === null)
235246
return {type: `NoProject`, target: path.join(initialCwd, `package.json`), envFilePath: localEnv?.path};
236247

237-
const rawPmSpec = parsePackageJSON(selection.data);
238-
if (typeof rawPmSpec === `undefined`)
248+
const {packageManagerField, devEnginesPackageManager} = parsePackageJSON(selection.data);
249+
250+
if (devEnginesPackageManager != null && !packageManagerField) {
251+
const {name, version} = devEnginesPackageManager;
252+
253+
// Without an exact version, there is nothing to install yet – the range (if
254+
// any) is resolved by the caller, as it would for a project without spec.
255+
if (!version || !semverValid(version)) {
256+
debugUtils.log(`${selection.manifestPath} defines ${name} as local package manager using devEngines.packageManager, without an exact version`);
257+
return {type: `NoSpec`, target: selection.manifestPath, envFilePath: localEnv?.path, devEnginesValue: devEnginesPackageManager};
258+
}
259+
260+
debugUtils.log(`${selection.manifestPath} defines ${name}@${version} as local package manager using devEngines.packageManager`);
261+
262+
return {
263+
type: `Found`,
264+
target: selection.manifestPath,
265+
field: `devEngines.packageManager`,
266+
envFilePath: localEnv?.path,
267+
devEnginesValue: devEnginesPackageManager,
268+
// Lazy-loading it so we do not throw errors on commands that do not need valid spec.
269+
getSpec: ({enforceExactVersion = true} = {}) => parseSpec(`${name}@${version}`, path.relative(initialCwd, selection.manifestPath), {enforceExactVersion}),
270+
};
271+
}
272+
273+
if (packageManagerField === undefined)
239274
return {type: `NoSpec`, target: selection.manifestPath, envFilePath: localEnv?.path};
240275

241-
debugUtils.log(`${selection.manifestPath} defines ${rawPmSpec} as local package manager`);
276+
debugUtils.log(`${selection.manifestPath} defines ${packageManagerField} as local package manager using the packageManager field`);
242277

243278
return {
244279
type: `Found`,
245280
target: selection.manifestPath,
281+
field: `packageManager`,
246282
envFilePath: localEnv?.path,
247-
range: selection.data.devEngines?.packageManager?.version && {
248-
name: selection.data.devEngines.packageManager.name,
249-
range: selection.data.devEngines.packageManager.version,
250-
onFail: selection.data.devEngines.packageManager.onFail,
251-
},
283+
devEnginesValue: devEnginesPackageManager,
252284
// Lazy-loading it so we do not throw errors on commands that do not need valid spec.
253-
getSpec: ({enforceExactVersion = true} = {}) => parseSpec(rawPmSpec, path.relative(initialCwd, selection.manifestPath), {enforceExactVersion}),
285+
getSpec: ({enforceExactVersion = true} = {}) => parseSpec(packageManagerField, path.relative(initialCwd, selection.manifestPath), {enforceExactVersion}),
254286
};
255287
}

0 commit comments

Comments
 (0)