diff --git a/README.md b/README.md index 5037b06f5..52806c6f3 100644 --- a/README.md +++ b/README.md @@ -84,15 +84,15 @@ recommended as a security practice. Permitted values for the package manager are ## Known Good Releases When running Corepack within projects that don't list a supported package -manager, it will default to a set of Known Good Releases. In a way, you can -compare this to Node.js, where each version ships with a specific version of -npm. +manager, it will default to a set of Known Good Releases. If there is no Known Good Release for the requested package manager, Corepack looks up the npm registry for the latest available version and cache it for future use. The Known Good Releases can be updated system-wide using `corepack install -g`. +When Corepack downloads a new version of a given package manager on the same +major line as the Known Good Release, it auto-updates it by default. ## Offline Workflow @@ -221,7 +221,8 @@ same major line. Should you need to upgrade to a new major, use an explicit - `COREPACK_DEFAULT_TO_LATEST` can be set to `0` in order to instruct Corepack not to lookup on the remote registry for the latest version of the selected - package manager. + package manager, and to not update the Last Known Good version when it + downloads a new version of the same major line. - `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 baad5dac7..63c220b96 100644 --- a/sources/Engine.ts +++ b/sources/Engine.ts @@ -1,4 +1,5 @@ import {UsageError} from 'clipanion'; +import type {FileHandle} from 'fs/promises'; import fs from 'fs'; import path from 'path'; import process from 'process'; @@ -7,13 +8,57 @@ import semver from 'semver'; import defaultConfig from '../config.json'; import * as corepackUtils from './corepackUtils'; +import * as debugUtils from './debugUtils'; import * as folderUtils from './folderUtils'; +import type {NodeError} from './nodeUtils'; import * as semverUtils from './semverUtils'; import {Config, Descriptor, Locator} from './types'; import {SupportedPackageManagers, SupportedPackageManagerSet} from './types'; export type PreparedPackageManagerInfo = Awaited>; +export function getLastKnownGoodFile(flag = `r`) { + return fs.promises.open(path.join(folderUtils.getInstallFolder(), `lastKnownGood.json`), flag); +} + +export async function getJSONFileContent(fh: FileHandle) { + let lastKnownGood: unknown; + try { + lastKnownGood = JSON.parse(await fh.readFile(`utf8`)); + } catch { + // Ignore errors; too bad + return undefined; + } + + return lastKnownGood; +} + +async function overwriteJSONFileContent(fh: FileHandle, content: unknown) { + await fh.truncate(0); + await fh.write(`${JSON.stringify(content, null, 2)}\n`, 0); +} + +export function getLastKnownGoodFromFileContent(lastKnownGood: unknown, packageManager: string) { + if (typeof lastKnownGood === `object` && lastKnownGood !== null && + Object.hasOwn(lastKnownGood, packageManager)) { + const override = (lastKnownGood as any)[packageManager]; + if (typeof override === `string`) { + return override; + } + } + return undefined; +} + +export async function activatePackageManagerFromFileHandle(lastKnownGoodFile: FileHandle, lastKnownGood: unknown, locator: Locator) { + if (typeof lastKnownGood !== `object` || lastKnownGood === null) + lastKnownGood = {}; + + (lastKnownGood as Record)[locator.name] = locator.reference; + + debugUtils.log(`Setting ${locator.name}@${locator.reference} as Last Known Good version`); + await overwriteJSONFileContent(lastKnownGoodFile, lastKnownGood); +} + export class Engine { constructor(public config: Config = defaultConfig as Config) { } @@ -77,51 +122,52 @@ export class Engine { if (typeof definition === `undefined`) throw new UsageError(`This package manager (${packageManager}) isn't supported by this corepack build`); - let lastKnownGood: unknown; - try { - lastKnownGood = JSON.parse(await fs.promises.readFile(this.getLastKnownGoodFile(), `utf8`)); - } catch { - // Ignore errors; too bad - } - - if (typeof lastKnownGood === `object` && lastKnownGood !== null && - Object.hasOwn(lastKnownGood, packageManager)) { - const override = (lastKnownGood as any)[packageManager]; - if (typeof override === `string`) { - return override; + let emptyFile = false; + const lastKnownGoodFile = await getLastKnownGoodFile(`r+`).catch(err => { + if ((err as NodeError)?.code === `ENOENT`) { + emptyFile = true; + return getLastKnownGoodFile(`w`); } - } - if (process.env.COREPACK_DEFAULT_TO_LATEST === `0`) - return definition.default; + throw err; + }); + try { + const lastKnownGood = emptyFile || await getJSONFileContent(lastKnownGoodFile); + const lastKnownGoodForThisPackageManager = getLastKnownGoodFromFileContent(lastKnownGood, packageManager); + if (lastKnownGoodForThisPackageManager) + return lastKnownGoodForThisPackageManager; - const reference = await corepackUtils.fetchLatestStableVersion(definition.fetchLatestFrom); + if (process.env.COREPACK_DEFAULT_TO_LATEST === `0`) + return definition.default; - await this.activatePackageManager({ - name: packageManager, - reference, - }); + const reference = await corepackUtils.fetchLatestStableVersion(definition.fetchLatestFrom); + + await activatePackageManagerFromFileHandle(lastKnownGoodFile, lastKnownGood, { + name: packageManager, + reference, + }); - return reference; + return reference; + } finally { + await lastKnownGoodFile.close(); + } } async activatePackageManager(locator: Locator) { - const lastKnownGoodFile = this.getLastKnownGoodFile(); + let emptyFile = false; + const lastKnownGoodFile = await getLastKnownGoodFile(`r+`).catch(err => { + if ((err as NodeError)?.code === `ENOENT`) { + emptyFile = true; + return getLastKnownGoodFile(`w`); + } - let lastKnownGood; + throw err; + }); try { - lastKnownGood = JSON.parse(await fs.promises.readFile(lastKnownGoodFile, `utf8`)); - } catch { - // Ignore errors; too bad + await activatePackageManagerFromFileHandle(lastKnownGoodFile, emptyFile || await getJSONFileContent(lastKnownGoodFile), locator); + } finally { + await lastKnownGoodFile.close(); } - - if (typeof lastKnownGood !== `object` || lastKnownGood === null) - lastKnownGood = {}; - - lastKnownGood[locator.name] = locator.reference; - - await fs.promises.mkdir(path.dirname(lastKnownGoodFile), {recursive: true}); - await fs.promises.writeFile(lastKnownGoodFile, `${JSON.stringify(lastKnownGood, null, 2)}\n`); } async ensurePackageManager(locator: Locator) { @@ -194,8 +240,4 @@ export class Engine { return {name: finalDescriptor.name, reference: highestVersion[0]}; } - - private getLastKnownGoodFile() { - return path.join(folderUtils.getInstallFolder(), `lastKnownGood.json`); - } } diff --git a/sources/corepackUtils.ts b/sources/corepackUtils.ts index db7b39d22..be4b6bd3a 100644 --- a/sources/corepackUtils.ts +++ b/sources/corepackUtils.ts @@ -1,11 +1,13 @@ import {createHash} from 'crypto'; import {once} from 'events'; +import {FileHandle} from 'fs/promises'; import fs from 'fs'; import type {Dir} from 'fs'; import Module from 'module'; import path from 'path'; import semver from 'semver'; +import * as engine from './Engine'; import * as debugUtils from './debugUtils'; import * as folderUtils from './folderUtils'; import * as fsUtils from './fsUtils'; @@ -103,8 +105,8 @@ export async function findInstalledVersion(installTarget: string, descriptor: De } export async function installVersion(installTarget: string, locator: Locator, {spec}: {spec: PackageManagerSpec}) { - const {default: tar} = await import(`tar`); - const {version, build} = semver.parse(locator.reference)!; + const locatorReference = semver.parse(locator.reference)!; + const {version, build} = locatorReference; const installFolder = path.join(installTarget, locator.name, version); const corepackFile = path.join(installFolder, `.corepack`); @@ -146,6 +148,7 @@ export async function installVersion(installTarget: string, locator: Locator, {s let sendTo: any; if (ext === `.tgz`) { + const {default: tar} = await import(`tar`); sendTo = tar.x({strip: 1, cwd: tmpFolder}); } else if (ext === `.js`) { outputFile = path.join(tmpFolder, path.posix.basename(parsedUrl.pathname)); @@ -193,6 +196,29 @@ export async function installVersion(installTarget: string, locator: Locator, {s } } + if (process.env.COREPACK_DEFAULT_TO_LATEST !== `0`) { + let lastKnownGoodFile: FileHandle; + try { + lastKnownGoodFile = await engine.getLastKnownGoodFile(`r+`); + const lastKnownGood = await engine.getJSONFileContent(lastKnownGoodFile); + const defaultVersion = engine.getLastKnownGoodFromFileContent(lastKnownGood, locator.name); + if (defaultVersion) { + const currentDefault = semver.parse(defaultVersion)!; + if (currentDefault.major === locatorReference.major && semver.lt(currentDefault, locatorReference)) { + await engine.activatePackageManagerFromFileHandle(lastKnownGoodFile, lastKnownGood, locator); + } + } + } catch (err) { + // ENOENT would mean there are no lastKnownGoodFile, in which case we can ignore. + if ((err as nodeUtils.NodeError)?.code !== `ENOENT`) { + throw err; + } + } finally { + // @ts-expect-error used before assigned + await lastKnownGoodFile?.close(); + } + } + debugUtils.log(`Install finished`); return { diff --git a/tests/main.test.ts b/tests/main.test.ts index a8e148994..9011721f0 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -100,6 +100,52 @@ for (const [name, version] of testedPackageManagers) { }); } +it(`should update the Known Good Release only when the major matches`, async () => { + await xfs.writeJsonPromise(ppath.join(corepackHome, `lastKnownGood.json`), { + yarn: `1.0.0`, + }); + + process.env.COREPACK_DEFAULT_TO_LATEST = `1`; + + await xfs.mktempPromise(async cwd => { + await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as Filename), { + packageManager: `yarn@1.22.4+sha224.0d6eecaf4d82ec12566fdd97143794d0f0c317e0d652bd4d1b305430`, + }); + + await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({ + exitCode: 0, + stderr: ``, + stdout: `1.22.4\n`, + }); + + await xfs.removePromise(ppath.join(cwd, `package.json` as Filename)); + + await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({ + exitCode: 0, + stderr: ``, + stdout: `1.22.4\n`, + }); + + await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as Filename), { + packageManager: `yarn@2.2.2`, + }); + + await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({ + exitCode: 0, + stderr: ``, + stdout: `2.2.2\n`, + }); + + await xfs.removePromise(ppath.join(cwd, `package.json` as Filename)); + + await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({ + exitCode: 0, + stderr: ``, + stdout: `1.22.4\n`, + }); + }); +}); + it(`should ignore the packageManager field when found within a node_modules vendor`, async () => { await xfs.mktempPromise(async cwd => { await xfs.mkdirPromise(ppath.join(cwd, `node_modules/foo` as PortablePath), {recursive: true}); diff --git a/tests/nock/AL__3okpCdfjA6kGuG2rFQ-1.dat b/tests/nock/AL__3okpCdfjA6kGuG2rFQ-1.dat index 6d9fe1164..c7c99414f 100644 Binary files a/tests/nock/AL__3okpCdfjA6kGuG2rFQ-1.dat and b/tests/nock/AL__3okpCdfjA6kGuG2rFQ-1.dat differ diff --git a/tests/nock/_ssVB5fpNumqL8RMl4TqHw-1.dat b/tests/nock/_ssVB5fpNumqL8RMl4TqHw-1.dat new file mode 100644 index 000000000..e090a6232 Binary files /dev/null and b/tests/nock/_ssVB5fpNumqL8RMl4TqHw-1.dat differ diff --git a/tests/nock/_ssVB5fpNumqL8RMl4TqHw-2.dat b/tests/nock/_ssVB5fpNumqL8RMl4TqHw-2.dat new file mode 100644 index 000000000..b03652e61 Binary files /dev/null and b/tests/nock/_ssVB5fpNumqL8RMl4TqHw-2.dat differ diff --git a/tests/nock/_ssVB5fpNumqL8RMl4TqHw-3.dat b/tests/nock/_ssVB5fpNumqL8RMl4TqHw-3.dat new file mode 100644 index 000000000..b7dbe0ac0 Binary files /dev/null and b/tests/nock/_ssVB5fpNumqL8RMl4TqHw-3.dat differ diff --git a/tests/nock/_ssVB5fpNumqL8RMl4TqHw-4.dat b/tests/nock/_ssVB5fpNumqL8RMl4TqHw-4.dat new file mode 100644 index 000000000..b03652e61 Binary files /dev/null and b/tests/nock/_ssVB5fpNumqL8RMl4TqHw-4.dat differ