diff --git a/README.md b/README.md index e2adeb949..922a7e5c8 100644 --- a/README.md +++ b/README.md @@ -271,12 +271,20 @@ calling their respective binaries outside of projects defining the Download the selected package managers and store them inside a tarball suitable for use with `corepack install -g`. -### `corepack use ]>` +### `corepack use ]> [--here]` When run, this command will retrieve the latest release matching the provided descriptor, assign it to the project's package.json file, and automatically perform an install. +Use `--here` for an independent project nested inside another project. For +example, running `corepack use pnpm@latest --here` from `examples/pnpm` updates +or creates `examples/pnpm/package.json`, even if a parent directory already +defines a package manager. Corepack validates the current manifest's +`devEngines.packageManager` and reads the current directory's `.corepack.env`, +without searching parent directories. Installation runs in the current directory. +Without `--here`, Corepack uses its normal project lookup. + ### `corepack up` Retrieve the latest available version for the current major release line of diff --git a/sources/commands/Base.ts b/sources/commands/Base.ts index 9c7214c08..299e805d6 100644 --- a/sources/commands/Base.ts +++ b/sources/commands/Base.ts @@ -6,11 +6,11 @@ import type {Context} from '../main.ts'; import * as specUtils from '../specUtils.ts'; export abstract class BaseCommand extends Command { - async resolvePatternsToDescriptors({patterns}: {patterns: Array}) { + async resolvePatternsToDescriptors({patterns, localOnly = false}: {patterns: Array, localOnly?: boolean}) { const resolvedSpecs = patterns.map(pattern => specUtils.parseSpec(pattern, `CLI arguments`, {enforceExactVersion: false})); if (resolvedSpecs.length === 0) { - const lookup = await specUtils.loadSpecAndEnv(this.context.cwd); + const lookup = await specUtils.loadSpecAndEnv(this.context.cwd, {localOnly}); switch (lookup.type) { case `NoProject`: throw new UsageError(`Couldn't find a project in the local directory - please specify the package manager to pack, or run this command from a valid project`); @@ -29,16 +29,16 @@ export abstract class BaseCommand extends Command { } } } else { - await specUtils.loadSpecAndEnv(this.context.cwd, {envOnly: true}); + await specUtils.loadSpecAndEnv(this.context.cwd, {envOnly: true, localOnly}); } return resolvedSpecs; } - async setAndInstallLocalPackageManager(info: PreparedPackageManagerInfo) { + async setAndInstallLocalPackageManager(info: PreparedPackageManagerInfo, {localOnly = false} = {}) { const { previousPackageManager, - } = await specUtils.setLocalPackageManager(this.context.cwd, info); + } = await specUtils.setLocalPackageManager(this.context.cwd, info, {localOnly}); const command = this.context.engine.getPackageManagerSpecFor(info.locator).commands?.use ?? null; if (command === null) diff --git a/sources/commands/Use.ts b/sources/commands/Use.ts index 508a2864e..fe58113d8 100644 --- a/sources/commands/Use.ts +++ b/sources/commands/Use.ts @@ -13,6 +13,10 @@ export class UseCommand extends BaseCommand { When run, this command will retrieve the latest release matching the provided descriptor, assign it to the project's package.json file, and automatically perform an install. + + With \`--here\`, treat the current directory as the project root. Update + or create its package.json and read its local .corepack.env without + searching parent directories. `, examples: [[ `Configure the project to use the latest Yarn release`, @@ -22,9 +26,14 @@ export class UseCommand extends BaseCommand { pattern = Option.String(); + here = Option.Boolean(`--here`, false, { + description: `Update or create package.json in the current directory`, + }); + async execute() { const [descriptor] = await this.resolvePatternsToDescriptors({ patterns: [this.pattern], + localOnly: this.here, }); const resolved = await this.context.engine.resolveDescriptor(descriptor, {allowTags: true, useCache: false}); @@ -34,6 +43,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.setAndInstallLocalPackageManager(packageManagerInfo); + await this.setAndInstallLocalPackageManager(packageManagerInfo, {localOnly: this.here}); } } diff --git a/sources/specUtils.ts b/sources/specUtils.ts index 275c49058..a027fb37f 100644 --- a/sources/specUtils.ts +++ b/sources/specUtils.ts @@ -128,8 +128,8 @@ function parsePackageJSON(packageJSONContent: CorepackPackageJSON): ParsedPackag return {packageManagerField: pm}; } -export async function setLocalPackageManager(cwd: string, info: PreparedPackageManagerInfo) { - const lookup = await loadSpecAndEnv(cwd); +export async function setLocalPackageManager(cwd: string, info: PreparedPackageManagerInfo, {localOnly = false} = {}) { + const lookup = await loadSpecAndEnv(cwd, {localOnly}); const projectFound = lookup.type !== `NoProject`; const devEnginesValue = projectFound ? lookup.devEnginesValue : undefined; @@ -193,7 +193,7 @@ async function loadEnvFileIfExists(cwd: string): Promise<{env: LocalEnvFile, pat return void 0; } -export async function loadSpecAndEnv(initialCwd: string, {envOnly} = {envOnly: false}): Promise { +export async function loadSpecAndEnv(initialCwd: string, {envOnly = false, localOnly = false} = {}): Promise { let nextCwd = initialCwd; let currCwd = ``; @@ -205,9 +205,9 @@ export async function loadSpecAndEnv(initialCwd: string, {envOnly} = {envOnly: f while (nextCwd !== currCwd && (!selection || !selection.data.packageManager)) { currCwd = nextCwd; - nextCwd = path.dirname(currCwd); + nextCwd = localOnly ? currCwd : path.dirname(currCwd); - if (nodeModulesRegExp.test(currCwd)) + if (!localOnly && nodeModulesRegExp.test(currCwd)) continue; if (process.env.COREPACK_ENV_FILE !== `0` && !localEnv) diff --git a/tests/Use.test.ts b/tests/Use.test.ts index 6dd37939e..13e8ae89e 100644 --- a/tests/Use.test.ts +++ b/tests/Use.test.ts @@ -54,7 +54,7 @@ describe(`UseCommand`, () => { await expect(runCli(cwd, [`use`, `yarn@1.22.4`])).resolves.toMatchObject({ exitCode: 1, stderr: ``, - stdout: `Installing yarn@1.22.4 in the project...\nUsage Error: The requested version of yarn@1.22.4+sha512.a1833b862fe52169bd6c2a033045a07df5bc6a23595c259e675fed1b2d035ab37abe6ce309720abb6636d68f03615054b6292dc0a70da31c8697fda228b50d18 does not match the devEngines specification (yarn@2.x)\n\n$ corepack use \n`, + stdout: `Installing yarn@1.22.4 in the project...\nUsage Error: The requested version of yarn@1.22.4+sha512.a1833b862fe52169bd6c2a033045a07df5bc6a23595c259e675fed1b2d035ab37abe6ce309720abb6636d68f03615054b6292dc0a70da31c8697fda228b50d18 does not match the devEngines specification (yarn@2.x)\n\n$ corepack use [--here] \n`, }); // Should accept setting to a compatible version: diff --git a/tests/UseHere.test.ts b/tests/UseHere.test.ts new file mode 100644 index 000000000..417d44fa3 --- /dev/null +++ b/tests/UseHere.test.ts @@ -0,0 +1,113 @@ +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 () => xfs.removePromise(home, {recursive: true}); +}); + +describe(`use --here`, () => { + it.each([true, false])(`targets the current directory (manifest exists: %s)`, async exists => { + await xfs.mktempPromise(async root => { + const parent = `{"packageManager":"yarn@1.22.4"}\n`; + await xfs.writeFilePromise(ppath.join(root, `package.json`), parent); + const cwd = ppath.join(root, `examples`, `pnpm`); + await xfs.mkdirpPromise(cwd); + if (exists) + await xfs.writeJsonPromise(ppath.join(cwd, `package.json`), {name: `example`}); + + await expect(runCli(cwd, [`use`, `pnpm@1.9998.9999`, `--here`], true)).resolves.toMatchObject({ + exitCode: 0, + stdout: expect.stringContaining(`pnpm: Hello from custom registry`), + stderr: ``, + }); + await expect(xfs.readFilePromise(ppath.join(root, `package.json`), `utf8`)).resolves.toBe(parent); + await expect(xfs.readJsonPromise(ppath.join(cwd, `package.json`))).resolves.toMatchObject({ + ...(exists ? {name: `example`} : {}), + packageManager: expect.stringMatching(/^pnpm@1\.9998\.9999\+sha512\.[0-9a-f]{128}$/), + }); + }); + }); + + it.each([ + `{"devEngines":{"packageManager":{"name":"yarn","version":"2.x"}}}`, + `{invalid JSON`, + ])(`ignores parent manifest %s`, async parent => { + await xfs.mktempPromise(async root => { + await xfs.writeFilePromise(ppath.join(root, `package.json`), parent); + const cwd = ppath.join(root, `child`); + await xfs.mkdirPromise(cwd); + await xfs.writeJsonPromise(ppath.join(cwd, `package.json`), {}); + await expect(runCli(cwd, [`use`, `--here`, `pnpm@1.9998.9999`], true)).resolves.toMatchObject({exitCode: 0}); + await expect(xfs.readFilePromise(ppath.join(root, `package.json`), `utf8`)).resolves.toBe(parent); + }); + }); + + it.each([{flags: []}, {flags: [`--no-here`]}])(`retains normal parent lookup with $flags`, async ({flags}) => { + await xfs.mktempPromise(async root => { + await xfs.writeJsonPromise(ppath.join(root, `package.json`), {packageManager: `yarn@1.22.4`}); + const cwd = ppath.join(root, `child`); + await xfs.mkdirPromise(cwd); + const child = `{ "name": "child" }\n`; + await xfs.writeFilePromise(ppath.join(cwd, `package.json`), child); + await expect(runCli(cwd, [`use`, ...flags, `pnpm@1.9998.9999`], true)).resolves.toMatchObject({exitCode: 0}); + await expect(xfs.readFilePromise(ppath.join(cwd, `package.json`), `utf8`)).resolves.toBe(child); + await expect(xfs.readJsonPromise(ppath.join(root, `package.json`))).resolves.toMatchObject({ + packageManager: expect.stringMatching(/^pnpm@1\.9998\.9999\+/), + }); + }); + }); + + it(`validates the current manifest's devEngines constraints`, async () => { + await xfs.mktempPromise(async root => { + await xfs.writeJsonPromise(ppath.join(root, `package.json`), {packageManager: `pnpm@1.0.0`}); + const cwd = ppath.join(root, `child`); + await xfs.mkdirPromise(cwd); + const child = `{"devEngines":{"packageManager":{"name":"yarn","version":"2.x"}}}`; + await xfs.writeFilePromise(ppath.join(cwd, `package.json`), child); + await expect(runCli(cwd, [`use`, `--here`, `pnpm@1.9998.9999`], true)).resolves.toMatchObject({ + exitCode: 1, + stdout: expect.stringContaining(`does not match the devEngines specification (yarn@2.x)`), + }); + await expect(xfs.readFilePromise(ppath.join(cwd, `package.json`), `utf8`)).resolves.toBe(child); + }); + }); + + it.each([true, false])(`loads environment only from the current directory (local: %s)`, async local => { + await xfs.mktempPromise(async root => { + const cwd = ppath.join(root, `child`); + await xfs.mkdirPromise(cwd); + await xfs.writeFilePromise(ppath.join(local ? cwd : root, `.corepack.env`), `COREPACK_ENABLE_NETWORK=0\n`); + const result = await runCli(cwd, [`use`, `--here`, `pnpm@1.9998.9999`], true); + expect(result.exitCode).toBe(local ? 1 : 0); + if (local) { + expect(result.stdout).toContain(`Network access disabled`); + await expect(xfs.existsPromise(ppath.join(cwd, `package.json`))).resolves.toBe(false); + } + }); + }); + + it(`runs the installation in the current directory`, async () => { + await xfs.mktempPromise(async root => { + const parent = `{"packageManager":"pnpm@1.0.0"}\n`; + await xfs.writeFilePromise(ppath.join(root, `package.json`), parent); + const cwd = ppath.join(root, `child`); + await xfs.mkdirPromise(cwd); + await xfs.writeJsonPromise(ppath.join(cwd, `package.json`), { + name: `child`, + license: `MIT`, + scripts: {postinstall: `node -e "require('fs').writeFileSync('installed-here', process.cwd())"`}, + }); + await expect(runCli(cwd, [`use`, `--here`, `yarn@1.22.4`])).resolves.toMatchObject({exitCode: 0}); + await expect(xfs.readFilePromise(ppath.join(cwd, `installed-here`), `utf8`)).resolves.toBe(npath.fromPortablePath(cwd)); + await expect(xfs.existsPromise(ppath.join(root, `installed-here`))).resolves.toBe(false); + await expect(xfs.readFilePromise(ppath.join(root, `package.json`), `utf8`)).resolves.toBe(parent); + }); + }); +});