Skip to content

Commit 8b6c6d4

Browse files
authored
feat: Pins the package manager as it's used for the first time (#413)
* feat: Pins the package manager as it's used for the first time * Adds a warning when auto-pinning the package manager version * Updates wording
1 parent a3ea1dd commit 8b6c6d4

File tree

10 files changed

+220
-177
lines changed

10 files changed

+220
-177
lines changed

README.md

+6
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,12 @@ same major line. Should you need to upgrade to a new major, use an explicit
235235
package manager, and to not update the Last Known Good version when it
236236
downloads a new version of the same major line.
237237

238+
- `COREPACK_ENABLE_AUTO_PIN` can be set to `0` to prevent Corepack from
239+
updating the `packageManager` field when it detects that the local package
240+
doesn't list it. In general we recommend to always list a `packageManager`
241+
field (which you can easily set through `corepack use [name]@[version]`), as
242+
it ensures that your project installs are always deterministic.
243+
238244
- `COREPACK_ENABLE_DOWNLOAD_PROMPT` can be set to `0` to
239245
prevent Corepack showing the URL when it needs to download software, or can be
240246
set to `1` to have the URL shown. By default, when Corepack is called

sources/Engine.ts

+134-2
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,22 @@ import defaultConfig from '../config.js
1010
import * as corepackUtils from './corepackUtils';
1111
import * as debugUtils from './debugUtils';
1212
import * as folderUtils from './folderUtils';
13+
import * as miscUtils from './miscUtils';
1314
import type {NodeError} from './nodeUtils';
1415
import * as semverUtils from './semverUtils';
16+
import * as specUtils from './specUtils';
1517
import {Config, Descriptor, Locator, PackageManagerSpec} from './types';
1618
import {SupportedPackageManagers, SupportedPackageManagerSet} from './types';
1719
import {isSupportedPackageManager} from './types';
1820

1921
export type PreparedPackageManagerInfo = Awaited<ReturnType<Engine[`ensurePackageManager`]>>;
2022

23+
export type PackageManagerRequest = {
24+
packageManager: SupportedPackageManagers;
25+
binaryName: string;
26+
binaryVersion: string | null;
27+
};
28+
2129
export function getLastKnownGoodFile(flag = `r`) {
2230
return fs.promises.open(path.join(folderUtils.getCorepackHomeFolder(), `lastKnownGood.json`), flag);
2331
}
@@ -200,15 +208,139 @@ export class Engine {
200208
spec,
201209
});
202210

211+
const noHashReference = locator.reference.replace(/\+.*/, ``);
212+
const fixedHashReference = `${noHashReference}+${packageManagerInfo.hash}`;
213+
214+
const fixedHashLocator = {
215+
name: locator.name,
216+
reference: fixedHashReference,
217+
};
218+
203219
return {
204220
...packageManagerInfo,
205-
locator,
221+
locator: fixedHashLocator,
206222
spec,
207223
};
208224
}
209225

