Skip to content
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

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

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
Open
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
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,16 @@ same major line. Should you need to upgrade to a new major, use an explicit
instruct Corepack to skip integrity checks, or to a JSON string containing
custom keys.

- `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_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 starts with `COREPACK_` 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.

## Troubleshooting

The environment variable `DEBUG` can be set to `corepack` to enable additional debug logging.
Expand Down
2 changes: 1 addition & 1 deletion sources/commands/Base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export abstract class BaseCommand extends Command<Context> {
throw new UsageError(`Couldn't find a project in the local directory - please explicit the package manager to pack, or run this command from a valid project`);

case `NoSpec`:
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`);
throw new UsageError(`The local project doesn't feature a 'packageManager' field nor 'devEngines.packageManager' field - please explicit the package manager to pack, or update the manifest to reference it`);

default: {
return [lookup.spec];
Expand Down
142 changes: 123 additions & 19 deletions sources/specUtils.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import {UsageError} from 'clipanion';
import fs from 'fs';
import path from 'path';
import semverValid from 'semver/functions/valid';

import {PreparedPackageManagerInfo} from './Engine';
import * as debugUtils from './debugUtils';
import {NodeError} from './nodeUtils';
import * as nodeUtils from './nodeUtils';
import {Descriptor, isSupportedPackageManager} from './types';
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 {parseEnv} from 'util';

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,30 +55,98 @@ export function parseSpec(raw: unknown, source: string, {enforceExactVersion = t
};
}

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

interface DevEngineDependency {
name: string;
version: string;
}
function parsePackageJSON(packageJSONContent: CorepackPackageJSON, localEnv?: LocalEnvFile) {
if (packageJSONContent.devEngines?.packageManager) {
const {packageManager} = packageJSONContent.devEngines;

if (Array.isArray(packageManager))
throw new UsageError(`Providing several package managers is currently not supported`);

let {version} = packageManager;
if (!version)
throw new UsageError(`Providing no version nor ranger for package manager is currently not supported`);

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

const localEnvKey = `COREPACK_DEV_ENGINES_${packageManager.name.toUpperCase()}`;
const localEnvVersion = localEnv?.[localEnvKey];
if (localEnvVersion) {
if (!semverSatisfies(localEnvVersion, version))
throw new UsageError(`Local env key ${localEnvKey} defines a value of ${localEnvVersion} which does not match the version defined in package.json devEngines.packageManager of ${version}`);

debugUtils.log(`Using ${localEnvVersion} from the environment as it matches ${version} defined in project manifest`);
version = localEnvVersion;
} else {
const {packageManager: pm} = packageJSONContent;
if (pm) {
if (!pm.startsWith(`${packageManager.name}@`))
throw new UsageError(`"packageManager" field is set to ${JSON.stringify(pm)} which does not match the "devEngines.packageManager" field set to ${JSON.stringify(packageManager.name)}`);

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

return pm;
}
}


return `${packageManager.name}@${version}`;
}

return packageJSONContent.packageManager;
}

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

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) {
const envKey = `COREPACK_DEV_ENGINES_${(lookup as FoundSpecResult).spec.name.toUpperCase()}`;
const index = content.lastIndexOf(`\n${envKey}=`) + 1;

const previousPackageManager = data.packageManager ?? `unknown`;
data.packageManager = `${info.locator.name}@${info.locator.reference}`;
if (index === 0 && !content.startsWith(`${envKey}=`))
throw new Error(`INTERNAL ASSERTION ERROR: missing expected ${envKey} in .corepack.env`);

const newContent = nodeUtils.normalizeLineEndings(content, `${JSON.stringify(data, null, indent)}\n`);
await fs.promises.writeFile(lookup.target, newContent, `utf8`);
const lineEndIndex = content.indexOf(`\n`, index);

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);

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,
};
}

type FoundSpecResult = {type: `Found`, target: string, spec: Descriptor, envFilePath?: string};
export type LoadSpecResult =
| {type: `NoProject`, target: string}
| {type: `NoSpec`, target: string}
| {type: `Found`, target: string, spec: Descriptor};
| FoundSpecResult;

