From d31ea641f906f59e441f9c02652419e0ad4a54a1 Mon Sep 17 00:00:00 2001 From: Aschenbath Date: Mon, 19 Oct 2026 03:14:58 +0000 Subject: [PATCH] feat: run commands in a directory with --dir --- README.md | 14 +++++ sources/commands/Dir.ts | 48 +++++++++++++++ sources/main.ts | 4 +- tests/Dir.test.ts | 125 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 190 insertions(+), 1 deletion(-) create mode 100644 sources/commands/Dir.ts create mode 100644 tests/Dir.test.ts diff --git a/README.md b/README.md index e2adeb949..53b27eacc 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,20 @@ The utility commands detailed in the next section. ## Utility Commands +### `corepack --dir [... args]` + +Run a Corepack command or package manager from the specified directory. Relative +paths are resolved from the current directory; `--dir=` is also supported. +Place this option before the command, for example: + +```sh +corepack --dir ./my-project install +corepack --dir ./my-project pnpm install +``` + +Corepack looks up the project's configuration in that directory, and the package +manager runs there. Arguments after the package manager name are passed to it. + ### `corepack [@] [... args]` This meta-command runs the specified package manager in the local folder. You diff --git a/sources/commands/Dir.ts b/sources/commands/Dir.ts new file mode 100644 index 000000000..02b1f7617 --- /dev/null +++ b/sources/commands/Dir.ts @@ -0,0 +1,48 @@ +import {Command, Option, UsageError} from 'clipanion'; +import path from 'path'; + +import type {Context} from '../main.ts'; +import {runMain} from '../main.ts'; +import {isNodeError} from '../nodeUtils.ts'; + +export class DirCommand extends Command { + static paths = [Command.Default]; + + static usage = Command.Usage({ + description: `Run a command from another directory`, + details: ` + Place \`--dir \` before the command to run it from the specified + directory. Relative paths are resolved from the current directory. + + This applies to both Corepack commands and package manager invocations. + Arguments after the package manager name are passed to that manager. + `, + examples: [[ + `Install another project's package manager`, + `corepack --dir ./my-project install`, + ], [ + `Run pnpm in another project`, + `corepack --dir ./my-project pnpm install`, + ]], + }); + + directory = Option.String(`--dir`, { + required: true, + description: `The directory in which to run the command`, + }); + + command = Option.Proxy({name: `command`, required: 1}); + + async execute() { + const directory = path.resolve(this.context.cwd, this.directory); + try { + process.chdir(directory); + } catch (error) { + if (isNodeError(error)) + throw new UsageError(`Couldn't change directory to ${JSON.stringify(directory)}: ${error.message}`); + throw error; + } + + await runMain(this.command); + } +} diff --git a/sources/main.ts b/sources/main.ts index e623af1f5..3738f40bd 100644 --- a/sources/main.ts +++ b/sources/main.ts @@ -6,6 +6,7 @@ import pJSON from '../package.json' with {type: 'json'} import type {PackageManagerRequest} from './Engine.ts'; import {Engine} from './Engine.ts'; import {CacheCommand} from './commands/Cache.ts'; +import {DirCommand} from './commands/Dir.ts'; import {DisableCommand} from './commands/Disable.ts'; import {EnableCommand} from './commands/Enable.ts'; import {InstallGlobalCommand} from './commands/InstallGlobal.ts'; @@ -22,7 +23,7 @@ export type Context = BaseContext & CustomContext; const {version: corepackVersion} = pJSON; function getPackageManagerRequestFromCli(parameter: string | undefined, engine: Engine): PackageManagerRequest | null { - if (!parameter) + if (!parameter || parameter.startsWith(`-`)) return null; const match = parameter.match(/^([^@]*)(?:@(.*))?$/); @@ -64,6 +65,7 @@ export async function runMain(argv: Array) { cli.register(CacheCommand); cli.register(DisableCommand); + cli.register(DirCommand); cli.register(EnableCommand); cli.register(InstallGlobalCommand); cli.register(InstallLocalCommand); diff --git a/tests/Dir.test.ts b/tests/Dir.test.ts new file mode 100644 index 000000000..d72d3aaf8 --- /dev/null +++ b/tests/Dir.test.ts @@ -0,0 +1,125 @@ +import {npath, ppath, xfs} from '@yarnpkg/fslib'; +import {beforeEach, describe, expect, it} from 'vitest'; + +import {runCli} from './_runCli.ts'; + +beforeEach(async () => { + const home = await xfs.mktempPromise(); + process.env.COREPACK_HOME = npath.fromPortablePath(home); + process.env.COREPACK_DEFAULT_TO_LATEST = `0`; + process.env.AUTH_TYPE = `COREPACK_NPM_TOKEN`; + process.env.TEST_INTEGRITY = `valid`; + + return async () => { + await xfs.removePromise(home, {recursive: true}); + }; +}); + +describe(`--dir`, () => { + it.each([ + {absolute: false, bound: false}, + {absolute: false, bound: true}, + {absolute: true, bound: false}, + {absolute: true, bound: true}, + ])(`uses the target manifest with absolute=$absolute, bound=$bound`, async ({absolute, bound}) => { + await xfs.mktempPromise(async cwd => { + const project = ppath.join(cwd, `project @scope`); + await xfs.mkdirPromise(project); + await xfs.writeJsonPromise(ppath.join(cwd, `package.json`), {packageManager: `yarn@1.22.4`}); + await xfs.writeJsonPromise(ppath.join(project, `package.json`), {packageManager: `pnpm@1.9998.9999`}); + + const directory = absolute ? npath.fromPortablePath(project) : `project @scope`; + const options = bound ? [`--dir=${directory}`] : [`--dir`, directory]; + await expect(runCli(cwd, [...options, `pnpm`, `--version`], true)).resolves.toEqual({ + exitCode: 0, + stdout: `pnpm: Hello from custom registry\n`, + stderr: ``, + }); + }); + }); + + it(`runs Corepack utility commands in the target project`, async () => { + await xfs.mktempPromise(async cwd => { + const project = ppath.join(cwd, `project`); + await xfs.mkdirPromise(project); + await xfs.writeJsonPromise(ppath.join(cwd, `package.json`), {packageManager: `yarn@1.22.4`}); + await xfs.writeJsonPromise(ppath.join(project, `package.json`), {packageManager: `pnpm@1.9998.9999`}); + + await expect(runCli(cwd, [`--dir`, `project`, `install`], true)).resolves.toEqual({ + exitCode: 0, + stdout: `Adding pnpm@1.9998.9999 to the cache...\n`, + stderr: ``, + }); + + process.env.COREPACK_ENABLE_NETWORK = `0`; + await expect(runCli(cwd, [`--dir`, `project`, `pnpm`, `--version`], true)).resolves.toEqual({ + exitCode: 0, + stdout: `pnpm: Hello from custom registry\n`, + stderr: ``, + }); + }); + }); + + it(`changes the executed process cwd and forwards the package manager's own --dir`, async () => { + process.env.CI = `1`; + + await xfs.mktempPromise(async cwd => { + const project = ppath.join(cwd, `project`); + const nested = ppath.join(project, `nested`); + await xfs.mkdirPromise(nested, {recursive: true}); + await xfs.writeJsonPromise(ppath.join(cwd, `package.json`), {packageManager: `yarn@1.22.4`}); + await xfs.writeJsonPromise(ppath.join(project, `package.json`), {packageManager: `pnpm@6.6.2`}); + await xfs.writeJsonPromise(ppath.join(nested, `package.json`), {private: true}); + await xfs.writeFilePromise(ppath.join(nested, `print-cwd.cjs`), `console.log(process.cwd());\n`); + + await expect(runCli(cwd, [`--dir`, `project`, `pnpm`, `--dir`, `nested`, `exec`, `node`, `print-cwd.cjs`])).resolves.toEqual({ + exitCode: 0, + stdout: `${npath.fromPortablePath(nested)}\n`, + stderr: ``, + }); + }); + }); + + it(`loads the target .corepack.env and propagates command failures`, async () => { + await xfs.mktempPromise(async cwd => { + const project = ppath.join(cwd, `project`); + await xfs.mkdirPromise(project); + await xfs.writeFilePromise(ppath.join(project, `.corepack.env`), `COREPACK_ENABLE_NETWORK=0\n`); + + await expect(runCli(cwd, [`--dir`, `project`, `use`, `pnpm@latest`], true)).resolves.toMatchObject({ + exitCode: 1, + stdout: expect.stringContaining(`Network access disabled by the environment`), + stderr: ``, + }); + }); + }); + + it.each([false, true])(`reports an invalid directory with existing file=%s`, async existingFile => { + await xfs.mktempPromise(async cwd => { + if (existingFile) + await xfs.writeFilePromise(ppath.join(cwd, `invalid`), ``); + + await expect(runCli(cwd, [`--dir`, `invalid`, `install`])).resolves.toMatchObject({ + exitCode: 1, + stdout: expect.stringContaining(`Couldn't change directory to`), + stderr: ``, + }); + }); + }); + + it.each([[`--dir`], [`--dir`, `.`]])(`rejects incomplete arguments %j`, async (...args) => { + await xfs.mktempPromise(async cwd => { + await expect(runCli(cwd, args)).resolves.toMatchObject({exitCode: 1}); + }); + }); + + it(`keeps the other commands visible in the main help`, async () => { + await xfs.mktempPromise(async cwd => { + const result = await runCli(cwd, [`--help`]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain(`corepack use`); + expect(result.stdout).toContain(`corepack up`); + expect(result.stdout).toContain(`--dir`); + }); + }); +});