210-
async fetchAvailableVersions() {
226+
/**
227+
* Locates the active project's package manager specification.
228+
*
229+
* If the specification exists but doesn't match the active package manager,
230+
* an error is thrown to prevent users from using the wrong package manager,
231+
* which would lead to inconsistent project layouts.
232+
*
233+
* If the project doesn't include a specification file, we just assume that
234+
* whatever the user uses is exactly what they want to use. Since the version
235+
* isn't explicited, we fallback on known good versions.
236+
*
237+
* Finally, if the project doesn't exist at all, we ask the user whether they
238+
* want to create one in the current project. If they do, we initialize a new
239+
* project using the default package managers, and configure it so that we
240+
* don't need to ask again in the future.
241+
*/
242+
async findProjectSpec(initialCwd: string, locator: Locator, {transparent = false}: {transparent?: boolean} = {}): Promise<Descriptor> {
243+
// A locator is a valid descriptor (but not the other way around)
244+
const fallbackDescriptor = {name: locator.name, range: `${locator.reference}`};
245+
246+
if (process.env.COREPACK_ENABLE_PROJECT_SPEC === `0`)
247+
return fallbackDescriptor;
248+
249+
if (process.env.COREPACK_ENABLE_STRICT === `0`)
250+
transparent = true;
251+
252+
while (true) {
253+
const result = await specUtils.loadSpec(initialCwd);
254+
255+
switch (result.type) {
256+
case `NoProject`:
257+
return fallbackDescriptor;
258+
259+
case `NoSpec`: {
260+
if (process.env.COREPACK_ENABLE_AUTO_PIN !== `0`) {
261+
const resolved = await this.resolveDescriptor(fallbackDescriptor, {allowTags: true});
262+
if (resolved === null)
263+
throw new UsageError(`Failed to successfully resolve '${fallbackDescriptor.range}' to a valid ${fallbackDescriptor.name} release`);
264+
265+
const installSpec = await this.ensurePackageManager(resolved);
266+
267+
console.error(`! The local project doesn't define a 'packageManager' field. Corepack will now add one referencing ${installSpec.locator.name}@${installSpec.locator.reference}.`);
268+
console.error(`! For more details about this field, consult the documentation at https://nodejs.org/api/packages.html#packagemanager`);
269+
console.error();
270+
271+
await specUtils.setLocalPackageManager(path.dirname(result.target), installSpec);
272+
}
273+
274+
return fallbackDescriptor;
275+
}
276+
277+
case `Found`: {
278+
if (result.spec.name !== locator.name) {
279+
if (transparent) {
280+
return fallbackDescriptor;
281+
} else {
282+
throw new UsageError(`This project is configured to use ${result.spec.name}`);
283+
}
284+
} else {
285+
return result.spec;
286+
}
287+
}
288+
}
289+
}
290+
}
291+
292+
async executePackageManagerRequest({packageManager, binaryName, binaryVersion}: PackageManagerRequest, {cwd, args}: {cwd: string, args: Array<string>}): Promise<number | void> {
293+
let fallbackLocator: Locator = {
294+
name: binaryName as SupportedPackageManagers,
295+
reference: undefined as any,
296+
};
297+
298+
let isTransparentCommand = false;
299+
if (packageManager != null) {
300+
const defaultVersion = await this.getDefaultVersion(packageManager);
301+
const definition = this.config.definitions[packageManager]!;
302+
303+
// If all leading segments match one of the patterns defined in the `transparent`
304+
// key, we tolerate calling this binary even if the local project isn't explicitly
305+
// configured for it, and we use the special default version if requested.
306+
for (const transparentPath of definition.transparent.commands) {
307+
if (transparentPath[0] === binaryName && transparentPath.slice(1).every((segment, index) => segment === args[index])) {
308+
isTransparentCommand = true;
309+
break;
310+
}
311+
}
312+
313+
const fallbackReference = isTransparentCommand
314+
? definition.transparent.default ?? defaultVersion
315+
: defaultVersion;
316+
317+
fallbackLocator = {
318+
name: packageManager,
319+
reference: fallbackReference,
320+
};
321+
}
322+
323+
let descriptor: Descriptor;
324+
try {
325+
descriptor = await this.findProjectSpec(cwd, fallbackLocator, {transparent: isTransparentCommand});
326+
} catch (err) {
327+
if (err instanceof miscUtils.Cancellation) {
328+
return 1;
329+
} else {
330+
throw err;
331+
}
332+
}
333+
334+
if (binaryVersion)
335+
descriptor.range = binaryVersion;
336+
337+
const resolved = await this.resolveDescriptor(descriptor, {allowTags: true});
338+
if (resolved === null)
339+
throw new UsageError(`Failed to successfully resolve '${descriptor.range}' to a valid ${descriptor.name} release`);
340+
341+
const installSpec = await this.ensurePackageManager(resolved);
211342

343+
return await corepackUtils.runVersion(resolved, installSpec, binaryName, args);
212344
}
213345

214346
async resolveDescriptor(descriptor: Descriptor, {allowTags = false, useCache = true}: {allowTags?: boolean, useCache?: boolean} = {}): Promise<Locator | null> {

sources/commands/Base.ts

+4-14
Original file line numberDiff line numberDiff line change
@@ -29,20 +29,10 @@ export abstract class BaseCommand extends Command<Context> {
2929
return resolvedSpecs;
3030
}
3131

32-
async setLocalPackageManager(info: PreparedPackageManagerInfo) {
33-
const lookup = await specUtils.loadSpec(this.context.cwd);
34-
35-
const content = lookup.type !== `NoProject`
36-
? await fs.promises.readFile(lookup.target, `utf8`)
37-
: ``;
38-
39-
const {data, indent} = nodeUtils.readPackageJson(content);
40-
41-
const previousPackageManager = data.packageManager ?? `unknown`;
42-
data.packageManager = `${info.locator.name}@${info.locator.reference}+${info.hash}`;
43-
44-
const newContent = nodeUtils.normalizeLineEndings(content, `${JSON.stringify(data, null, indent)}\n`);
45-
await fs.promises.writeFile(lookup.target, newContent, `utf8`);
32+
async setAndInstallLocalPackageManager(info: PreparedPackageManagerInfo) {
33+
const {
34+
previousPackageManager,
35+
} = await specUtils.setLocalPackageManager(this.context.cwd, info);
4636

4737
const command = this.context.engine.getPackageManagerSpecFor(info.locator).commands?.use ?? null;
4838
if (command === null)

sources/commands/Up.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,6 @@ export class UpCommand extends BaseCommand {
5050
this.context.stdout.write(`Installing ${highestVersion.name}@${highestVersion.reference} in the project...\n`);
5151

5252
const packageManagerInfo = await this.context.engine.ensurePackageManager(highestVersion);
53-
await this.setLocalPackageManager(packageManagerInfo);
53+
await this.setAndInstallLocalPackageManager(packageManagerInfo);
5454
}
5555
}

sources/commands/Use.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,6 @@ export class UseCommand extends BaseCommand {
3434
this.context.stdout.write(`Installing ${resolved.name}@${resolved.reference} in the project...\n`);
3535

3636
const packageManagerInfo = await this.context.engine.ensurePackageManager(resolved);
37-
await this.setLocalPackageManager(packageManagerInfo);
37+
await this.setAndInstallLocalPackageManager(packageManagerInfo);
3838
}
3939
}

sources/httpUtils.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,9 @@ export async function fetchAsJson(input: string | URL, init?: RequestInit) {
4040

4141
export async function fetchUrlStream(input: string | URL, init?: RequestInit) {
4242
if (process.env.COREPACK_ENABLE_DOWNLOAD_PROMPT === `1`) {
43-
console.error(`Corepack is about to download ${input}`);
43+
console.error(`! Corepack is about to download ${input}`);
4444
if (stdin.isTTY && !process.env.CI) {
45-
stderr.write(`Do you want to continue? [Y/n] `);
45+
stderr.write(`? Do you want to continue? [Y/n] `);
4646
stdin.resume();
4747
const chars = await once(stdin, `data`);
4848
stdin.pause();

sources/main.ts

+19-79
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,22 @@
1-
import {BaseContext, Builtins, Cli, Command, Option, UsageError} from 'clipanion';
2-
3-
import {version as corepackVersion} from '../package.json';
4-
5-
import {Engine} from './Engine';
6-
import {CacheCommand} from './commands/Cache';
7-
import {DisableCommand} from './commands/Disable';
8-
import {EnableCommand} from './commands/Enable';
9-
import {InstallGlobalCommand} from './commands/InstallGlobal';
10-
import {InstallLocalCommand} from './commands/InstallLocal';
11-
import {PackCommand} from './commands/Pack';
12-
import {UpCommand} from './commands/Up';
13-
import {UseCommand} from './commands/Use';
14-
import {HydrateCommand} from './commands/deprecated/Hydrate';
15-
import {PrepareCommand} from './commands/deprecated/Prepare';
16-
import * as corepackUtils from './corepackUtils';
17-
import * as miscUtils from './miscUtils';
18-
import * as specUtils from './specUtils';
19-
import {Locator, SupportedPackageManagers, Descriptor} from './types';
1+
import {BaseContext, Builtins, Cli, Command, Option} from 'clipanion';
2+
3+
import {version as corepackVersion} from '../package.json';
4+
5+
import {Engine, PackageManagerRequest} from './Engine';
6+
import {CacheCommand} from './commands/Cache';
7+
import {DisableCommand} from './commands/Disable';
8+
import {EnableCommand} from './commands/Enable';
9+
import {InstallGlobalCommand} from './commands/InstallGlobal';
10+
import {InstallLocalCommand} from './commands/InstallLocal';
11+
import {PackCommand} from './commands/Pack';
12+
import {UpCommand} from './commands/Up';
13+
import {UseCommand} from './commands/Use';
14+
import {HydrateCommand} from './commands/deprecated/Hydrate';
15+
import {PrepareCommand} from './commands/deprecated/Prepare';
2016

2117
export type CustomContext = {cwd: string, engine: Engine};
2218
export type Context = BaseContext & CustomContext;
2319

24-
type PackageManagerRequest = {
25-
packageManager: SupportedPackageManagers;
26-
binaryName: string;
27-
binaryVersion: string | null;
28-
};
29-
3020
function getPackageManagerRequestFromCli(parameter: string | undefined, context: CustomContext & Partial<Context>): PackageManagerRequest | null {
3121
if (!parameter)
3222
return null;
@@ -47,59 +37,6 @@ function getPackageManagerRequestFromCli(parameter: string | undefined, context:
4737
};
4838
}
4939

50-
async function executePackageManagerRequest({packageManager, binaryName, binaryVersion}: PackageManagerRequest, args: Array<string>, context: Context) {
51-
let fallbackLocator: Locator = {
52-
name: binaryName as SupportedPackageManagers,
53-
reference: undefined as any,
54-
};
55-
let isTransparentCommand = false;
56-
if (packageManager != null) {
57-
const defaultVersion = await context.engine.getDefaultVersion(packageManager);
58-
const definition = context.engine.config.definitions[packageManager]!;
59-
60-
// If all leading segments match one of the patterns defined in the `transparent`
61-
// key, we tolerate calling this binary even if the local project isn't explicitly
62-
// configured for it, and we use the special default version if requested.
63-
for (const transparentPath of definition.transparent.commands) {
64-
if (transparentPath[0] === binaryName && transparentPath.slice(1).every((segment, index) => segment === args[index])) {
65-
isTransparentCommand = true;
66-
break;
67-
}
68-
}
69-
70-
const fallbackReference = isTransparentCommand
71-
? definition.transparent.default ?? defaultVersion
72-
: defaultVersion;
73-
74-
fallbackLocator = {
75-
name: packageManager,
76-
reference: fallbackReference,
77-
};
78-
}
79-
80-
let descriptor: Descriptor;
81-
try {
82-
descriptor = await specUtils.findProjectSpec(context.cwd, fallbackLocator, {transparent: isTransparentCommand});
83-
} catch (err) {
84-
if (err instanceof miscUtils.Cancellation) {
85-
return 1;
86-
} else {
87-
throw err;
88-
}
89-
}
90-
91-
if (binaryVersion)
92-
descriptor.range = binaryVersion;
93-
94-
const resolved = await context.engine.resolveDescriptor(descriptor, {allowTags: true});
95-
if (resolved === null)
96-
throw new UsageError(`Failed to successfully resolve '${descriptor.range}' to a valid ${descriptor.name} release`);
97-
98-
const installSpec = await context.engine.ensurePackageManager(resolved);
99-
100-
return await corepackUtils.runVersion(resolved, installSpec, binaryName, args);
101-
}
102-
10340
export async function runMain(argv: Array<string>) {
10441
// Because we load the binaries in the same process, we don't support custom contexts.
10542
const context = {
@@ -149,7 +86,10 @@ export async function runMain(argv: Array<string>) {
14986
cli.register(class BinaryCommand extends Command<Context> {
15087
proxy = Option.Proxy();
15188
async execute() {
152-
return executePackageManagerRequest(request, this.proxy, this.context);
89+
return this.context.engine.executePackageManagerRequest(request, {
90+
cwd: this.context.cwd,
91+
args: this.proxy,
92+
});
15393
}
15494
});
15595

0 commit comments

Comments
 (0)