Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 1 addition & 3 deletions mkshims.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`));
Expand All @@ -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);
Expand Down
56 changes: 56 additions & 0 deletions sources/envUtils.ts
Original file line number Diff line number Diff line change
@@ -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<NodeJS.ProcessEnv, Record<string, string>>();

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<string, string> = {};
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;
}
6 changes: 5 additions & 1 deletion sources/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -45,7 +46,10 @@ function isUsageError(error: any): error is UsageError {
return error?.name === `UsageError`;
}

export async function runMain(argv: Array<string>) {
export async function runMain(argv: Array<string>, {enableDownloadPrompt = false} = {}) {
await envUtils.loadUserConfig();
process.env.COREPACK_ENABLE_DOWNLOAD_PROMPT ??= enableDownloadPrompt ? `1` : `0`;

const engine = new Engine();

const [firstArg, ...restArgs] = argv;
Expand Down
7 changes: 2 additions & 5 deletions sources/specUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) {
Expand Down
74 changes: 74 additions & 0 deletions tests/UserConfig.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
89 changes: 89 additions & 0 deletions tests/envUtils.test.ts
Original file line number Diff line number Diff line change
@@ -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`});
});
1 change: 1 addition & 0 deletions tests/setupTests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const processEnv = Object.fromEntries(

beforeEach(() => {
process.env = {...processEnv};
process.env.COREPACK_CONFIG_FILE = `0`;
});

afterAll(() => {
Expand Down