From d6e3e652ed9d28c424fc1d97d685a2726095dfd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C3=ABl=20Nison?= Date: Sun, 15 Aug 2021 20:05:04 +0200 Subject: [PATCH] Makes it possible to call arbitrary package manager versions from the Corepack CLI (#47) --- README.md | 8 ++- mkshims.ts | 2 +- sources/Engine.ts | 16 +++++ sources/main.ts | 163 +++++++++++++++++++++++++++------------------ tests/main.test.ts | 44 ++++++------ 5 files changed, 143 insertions(+), 90 deletions(-) diff --git a/README.md b/README.md index 2d4b7c0..d557a1b 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,12 @@ The utility commands detailed in the next section. ## Utility Commands +### `corepack [@] [... args]` + +This meta-command runs the specified package manager in the local folder. You can use it to force an install to run with a given version, which can be useful when looking for regressions. + +Note that those commands still check whether the local project is configured for the given package manager (ie you won't be able to run `corepack yarn install` on a project where the `packageManager` field references `pnpm`). + ### `corepack enable [... name]` | Option | Description | @@ -117,7 +123,7 @@ This command will retrieve the given package manager from the specified archive ## Contributing -If you want to build corepack yourself things yourself, you can build the project like this: +If you want to build corepack yourself, you can build the project like this: 1. Clone this repository 2. Run `yarn build` (no need for `yarn install`) diff --git a/mkshims.ts b/mkshims.ts index e36e526..869a763 100644 --- a/mkshims.ts +++ b/mkshims.ts @@ -29,7 +29,7 @@ async function main() { const entryPath = path.join(distDir, `${binaryName}.js`); const entryScript = [ `#!/usr/bin/env node\n`, - `require('./corepack').runMain(['${packageManager}', '${binaryName}', ...process.argv.slice(2)]);\n`, + `require('./corepack').runMain(['${binaryName}', ...process.argv.slice(2)]);\n`, ].join(``); fs.writeFileSync(entryPath, entryScript); diff --git a/sources/Engine.ts b/sources/Engine.ts index cc50673..f1e5f4c 100644 --- a/sources/Engine.ts +++ b/sources/Engine.ts @@ -16,6 +16,22 @@ export class Engine { constructor(public config: Config = defaultConfig as Config) { } + getPackageManagerFor(binaryName: string): SupportedPackageManagers | null { + for (const packageManager of SupportedPackageManagerSet) { + for (const rangeDefinition of Object.values(this.config.definitions[packageManager]!.ranges)) { + const bins = Array.isArray(rangeDefinition.bin) + ? rangeDefinition.bin + : Object.keys(rangeDefinition.bin); + + if (bins.includes(binaryName)) { + return packageManager; + } + } + } + + return null; + } + getBinariesFor(name: SupportedPackageManagers) { const binNames = new Set(); diff --git a/sources/main.ts b/sources/main.ts index 114609a..ed32c8c 100644 --- a/sources/main.ts +++ b/sources/main.ts @@ -8,86 +8,98 @@ import {PrepareCommand} from './command import * as miscUtils from './miscUtils'; import * as pmmUtils from './pmmUtils'; import * as specUtils from './specUtils'; -import {Locator, isSupportedPackageManager} from './types'; +import {Locator, SupportedPackageManagers, Descriptor} from './types'; export type CustomContext = {cwd: string, engine: Engine}; export type Context = BaseContext & CustomContext; -export async function main(argv: Array, context: CustomContext & Partial) { - const firstArg = argv[0]; - const [, candidatePackageManager, requestedVersion] = firstArg.match(/^([^@]*)(?:@(.*))?$/)!; +type PackageManagerRequest = { + packageManager: SupportedPackageManagers; + binaryName: string; + binaryVersion: string | null; +}; - if (isSupportedPackageManager(candidatePackageManager)) { - const packageManager = candidatePackageManager; - const binaryName = argv[1]; +function getPackageManagerRequestFromCli(parameter: string | undefined, context: CustomContext & Partial): PackageManagerRequest { + if (!parameter) + return null; - // Note: we're playing a bit with Clipanion here, since instead of letting it - // decide how to route the commands, we'll instead tweak the init settings - // based on the arguments. - const cli = new Cli({binaryName}); - const defaultVersion = await context.engine.getDefaultVersion(packageManager); + const match = parameter.match(/^([^@]*)(?:@(.*))?$/); + if (!match) + return null; - class BinaryCommand extends Command { - proxy = Option.Proxy(); + const [, binaryName, binaryVersion] = match; + const packageManager = context.engine.getPackageManagerFor(binaryName); + if (!packageManager) + return null; - async execute() { - const definition = context.engine.config.definitions[packageManager]!; + return { + packageManager, + binaryName, + binaryVersion: binaryVersion || null, + }; +} - // 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. - let isTransparentCommand = false; - for (const transparentPath of definition.transparent.commands) { - if (transparentPath[0] === binaryName && transparentPath.slice(1).every((segment, index) => segment === this.proxy[index])) { - isTransparentCommand = true; - break; - } - } +async function executePackageManagerRequest({packageManager, binaryName, binaryVersion}: PackageManagerRequest, args: Array, context: Context) { + const defaultVersion = await context.engine.getDefaultVersion(packageManager); + const definition = context.engine.config.definitions[packageManager]!; - const fallbackReference = isTransparentCommand - ? definition.transparent.default ?? defaultVersion - : defaultVersion; - - const fallbackLocator: Locator = { - name: packageManager, - reference: fallbackReference, - }; - - let descriptor; - try { - descriptor = await specUtils.findProjectSpec(this.context.cwd, fallbackLocator, {transparent: isTransparentCommand}); - } catch (err) { - if (err instanceof miscUtils.Cancellation) { - return 1; - } else { - throw err; - } - } - - if (requestedVersion) - descriptor.range = requestedVersion; - - 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); - const exitCode = await pmmUtils.runVersion(installSpec, resolved, binaryName, this.proxy, this.context); - - return exitCode; - } + // 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. + let isTransparentCommand = false; + for (const transparentPath of definition.transparent.commands) { + if (transparentPath[0] === binaryName && transparentPath.slice(1).every((segment, index) => segment === args[index])) { + isTransparentCommand = true; + break; } + } - cli.register(BinaryCommand); + const fallbackReference = isTransparentCommand + ? definition.transparent.default ?? defaultVersion + : defaultVersion; - return await cli.run(argv.slice(2), { - ...Cli.defaultContext, - ...context, - }); - } else { - const cli = new Cli({ + const fallbackLocator: Locator = { + 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); + const exitCode = await pmmUtils.runVersion(installSpec, resolved, binaryName, args, context); + + return exitCode; +} + +export async function main(argv: Array, context: CustomContext & Partial) { + const corepackVersion = require(`../package.json`).version; + + const [firstArg, ...restArgs] = argv; + const request = getPackageManagerRequestFromCli(firstArg, context); + + let cli: Cli; + if (!request) { + // If the first argument doesn't match any supported package manager, we fallback to the standard Corepack CLI + cli = new Cli({ + binaryLabel: `Corepack`, binaryName: `corepack`, - binaryVersion: require(`../package.json`).version, + binaryVersion: corepackVersion, }); cli.register(Builtins.HelpCommand); @@ -102,6 +114,25 @@ export async function main(argv: Array, context: CustomContext & Partial ...Cli.defaultContext, ...context, }); + } else { + // Otherwise, we create a single-command CLI to run the specified package manager (we still use Clipanion in order to pretty-print usage errors). + const cli = new Cli({ + binaryLabel: `'${request.binaryName}', via Corepack`, + binaryName: request.binaryName, + binaryVersion: `corepack/${corepackVersion}`, + }); + + cli.register(class BinaryCommand extends Command { + proxy = Option.Proxy(); + async execute() { + return executePackageManagerRequest(request, this.proxy, this.context); + } + }); + + return await cli.run(restArgs, { + ...Cli.defaultContext, + ...context, + }); } } diff --git a/tests/main.test.ts b/tests/main.test.ts index 2237c74..ec83898 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -24,7 +24,7 @@ for (const [name, version] of testedPackageManagers) { packageManager: `${name}@${version}`, }); - await expect(runCli(cwd, [name, name, `--version`])).resolves.toMatchObject({ + await expect(runCli(cwd, [name, `--version`])).resolves.toMatchObject({ exitCode: 0, stdout: `${version}\n`, }); @@ -49,12 +49,12 @@ it(`should ignore the packageManager field when found within a node_modules vend packageManager: `npm@6.14.2`, }); - await expect(runCli(ppath.join(cwd, `node_modules/foo` as PortablePath), [`yarn`, `yarn`, `--version`])).resolves.toMatchObject({ + await expect(runCli(ppath.join(cwd, `node_modules/foo` as PortablePath), [`yarn`, `--version`])).resolves.toMatchObject({ exitCode: 0, stdout: `1.22.4\n`, }); - await expect(runCli(ppath.join(cwd, `node_modules/@foo/bar` as PortablePath), [`yarn`, `yarn`, `--version`])).resolves.toMatchObject({ + await expect(runCli(ppath.join(cwd, `node_modules/@foo/bar` as PortablePath), [`yarn`, `--version`])).resolves.toMatchObject({ exitCode: 0, stdout: `1.22.4\n`, }); @@ -73,7 +73,7 @@ it(`should use the closest matching packageManager field`, async () => { packageManager: `npm@6.14.2`, }); - await expect(runCli(ppath.join(cwd, `foo` as PortablePath), [`npm`, `npm`, `--version`])).resolves.toMatchObject({ + await expect(runCli(ppath.join(cwd, `foo` as PortablePath), [`npm`, `--version`])).resolves.toMatchObject({ exitCode: 0, stdout: `6.14.2\n`, }); @@ -86,7 +86,7 @@ it(`should expose its root to spawned processes`, async () => { packageManager: `npm@6.14.2`, }); - await expect(runCli(cwd, [`npm`, `npm`, `run`, `env`])).resolves.toMatchObject({ + await expect(runCli(cwd, [`npm`, `run`, `env`])).resolves.toMatchObject({ exitCode: 0, stdout: expect.stringContaining(`COREPACK_ROOT=${npath.dirname(__dirname)}\n`), }); @@ -99,7 +99,7 @@ it(`shouldn't allow using regular Yarn commands on npm-configured projects`, asy packageManager: `npm@6.14.2`, }); - await expect(runCli(cwd, [`yarn`, `yarn`, `--version`])).resolves.toMatchObject({ + await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({ exitCode: 1, }); }); @@ -111,7 +111,7 @@ it(`should allow using transparent commands on npm-configured projects`, async ( packageManager: `npm@6.14.2`, }); - await expect(runCli(cwd, [`yarn`, `yarn`, `dlx`, `cat@0.2.0`, __filename])).resolves.toMatchObject({ + await expect(runCli(cwd, [`yarn`, `dlx`, `cat@0.2.0`, __filename])).resolves.toMatchObject({ exitCode: 0, }); }); @@ -119,7 +119,7 @@ it(`should allow using transparent commands on npm-configured projects`, async ( it(`should transparently use the preconfigured version when there is no local project`, async () => { await xfs.mktempPromise(async cwd => { - await expect(runCli(cwd, [`yarn`, `yarn`, `--version`])).resolves.toMatchObject({ + await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({ exitCode: 0, }); }); @@ -135,17 +135,17 @@ it(`should use the pinned version when local projects don't list any spec`, asyn // empty package.json file }); - await expect(runCli(cwd, [`yarn`, `yarn`, `--version`])).resolves.toMatchObject({ + await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({ stdout: `${config.definitions.yarn.default}\n`, exitCode: 0, }); - await expect(runCli(cwd, [`pnpm`, `pnpm`, `--version`])).resolves.toMatchObject({ + await expect(runCli(cwd, [`pnpm`, `--version`])).resolves.toMatchObject({ stdout: `${config.definitions.pnpm.default}\n`, exitCode: 0, }); - await expect(runCli(cwd, [`npm`, `npm`, `--version`])).resolves.toMatchObject({ + await expect(runCli(cwd, [`npm`, `--version`])).resolves.toMatchObject({ stdout: `${config.definitions.npm.default}\n`, exitCode: 0, }); @@ -162,7 +162,7 @@ it(`should allow updating the pinned version using the "prepare" command`, async // empty package.json file }); - await expect(runCli(cwd, [`yarn`, `yarn`, `--version`])).resolves.toMatchObject({ + await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({ stdout: `1.0.0\n`, exitCode: 0, }); @@ -179,7 +179,7 @@ it(`should allow to call "prepare" without arguments within a configured project exitCode: 0, }); - await expect(runCli(cwd, [`yarn`, `yarn`, `--version`])).resolves.toMatchObject({ + await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({ stdout: `1.0.0\n`, exitCode: 0, }); @@ -199,17 +199,17 @@ it(`should allow to call "prepare" with --all to prepare all package managers`, process.env.COREPACK_ENABLE_NETWORK = `0`; try { - await expect(runCli(cwd, [`yarn`, `yarn`, `--version`])).resolves.toMatchObject({ + await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({ stdout: `${config.definitions.yarn.default}\n`, exitCode: 0, }); - await expect(runCli(cwd, [`pnpm`, `pnpm`, `--version`])).resolves.toMatchObject({ + await expect(runCli(cwd, [`pnpm`, `--version`])).resolves.toMatchObject({ stdout: `${config.definitions.pnpm.default}\n`, exitCode: 0, }); - await expect(runCli(cwd, [`npm`, `npm`, `--version`])).resolves.toMatchObject({ + await expect(runCli(cwd, [`npm`, `--version`])).resolves.toMatchObject({ stdout: `${config.definitions.npm.default}\n`, exitCode: 0, }); @@ -228,7 +228,7 @@ it(`should support disabling the network accesses from the environment`, async ( packageManager: `yarn@2.2.2`, }); - await expect(runCli(cwd, [`yarn`, `yarn`, `--version`])).resolves.toMatchObject({ + await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({ stdout: expect.stringContaining(`Network access disabled by the environment`), exitCode: 1, }); @@ -260,7 +260,7 @@ it(`should support hydrating package managers from cached archives`, async () => packageManager: `yarn@2.2.2`, }); - await expect(runCli(cwd, [`yarn`, `yarn`, `--version`])).resolves.toMatchObject({ + await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({ stdout: `2.2.2\n`, exitCode: 0, }); @@ -292,7 +292,7 @@ it(`should support hydrating multiple package managers from cached archives`, as packageManager: `yarn@2.2.2`, }); - await expect(runCli(cwd, [`yarn`, `yarn`, `--version`])).resolves.toMatchObject({ + await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({ stdout: `2.2.2\n`, exitCode: 0, }); @@ -301,7 +301,7 @@ it(`should support hydrating multiple package managers from cached archives`, as packageManager: `pnpm@5.8.0`, }); - await expect(runCli(cwd, [`pnpm`, `pnpm`, `--version`])).resolves.toMatchObject({ + await expect(runCli(cwd, [`pnpm`, `--version`])).resolves.toMatchObject({ stdout: `5.8.0\n`, exitCode: 0, }); @@ -317,12 +317,12 @@ it(`should support running package managers with bin array`, async () => { packageManager: `yarn@2.2.2`, }); - await expect(runCli(cwd, [`yarn`, `yarnpkg`, `--version`])).resolves.toMatchObject({ + await expect(runCli(cwd, [`yarnpkg`, `--version`])).resolves.toMatchObject({ stdout: `2.2.2\n`, exitCode: 0, }); - await expect(runCli(cwd, [`yarn`, `yarn`, `--version`])).resolves.toMatchObject({ + await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({ stdout: `2.2.2\n`, exitCode: 0, });