Skip to content
Closed
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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,11 @@ same major line. Should you need to upgrade to a new major, use an explicit
doesn't list it. In general we recommend to always list a `packageManager`
field (which you can easily set through `corepack use [name]@[version]`), as
it ensures that your project installs are always deterministic.
When standard input is a TTY and no CI environment is detected, Corepack asks
for confirmation before adding the field. Enter `y` or `yes` to accept;
declining leaves the manifest unchanged and still runs the requested command.
In CI or with non-interactive input, explicitly enabling auto-pinning adds the
field without prompting.

- `COREPACK_ENABLE_DOWNLOAD_PROMPT` can be set to `0` to
prevent Corepack showing the URL when it needs to download software, or can be
Expand Down
27 changes: 23 additions & 4 deletions sources/Engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {UsageError} from 'clipanion';
import fs from 'fs';
import path from 'path';
import process from 'process';
import {createInterface} from 'readline';
import semverRcompare from 'semver/functions/rcompare.js';
import semverValid from 'semver/functions/valid.js';
import semverValidRange from 'semver/ranges/valid.js';
Expand Down Expand Up @@ -294,11 +295,29 @@ export class Engine {

const installSpec = await this.ensurePackageManager(resolved);

console.error(`! The local project doesn't define a 'packageManager' field. Corepack will now add one referencing ${installSpec.locator.name}@${installSpec.locator.reference}.`);
console.error(`! For more details about this field, consult the documentation at https://nodejs.org/api/packages.html#packagemanager`);
console.error();
let shouldPin = true;
if (process.stdin.isTTY && !process.env.CI) {
shouldPin = false;
const input = createInterface({input: process.stdin});
try {
process.stderr.write(`? Add a 'packageManager' field referencing ${installSpec.locator.name}@${installSpec.locator.reference} to ${result.target}? [y/N] `);
for await (const answer of input) {
shouldPin = /^y(?:es)?$/i.test(answer.trim());
break;
}
} finally {
input.close();
process.stderr.write(`\n`);
}
}

if (shouldPin) {
console.error(`! The local project doesn't define a 'packageManager' field. Corepack will now add one referencing ${installSpec.locator.name}@${installSpec.locator.reference}.`);
console.error(`! For more details about this field, consult the documentation at https://nodejs.org/api/packages.html#packagemanager`);
console.error();

await specUtils.setLocalPackageManager(path.dirname(result.target), installSpec);
await specUtils.setLocalPackageManager(path.dirname(result.target), installSpec);
}
}

debugUtils.log(`Falling back to ${fallbackDescriptor.name}@${fallbackDescriptor.range} in the absence of "packageManager" field in ${result.target}`);
Expand Down
113 changes: 113 additions & 0 deletions tests/AutoPin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import type {PortablePath} from '@yarnpkg/fslib';
import {npath, ppath, xfs} from '@yarnpkg/fslib';
import {spawn} from 'node:child_process';
import process from 'node:process';
import {beforeEach, expect, it} from 'vitest';

const reference = `yarn@1.22.4+sha512.${`ab`.repeat(64)}`;
const manifest = `{\n "name": "auto-pin-test"\n}\n`;

beforeEach(async () => {
const home = await xfs.mktempPromise();
process.env.COREPACK_HOME = npath.fromPortablePath(home);
process.env.COREPACK_ENABLE_AUTO_PIN = `1`;
process.env.COREPACK_ENABLE_NETWORK = `0`;
process.env.COREPACK_DEFAULT_TO_LATEST = `0`;
delete process.env.CI;

// A cached executable keeps prompt tests independent of registry downloads.
const cache = ppath.join(home, `v1/yarn/1.22.4`);
await xfs.mkdirPromise(cache, {recursive: true});
await xfs.writeJsonPromise(ppath.join(cache, `.corepack`), {
locator: {name: `yarn`, reference: `1.22.4`},
bin: {yarn: `yarn.js`},
hash: reference.split(`+`)[1],
});
await xfs.writeFilePromise(ppath.join(cache, `yarn.js`), `process.stdout.write('package manager ran\\n');`);
await xfs.writeJsonPromise(ppath.join(home, `lastKnownGood.json`), {yarn: `1.22.4`});

return () => xfs.removePromise(home, {recursive: true});
});

