Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Pins the package manager as it's used for the first time #413

Merged
merged 3 commits into from
Mar 3, 2024
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,12 @@ same major line. Should you need to upgrade to a new major, use an explicit
package manager, and to not update the Last Known Good version when it
downloads a new version of the same major line.

- `COREPACK_ENABLE_AUTO_PIN` can be set to `0` to prevent Corepack from
updating the `packageManager` field when it detects that the local package
doesn't list it. In general we recommend to always list a `packageManager`
field (which you can easily set through `corepack use [name]@[version]`), as
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
field (which you can easily set through `corepack use [name]@[version]`), as
field (which you can set through `corepack use [name]@[version]`), as

it ensures that your project installs are always deterministic.

- `COREPACK_ENABLE_DOWNLOAD_PROMPT` can be set to `0` to
prevent Corepack showing the URL when it needs to download software, or can be
set to `1` to have the URL shown. By default, when Corepack is called
Expand Down
136 changes: 134 additions & 2 deletions sources/Engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,22 @@ import defaultConfig from '../config.js
import * as corepackUtils from './corepackUtils';
import * as debugUtils from './debugUtils';
import * as folderUtils from './folderUtils';
import * as miscUtils from './miscUtils';
import type {NodeError} from './nodeUtils';
import * as semverUtils from './semverUtils';
import * as specUtils from './specUtils';
import {Config, Descriptor, Locator, PackageManagerSpec} from './types';
import {SupportedPackageManagers, SupportedPackageManagerSet} from './types';
import {isSupportedPackageManager} from './types';

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

export type PackageManagerRequest = {
packageManager: SupportedPackageManagers;
binaryName: string;
binaryVersion: string | null;
};

export function getLastKnownGoodFile(flag = `r`) {
return fs.promises.open(path.join(folderUtils.getCorepackHomeFolder(), `lastKnownGood.json`), flag);
}
Expand Down Expand Up @@ -200,15 +208,139 @@ export class Engine {
spec,
});

const noHashReference = locator.reference.replace(/\+.*/, ``);
const fixedHashReference = `${noHashReference}+${packageManagerInfo.hash}`;

const fixedHashLocator = {
name: locator.name,
reference: fixedHashReference,
};

return {
...packageManagerInfo,
locator,
locator: fixedHashLocator,
spec,
};
}

