Skip to content

Commit

Permalink
feat: Pins the package manager as it's used for the first time (#413)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
arcanis committed Mar 3, 2024
1 parent a3ea1dd commit 8b6c6d4
Show file tree
Hide file tree
Showing 10 changed files with 220 additions and 177 deletions.
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
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
Expand Up @@ -29,20 +29,10 @@ export abstract class BaseCommand extends Command<Context> {
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

0 comments on commit 8b6c6d4

Please sign in to comment.