diff --git a/README.md b/README.md index 67d396d5c..4c9bac20f 100644 --- a/README.md +++ b/README.md @@ -286,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` diff --git a/sources/specUtils.ts b/sources/specUtils.ts index 8a015984d..aaf305848 100644 --- a/sources/specUtils.ts +++ b/sources/specUtils.ts @@ -103,6 +103,17 @@ function parsePackageJSON(packageJSONContent: CorepackPackageJSON) { 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}`; + } + 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); @@ -131,16 +142,33 @@ export async function setLocalPackageManager(cwd: string, info: PreparedPackageM } 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 {name} = range || (lookup as FoundSpecResult).getSpec(); + const envKey = `COREPACK_DEV_ENGINES_${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 previousPackageManager = data.packageManager ?? (range ? `${range.name}@${range.range}` : `unknown`); - data.packageManager = `${info.locator.name}@${info.locator.reference}`; + 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 ?? (range ? `${range.name}@${range.range}` : `unknown`); + data.packageManager = `${info.locator.name}@${info.locator.reference}`; + + newContent = `${JSON.stringify(data, null, indent)}\n`; + } - const newContent = nodeUtils.normalizeLineEndings(content, `${JSON.stringify(data, null, indent)}\n`); - await fs.promises.writeFile(lookup.target, newContent, `utf8`); + newContent = nodeUtils.normalizeLineEndings(content, newContent); + await fs.promises.writeFile((lookup as FoundSpecResult).envFilePath ?? lookup.target, newContent, `utf8`); return { previousPackageManager, diff --git a/tests/Up.test.ts b/tests/Up.test.ts index dac6e9d96..bab94a42d 100644 --- a/tests/Up.test.ts +++ b/tests/Up.test.ts @@ -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'; @@ -133,4 +134,42 @@ 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=1.1.0\n`, + `\nCOREPACK_DEV_ENGINES_YARN=1.1.0\n`, + `COREPACK_DEV_ENGINES_YARN=1.1.0`, + `\nCOREPACK_DEV_ENGINES_YARN=1.1.0`, + `FOO=bar\nCOREPACK_DEV_ENGINES_YARN=1.1.0\n`, + `FOO=bar\nCOREPACK_DEV_ENGINES_YARN=1.1.0`, + ].map(originalEnv => xfs.mktempPromise(async cwd => { + await xfs.writeJsonPromise(ppath.join(cwd, `package.json`), { + devEngines: {packageManager: {name: `yarn`, version: `1.x || 2.x`}}, + }); + await xfs.writeFilePromise(ppath.join(cwd, `.corepack.env`), originalEnv); + + await expect(runCli(cwd, [`up`])).resolves.toMatchObject({ + exitCode: 0, + stderr: ``, + stdout: expect.stringMatching(/^Installing yarn@2\.4\.3 in the project\.\.\.\n\n/), + }); + + 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`, + stderr: ``, + }); + }))); + }); }); diff --git a/tests/Use.test.ts b/tests/Use.test.ts index 978c2e3a8..2ee9d9bda 100644 --- a/tests/Use.test.ts +++ b/tests/Use.test.ts @@ -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'; @@ -115,6 +116,85 @@ describe(`UseCommand`, () => { }); }); + it(`should update .corepack.env if present and contains definition for pm version`, 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=1.1.0\n`, + `\nCOREPACK_DEV_ENGINES_YARN=1.1.0\n`, + `COREPACK_DEV_ENGINES_YARN=1.1.0`, + `\nCOREPACK_DEV_ENGINES_YARN=1.1.0`, + `FOO=bar\nCOREPACK_DEV_ENGINES_YARN=1.1.0\n`, + `FOO=bar\nCOREPACK_DEV_ENGINES_YARN=1.1.0`, + ].map(originalEnv => xfs.mktempPromise(async cwd => { + const pJSONContent = { + devEngines: {packageManager: {name: `yarn`, version: `1.x`}}, + license: `MIT`, + }; + await xfs.writeJsonPromise(ppath.join(cwd, `package.json`), pJSONContent); + await xfs.writeFilePromise(ppath.join(cwd, `.corepack.env`), originalEnv); + + await expect(runCli(cwd, [`use`, `yarn@1.22.4`])).resolves.toMatchObject({ + exitCode: 0, + stdout: expect.stringContaining(`Installing yarn@1.22.4 in the project...`), + stderr: ``, + }); + + try { + await expect(xfs.readFilePromise(ppath.join(cwd, `.corepack.env`), `utf-8`).then(parseEnv)).resolves.toMatchObject({ + COREPACK_DEV_ENGINES_YARN: `1.22.4+sha512.a1833b862fe52169bd6c2a033045a07df5bc6a23595c259e675fed1b2d035ab37abe6ce309720abb6636d68f03615054b6292dc0a70da31c8697fda228b50d18`, + }); + } catch (cause) { + throw new Error(JSON.stringify(originalEnv), {cause}); + } + // It should not have touched package.json. + await expect(xfs.readJsonPromise(ppath.join(cwd, `package.json`))).resolves.toStrictEqual(pJSONContent); + + await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({ + exitCode: 0, + stdout: `1.22.4\n`, + }); + }))); + }); + + it(`should update .other.env if present`, 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=1.1.0\n`, + `\nCOREPACK_DEV_ENGINES_YARN=1.1.0\n`, + `COREPACK_DEV_ENGINES_YARN=1.1.0`, + `\nCOREPACK_DEV_ENGINES_YARN=1.1.0`, + `FOO=bar\nCOREPACK_DEV_ENGINES_YARN=1.1.0\n`, + `FOO=bar\nCOREPACK_DEV_ENGINES_YARN=1.1.0`, + ].map(originalEnv => xfs.mktempPromise(async cwd => { + await xfs.writeJsonPromise(ppath.join(cwd, `package.json`), { + devEngines: {packageManager: {name: `yarn`, version: `1.x`}}, + }); + await xfs.writeFilePromise(ppath.join(cwd, `.other.env`), `COREPACK_DEV_ENGINES_YARN=1.0.0\n`); + + process.env.COREPACK_ENV_FILE = `.other.env`; + await expect(runCli(cwd, [`use`, `yarn@1.22.4`])).resolves.toMatchObject({ + exitCode: 0, + }); + + try { + await expect(xfs.readFilePromise(ppath.join(cwd, `.other.env`), `utf-8`).then(parseEnv)).resolves.toMatchObject({ + COREPACK_DEV_ENGINES_YARN: `1.22.4+sha512.a1833b862fe52169bd6c2a033045a07df5bc6a23595c259e675fed1b2d035ab37abe6ce309720abb6636d68f03615054b6292dc0a70da31c8697fda228b50d18`, + }); + } catch (cause) { + throw new Error(JSON.stringify(originalEnv), {cause}); + } + + await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({ + exitCode: 0, + stdout: `1.22.4\n`, + }); + }))); + }); + it(`should create a package.json if absent`, async () => { await xfs.mktempPromise(async cwd => { await expect(runCli(cwd, [`use`, `yarn@1.22.4`])).resolves.toMatchObject({ @@ -193,4 +273,43 @@ describe(`UseCommand`, () => { }); } }); + + 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: `1.x || 2.x`}}, + license: `MIT`, // To avoid Yarn warning. + }); + await xfs.writeFilePromise(ppath.join(cwd, `.corepack.env`), originalEnv); + + await expect(runCli(cwd, [`use`, `yarn@1.22.4`])).resolves.toMatchObject({ + exitCode: 0, + stderr: ``, + stdout: expect.stringMatching(/^Installing yarn@1\.22\.4 in the project\.\.\.\n\n/), + }); + + try { + await expect(xfs.readFilePromise(ppath.join(cwd, `.corepack.env`), `utf-8`).then(parseEnv)).resolves.toMatchObject({ + COREPACK_DEV_ENGINES_YARN: `1.22.4+sha512.a1833b862fe52169bd6c2a033045a07df5bc6a23595c259e675fed1b2d035ab37abe6ce309720abb6636d68f03615054b6292dc0a70da31c8697fda228b50d18`, + }); + } catch (cause) { + throw new Error(JSON.stringify(originalEnv), {cause}); + } + + await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({ + exitCode: 0, + stdout: `1.22.4\n`, + stderr: ``, + }); + }))); + }); });