diff --git a/.changeset/yarn-berry-corepack.md b/.changeset/yarn-berry-corepack.md new file mode 100644 index 00000000..e7ae7eeb --- /dev/null +++ b/.changeset/yarn-berry-corepack.md @@ -0,0 +1,11 @@ +--- +"uptide": patch +--- + +`fix` and `verify` install a repository that pins Yarn 2 or later in `packageManager` even +when the `yarn` on PATH is classic 1.x or absent: the pinned version runs through corepack +(`corepack yarn install ...`, download prompt off) without enabling corepack or changing +anything on your machine outside corepack's cache. Before, Yarn stopped the run with "the +current global version of Yarn is 1.22.22". When corepack is missing, the run stops before +cloning with exit code 2 and the command to run (`corepack enable`); when corepack cannot +fetch the pinned version, the message names `COREPACK_NPM_REGISTRY` for corporate mirrors. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 6876dd5f..b20d1e5c 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -27,8 +27,10 @@ installed versions. `fix` runs the package manager the repository names in `packageManager` (corepack's choice), else the one its lockfile implies, with lifecycle scripts disabled and corepack's network -access off, so the binary must already be there at the pinned version. When it is not, the -run fails before changing anything and prints the recovery command: +access off, so the binary must already be there at the pinned version. A pinned Yarn 2 or +later is the exception: `fix` runs it through corepack when the `yarn` on PATH cannot (next +section). Otherwise, when the binary is missing or at another version, the run fails before +changing anything and prints the recovery command: ```sh corepack enable pnpm && corepack prepare pnpm@ --activate @@ -37,6 +39,30 @@ corepack enable pnpm && corepack prepare pnpm@ --activate Unsupported range or lockfile syntax fails explicitly rather than silently broadening the upgrade. +## Yarn 4 says the global Yarn is 1.x + +A repository that pins Yarn 2 or later in `packageManager` (`"packageManager": "yarn@4.7.0"`) +on a machine whose `yarn` is classic 1.x, or has none, used to stop `fix` and `verify` with +Yarn's own message, "This project's package.json defines packageManager yarn@4.7.0. However +the current global version of Yarn is 1.22.22". Now `fix` and `verify` notice the mismatch +and run the pinned version through corepack (`corepack yarn install ...`, with the download +prompt off), which fetches it into corepack's own cache. Nothing on your machine is changed: +corepack is not enabled, no shim is installed, and the `packageManager` field is not +rewritten. A Berry `yarn` on PATH at the pinned version is used as it is. + +When corepack itself is not available (it ships with Node 16.9 and later, and some +distributions leave it out), the run stops before cloning, with exit code 2: + +``` +This repository pins yarn@4.7.0 (packageManager), but the yarn on PATH is 1.22.22 and corepack is not available to run the pinned version. Nothing was cloned or installed. +Next: corepack enable +``` + +When corepack is there but cannot fetch the pinned version (an offline machine, a registry +it cannot reach), the message says so and names `COREPACK_NPM_REGISTRY`: set it to your npm +mirror and run again, or activate the version by hand with `corepack enable yarn && corepack +prepare yarn@4.7.0 --activate`. + ## Corporate proxy and corepack registry Target versions and their type declarations come from your npm registry. Behind a proxy, set diff --git a/fixtures/repos/yarn-berry-pinned/.yarnrc.yml b/fixtures/repos/yarn-berry-pinned/.yarnrc.yml new file mode 100644 index 00000000..3186f3f0 --- /dev/null +++ b/fixtures/repos/yarn-berry-pinned/.yarnrc.yml @@ -0,0 +1 @@ +nodeLinker: node-modules diff --git a/fixtures/repos/yarn-berry-pinned/README.md b/fixtures/repos/yarn-berry-pinned/README.md new file mode 100644 index 00000000..c4b6245f --- /dev/null +++ b/fixtures/repos/yarn-berry-pinned/README.md @@ -0,0 +1,6 @@ +# yarn-berry-pinned + +A synthetic repository whose `package.json` pins `yarn@4.7.0` through `packageManager`, with a +Yarn Berry lockfile and the node-modules linker. It is test data for the installer: on a +machine whose global `yarn` is classic 1.x (or none), `fix` runs the pinned version through +corepack. Nothing here is installed by the tests. diff --git a/fixtures/repos/yarn-berry-pinned/package.json b/fixtures/repos/yarn-berry-pinned/package.json new file mode 100644 index 00000000..fd31beb8 --- /dev/null +++ b/fixtures/repos/yarn-berry-pinned/package.json @@ -0,0 +1,8 @@ +{ + "name": "yarn-berry-pinned", + "private": true, + "packageManager": "yarn@4.7.0", + "dependencies": { + "zod": "3.25.76" + } +} diff --git a/fixtures/repos/yarn-berry-pinned/yarn.lock b/fixtures/repos/yarn-berry-pinned/yarn.lock new file mode 100644 index 00000000..b0b6c208 --- /dev/null +++ b/fixtures/repos/yarn-berry-pinned/yarn.lock @@ -0,0 +1,21 @@ +# This file is generated by running "yarn install" inside your project. +# Manual changes might be lost - proceed with caution! + +__metadata: + version: 8 + cacheKey: 10c0 + +"yarn-berry-pinned@workspace:.": + version: 0.0.0-use.local + resolution: "yarn-berry-pinned@workspace:." + dependencies: + zod: "npm:3.25.76" + languageName: unknown + linkType: soft + +"zod@npm:3.25.76": + version: 3.25.76 + resolution: "zod@npm:3.25.76" + checksum: 10c0/0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 + languageName: node + linkType: hard diff --git a/packages/core/src/fix/managers/availability.test.ts b/packages/core/src/fix/managers/availability.test.ts index 8c138b52..edbe4406 100644 --- a/packages/core/src/fix/managers/availability.test.ts +++ b/packages/core/src/fix/managers/availability.test.ts @@ -1,9 +1,21 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { afterAll, expect, it, vi } from 'vitest'; +import { fileURLToPath } from 'node:url'; +import { afterAll, describe, expect, it, vi } from 'vitest'; import type { command } from '../process.js'; -import { assertManagerAvailable } from './availability.js'; +import { + assertManagerAvailable, + COREPACK_ENV, + needsCorepack, + throughCorepack, +} from './availability.js'; +import { packageManager, updateArgs } from './manager.js'; + +/** package.json pins yarn@4.7.0, Berry lockfile, node-modules linker. */ +const PINNED = fileURLToPath( + new URL('../../../../../fixtures/repos/yarn-berry-pinned', import.meta.url), +); const roots: string[] = []; afterAll(() => { @@ -16,7 +28,7 @@ function fixture(pm: string, lockfile: string, text: string) { writeFileSync(join(root, lockfile), text); return root; } -it('explains exactly how to activate a missing Yarn Berry version without downloading it silently', async () => { +it('without yarn or corepack, says what to run and downloads nothing silently', async () => { const root = fixture('yarn@4.9.2', 'yarn.lock', '__metadata:\n version: 8\n'); const probe = vi.fn(async () => ({ code: 1, @@ -25,11 +37,14 @@ it('explains exactly how to activate a missing Yarn Berry version without downlo })); await expect(assertManagerAvailable(root, probe)).rejects.toMatchObject({ code: 'PACKAGE_MANAGER_UNAVAILABLE', - message: expect.stringContaining( - 'Next: corepack enable yarn && corepack prepare yarn@4.9.2 --activate', - ), + message: expect.stringContaining('Next: corepack enable'), }); expect(probe.mock.calls[0]?.[0]).toBe(root); + // yarn, then corepack itself; never an install, never `corepack enable`. + expect(probe.mock.calls.map((c) => [c[1], c[2][0]])).toEqual([ + ['yarn', '--version'], + ['corepack', '--version'], + ]); }); it('rejects a classic binary for a Berry lockfile even without an explicit packageManager', async () => { const root = fixture('', 'yarn.lock', '__metadata:\n version: 8\n'); @@ -52,3 +67,103 @@ it('reports an unavailable npm version, and accepts the exact declared version', ).kind, ).toBe('npm'); }); + +describe('a pinned Yarn Berry on a machine with Yarn classic', () => { + /** `yarn --version` answers `yarn`; `corepack --version` and `corepack yarn --version` answer the rest. */ + const answering = + (yarn: string | undefined, corepack: string | undefined, pinned: string | undefined) => + async (_cwd: string, bin: string, args: string[]) => { + const version = + bin === 'yarn' ? yarn : bin === 'corepack' && args[0] === 'yarn' ? pinned : corepack; + return version === undefined + ? { code: 1, output: `${bin}: not found`, timeout: false } + : { code: 0, output: `${version}\n`, timeout: false }; + }; + + it('detects when the pinned version cannot run through the yarn on PATH', () => { + const pm = packageManager(PINNED); + expect(pm).toMatchObject({ kind: 'yarn-berry', requestedVersion: '4.7.0' }); + expect(needsCorepack(pm, { code: 1, output: 'not found' })).toBe(true); + expect(needsCorepack(pm, { code: 0, output: '1.22.22\n' })).toBe(true); + expect(needsCorepack(pm, { code: 0, output: '4.6.0\n' })).toBe(true); + expect(needsCorepack(pm, { code: 0, output: '4.7.0\n' })).toBe(false); + const classic = packageManager(fixture('yarn@1.22.22', 'yarn.lock', '# yarn lockfile v1\n')); + expect(needsCorepack(classic, { code: 0, output: '1.22.22\n' })).toBe(false); + const pnpm = packageManager( + fixture('pnpm@10.17.1', 'pnpm-lock.yaml', "lockfileVersion: '9.0'\n"), + ); + expect(needsCorepack(pnpm, { code: 1, output: '' })).toBe(false); + }); + + it('runs the same install as `corepack yarn`, prompt off, without enabling corepack', () => { + const pm = throughCorepack(packageManager(PINNED)); + expect(pm.bin).toBe('corepack'); + expect(pm.via).toBe('corepack'); + expect(pm.args).toEqual(['yarn', 'install', '--immutable', '--mode=skip-build']); + expect(pm.env).toMatchObject({ + ...COREPACK_ENV, + YARN_ENABLE_SCRIPTS: 'false', + COREPACK_ENABLE_DOWNLOAD_PROMPT: '0', + }); + expect(updateArgs(pm, PINNED, ['.'])).toEqual(['yarn', 'install', '--mode=skip-build']); + }); + + it('selects corepack when the yarn on PATH is classic and corepack can run the pin', async () => { + const probe = vi.fn(answering('1.22.22', '0.34.0', '4.7.0')); + const pm = await assertManagerAvailable(PINNED, probe); + expect(pm).toMatchObject({ kind: 'yarn-berry', bin: 'corepack', via: 'corepack' }); + expect(pm.args).toEqual(['yarn', 'install', '--immutable', '--mode=skip-build']); + expect(probe.mock.calls.map((c) => [c[1], ...c[2]])).toEqual([ + ['yarn', '--version'], + ['corepack', '--version'], + ['corepack', 'yarn', '--version'], + ]); + // The pinned version is fetched with the prompt off, and the pin is never rewritten. + expect(probe.mock.calls[2]?.[4]).toMatchObject(COREPACK_ENV); + }); + + it('selects corepack when no yarn is on PATH at all', async () => { + const pm = await assertManagerAvailable(PINNED, answering(undefined, '0.34.0', '4.7.0')); + expect(pm.bin).toBe('corepack'); + }); + + it('keeps a matching Berry on PATH as it is', async () => { + const pm = await assertManagerAvailable(PINNED, answering('4.7.0', '0.34.0', '4.7.0')); + expect(pm.bin).toBe('yarn'); + expect(pm.via).toBeUndefined(); + }); + + it('exits before cloning with the command to run when corepack is missing', async () => { + await expect( + assertManagerAvailable(PINNED, answering('1.22.22', undefined, undefined)), + ).rejects.toMatchObject({ + code: 'PACKAGE_MANAGER_UNAVAILABLE', + message: + 'This repository pins yarn@4.7.0 (packageManager), but the yarn on PATH is 1.22.22 and corepack is not available to run the pinned version. Nothing was cloned or installed.\nNext: corepack enable', + }); + }); + + it('names COREPACK_NPM_REGISTRY only when corepack could not fetch the pinned version', async () => { + const withCorepack = answering('1.22.22', '0.34.0', '4.7.0'); + await expect(assertManagerAvailable(PINNED, withCorepack)).resolves.toBeDefined(); + const failing = answering('1.22.22', '0.34.0', undefined); + const error = await assertManagerAvailable(PINNED, failing).catch((e: Error) => e); + expect(error).toMatchObject({ code: 'PACKAGE_MANAGER_UNAVAILABLE' }); + expect(String(error)).toContain('corepack could not run the pinned version'); + expect(String(error)).toContain('set COREPACK_NPM_REGISTRY to your npm mirror'); + expect(String(error)).toContain( + 'Next: corepack enable yarn && corepack prepare yarn@4.7.0 --activate', + ); + const missing = await assertManagerAvailable( + PINNED, + answering('1.22.22', undefined, undefined), + ).catch((e: Error) => e); + expect(String(missing)).not.toContain('COREPACK_NPM_REGISTRY'); + }); + + it('still refuses a corepack yarn that is not the pinned version', async () => { + await expect( + assertManagerAvailable(PINNED, answering('1.22.22', '0.34.0', '4.6.0')), + ).rejects.toMatchObject({ code: 'PACKAGE_MANAGER_VERSION' }); + }); +}); diff --git a/packages/core/src/fix/managers/availability.ts b/packages/core/src/fix/managers/availability.ts index c81cc877..d984c529 100644 --- a/packages/core/src/fix/managers/availability.ts +++ b/packages/core/src/fix/managers/availability.ts @@ -8,11 +8,57 @@ export function repairCommand(pm: PackageManager): string { (pm.kind === 'yarn-classic' ? '1.22.22' : pm.kind === 'yarn-berry' ? 'stable' : 'latest'); return `corepack enable ${pm.bin} && corepack prepare ${pm.bin}@${wanted} --activate`; } + +/** The environment corepack runs with: no prompt, no edit to the repository's `packageManager`. */ +export const COREPACK_ENV: Record = { + COREPACK_ENABLE_DOWNLOAD_PROMPT: '0', + COREPACK_ENABLE_AUTO_PIN: '0', +}; + +const versionOf = (output: string): string => output.trim().split('\n').at(-1) ?? ''; +const majorOf = (version: string): number => Number(/^(\d+)\./.exec(version)?.[1]); + +function matches(pm: PackageManager, version: string): boolean { + const major = majorOf(version); + const family = + pm.kind === 'yarn-classic' ? major === 1 : pm.kind === 'yarn-berry' ? major >= 2 : major >= 7; + return family && (!pm.requestedVersion || version === pm.requestedVersion); +} + +/** + * Whether the repository pins Yarn 2 or later (`packageManager`, a Berry lockfile) while the + * `yarn` on PATH is classic 1.x, another version, or absent: the pinned one has to run + * through corepack, which reads the pin itself. `probe` is `yarn --version` as it answered. + */ +export function needsCorepack( + pm: PackageManager, + probe: { code: number; output: string }, +): boolean { + if (pm.kind !== 'yarn-berry') return false; + return probe.code !== 0 || !matches(pm, versionOf(probe.output)); +} + +/** + * The same install, run as `corepack yarn …`: corepack resolves and caches the pinned + * version on its own, with the download prompt off and without `corepack enable`, so + * nothing on the user's machine changes outside corepack's cache. `COREPACK_NPM_REGISTRY` + * from the environment is passed through as it is. + */ +export function throughCorepack(pm: PackageManager): PackageManager { + return { + ...pm, + bin: 'corepack', + args: ['yarn', ...pm.args], + env: { ...pm.env, ...COREPACK_ENV }, + via: 'corepack', + }; +} + export async function assertManagerAvailable( root: string, probe = command, ): Promise { - const pm = packageManager(root); + let pm = packageManager(root); const result = await probe(root, pm.bin, ['--version'], 15000, { ...pm.env, COREPACK_ENABLE_NETWORK: '0', @@ -20,29 +66,38 @@ export async function assertManagerAvailable( COREPACK_ENABLE_DOWNLOAD_PROMPT: '0', }); const next = repairCommand(pm); - if (result.code) + let actual = versionOf(result.output); + if (needsCorepack(pm, result)) { + const pinned = `yarn@${pm.requestedVersion ?? '2 or later'}`; + const found = result.code ? 'no yarn is on PATH' : `the yarn on PATH is ${actual || 'unknown'}`; + const corepack = await probe(root, 'corepack', ['--version'], 15000, COREPACK_ENV); + if (corepack.code) + throw new UptideError( + 'PACKAGE_MANAGER_UNAVAILABLE', + `This repository pins ${pinned} (packageManager), but ${found} and corepack is not available to run the pinned version. Nothing was cloned or installed.\nNext: corepack enable`, + ); + const viaCorepack = throughCorepack(pm); + const run = await probe(root, 'corepack', ['yarn', '--version'], 120_000, viaCorepack.env); + if (run.code) + throw new UptideError( + 'PACKAGE_MANAGER_UNAVAILABLE', + `This repository pins ${pinned} (packageManager), but ${found} and corepack could not run the pinned version${run.timeout ? ' (timed out)' : ''}: ${versionOf(run.output) || 'no output'}. Behind a corporate registry, set COREPACK_NPM_REGISTRY to your npm mirror. Nothing was cloned or installed.\nNext: ${next}`, + ); + pm = viaCorepack; + actual = versionOf(run.output); + } else if (result.code) throw new UptideError( 'PACKAGE_MANAGER_UNAVAILABLE', `${pm.bin}${pm.requestedVersion ? `@${pm.requestedVersion}` : ''} is not available in this environment. Enable Corepack and activate the repository's package manager.\nNext: ${next}`, ); - const actual = result.output.trim().split('\n').at(-1) ?? ''; - const major = Number(/^(\d+)\./.exec(actual)?.[1]); - const family = - pm.kind === 'yarn-classic' - ? major === 1 - : pm.kind === 'yarn-berry' - ? major >= 2 - : pm.kind === 'npm' - ? major >= 7 - : major >= 7; - if (!family || (pm.requestedVersion && actual !== pm.requestedVersion)) + if (!matches(pm, actual)) throw new UptideError( 'PACKAGE_MANAGER_VERSION', - `Found ${pm.bin}@${actual || 'unknown'}, but this repository requires ${pm.requestedVersion ? `${pm.bin}@${pm.requestedVersion}` : pm.kind}. No files were installed.\nNext: ${next}`, + `Found ${pm.via === 'corepack' ? 'corepack yarn' : pm.bin}@${actual || 'unknown'}, but this repository requires ${pm.requestedVersion ? `${pm.kind === 'npm' ? 'npm' : pm.kind === 'pnpm' ? 'pnpm' : 'yarn'}@${pm.requestedVersion}` : pm.kind}. No files were installed.\nNext: ${next}`, ); if (pm.kind === 'yarn-berry') pm.args = pm.args.map((a) => - a.startsWith('--mode=') || a === '--skip-builds' ? berrySkipBuild(major) : a, + a.startsWith('--mode=') || a === '--skip-builds' ? berrySkipBuild(majorOf(actual)) : a, ); return pm; } diff --git a/packages/core/src/fix/managers/manager.ts b/packages/core/src/fix/managers/manager.ts index 8035bb51..273d1404 100644 --- a/packages/core/src/fix/managers/manager.ts +++ b/packages/core/src/fix/managers/manager.ts @@ -8,6 +8,8 @@ export interface PackageManager { lockfile: string; args: string[]; env?: Record; + /** Set when `bin` is corepack running the pinned Yarn (`args` then start with `yarn`); see availability.ts. */ + via?: 'corepack'; requestedVersion?: string; /** Why this manager, when the repository could have meant another: other lockfiles present. */ chosen?: string; @@ -99,6 +101,10 @@ export function packageManager(root: string): PackageManager { } /** Manifests have already been updated, so npm can preserve dependency sections with no add/save flags. */ export function updateArgs(pm: PackageManager, root: string, workspaces: string[]): string[] { + const args = managerArgs(pm, root, workspaces); + return pm.via === 'corepack' ? ['yarn', ...args] : args; +} +function managerArgs(pm: PackageManager, root: string, workspaces: string[]): string[] { switch (pm.kind) { case 'npm': { const version = JSON.parse(readFileSync(join(root, pm.lockfile), 'utf8')).lockfileVersion;