async fetchAvailableVersions() {
/**
* Locates the active project's package manager specification.
*
* If the specification exists but doesn't match the active package manager,
* an error is thrown to prevent users from using the wrong package manager,
* which would lead to inconsistent project layouts.
*
* If the project doesn't include a specification file, we just assume that
* whatever the user uses is exactly what they want to use. Since the version
* isn't explicited, we fallback on known good versions.
*
* Finally, if the project doesn't exist at all, we ask the user whether they
* want to create one in the current project. If they do, we initialize a new
* project using the default package managers, and configure it so that we
* don't need to ask again in the future.
*/
async findProjectSpec(initialCwd: string, locator: Locator, {transparent = false}: {transparent?: boolean} = {}): Promise<Descriptor> {
// A locator is a valid descriptor (but not the other way around)
const fallbackDescriptor = {name: locator.name, range: `${locator.reference}`};

if (process.env.COREPACK_ENABLE_PROJECT_SPEC === `0`)
return fallbackDescriptor;

if (process.env.COREPACK_ENABLE_STRICT === `0`)
transparent = true;

while (true) {
const result = await specUtils.loadSpec(initialCwd);

switch (result.type) {
case `NoProject`:
return fallbackDescriptor;

case `NoSpec`: {
if (process.env.COREPACK_ENABLE_AUTO_PIN !== `0`) {
const resolved = await this.resolveDescriptor(fallbackDescriptor, {allowTags: true});
if (resolved === null)
throw new UsageError(`Failed to successfully resolve '${fallbackDescriptor.range}' to a valid ${fallbackDescriptor.name} release`);

const installSpec = await this.ensurePackageManager(resolved);

console.error(`! The local project doesn't define a 'packageManager' field. Corepack will now add one referencing ${installSpec.locator.name}@${installSpec.locator.reference}.`);
console.error(`! For more details about this field, consult the documentation at https://nodejs.org/api/packages.html#packagemanager`);
console.error();

await specUtils.setLocalPackageManager(path.dirname(result.target), installSpec);
}

return fallbackDescriptor;
}

case `Found`: {
if (result.spec.name !== locator.name) {
if (transparent) {
return fallbackDescriptor;
} else {
throw new UsageError(`This project is configured to use ${result.spec.name}`);
}
} else {
return result.spec;
}
}
}
}
}

async executePackageManagerRequest({packageManager, binaryName, binaryVersion}: PackageManagerRequest, {cwd, args}: {cwd: string, args: Array<string>}): Promise<number | void> {
let fallbackLocator: Locator = {
name: binaryName as SupportedPackageManagers,
reference: undefined as any,
};

let isTransparentCommand = false;
if (packageManager != null) {
const defaultVersion = await this.getDefaultVersion(packageManager);
const definition = this.config.definitions[packageManager]!;

// If all leading segments match one of the patterns defined in the `transparent`
// key, we tolerate calling this binary even if the local project isn't explicitly
// configured for it, and we use the special default version if requested.
for (const transparentPath of definition.transparent.commands) {
if (transparentPath[0] === binaryName && transparentPath.slice(1).every((segment, index) => segment === args[index])) {
isTransparentCommand = true;
break;
}
}

const fallbackReference = isTransparentCommand
? definition.transparent.default ?? defaultVersion
: defaultVersion;

fallbackLocator = {
name: packageManager,
reference: fallbackReference,
};
}

let descriptor: Descriptor;
try {
descriptor = await this.findProjectSpec(cwd, fallbackLocator, {transparent: isTransparentCommand});
} catch (err) {
if (err instanceof miscUtils.Cancellation) {
return 1;
} else {
throw err;
}
}

if (binaryVersion)
descriptor.range = binaryVersion;

const resolved = await this.resolveDescriptor(descriptor, {allowTags: true});
if (resolved === null)
throw new UsageError(`Failed to successfully resolve '${descriptor.range}' to a valid ${descriptor.name} release`);

const installSpec = await this.ensurePackageManager(resolved);

return await corepackUtils.runVersion(resolved, installSpec, binaryName, args);
}

async resolveDescriptor(descriptor: Descriptor, {allowTags = false, useCache = true}: {allowTags?: boolean, useCache?: boolean} = {}): Promise<Locator | null> {
Expand Down
18 changes: 4 additions & 14 deletions sources/commands/Base.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import {Command, UsageError} from 'clipanion';
import fs from 'fs';

Check warning on line 2 in sources/commands/Base.ts

View workflow job for this annotation

GitHub Actions / Testing chores

'fs' is defined but never used

import {PreparedPackageManagerInfo} from '../Engine';
import * as corepackUtils from '../corepackUtils';
import {Context} from '../main';
import * as nodeUtils from '../nodeUtils';

Check warning on line 7 in sources/commands/Base.ts

View workflow job for this annotation

GitHub Actions / Testing chores

'nodeUtils' is defined but never used
import * as specUtils from '../specUtils';

export abstract class BaseCommand extends Command<Context> {
Expand All @@ -29,20 +29,10 @@
return resolvedSpecs;
}

async setLocalPackageManager(info: PreparedPackageManagerInfo) {
const lookup = await specUtils.loadSpec(this.context.cwd);

const content = lookup.type !== `NoProject`
? await fs.promises.readFile(lookup.target, `utf8`)
: ``;

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

const previousPackageManager = data.packageManager ?? `unknown`;
data.packageManager = `${info.locator.name}@${info.locator.reference}+${info.hash}`;

const newContent = nodeUtils.normalizeLineEndings(content, `${JSON.stringify(data, null, indent)}\n`);
await fs.promises.writeFile(lookup.target, newContent, `utf8`);
async setAndInstallLocalPackageManager(info: PreparedPackageManagerInfo) {
const {
previousPackageManager,
} = await specUtils.setLocalPackageManager(this.context.cwd, info);

const command = this.context.engine.getPackageManagerSpecFor(info.locator).commands?.use ?? null;
if (command === null)
Expand Down
2 changes: 1 addition & 1 deletion sources/commands/Up.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,6 @@ export class UpCommand extends BaseCommand {
this.context.stdout.write(`Installing ${highestVersion.name}@${highestVersion.reference} in the project...\n`);

const packageManagerInfo = await this.context.engine.ensurePackageManager(highestVersion);
await this.setLocalPackageManager(packageManagerInfo);
await this.setAndInstallLocalPackageManager(packageManagerInfo);
}
}
2 changes: 1 addition & 1 deletion sources/commands/Use.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,6 @@ export class UseCommand extends BaseCommand {
this.context.stdout.write(`Installing ${resolved.name}@${resolved.reference} in the project...\n`);

const packageManagerInfo = await this.context.engine.ensurePackageManager(resolved);
await this.setLocalPackageManager(packageManagerInfo);
await this.setAndInstallLocalPackageManager(packageManagerInfo);
}
}
4 changes: 2 additions & 2 deletions sources/httpUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,9 @@ export async function fetchAsJson(input: string | URL, init?: RequestInit) {

export async function fetchUrlStream(input: string | URL, init?: RequestInit) {
if (process.env.COREPACK_ENABLE_DOWNLOAD_PROMPT === `1`) {
console.error(`Corepack is about to download ${input}`);
console.error(`! Corepack is about to download ${input}`);
if (stdin.isTTY && !process.env.CI) {
stderr.write(`Do you want to continue? [Y/n] `);
stderr.write(`? Do you want to continue? [Y/n] `);
stdin.resume();
const chars = await once(stdin, `data`);
stdin.pause();
Expand Down
98 changes: 19 additions & 79 deletions sources/main.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,22 @@
import {BaseContext, Builtins, Cli, Command, Option, UsageError} from 'clipanion';

import {version as corepackVersion} from '../package.json';

import {Engine} from './Engine';
import {CacheCommand} from './commands/Cache';
import {DisableCommand} from './commands/Disable';
import {EnableCommand} from './commands/Enable';
import {InstallGlobalCommand} from './commands/InstallGlobal';
import {InstallLocalCommand} from './commands/InstallLocal';
import {PackCommand} from './commands/Pack';
import {UpCommand} from './commands/Up';
import {UseCommand} from './commands/Use';
import {HydrateCommand} from './commands/deprecated/Hydrate';
import {PrepareCommand} from './commands/deprecated/Prepare';
import * as corepackUtils from './corepackUtils';
import * as miscUtils from './miscUtils';
import * as specUtils from './specUtils';
import {Locator, SupportedPackageManagers, Descriptor} from './types';
import {BaseContext, Builtins, Cli, Command, Option} from 'clipanion';

import {version as corepackVersion} from '../package.json';

import {Engine, PackageManagerRequest} from './Engine';
import {CacheCommand} from './commands/Cache';
import {DisableCommand} from './commands/Disable';
import {EnableCommand} from './commands/Enable';
import {InstallGlobalCommand} from './commands/InstallGlobal';
import {InstallLocalCommand} from './commands/InstallLocal';
import {PackCommand} from './commands/Pack';
import {UpCommand} from './commands/Up';
import {UseCommand} from './commands/Use';
import {HydrateCommand} from './commands/deprecated/Hydrate';
import {PrepareCommand} from './commands/deprecated/Prepare';

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

type PackageManagerRequest = {
packageManager: SupportedPackageManagers;
binaryName: string;
binaryVersion: string | null;
};

function getPackageManagerRequestFromCli(parameter: string | undefined, context: CustomContext & Partial<Context>): PackageManagerRequest | null {
if (!parameter)
return null;
Expand All @@ -47,59 +37,6 @@ function getPackageManagerRequestFromCli(parameter: string | undefined, context:
};
}

async function executePackageManagerRequest({packageManager, binaryName, binaryVersion}: PackageManagerRequest, args: Array<string>, context: Context) {
let fallbackLocator: Locator = {
name: binaryName as SupportedPackageManagers,
reference: undefined as any,
};
let isTransparentCommand = false;
if (packageManager != null) {
const defaultVersion = await context.engine.getDefaultVersion(packageManager);
const definition = context.engine.config.definitions[packageManager]!;

// If all leading segments match one of the patterns defined in the `transparent`
// key, we tolerate calling this binary even if the local project isn't explicitly
// configured for it, and we use the special default version if requested.
for (const transparentPath of definition.transparent.commands) {
if (transparentPath[0] === binaryName && transparentPath.slice(1).every((segment, index) => segment === args[index])) {
isTransparentCommand = true;
break;
}
}

const fallbackReference = isTransparentCommand
? definition.transparent.default ?? defaultVersion
: defaultVersion;

fallbackLocator = {
name: packageManager,
reference: fallbackReference,
};
}

let descriptor: Descriptor;
try {
descriptor = await specUtils.findProjectSpec(context.cwd, fallbackLocator, {transparent: isTransparentCommand});
} catch (err) {
if (err instanceof miscUtils.Cancellation) {
return 1;
} else {
throw err;
}
}

if (binaryVersion)
descriptor.range = binaryVersion;

const resolved = await context.engine.resolveDescriptor(descriptor, {allowTags: true});
if (resolved === null)
throw new UsageError(`Failed to successfully resolve '${descriptor.range}' to a valid ${descriptor.name} release`);

const installSpec = await context.engine.ensurePackageManager(resolved);

return await corepackUtils.runVersion(resolved, installSpec, binaryName, args);
}

export async function runMain(argv: Array<string>) {
// Because we load the binaries in the same process, we don't support custom contexts.
const context = {
Expand Down Expand Up @@ -149,7 +86,10 @@ export async function runMain(argv: Array<string>) {
cli.register(class BinaryCommand extends Command<Context> {
proxy = Option.Proxy();
async execute() {
return executePackageManagerRequest(request, this.proxy, this.context);
return this.context.engine.executePackageManagerRequest(request, {
cwd: this.context.cwd,
args: this.proxy,
});
}
});

Expand Down