async function runPrompt(cwd: PortablePath, {answer, tty = true}: {answer?: string, tty?: boolean} = {}) {
const preload = ppath.join(cwd, `tty.cjs`);
await xfs.writeFilePromise(preload, `Object.defineProperty(process.stdin, 'isTTY', {value: ${tty}});`);

return await new Promise<{exitCode: number | null, stdout: string, stderr: string}>((resolve, reject) => {
const child = spawn(process.execPath, [`--require`, npath.fromPortablePath(preload), require.resolve(`../dist/corepack.js`), `yarn`, `--version`], {
cwd: npath.fromPortablePath(cwd),
env: process.env,
stdio: `pipe`,
});
let stdout = ``;
let stderr = ``;
let answered = false;
const timeout = setTimeout(() => {
child.kill();
reject(new Error(`Timed out waiting for auto-pin prompt: ${stderr}`));
}, 10000);
child.stdout.on(`data`, chunk => {
stdout += chunk;
});
child.stderr.on(`data`, chunk => {
stderr += chunk;
if (!answered && stderr.includes(`[y/N] `)) {
answered = true;
child.stdin.end(answer);
}
});
child.on(`error`, error => {
clearTimeout(timeout);
reject(error);
});
child.on(`close`, exitCode => {
clearTimeout(timeout);
resolve({exitCode, stdout, stderr});
});
});
}

it.each([`y\n`, `YES\n`])(`pins after confirmation %j and runs the package manager`, async answer => {
await xfs.mktempPromise(async cwd => {
await xfs.writeFilePromise(ppath.join(cwd, `package.json`), manifest);
const result = await runPrompt(cwd, {answer});
expect(result).toMatchObject({exitCode: 0, stdout: `package manager ran\n`});
expect(result.stderr).toContain(`to ${npath.fromPortablePath(ppath.join(cwd, `package.json`))}? [y/N] `);
await expect(xfs.readJsonPromise(ppath.join(cwd, `package.json`))).resolves.toMatchObject({packageManager: reference});

// Once pinned, invoking the same command does not prompt again.
await expect(runPrompt(cwd)).resolves.toEqual({exitCode: 0, stdout: `package manager ran\n`, stderr: ``});
});
});

it.each([`n\n`, `\n`, `unexpected\n`, undefined])(`leaves the manifest unchanged after %j and still runs the command`, async answer => {
await xfs.mktempPromise(async cwd => {
await xfs.writeFilePromise(ppath.join(cwd, `package.json`), manifest);
const result = await runPrompt(cwd, {answer});
expect(result).toMatchObject({exitCode: 0, stdout: `package manager ran\n`});
expect(result.stderr).toContain(`[y/N] `);
expect(result.stderr).not.toContain(`Corepack will now add one`);
await expect(xfs.readFilePromise(ppath.join(cwd, `package.json`), `utf8`)).resolves.toBe(manifest);
});
});

it.each([`CI`, `pipe`])(`pins without prompting with %s input`, async mode => {
if (mode === `CI`)
process.env.CI = `1`;
await xfs.mktempPromise(async cwd => {
await xfs.writeFilePromise(ppath.join(cwd, `package.json`), manifest);
const result = await runPrompt(cwd, {tty: mode !== `pipe`});
expect(result).toMatchObject({exitCode: 0, stdout: `package manager ran\n`});
expect(result.stderr).not.toContain(`[y/N] `);
await expect(xfs.readJsonPromise(ppath.join(cwd, `package.json`))).resolves.toMatchObject({packageManager: reference});
});
});

it(`does not prompt or pin when auto-pinning is disabled`, async () => {
process.env.COREPACK_ENABLE_AUTO_PIN = `0`;
await xfs.mktempPromise(async cwd => {
await xfs.writeFilePromise(ppath.join(cwd, `package.json`), manifest);
await expect(runPrompt(cwd)).resolves.toEqual({exitCode: 0, stdout: `package manager ran\n`, stderr: ``});
await expect(xfs.readFilePromise(ppath.join(cwd, `package.json`), `utf8`)).resolves.toBe(manifest);
});
});