diff --git a/README.md b/README.md index 308b806..67d396d 100644 --- a/README.md +++ b/README.md @@ -297,6 +297,7 @@ same major line. Should you need to upgrade to a new major, use an explicit set to `1` to have the URL shown. By default, when Corepack is called explicitly (e.g. `corepack pnpm …`), it is set to `0`; when Corepack is called implicitly (e.g. `pnpm …`), it is set to `1`. + The default value cannot be overridden in a `.corepack.env` file. When standard input is a TTY and no CI environment is detected, Corepack will ask for user input before starting the download. @@ -322,6 +323,14 @@ same major line. Should you need to upgrade to a new major, use an explicit project. This means that it will always use the system-wide package manager regardless of what is being specified in the project's `packageManager` field. +- `COREPACK_ENV_FILE` can be set to `0` to request Corepack to not attempt to + load `.corepack.env`; it can be set to a path to specify a different env file. + Only keys that start with `COREPACK_` and are not in the exception list + (`COREPACK_ENABLE_DOWNLOAD_PROMPT` and `COREPACK_ENV_FILE` are ignored) + will be taken into account. + For Node.js 18.x users, this setting has no effect as that version doesn't + support parsing of `.env` files. + - `COREPACK_HOME` can be set in order to define where Corepack should install the package managers. By default it is set to `%LOCALAPPDATA%\node\corepack` on Windows, and to `$HOME/.cache/node/corepack` everywhere else. diff --git a/sources/specUtils.ts b/sources/specUtils.ts index 36af037..8a01598 100644 --- a/sources/specUtils.ts +++ b/sources/specUtils.ts @@ -4,12 +4,14 @@ import path from 'path'; import semverSatisfies from 'semver/functions/satisfies'; import semverValid from 'semver/functions/valid'; import semverValidRange from 'semver/ranges/valid'; +import {parseEnv} from 'util'; import {PreparedPackageManagerInfo} from './Engine'; import * as debugUtils from './debugUtils'; import {NodeError} from './nodeUtils'; import * as nodeUtils from './nodeUtils'; import {Descriptor, isSupportedPackageManager} from './types'; +import type {LocalEnvFile} from './types'; const nodeModulesRegExp = /[\\/]node_modules[\\/](@[^\\/]*[\\/])?([^@\\/][^\\/]*)$/; @@ -145,10 +147,17 @@ export async function setLocalPackageManager(cwd: string, info: PreparedPackageM }; } +interface FoundSpecResult { + type: `Found`; + target: string; + getSpec: () => Descriptor; + range?: Descriptor & {onFail?: DevEngineDependency['onFail']}; + envFilePath?: string; +} export type LoadSpecResult = | {type: `NoProject`, target: string} | {type: `NoSpec`, target: string} - | {type: `Found`, target: string, getSpec: () => Descriptor, range?: Descriptor & {onFail?: DevEngineDependency['onFail']}}; + | FoundSpecResult; export async function loadSpec(initialCwd: string): Promise { let nextCwd = initialCwd; @@ -157,6 +166,8 @@ export async function loadSpec(initialCwd: string): Promise { let selection: { data: any; manifestPath: string; + envFilePath?: string; + localEnv: LocalEnvFile; } | null = null; while (nextCwd !== currCwd && (!selection || !selection.data.packageManager)) { @@ -184,12 +195,44 @@ export async function loadSpec(initialCwd: string): Promise { if (typeof data !== `object` || data === null) throw new UsageError(`Invalid package.json in ${path.relative(initialCwd, manifestPath)}`); - selection = {data, manifestPath}; + let localEnv: LocalEnvFile; + const envFilePath = path.resolve(currCwd, process.env.COREPACK_ENV_FILE ?? `.corepack.env`); + if (process.env.COREPACK_ENV_FILE == `0`) { + debugUtils.log(`Skipping env file as configured with COREPACK_ENV_FILE`); + localEnv = process.env; + } else if (typeof parseEnv !== `function`) { + // TODO: remove this block when support for Node.js 18.x is dropped. + debugUtils.log(`Skipping env file as it is not supported by the current version of Node.js`); + localEnv = process.env; + } else { + debugUtils.log(`Checking ${envFilePath}`); + try { + localEnv = { + ...Object.fromEntries(Object.entries(parseEnv(await fs.promises.readFile(envFilePath, `utf8`))).filter(e => e[0].startsWith(`COREPACK_`))), + ...process.env, + }; + debugUtils.log(`Successfully loaded env file found at ${envFilePath}`); + } catch (err) { + if ((err as NodeError)?.code !== `ENOENT`) + throw err; + + debugUtils.log(`No env file found at ${envFilePath}`); + localEnv = process.env; + } + } + + selection = {data, manifestPath, localEnv, envFilePath}; } if (selection === null) return {type: `NoProject`, target: path.join(initialCwd, `package.json`)}; + let envFilePath: string | undefined; + if (selection.localEnv !== process.env) { + envFilePath = selection.envFilePath; + process.env = selection.localEnv; + } + const rawPmSpec = parsePackageJSON(selection.data); if (typeof rawPmSpec === `undefined`) return {type: `NoSpec`, target: selection.manifestPath}; @@ -199,6 +242,7 @@ export async function loadSpec(initialCwd: string): Promise { return { type: `Found`, target: selection.manifestPath, + envFilePath, range: selection.data.devEngines?.packageManager?.version && { name: selection.data.devEngines.packageManager.name, range: selection.data.devEngines.packageManager.version, diff --git a/sources/types.ts b/sources/types.ts index b9fe3e3..1af427c 100644 --- a/sources/types.ts +++ b/sources/types.ts @@ -160,3 +160,5 @@ export interface LazyLocator { */ reference: () => Promise; } + +export type LocalEnvFile = Record; diff --git a/tests/main.test.ts b/tests/main.test.ts index ed0771a..2560089 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -647,18 +647,72 @@ for (const name of SupportedPackageManagerSet) { }); } -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), { +describe(`when called on a project without any defined packageManager`, () => { + it(`should append the field to package.json by default`, async () => { + await xfs.mktempPromise(async cwd => { + await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as Filename), { // empty package.json file + }); + + await runCli(cwd, [`yarn`]); + + const data = await xfs.readJsonPromise(ppath.join(cwd, `package.json` as Filename)); + + expect(data).toMatchObject({ + packageManager: `yarn@${config.definitions.yarn.default}`, + }); }); + }); - await runCli(cwd, [`yarn`]); + it(`should not modify package.json if disabled by env`, async () => { + process.env.COREPACK_ENABLE_AUTO_PIN = `0`; - const data = await xfs.readJsonPromise(ppath.join(cwd, `package.json` as Filename)); + await xfs.mktempPromise(async cwd => { + await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as Filename), { + // empty package.json file + }); - expect(data).toMatchObject({ - packageManager: `yarn@${config.definitions.yarn.default}`, + await runCli(cwd, [`yarn`]); + + const data = await xfs.readJsonPromise(ppath.join(cwd, `package.json` as Filename)); + + expect(Object.hasOwn(data, `packageManager`)).toBeFalsy(); + }); + }); + + it(`should not modify package.json if disabled by .corepack.env`, async t => { + // Skip that test on Node.js 18.x as it lacks support for .env files. + if (process.version.startsWith(`v18.`)) t.skip(); + + await xfs.mktempPromise(async cwd => { + await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as Filename), { + // empty package.json file + }); + await xfs.writeFilePromise(ppath.join(cwd, `.corepack.env` as Filename), `COREPACK_ENABLE_AUTO_PIN=0\n`); + + await runCli(cwd, [`yarn`]); + + const data = await xfs.readJsonPromise(ppath.join(cwd, `package.json` as Filename)); + + expect(Object.hasOwn(data, `packageManager`)).toBeFalsy(); + }); + }); + it(`should modify package.json if .corepack.env if disabled`, async () => { + process.env.COREPACK_ENV_FILE = `0`; + + await xfs.mktempPromise(async cwd => { + await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as Filename), { + // empty package.json file + }); + await xfs.writeFilePromise(ppath.join(cwd, `.corepack.env` as Filename), `COREPACK_ENABLE_AUTO_PIN=0\n`); + + await runCli(cwd, [`yarn`]); + + const data = await xfs.readJsonPromise(ppath.join(cwd, `package.json` as Filename)); + + expect(data).toMatchObject({ + packageManager: `yarn@${config.definitions.yarn.default}`, + }); }); }); }); @@ -1141,16 +1195,35 @@ it(`should support package managers in ESM format`, async () => { }); }); -it(`should show a warning on stderr before downloading when enable`, async() => { - await xfs.mktempPromise(async cwd => { - process.env.COREPACK_ENABLE_DOWNLOAD_PROMPT = `1`; - await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as Filename), { - packageManager: `yarn@3.0.0`, +describe(`should show a warning on stderr before downloading when enable`, () => { + it(`when enabled by the environment`, async () => { + await xfs.mktempPromise(async cwd => { + process.env.COREPACK_ENABLE_DOWNLOAD_PROMPT = `1`; + await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as Filename), { + packageManager: `yarn@3.0.0`, + }); + 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`, + }); }); - 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`, + }); + + it(`should ignore setting in .corepack.env`, async () => { + await xfs.mktempPromise(async cwd => { + await xfs.writeFilePromise( + ppath.join(cwd, `.corepack.env` as Filename), + `COREPACK_ENABLE_DOWNLOAD_PROMPT=1\n`, + ); + await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as Filename), { + packageManager: `yarn@3.0.0`, + }); + await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({ + exitCode: 0, + stdout: `3.0.0\n`, + stderr: ``, + }); }); }); }); @@ -1241,6 +1314,156 @@ it(`should download latest pnpm from custom registry`, async () => { }); }); +describe(`should pick up COREPACK_INTEGRITY_KEYS from env`, () => { + beforeEach(() => { + process.env.AUTH_TYPE = `COREPACK_NPM_TOKEN`; // See `_registryServer.mjs` + process.env.COREPACK_DEFAULT_TO_LATEST = `1`; + }); + + it(`from env variable`, async () => { + await xfs.mktempPromise(async cwd => { + await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as Filename), { + }); + + await expect(runCli(cwd, [`pnpm`, `--version`], true)).resolves.toMatchObject({ + exitCode: 1, + stdout: ``, + stderr: expect.stringContaining(`No compatible signature found in package metadata`), + }); + + process.env.COREPACK_INTEGRITY_KEYS = `0`; + await expect(runCli(cwd, [`pnpm`, `--version`], true)).resolves.toMatchObject({ + exitCode: 0, + stdout: `pnpm: Hello from custom registry\n`, + stderr: expect.stringContaining(`The local project doesn't define a 'packageManager' field`), + }); + }); + }); + + it(`from .corepack.env file`, async t => { + // Skip that test on Node.js 18.x as it lacks support for .env files. + if (process.version.startsWith(`v18.`)) t.skip(); + + await xfs.mktempPromise(async cwd => { + await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as Filename), { + }); + + await expect(runCli(cwd, [`pnpm`, `--version`], true)).resolves.toMatchObject({ + exitCode: 1, + stdout: ``, + stderr: expect.stringContaining(`No compatible signature found in package metadata`), + }); + + await xfs.writeFilePromise(ppath.join(cwd, `.corepack.env` as Filename), `COREPACK_INTEGRITY_KEYS=0\n`); + await expect(runCli(cwd, [`pnpm`, `--version`], true)).resolves.toMatchObject({ + exitCode: 0, + stdout: `pnpm: Hello from custom registry\n`, + stderr: expect.stringContaining(`The local project doesn't define a 'packageManager' field`), + }); + }); + }); + + it(`from env file defined by COREPACK_ENV_FILE`, async t => { + // Skip that test on Node.js 18.x as it lacks support for .env files. + if (process.version.startsWith(`v18.`)) t.skip(); + + await xfs.mktempPromise(async cwd => { + await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as Filename), { + }); + + await xfs.writeFilePromise(ppath.join(cwd, `.corepack.env` as Filename), `COREPACK_INTEGRITY_KEYS={}\n`); + await xfs.writeFilePromise(ppath.join(cwd, `.other.env` as Filename), `COREPACK_INTEGRITY_KEYS=0\n`); + + // By default, Corepack should be using .corepack.env and fail. + await expect(runCli(cwd, [`pnpm`, `--version`], true)).resolves.toMatchObject({ + exitCode: 1, + stdout: ``, + stderr: expect.stringContaining(`No compatible signature found in package metadata`), + }); + + process.env.COREPACK_ENV_FILE = `.other.env`; + await expect(runCli(cwd, [`pnpm`, `--version`], true)).resolves.toMatchObject({ + exitCode: 0, + stdout: `pnpm: Hello from custom registry\n`, + stderr: expect.stringContaining(`The local project doesn't define a 'packageManager' field`), + }); + }); + }); + + it(`from env even if there's a .corepack.env file`, async () => { + await xfs.mktempPromise(async cwd => { + await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as Filename), { + }); + + await xfs.writeFilePromise(ppath.join(cwd, `.corepack.env` as Filename), `COREPACK_INTEGRITY_KEYS={}\n`); + + // By default, Corepack should be using .corepack.env (or the built-in ones on Node.js 18.x) and fail. + await expect(runCli(cwd, [`pnpm`, `--version`], true)).resolves.toMatchObject({ + exitCode: 1, + stdout: ``, + stderr: expect.stringContaining(`No compatible signature found in package metadata`), + }); + + process.env.COREPACK_INTEGRITY_KEYS = ``; + await expect(runCli(cwd, [`pnpm`, `--version`], true)).resolves.toMatchObject({ + exitCode: 0, + stdout: `pnpm: Hello from custom registry\n`, + stderr: expect.stringContaining(`The local project doesn't define a 'packageManager' field`), + }); + }); + }); + + it(`should ignore .corepack.env file if COREPACK_ENV_FILE is set to 0`, async t => { + // Skip that test on Node.js 18.x as it lacks support for .env files. + if (process.version.startsWith(`v18.`)) t.skip(); + + await xfs.mktempPromise(async cwd => { + await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as Filename), { + }); + + await xfs.writeFilePromise(ppath.join(cwd, `.corepack.env` as Filename), `COREPACK_INTEGRITY_KEYS=0\n`); + + process.env.COREPACK_ENV_FILE = `0`; + await expect(runCli(cwd, [`pnpm`, `--version`], true)).resolves.toMatchObject({ + exitCode: 1, + stdout: ``, + stderr: expect.stringContaining(`No compatible signature found in package metadata`), + }); + + delete process.env.COREPACK_ENV_FILE; + await expect(runCli(cwd, [`pnpm`, `--version`], true)).resolves.toMatchObject({ + exitCode: 0, + stdout: `pnpm: Hello from custom registry\n`, + stderr: expect.stringContaining(`The local project doesn't define a 'packageManager' field`), + }); + }); + }); + + it(`from env file defined by COREPACK_ENV_FILE`, async t => { + // Skip that test on Node.js 18.x as it lacks support for .env files. + if (process.version.startsWith(`v18.`)) t.skip(); + + process.env.COREPACK_ENV_FILE = `.other.env`; + await xfs.mktempPromise(async cwd => { + await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as Filename), { + }); + + await expect(runCli(cwd, [`pnpm`, `--version`], true)).resolves.toMatchObject({ + exitCode: 1, + stdout: ``, + stderr: expect.stringContaining(`No compatible signature found in package metadata`), + }); + + await xfs.writeFilePromise(ppath.join(cwd, `.other.env` as Filename), `COREPACK_INTEGRITY_KEYS=0\n`); + await expect(runCli(cwd, [`pnpm`, `--version`], true)).resolves.toMatchObject({ + exitCode: 0, + stdout: `pnpm: Hello from custom registry\n`, + stderr: expect.stringContaining(`The local project doesn't define a 'packageManager' field`), + }); + }); + }); +}); + for (const authType of [`COREPACK_NPM_REGISTRY`, `COREPACK_NPM_TOKEN`, `COREPACK_NPM_PASSWORD`, `PROXY`]) { describe(`custom registry with auth ${authType}`, () => { beforeEach(() => {