diff --git a/README.md b/README.md index e2adeb949..5dc10e026 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/sources/Engine.ts b/sources/Engine.ts index 31dbe4ea6..89292e38c 100644 --- a/sources/Engine.ts +++ b/sources/Engine.ts @@ -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'; @@ -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}`); diff --git a/tests/AutoPin.test.ts b/tests/AutoPin.test.ts new file mode 100644 index 000000000..da5e5ce68 --- /dev/null +++ b/tests/AutoPin.test.ts @@ -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); + }); +});