export async function loadSpec(initialCwd: string): Promise<LoadSpecResult> {
let nextCwd = initialCwd;
Expand All @@ -84,6 +155,8 @@ export async function loadSpec(initialCwd: string): Promise<LoadSpecResult> {
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 +184,50 @@ 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 {
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;
const rawPmSpec = parsePackageJSON(selection.data, selection.localEnv);
if (typeof rawPmSpec === `undefined`)
return {type: `NoSpec`, target: selection.manifestPath};

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

let envFilePath: string | undefined;
if (selection.localEnv !== process.env) {
envFilePath = selection.envFilePath;
process.env = selection.localEnv;
}

return {
type: `Found`,
target: selection.manifestPath,
envFilePath,
spec: parseSpec(rawPmSpec, path.relative(initialCwd, selection.manifestPath)),
};
}
2 changes: 2 additions & 0 deletions sources/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,3 +145,5 @@ export interface Locator {
*/
reference: string;
}

export type LocalEnvFile = Record<string, string | undefined>;
77 changes: 76 additions & 1 deletion tests/Up.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {ppath, xfs, npath} from '@yarnpkg/fslib';
import process from 'node:process';
import {parseEnv} from 'node:util';
import {describe, beforeEach, it, expect} from 'vitest';

import {runCli} from './_runCli';
Expand All @@ -11,7 +12,7 @@ beforeEach(async () => {
});

describe(`UpCommand`, () => {
it(`should upgrade the package manager from the current project`, async () => {
it(`should update the "packageManager" field from the current project`, async () => {
await xfs.mktempPromise(async cwd => {
await xfs.writeJsonPromise(ppath.join(cwd, `package.json`), {
packageManager: `yarn@2.1.0`,
Expand All @@ -32,4 +33,78 @@ describe(`UpCommand`, () => {
});
});
});

it(`should update the ".corepack.env" file from the current project`, 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 Promise.all([
`COREPACK_DEV_ENGINES_YARN=2.1.0\n`,
`\nCOREPACK_DEV_ENGINES_YARN=2.1.0\n`,
`COREPACK_DEV_ENGINES_YARN=2.1.0`,
`\nCOREPACK_DEV_ENGINES_YARN=2.1.0`,
`FOO=bar\nCOREPACK_DEV_ENGINES_YARN=2.1.0\n`,
`FOO=bar\nCOREPACK_DEV_ENGINES_YARN=2.1.0`,
].map(originalEnv => xfs.mktempPromise(async cwd => {
await xfs.writeJsonPromise(ppath.join(cwd, `package.json`), {
devEngines: {packageManager: {name: `yarn`, version: `2.x`}},
});
await xfs.writeFilePromise(ppath.join(cwd, `.corepack.env`), originalEnv);

await expect(runCli(cwd, [`up`])).resolves.toMatchObject({
exitCode: 0,
stderr: ``,
});

try {
await expect(xfs.readFilePromise(ppath.join(cwd, `.corepack.env`), `utf-8`).then(parseEnv)).resolves.toMatchObject({
COREPACK_DEV_ENGINES_YARN: `2.4.3+sha512.8dd9fedc5451829619e526c56f42609ad88ae4776d9d3f9456d578ac085115c0c2f0fb02bb7d57fd2e1b6e1ac96efba35e80a20a056668f61c96934f67694fd0`,
});
} catch (cause) {
throw new Error(JSON.stringify(originalEnv), {cause});
}

await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({
exitCode: 0,
stdout: `2.4.3\n`,
});
})));
});

it(`should update the ".other.env" file from the current project when configured to use that`, 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 Promise.all([
`COREPACK_DEV_ENGINES_YARN=2.1.0\n`,
`\nCOREPACK_DEV_ENGINES_YARN=2.1.0\n`,
`COREPACK_DEV_ENGINES_YARN=2.1.0`,
`\nCOREPACK_DEV_ENGINES_YARN=2.1.0`,
`FOO=bar\nCOREPACK_DEV_ENGINES_YARN=2.1.0\n`,
`FOO=bar\nCOREPACK_DEV_ENGINES_YARN=2.1.0`,
].map(originalEnv => xfs.mktempPromise(async cwd => {
await xfs.writeJsonPromise(ppath.join(cwd, `package.json`), {
devEngines: {packageManager: {name: `yarn`, version: `2.x`}},
});
await xfs.writeFilePromise(ppath.join(cwd, `.other.env`), originalEnv);

process.env.COREPACK_ENV_FILE = `.other.env`;
await expect(runCli(cwd, [`up`])).resolves.toMatchObject({
exitCode: 0,
stderr: ``,
});

try {
await expect(xfs.readFilePromise(ppath.join(cwd, `.other.env`), `utf-8`).then(parseEnv)).resolves.toMatchObject({
COREPACK_DEV_ENGINES_YARN: `2.4.3+sha512.8dd9fedc5451829619e526c56f42609ad88ae4776d9d3f9456d578ac085115c0c2f0fb02bb7d57fd2e1b6e1ac96efba35e80a20a056668f61c96934f67694fd0`,
});
} catch (cause) {
throw new Error(JSON.stringify(originalEnv), {cause});
}

await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({
exitCode: 0,
stdout: `2.4.3\n`,
});
})));
});
});
Loading
Loading