Skip to content

feat: add limited support for devEngines and .corepack.env #634

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 5 commits into from
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
43 changes: 43 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,35 @@ use in the archive).
}
```

#### `devEngines.packageManager`

When a `devEngines.packageManager` field is defined, and is an object containing
a `"name"` field (can also optionally contain `version` and `onFail` fields),
Corepack will use it to validate you're using a compatible package manager.

Depending on the value of `devEngines.packageManager.onFail`:

- if set to `ignore`, Corepack won't print any warning or error.
- if unset or set to `error`, Corepack will throw an error in case of a mismatch.
- if set to `warn` or some other value, Corepack will print a warning in case
of mismatch.

If the top-level `packageManager` field is missing, Corepack will use the
package manager defined in `devEngines.packageManager` – in which case you must
provide a specific version in `devEngines.packageManager.version`, ideally with
a hash, as explained in the previous section:

```json
{
"devEngines":{
"packageManager": {
"name": "yarn",
"version": "3.2.3+sha224.953c8233f7a92884eee2de69a1b92d1f2ec1655e66d08071ba9a02fa"
}
}
}
```

## Known Good Releases

When running Corepack within projects that don't list a supported package
Expand Down Expand Up @@ -246,6 +275,7 @@ it.

Unlike `corepack use` this command doesn't take a package manager name nor a
version range, as it will always select the latest available version from the
range specified in `devEngines.packageManager.version`, or fallback to the
same major line. Should you need to upgrade to a new major, use an explicit
`corepack use {name}@latest` call (or simply `corepack use {name}`).

Expand All @@ -256,6 +286,10 @@ same major line. Should you need to upgrade to a new major, use an explicit
package manager, and to not update the Last Known Good version when it
downloads a new version of the same major line.

- `COREPACK_DEV_ENGINES_${UPPER_CASE_PACKAGE_MANAGER_NAME}` can be set to give
Corepack a specific version matching the range defined in `package.json`'s
`devEngines.packageManager` field.

- `COREPACK_ENABLE_AUTO_PIN` can be set to `0` to prevent Corepack from
updating the `packageManager` field when it detects that the local package
doesn't list it. In general we recommend to always list a `packageManager`
Expand All @@ -267,6 +301,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.

Expand All @@ -292,6 +327,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.
Expand Down
2 changes: 1 addition & 1 deletion sources/commands/Base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export abstract class BaseCommand extends Command<Context> {
throw new UsageError(`The local project doesn't feature a 'packageManager' field - please explicit the package manager to pack, or update the manifest to reference it`);

