diff --git a/README.md b/README.md index e2adeb949..2b4358df3 100644 --- a/README.md +++ b/README.md @@ -291,6 +291,28 @@ same major line. Should you need to upgrade to a new major, use an explicit ## Environment Variables +Corepack also reads user-wide defaults from `.corepackrc` in your home directory. +The file uses the same dotenv syntax and `COREPACK_` variable names as +`.corepack.env`, for example: + +```dotenv +# Apply to interactive shells, Git hooks, and Corepack utility commands +COREPACK_ENABLE_DOWNLOAD_PROMPT=0 +COREPACK_DEFAULT_TO_LATEST=0 +``` + +Explicit environment variables take precedence over project `.corepack.env` +settings, which take precedence over `.corepackrc`, followed by built-in defaults. +Only keys beginning with `COREPACK_` are read. Unlike project `.corepack.env`, +the user configuration can set `COREPACK_ENABLE_DOWNLOAD_PROMPT` and +`COREPACK_ENV_FILE`. Relative paths in configuration values are resolved from +the command's working directory, as they are for environment variables. + +- `COREPACK_CONFIG_FILE` can be set in the environment to an alternative user + configuration file, or to `0` to disable user configuration. An absent default + `.corepackrc` is ignored; an explicitly selected file must exist and be readable. + This setting is ignored inside configuration files. + - `COREPACK_DEFAULT_TO_LATEST` can be set to `0` in order to instruct Corepack not to lookup on the remote registry for the latest version of the selected package manager, and to not update the Last Known Good version when it diff --git a/mkshims.ts b/mkshims.ts index 74c9078ad..f934e71c3 100644 --- a/mkshims.ts +++ b/mkshims.ts @@ -21,7 +21,6 @@ async function main() { const corepackPath = path.join(distDir, `corepack.js`); fs.writeFileSync(corepackPath, [ `#!/usr/bin/env node`, - `process.env.COREPACK_ENABLE_DOWNLOAD_PROMPT??='0';`, `require('module').enableCompileCache?.();`, `require('./lib/corepack.cjs').runMain(process.argv.slice(2));`, ].join(`\n`)); @@ -34,9 +33,8 @@ async function main() { const entryPath = path.join(distDir, `${binaryName}.js`); const entryScript = [ `#!/usr/bin/env node`, - `process.env.COREPACK_ENABLE_DOWNLOAD_PROMPT??='1'`, `require('module').enableCompileCache?.();`, - `require('./lib/corepack.cjs').runMain(['${binaryName}', ...process.argv.slice(2)]);`, + `require('./lib/corepack.cjs').runMain(['${binaryName}', ...process.argv.slice(2)], {enableDownloadPrompt: true});`, ].join(`\n`); fs.writeFileSync(entryPath, entryScript); diff --git a/sources/envUtils.ts b/sources/envUtils.ts new file mode 100644 index 000000000..e0bcfc894 --- /dev/null +++ b/sources/envUtils.ts @@ -0,0 +1,56 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import {parseEnv} from 'util'; + +import * as debugUtils from './debugUtils.ts'; +import {isNodeError} from './nodeUtils.ts'; + +// Remember which values came from the user file so project configuration can +// override them without overriding variables supplied by the calling process. +const userDefaults = new WeakMap>(); + +export async function loadUserConfig() { + const configuredPath = process.env.COREPACK_CONFIG_FILE; + if (configuredPath === `0`) + return; + + const configPath = configuredPath ?? path.join(os.homedir(), `.corepackrc`); + let content: string; + try { + content = await fs.promises.readFile(configPath, `utf8`); + } catch (error) { + if (isNodeError(error) && error.code === `ENOENT` && configuredPath === undefined) + return; + throw error; + } + + const defaults: Record = {}; + for (const [key, value] of Object.entries(parseEnv(content))) { + if (value === undefined) + continue; + + if (key.startsWith(`COREPACK_`) && key !== `COREPACK_CONFIG_FILE` && process.env[key] === undefined) { + defaults[key] = value; + process.env[key] = value; + } + } + userDefaults.set(process.env, defaults); + debugUtils.log(`Loaded user configuration from ${configPath}`); +} + +export function mergeProjectEnv(content: string): NodeJS.ProcessEnv { + const env = {...process.env}; + const defaults = userDefaults.get(process.env); + + for (const [key, value] of Object.entries(parseEnv(content))) { + if (!key.startsWith(`COREPACK_`) || key === `COREPACK_CONFIG_FILE` || key === `COREPACK_ENV_FILE` || key === `COREPACK_ENABLE_DOWNLOAD_PROMPT`) + continue; + + if (env[key] === undefined || (defaults && Object.hasOwn(defaults, key) && defaults[key] === env[key])) { + env[key] = value; + } + } + + return env; +} diff --git a/sources/main.ts b/sources/main.ts index e623af1f5..b554b0737 100644 --- a/sources/main.ts +++ b/sources/main.ts @@ -15,6 +15,7 @@ import {UpCommand} from './commands/Up.ts'; import {UseCommand} from './commands/Use.ts'; import {HydrateCommand} from './commands/deprecated/Hydrate.ts'; import {PrepareCommand} from './commands/deprecated/Prepare.ts'; +import * as envUtils from './envUtils.ts'; export type CustomContext = {cwd: string, engine: Engine}; export type Context = BaseContext & CustomContext; @@ -45,7 +46,10 @@ function isUsageError(error: any): error is UsageError { return error?.name === `UsageError`; } -export async function runMain(argv: Array) { +export async function runMain(argv: Array, {enableDownloadPrompt = false} = {}) { + await envUtils.loadUserConfig(); + process.env.COREPACK_ENABLE_DOWNLOAD_PROMPT ??= enableDownloadPrompt ? `1` : `0`; + const engine = new Engine(); const [firstArg, ...restArgs] = argv; diff --git a/sources/specUtils.ts b/sources/specUtils.ts index 275c49058..a643f65cd 100644 --- a/sources/specUtils.ts +++ b/sources/specUtils.ts @@ -4,10 +4,10 @@ import path from 'path'; import semverSatisfies from 'semver/functions/satisfies.js'; import semverValid from 'semver/functions/valid.js'; import semverValidRange from 'semver/ranges/valid.js'; -import {parseEnv} from 'util'; import type {PreparedPackageManagerInfo} from './Engine.ts'; import * as debugUtils from './debugUtils.ts'; +import * as envUtils from './envUtils.ts'; import type {NodeError} from './nodeUtils.ts'; import * as nodeUtils from './nodeUtils.ts'; import {isSupportedPackageManager} from './types.ts'; @@ -178,10 +178,7 @@ async function loadEnvFileIfExists(cwd: string): Promise<{env: LocalEnvFile, pat } debugUtils.log(`Checking ${envFilePath}`); try { - const localEnv = { - ...Object.fromEntries(Object.entries(parseEnv(await fs.promises.readFile(envFilePath, `utf8`))).filter(e => e[0].startsWith(`COREPACK_`))), - ...process.env, - }; + const localEnv = envUtils.mergeProjectEnv(await fs.promises.readFile(envFilePath, `utf8`)); debugUtils.log(`Successfully loaded env file found at ${envFilePath}`); return {env: localEnv, path: envFilePath}; } catch (err) { diff --git a/tests/UserConfig.test.ts b/tests/UserConfig.test.ts new file mode 100644 index 000000000..307d0918f --- /dev/null +++ b/tests/UserConfig.test.ts @@ -0,0 +1,74 @@ +import {npath, ppath, xfs} from '@yarnpkg/fslib'; +import {execFile} from 'node:child_process'; +import {promisify} from 'node:util'; +import {beforeEach, expect, it} from 'vitest'; + +import {runCli} from './_runCli.ts'; + +beforeEach(() => { + process.env.COREPACK_DEFAULT_TO_LATEST = `0`; +}); + +it(`uses user configuration for global utility commands`, async () => { + await xfs.mktempPromise(async cwd => { + const home = ppath.join(cwd, `cache`); + const userConfig = ppath.join(cwd, `user.rc`); + await xfs.mkdirPromise(ppath.join(home, `v1/yarn/1.22.4`), {recursive: true}); + await xfs.writeFilePromise(userConfig, `COREPACK_HOME=${JSON.stringify(npath.fromPortablePath(home))}\n`); + process.env.COREPACK_CONFIG_FILE = `user.rc`; + await expect(runCli(cwd, [`cache`, `clean`])).resolves.toEqual({exitCode: 0, stdout: ``, stderr: ``}); + expect(await xfs.existsPromise(ppath.join(home, `v1`))).toBe(false); + }); +}); + +it.each([`user`, `project`, `environment`])(`respects configuration precedence through the CLI (%s)`, async level => { + await xfs.mktempPromise(async cwd => { + const home = ppath.join(cwd, `cache`); + const cache = ppath.join(home, `v1/yarn/1.22.4`); + const config = ppath.join(cwd, `user.rc`); + await xfs.mkdirPromise(cache, {recursive: true}); + await xfs.writeJsonPromise(ppath.join(cache, `.corepack`), {bin: {yarn: `yarn.js`}, hash: `sha512.${`ab`.repeat(64)}`}); + await xfs.writeFilePromise(ppath.join(cache, `yarn.js`), `console.log('1.22.4');`); + await xfs.writeJsonPromise(ppath.join(home, `lastKnownGood.json`), {yarn: `1.22.4`}); + await xfs.writeJsonPromise(ppath.join(cwd, `package.json`), {name: `config-test`}); + await xfs.writeFilePromise(config, `COREPACK_HOME=${JSON.stringify(npath.fromPortablePath(home))}\nCOREPACK_ENABLE_NETWORK=0\nCOREPACK_ENABLE_AUTO_PIN=1\nCOREPACK_ENV_FILE=project.env\n`); + process.env.COREPACK_CONFIG_FILE = npath.fromPortablePath(config); + if (level !== `user`) + await xfs.writeFilePromise(ppath.join(cwd, `project.env`), `COREPACK_ENABLE_AUTO_PIN=0\n`); + if (level === `environment`) + process.env.COREPACK_ENABLE_AUTO_PIN = `1`; + + const result = await runCli(cwd, [`yarn`, `--version`]); + expect(result).toMatchObject({exitCode: 0, stdout: `1.22.4\n`}); + const manifest = await xfs.readJsonPromise(ppath.join(cwd, `package.json`)); + if (level === `project`) { + expect(manifest.packageManager).toBeUndefined(); + expect(result.stderr).toBe(``); + } else { + expect(manifest.packageManager).toMatch(/^yarn@1\.22\.4\+sha512\./); + } + }); +}); + +it.each([ + {fileValue: `0`, envValue: undefined, shouldPrompt: false}, + {fileValue: `1`, envValue: undefined, shouldPrompt: true}, + {fileValue: `0`, envValue: `1`, shouldPrompt: true}, +])(`loads the download-prompt setting before initializing shims ($fileValue, $envValue)`, async ({fileValue, envValue, shouldPrompt}) => { + await xfs.mktempPromise(async cwd => { + const config = ppath.join(cwd, `user.rc`); + process.env.COREPACK_HOME = npath.fromPortablePath(ppath.join(cwd, `cache`)); + process.env.COREPACK_CONFIG_FILE = npath.fromPortablePath(config); + await xfs.writeFilePromise(config, `COREPACK_ENABLE_DOWNLOAD_PROMPT=${fileValue}\n`); + await xfs.writeJsonPromise(ppath.join(cwd, `package.json`), {packageManager: `yarn@1.22.4`}); + if (envValue !== undefined) + process.env.COREPACK_ENABLE_DOWNLOAD_PROMPT = envValue; + const {stdout, stderr} = await promisify(execFile)(process.execPath, [`--no-warnings`, `--require`, require.resolve(`./recordRequests.js`), require.resolve(`../dist/yarn.js`), `--version`], { + cwd: npath.fromPortablePath(cwd), + env: process.env, + timeout: 20000, + }); + expect(stdout).toBe(`1.22.4\n`); + expect(stderr.includes(`Corepack is about to download`)).toBe(shouldPrompt); + }); +}); diff --git a/tests/envUtils.test.ts b/tests/envUtils.test.ts new file mode 100644 index 000000000..72e5f722b --- /dev/null +++ b/tests/envUtils.test.ts @@ -0,0 +1,89 @@ +import {npath, ppath, xfs} from '@yarnpkg/fslib'; +import os from 'node:os'; +import {afterEach, beforeEach, expect, it, vi} from 'vitest'; + +import {loadUserConfig, mergeProjectEnv} from '../sources/envUtils.ts'; + +beforeEach(async () => { + const home = await xfs.mktempPromise(); + vi.spyOn(os, `homedir`).mockReturnValue(npath.fromPortablePath(home)); + delete process.env.COREPACK_CONFIG_FILE; + return () => xfs.removePromise(home, {recursive: true}); +}); + +afterEach(() => vi.restoreAllMocks()); + +async function writeConfig(content: string) { + await xfs.writeFilePromise(ppath.join(npath.toPortablePath(os.homedir()), `.corepackrc`), content); +} + +it(`ignores a missing default user configuration`, async () => { + await loadUserConfig(); + expect(process.env.COREPACK_ENABLE_AUTO_PIN).toBeUndefined(); + expect(process.env.COREPACK_ENABLE_DOWNLOAD_PROMPT).toBeUndefined(); +}); + +it(`reads dotenv syntax but only applies Corepack settings`, async () => { + await writeConfig(` + # User defaults + COREPACK_DEFAULT_TO_LATEST="0" + COREPACK_ENABLE_DOWNLOAD_PROMPT='0' + COREPACK_CONFIG_FILE=ignored + NODE_OPTIONS=--invalid + PATH=ignored + `); + const {PATH, NODE_OPTIONS} = process.env; + await loadUserConfig(); + expect(process.env.COREPACK_DEFAULT_TO_LATEST).toBe(`0`); + expect(process.env.COREPACK_ENABLE_DOWNLOAD_PROMPT).toBe(`0`); + expect(process.env.COREPACK_CONFIG_FILE).toBeUndefined(); + expect(process.env.PATH).toBe(PATH); + expect(process.env.NODE_OPTIONS).toBe(NODE_OPTIONS); +}); + +it(`lets project settings override user defaults`, async () => { + await writeConfig(`COREPACK_ENABLE_AUTO_PIN=0\n`); + await loadUserConfig(); + expect(mergeProjectEnv(`COREPACK_ENABLE_AUTO_PIN=1\n`).COREPACK_ENABLE_AUTO_PIN).toBe(`1`); +}); + +it.each([`0`, `1`, ``])(`preserves explicit environment values %j over both files`, async value => { + process.env.COREPACK_ENABLE_AUTO_PIN = value; + await writeConfig(`COREPACK_ENABLE_AUTO_PIN=0\n`); + await loadUserConfig(); + expect(mergeProjectEnv(`COREPACK_ENABLE_AUTO_PIN=other\n`).COREPACK_ENABLE_AUTO_PIN).toBe(value); +}); + +it(`keeps user-only settings out of project environment files`, async () => { + await writeConfig(`COREPACK_ENABLE_DOWNLOAD_PROMPT=0\nCOREPACK_ENV_FILE=project.env\n`); + await loadUserConfig(); + const env = mergeProjectEnv(`COREPACK_ENABLE_DOWNLOAD_PROMPT=1\nCOREPACK_ENV_FILE=other.env\nCOREPACK_CONFIG_FILE=other.rc\nNODE_OPTIONS=--invalid\n`); + expect(env.COREPACK_ENABLE_DOWNLOAD_PROMPT).toBe(`0`); + expect(env.COREPACK_ENV_FILE).toBe(`project.env`); + expect(env.COREPACK_CONFIG_FILE).toBeUndefined(); + expect(env.NODE_OPTIONS).toBe(process.env.NODE_OPTIONS); +}); + +it(`does not apply user defaults to a different process environment`, async () => { + await writeConfig(`COREPACK_ENABLE_AUTO_PIN=0\n`); + await loadUserConfig(); + process.env = {...process.env}; + expect(mergeProjectEnv(`COREPACK_ENABLE_AUTO_PIN=1\n`).COREPACK_ENABLE_AUTO_PIN).toBe(`0`); +}); + +it(`can disable user configuration`, async () => { + await writeConfig(`COREPACK_ENABLE_AUTO_PIN=1\n`); + process.env.COREPACK_CONFIG_FILE = `0`; + await loadUserConfig(); + expect(process.env.COREPACK_ENABLE_AUTO_PIN).toBeUndefined(); +}); + +it(`reports an explicitly selected missing configuration file`, async () => { + process.env.COREPACK_CONFIG_FILE = `${os.homedir()}/missing`; + await expect(loadUserConfig()).rejects.toMatchObject({code: `ENOENT`}); +}); + +it(`reports an unreadable configuration instead of silently ignoring it`, async () => { + process.env.COREPACK_CONFIG_FILE = os.homedir(); + await expect(loadUserConfig()).rejects.toMatchObject({code: `EISDIR`}); +}); diff --git a/tests/setupTests.ts b/tests/setupTests.ts index 6af41abd8..9c0300d57 100644 --- a/tests/setupTests.ts +++ b/tests/setupTests.ts @@ -12,6 +12,7 @@ const processEnv = Object.fromEntries( beforeEach(() => { process.env = {...processEnv}; + process.env.COREPACK_CONFIG_FILE = `0`; }); afterAll(() => {