Skip to content
Merged
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
11 changes: 11 additions & 0 deletions .changeset/yarn-berry-corepack.md
Original file line number Diff line number Diff line change
@@ -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.
30 changes: 28 additions & 2 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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@<version> --activate
Expand All @@ -37,6 +39,30 @@ corepack enable pnpm && corepack prepare pnpm@<version> --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
Expand Down
1 change: 1 addition & 0 deletions fixtures/repos/yarn-berry-pinned/.yarnrc.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
nodeLinker: node-modules
6 changes: 6 additions & 0 deletions fixtures/repos/yarn-berry-pinned/README.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 8 additions & 0 deletions fixtures/repos/yarn-berry-pinned/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "yarn-berry-pinned",
"private": true,
"packageManager": "yarn@4.7.0",
"dependencies": {
"zod": "3.25.76"
}
}
21 changes: 21 additions & 0 deletions fixtures/repos/yarn-berry-pinned/yarn.lock
Original file line number Diff line number Diff line change
@@ -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
127 changes: 121 additions & 6 deletions packages/core/src/fix/managers/availability.test.ts
Original file line number Diff line number Diff line change
@@ -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(() => {
Expand All @@ -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<typeof command>(async () => ({
code: 1,
Expand All @@ -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');
Expand All @@ -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<typeof command>(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' });
});
});
85 changes: 70 additions & 15 deletions packages/core/src/fix/managers/availability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,41 +8,96 @@ 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<string, string> = {
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<PackageManager> {
const pm = packageManager(root);
let pm = packageManager(root);
const result = await probe(root, pm.bin, ['--version'], 15000, {
...pm.env,
COREPACK_ENABLE_NETWORK: '0',
COREPACK_ENABLE_AUTO_PIN: '0',
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;
}
6 changes: 6 additions & 0 deletions packages/core/src/fix/managers/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ export interface PackageManager {
lockfile: string;
args: string[];
env?: Record<string, string>;
/** 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;
Expand Down Expand Up @@ -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;
Expand Down
Loading