default: {
return [lookup.spec];
return [lookup.range ?? lookup.spec];
}
}
}
Expand Down
2 changes: 2 additions & 0 deletions sources/npmRegistryUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ export function verifySignature({signatures, integrity, packageName, version}: {
packageName: string;
version: string;
}) {
if (signatures == null) throw new Error(`No compatible signature found in package metadata for ${packageName}@${version}`);

const {npm: keys} = process.env.COREPACK_INTEGRITY_KEYS ?
JSON.parse(process.env.COREPACK_INTEGRITY_KEYS) as typeof defaultConfig.keys :
defaultConfig.keys;
Expand Down
184 changes: 162 additions & 22 deletions sources/specUtils.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import {UsageError} from 'clipanion';
import fs from 'fs';
import path from 'path';
import semverValid from 'semver/functions/valid';
import {UsageError} from 'clipanion';
import fs from 'fs';
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 {PreparedPackageManagerInfo} from './Engine';
import * as debugUtils from './debugUtils';
import type {NodeError} from './nodeUtils';
import * as nodeUtils from './nodeUtils';
import {isSupportedPackageManager} from './types';
import type {LocalEnvFile, Descriptor} from './types';

const nodeModulesRegExp = /[\\/]node_modules[\\/](@[^\\/]*[\\/])?([^@\\/][^\\/]*)$/;

Expand Down Expand Up @@ -52,38 +56,133 @@ export function parseSpec(raw: unknown, source: string, {enforceExactVersion = t
};
}

type CorepackPackageJSON = {
packageManager?: string;
devEngines?: { packageManager?: DevEngineDependency };
};

interface DevEngineDependency {
name: string;
version: string;
onFail?: 'ignore' | 'warn' | 'error';
}
function warnOrThrow(errorMessage: string, onFail?: DevEngineDependency['onFail']) {
switch (onFail) {
case `ignore`:
break;
case `error`:
case undefined:
throw new UsageError(errorMessage);
default:
console.warn(`! Corepack validation warning: ${errorMessage}`);
}
}
function parsePackageJSON(packageJSONContent: CorepackPackageJSON) {
if (packageJSONContent.devEngines?.packageManager != null) {
const {packageManager} = packageJSONContent.devEngines;

if (typeof packageManager !== `object`) {
console.warn(`! Corepack only supports objects as valid value for devEngines.packageManager. The current value (${JSON.stringify(packageManager)}) will be ignored.`);
return packageJSONContent.packageManager;
}
if (Array.isArray(packageManager)) {
console.warn(`! Corepack does not currently support array values for devEngines.packageManager`);
return packageJSONContent.packageManager;
}

const {name, version, onFail} = packageManager;
if (typeof name !== `string` || name.includes(`@`)) {
warnOrThrow(`The value of devEngines.packageManager.name ${JSON.stringify(name)} is not a supported string value`, onFail);
return packageJSONContent.packageManager;
}
if (version != null && (typeof version !== `string` || !semverValidRange(version))) {
warnOrThrow(`The value of devEngines.packageManager.version ${JSON.stringify(version)} is not a valid semver range`, onFail);
return packageJSONContent.packageManager;
}

debugUtils.log(`devEngines.packageManager defines that ${name}@${version} is the local package manager`);

const localEnvKey = `COREPACK_DEV_ENGINES_${packageManager.name.toUpperCase()}`;
const localEnvVersion = process.env[localEnvKey];
if (localEnvVersion) {
debugUtils.log(`Environment defines that ${name}@${localEnvVersion} is the local package manager`);

if (!semverSatisfies(localEnvVersion, version))
warnOrThrow(`"${localEnvKey}" environment variable is set to ${JSON.stringify(localEnvVersion)} which does not match the value defined in "devEngines.packageManager" for ${JSON.stringify(name)} of ${JSON.stringify(version)}`, onFail);

return `${name}@${localEnvVersion}`;
}

const {packageManager: pm} = packageJSONContent;
if (pm) {
if (!pm.startsWith(`${name}@`))
warnOrThrow(`"packageManager" field is set to ${JSON.stringify(pm)} which does not match the "devEngines.packageManager" field set to ${JSON.stringify(name)}`, onFail);

else if (version != null && !semverSatisfies(pm.slice(packageManager.name.length + 1), version))
warnOrThrow(`"packageManager" field is set to ${JSON.stringify(pm)} which does not match the value defined in "devEngines.packageManager" for ${JSON.stringify(name)} of ${JSON.stringify(version)}`, onFail);

return pm;
}


return `${name}@${version ?? `*`}`;
}

return packageJSONContent.packageManager;
}

export async function setLocalPackageManager(cwd: string, info: PreparedPackageManagerInfo) {
const lookup = await loadSpec(cwd);
const lookup = await loadSpec(cwd, true);

const content = lookup.type !== `NoProject`
? await fs.promises.readFile(lookup.target, `utf8`)
? await fs.promises.readFile((lookup as FoundSpecResult).envFilePath ?? lookup.target, `utf8`)
: ``;

const {data, indent} = nodeUtils.readPackageJson(content);
let previousPackageManager: string;
let newContent: string;
if ((lookup as FoundSpecResult).envFilePath && (lookup as FoundSpecResult).range) {
const envKey = `COREPACK_DEV_ENGINES_${(lookup as FoundSpecResult).range!.name.toUpperCase()}`;
const index = content.lastIndexOf(`\n${envKey}=`) + 1;

if (index === 0 && !content.startsWith(`${envKey}=`))
throw new Error(`INTERNAL ASSERTION ERROR: missing expected ${envKey} in .corepack.env`);

const lineEndIndex = content.indexOf(`\n`, index);

const previousPackageManager = data.packageManager ?? `unknown`;
data.packageManager = `${info.locator.name}@${info.locator.reference}`;
previousPackageManager = content.slice(index, lineEndIndex === -1 ? undefined : lineEndIndex);
newContent = `${content.slice(0, index)}\n${envKey}=${info.locator.reference}\n${lineEndIndex === -1 ? `` : content.slice(lineEndIndex)}`;
} else {
const {data, indent} = nodeUtils.readPackageJson(content);

const newContent = nodeUtils.normalizeLineEndings(content, `${JSON.stringify(data, null, indent)}\n`);
await fs.promises.writeFile(lookup.target, newContent, `utf8`);
previousPackageManager = data.packageManager ?? `unknown`;
data.packageManager = `${info.locator.name}@${info.locator.reference}`;

newContent = `${JSON.stringify(data, null, indent)}\n`;
}

newContent = nodeUtils.normalizeLineEndings(content, newContent);
await fs.promises.writeFile((lookup as FoundSpecResult).envFilePath ?? lookup.target, newContent, `utf8`);

return {
previousPackageManager,
};
}

export type LoadSpecResult =
type FoundSpecResult<SkipSpecParsing extends boolean = true> = {type: `Found`, target: string, spec: SkipSpecParsing extends true ? undefined : Descriptor, range?: Descriptor, envFilePath?: string};
export type LoadSpecResult<SkipSpecParsing extends boolean> =
| {type: `NoProject`, target: string}
| {type: `NoSpec`, target: string}
| {type: `Found`, target: string, spec: Descriptor};
| FoundSpecResult<SkipSpecParsing>;

export async function loadSpec(initialCwd: string): Promise<LoadSpecResult> {
export async function loadSpec<SkipSpecParsing extends boolean = false>(initialCwd: string, skipSpecParsing?: SkipSpecParsing): Promise<LoadSpecResult<SkipSpecParsing>> {
let nextCwd = initialCwd;
let currCwd = ``;

let selection: {
data: any;
manifestPath: string;
envFilePath?: string;
localEnv: LocalEnvFile;
} | null = null;

while (nextCwd !== currCwd && (!selection || !selection.data.packageManager)) {
Expand Down Expand Up @@ -111,19 +210,60 @@ export async function loadSpec(initialCwd: string): Promise<LoadSpecResult> {
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`)};

const rawPmSpec = selection.data.packageManager;
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};

debugUtils.log(`${selection.manifestPath} defines ${rawPmSpec} as local package manager`);

return {
type: `Found`,
target: selection.manifestPath,
spec: parseSpec(rawPmSpec, path.relative(initialCwd, selection.manifestPath)),
envFilePath,
range: selection.data.devEngines?.packageManager?.version && {
name: selection.data.devEngines.packageManager.name,
range: selection.data.devEngines.packageManager.version,
},
spec: skipSpecParsing ?
(undefined as SkipSpecParsing extends true ? undefined : never) :
parseSpec(rawPmSpec, path.relative(initialCwd, selection.manifestPath)) as SkipSpecParsing extends true ? never : ReturnType<typeof parseSpec>,
};
}
2 changes: 2 additions & 0 deletions sources/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,3 +160,5 @@ export interface LazyLocator {
*/
reference: () => Promise<string>;
}

export type LocalEnvFile = Record<string, string | undefined>;
Loading
Loading