mirror of https://github.com/nodejs/corepack.git
255 lines
9.3 KiB
TypeScript
255 lines
9.3 KiB
TypeScript
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 {LocalEnvFile} from './types';
|
|
|
|
const nodeModulesRegExp = /[\\/]node_modules[\\/](@[^\\/]*[\\/])?([^@\\/][^\\/]*)$/;
|
|
|
|
export function parseSpec(raw: unknown, source: string, {enforceExactVersion = true} = {}): Descriptor {
|
|
if (typeof raw !== `string`)
|
|
throw new UsageError(`Invalid package manager specification in ${source}; expected a string`);
|
|
|
|
const atIndex = raw.indexOf(`@`);
|
|
|
|
if (atIndex === -1 || atIndex === raw.length - 1) {
|
|
if (enforceExactVersion)
|
|
throw new UsageError(`No version specified for ${raw} in "packageManager" of ${source}`);
|
|
|
|
const name = atIndex === -1 ? raw : raw.slice(0, -1);
|
|
if (!isSupportedPackageManager(name))
|
|
throw new UsageError(`Unsupported package manager specification (${name})`);
|
|
|
|
return {
|
|
name, range: `*`,
|
|
};
|
|
}
|
|
|
|
const name = raw.slice(0, atIndex);
|
|
const range = raw.slice(atIndex + 1);
|
|
|
|
const isURL = URL.canParse(range);
|
|
if (!isURL) {
|
|
if (enforceExactVersion && !semverValid(range))
|
|
throw new UsageError(`Invalid package manager specification in ${source} (${raw}); expected a semver version${enforceExactVersion ? `` : `, range, or tag`}`);
|
|
|
|
if (!isSupportedPackageManager(name)) {
|
|
throw new UsageError(`Unsupported package manager specification (${raw})`);
|
|
}
|
|
} else if (isSupportedPackageManager(name) && process.env.COREPACK_ENABLE_UNSAFE_CUSTOM_URLS !== `1`) {
|
|
throw new UsageError(`Illegal use of URL for known package manager. Instead, select a specific version, or set COREPACK_ENABLE_UNSAFE_CUSTOM_URLS=1 in your environment (${raw})`);
|
|
}
|
|
|
|
|
|
return {
|
|
name,
|
|
range,
|
|
};
|
|
}
|
|
|
|
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) {
|
|
const {packageManager: pm} = packageJSONContent;
|
|
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 pm;
|
|
}
|
|
if (Array.isArray(packageManager)) {
|
|
console.warn(`! Corepack does not currently support array values for devEngines.packageManager`);
|
|
return pm;
|
|
}
|
|
|
|
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 pm;
|
|
}
|
|
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 pm;
|
|
}
|
|
|
|
debugUtils.log(`devEngines.packageManager defines that ${name}@${version} is the local package manager`);
|
|
|
|
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 pm;
|
|
}
|
|
|
|
export async function setLocalPackageManager(cwd: string, info: PreparedPackageManagerInfo) {
|
|
const lookup = await loadSpec(cwd);
|
|
|
|
const range = `range` in lookup && lookup.range;
|
|
if (range) {
|
|
if (info.locator.name !== range.name || !semverSatisfies(info.locator.reference, range.range)) {
|
|
warnOrThrow(`The requested version of ${info.locator.name}@${info.locator.reference} does not match the devEngines specification (${range.name}@${range.range})`, range.onFail);
|
|
}
|
|
}
|
|
|
|
const content = lookup.type !== `NoProject`
|
|
? await fs.promises.readFile(lookup.target, `utf8`)
|
|
: ``;
|
|
|
|
const {data, indent} = nodeUtils.readPackageJson(content);
|
|
|
|
const previousPackageManager = data.packageManager ?? (range ? `${range.name}@${range.range}` : `unknown`);
|
|
data.packageManager = `${info.locator.name}@${info.locator.reference}`;
|
|
|
|
const newContent = nodeUtils.normalizeLineEndings(content, `${JSON.stringify(data, null, indent)}\n`);
|
|
await fs.promises.writeFile(lookup.target, newContent, `utf8`);
|
|
|
|
return {
|
|
previousPackageManager,
|
|
};
|
|
}
|
|
|
|
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}
|
|
| FoundSpecResult;
|
|
|
|
export async function loadSpec(initialCwd: string): Promise<LoadSpecResult> {
|
|
let nextCwd = initialCwd;
|
|
let currCwd = ``;
|
|
|
|
let selection: {
|
|
data: any;
|
|
manifestPath: string;
|
|
envFilePath?: string;
|
|
localEnv: LocalEnvFile;
|
|
} | null = null;
|
|
|
|
while (nextCwd !== currCwd && (!selection || !selection.data.packageManager)) {
|
|
currCwd = nextCwd;
|
|
nextCwd = path.dirname(currCwd);
|
|
|
|
if (nodeModulesRegExp.test(currCwd))
|
|
continue;
|
|
|
|
const manifestPath = path.join(currCwd, `package.json`);
|
|
debugUtils.log(`Checking ${manifestPath}`);
|
|
let content: string;
|
|
try {
|
|
content = await fs.promises.readFile(manifestPath, `utf8`);
|
|
} catch (err) {
|
|
if ((err as NodeError)?.code === `ENOENT`) continue;
|
|
throw err;
|
|
}
|
|
|
|
let data;
|
|
try {
|
|
data = JSON.parse(content);
|
|
} catch {}
|
|
|
|
if (typeof data !== `object` || data === null)
|
|
throw new UsageError(`Invalid package.json in ${path.relative(initialCwd, 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};
|
|
|
|
debugUtils.log(`${selection.manifestPath} defines ${rawPmSpec} as local package manager`);
|
|
|
|
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,
|
|
onFail: selection.data.devEngines.packageManager.onFail,
|
|
},
|
|
// Lazy-loading it so we do not throw errors on commands that do not need valid spec.
|
|
getSpec: () => parseSpec(rawPmSpec, path.relative(initialCwd, selection.manifestPath)),
|
|
};
|
|
}
|