diff --git a/README.md b/README.md index 209b6027f..4d65c2b1a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/sources/Engine.ts b/sources/Engine.ts index 82c1513a9..325563f30 100644 --- a/sources/Engine.ts +++ b/sources/Engine.ts @@ -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>; +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); } @@ -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 { + // 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}): Promise { + 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 { diff --git a/sources/commands/Base.ts b/sources/commands/Base.ts index 54c815c14..0067ae716 100644 --- a/sources/commands/Base.ts +++ b/sources/commands/Base.ts @@ -29,20 +29,10 @@ export abstract class BaseCommand extends Command { 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) diff --git a/sources/commands/Up.ts b/sources/commands/Up.ts index 8d5eb14d8..383397a25 100644 --- a/sources/commands/Up.ts +++ b/sources/commands/Up.ts @@ -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); } } diff --git a/sources/commands/Use.ts b/sources/commands/Use.ts index 0ac4dd3f4..76696c467 100644 --- a/sources/commands/Use.ts +++ b/sources/commands/Use.ts @@ -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); } } diff --git a/sources/httpUtils.ts b/sources/httpUtils.ts index 6e23c97ca..2841907e7 100644 --- a/sources/httpUtils.ts +++ b/sources/httpUtils.ts @@ -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(); diff --git a/sources/main.ts b/sources/main.ts index 4bfcd0d6e..d58dcb4f2 100644 --- a/sources/main.ts +++ b/sources/main.ts @@ -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): PackageManagerRequest | null { if (!parameter) return null; @@ -47,59 +37,6 @@ function getPackageManagerRequestFromCli(parameter: string | undefined, context: }; } -async function executePackageManagerRequest({packageManager, binaryName, binaryVersion}: PackageManagerRequest, args: Array, 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) { // Because we load the binaries in the same process, we don't support custom contexts. const context = { @@ -149,7 +86,10 @@ export async function runMain(argv: Array) { cli.register(class BinaryCommand extends Command { 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, + }); } }); diff --git a/sources/specUtils.ts b/sources/specUtils.ts index 9d366675a..bb588654c 100644 --- a/sources/specUtils.ts +++ b/sources/specUtils.ts @@ -1,10 +1,12 @@ -import {UsageError} from 'clipanion'; -import fs from 'fs'; -import path from 'path'; -import semver from 'semver'; +import {UsageError} from 'clipanion'; +import fs from 'fs'; +import path from 'path'; +import semver from 'semver'; -import {NodeError} from './nodeUtils'; -import {Descriptor, Locator, isSupportedPackageManager} from './types'; +import {PreparedPackageManagerInfo} from './Engine'; +import {NodeError} from './nodeUtils'; +import * as nodeUtils from './nodeUtils'; +import {Descriptor, isSupportedPackageManager} from './types'; const nodeModulesRegExp = /[\\/]node_modules[\\/](@[^\\/]*[\\/])?([^@\\/][^\\/]*)$/; @@ -49,54 +51,24 @@ export function parseSpec(raw: unknown, source: string, {enforceExactVersion = t }; } -/** - * 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. - */ -export async function findProjectSpec(initialCwd: string, locator: Locator, {transparent = false}: {transparent?: boolean} = {}): Promise { - // A locator is a valid descriptor (but not the other way around) - const fallbackLocator = {name: locator.name, range: `${locator.reference}`}; - - if (process.env.COREPACK_ENABLE_PROJECT_SPEC === `0`) - return fallbackLocator; - - if (process.env.COREPACK_ENABLE_STRICT === `0`) - transparent = true; - - while (true) { - const result = await loadSpec(initialCwd); - - switch (result.type) { - case `NoProject`: - case `NoSpec`: { - return fallbackLocator; - } - - case `Found`: { - if (result.spec.name !== locator.name) { - if (transparent) { - return fallbackLocator; - } else { - throw new UsageError(`This project is configured to use ${result.spec.name}`); - } - } else { - return result.spec; - } - } - } - } +export async function setLocalPackageManager(cwd: string, info: PreparedPackageManagerInfo) { + const lookup = await loadSpec(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}`; + + const newContent = nodeUtils.normalizeLineEndings(content, `${JSON.stringify(data, null, indent)}\n`); + await fs.promises.writeFile(lookup.target, newContent, `utf8`); + + return { + previousPackageManager, + }; } export type LoadSpecResult = diff --git a/tests/main.test.ts b/tests/main.test.ts index e006131f3..41616c7fb 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -4,6 +4,7 @@ import process from 'node:process'; import config from '../config.json'; import * as folderUtils from '../sources/folderUtils'; +import {SupportedPackageManagerSet} from '../sources/types'; import {runCli} from './_runCli'; @@ -297,32 +298,37 @@ it(`should transparently use the preconfigured version when there is no local pr }); }); -it(`should use the pinned version when local projects don't list any spec`, async () => { - // Note that we don't prevent using any package manager. This ensures that - // projects will receive as little disruption as possible (for example, we - // don't prompt to set the packageManager field). +// Note that we don't prevent using any package manager. This ensures that +// projects will receive as little disruption as possible (for example, we +// don't prompt to set the packageManager field). +for (const name of SupportedPackageManagerSet) { + it(`should use the pinned version when local projects don't list any spec (${name})`, async () => { + await xfs.mktempPromise(async cwd => { + await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as Filename), { + // empty package.json file + }); + + await expect(runCli(cwd, [name, `--version`])).resolves.toMatchObject({ + stdout: `${config.definitions[name].default.split(`+`, 1)[0]}\n`, + exitCode: 0, + }); + }); + }); +} + +it(`should configure the project when calling a package manager on it for the first time`, async () => { await xfs.mktempPromise(async cwd => { await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as Filename), { // empty package.json file }); - await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({ - stdout: `${config.definitions.yarn.default.split(`+`, 1)[0]}\n`, - stderr: ``, - exitCode: 0, - }); + await runCli(cwd, [`yarn`]); - await expect(runCli(cwd, [`pnpm`, `--version`])).resolves.toMatchObject({ - stdout: `${config.definitions.pnpm.default.split(`+`, 1)[0]}\n`, - stderr: ``, - exitCode: 0, - }); + const data = await xfs.readJsonPromise(ppath.join(cwd, `package.json` as Filename)); - await expect(runCli(cwd, [`npm`, `--version`])).resolves.toMatchObject({ - stdout: `${config.definitions.npm.default.split(`+`, 1)[0]}\n`, - stderr: ``, - exitCode: 0, + expect(data).toMatchObject({ + packageManager: `yarn@${config.definitions.yarn.default}`, }); }); }); @@ -340,7 +346,6 @@ it(`should allow updating the pinned version using the "corepack install -g" com await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({ stdout: `1.0.0\n`, - stderr: ``, exitCode: 0, }); }); @@ -359,7 +364,6 @@ it(`should allow to call "corepack install -g" with a tag`, async () => { await expect(runCli(cwd, [`npm`, `--version`])).resolves.toMatchObject({ stdout: expect.stringMatching(/^7\./), - stderr: ``, exitCode: 0, }); }); @@ -378,7 +382,6 @@ it(`should allow to call "corepack install -g" without any range`, async () => { await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({ stdout: expect.not.stringMatching(/^[123]\./), - stderr: ``, exitCode: 0, }); }); @@ -735,7 +738,7 @@ it(`should show a warning on stderr before downloading when enable`, async() => await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({ exitCode: 0, stdout: `3.0.0\n`, - stderr: `Corepack is about to download https://repo.yarnpkg.com/3.0.0/packages/yarnpkg-cli/bin/yarn.js\n`, + stderr: `! Corepack is about to download https://repo.yarnpkg.com/3.0.0/packages/yarnpkg-cli/bin/yarn.js\n`, }); }); }); @@ -766,7 +769,7 @@ it(`should download yarn classic from custom registry`, async () => { await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({ exitCode: 0, stdout: /^1\.\d+\.\d+\r?\n$/, - stderr: /^Corepack is about to download https:\/\/registry\.npmmirror\.com\/yarn\/-\/yarn-1\.\d+\.\d+\.tgz\r?\n$/, + stderr: /^! Corepack is about to download https:\/\/registry\.npmmirror\.com\/yarn\/-\/yarn-1\.\d+\.\d+\.tgz\r?\n$/, }); // Should keep working with cache @@ -790,7 +793,7 @@ it(`should download yarn berry from custom registry`, async () => { await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({ exitCode: 0, stdout: `3.0.0\n`, - stderr: `Corepack is about to download https://registry.npmmirror.com/@yarnpkg/cli-dist/-/cli-dist-3.0.0.tgz\n`, + stderr: `! Corepack is about to download https://registry.npmmirror.com/@yarnpkg/cli-dist/-/cli-dist-3.0.0.tgz\n`, }); // Should keep working with cache diff --git a/tests/nocks.db b/tests/nocks.db index 4c88f9407..74660b09e 100644 Binary files a/tests/nocks.db and b/tests/nocks.db differ