Skip to content

Commit a56c13b

Browse files
authored
feat: bump Known Good Release when downloading new version (#364)
1 parent 6b8d87f commit a56c13b

9 files changed

+159
-44
lines changed

README.md

+5-4
Original file line numberDiff line numberDiff line change
@@ -84,15 +84,15 @@ recommended as a security practice. Permitted values for the package manager are
8484
## Known Good Releases
8585

8686
When running Corepack within projects that don't list a supported package
87-
manager, it will default to a set of Known Good Releases. In a way, you can
88-
compare this to Node.js, where each version ships with a specific version of
89-
npm.
87+
manager, it will default to a set of Known Good Releases.
9088

9189
If there is no Known Good Release for the requested package manager, Corepack
9290
looks up the npm registry for the latest available version and cache it for
9391
future use.
9492

9593
The Known Good Releases can be updated system-wide using `corepack install -g`.
94+
When Corepack downloads a new version of a given package manager on the same
95+
major line as the Known Good Release, it auto-updates it by default.
9696

9797
## Offline Workflow
9898

@@ -221,7 +221,8 @@ same major line. Should you need to upgrade to a new major, use an explicit
221221

222222
- `COREPACK_DEFAULT_TO_LATEST` can be set to `0` in order to instruct Corepack
223223
not to lookup on the remote registry for the latest version of the selected
224-
package manager.
224+
package manager, and to not update the Last Known Good version when it
225+
downloads a new version of the same major line.
225226

226227
- `COREPACK_ENABLE_DOWNLOAD_PROMPT` can be set to `0` to
227228
prevent Corepack showing the URL when it needs to download software, or can be

sources/Engine.ts

+80-38
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {UsageError} from 'clipanion';
2+
import type {FileHandle} from 'fs/promises';
23
import fs from 'fs';
34
import path from 'path';
45
import process from 'process';
@@ -7,13 +8,57 @@ import semver from 'semver';
78
import defaultConfig from '../config.json';
89

910
import * as corepackUtils from './corepackUtils';
11+
import * as debugUtils from './debugUtils';
1012
import * as folderUtils from './folderUtils';
13+
import type {NodeError} from './nodeUtils';
1114
import * as semverUtils from './semverUtils';
1215
import {Config, Descriptor, Locator} from './types';
1316
import {SupportedPackageManagers, SupportedPackageManagerSet} from './types';
1417

1518
export type PreparedPackageManagerInfo = Awaited<ReturnType<Engine[`ensurePackageManager`]>>;
1619

20+
export function getLastKnownGoodFile(flag = `r`) {
21+
return fs.promises.open(path.join(folderUtils.getInstallFolder(), `lastKnownGood.json`), flag);
22+
}
23+
24+
export async function getJSONFileContent(fh: FileHandle) {
25+
let lastKnownGood: unknown;
26+
try {
27+
lastKnownGood = JSON.parse(await fh.readFile(`utf8`));
28+
} catch {
29+
// Ignore errors; too bad
30+
return undefined;
31+
}
32+
33+
return lastKnownGood;
34+
}
35+
36+
async function overwriteJSONFileContent(fh: FileHandle, content: unknown) {
37+
await fh.truncate(0);
38+
await fh.write(`${JSON.stringify(content, null, 2)}\n`, 0);
39+
}
40+
41+
export function getLastKnownGoodFromFileContent(lastKnownGood: unknown, packageManager: string) {
42+
if (typeof lastKnownGood === `object` && lastKnownGood !== null &&
43+
Object.hasOwn(lastKnownGood, packageManager)) {
44+
const override = (lastKnownGood as any)[packageManager];
45+
if (typeof override === `string`) {
46+
return override;
47+
}
48+
}
49+
return undefined;
50+
}
51+
52+
export async function activatePackageManagerFromFileHandle(lastKnownGoodFile: FileHandle, lastKnownGood: unknown, locator: Locator) {
53+
if (typeof lastKnownGood !== `object` || lastKnownGood === null)
54+
lastKnownGood = {};
55+
56+
(lastKnownGood as Record<string, string>)[locator.name] = locator.reference;
57+
58+
debugUtils.log(`Setting ${locator.name}@${locator.reference} as Last Known Good version`);
59+
await overwriteJSONFileContent(lastKnownGoodFile, lastKnownGood);
60+
}
61+
1762
export class Engine {
1863
constructor(public config: Config = defaultConfig as Config) {
1964
}
@@ -77,51 +122,52 @@ export class Engine {
77122
if (typeof definition === `undefined`)
78123
throw new UsageError(`This package manager (${packageManager}) isn't supported by this corepack build`);
79124

80-
let lastKnownGood: unknown;
81-
try {
82-
lastKnownGood = JSON.parse(await fs.promises.readFile(this.getLastKnownGoodFile(), `utf8`));
83-
} catch {
84-
// Ignore errors; too bad
85-
}
86-
87-
if (typeof lastKnownGood === `object` && lastKnownGood !== null &&
88-
Object.hasOwn(lastKnownGood, packageManager)) {
89-
const override = (lastKnownGood as any)[packageManager];
90-
if (typeof override === `string`) {
91-
return override;
125+
let emptyFile = false;
126+
const lastKnownGoodFile = await getLastKnownGoodFile(`r+`).catch(err => {
127+
if ((err as NodeError)?.code === `ENOENT`) {
128+
emptyFile = true;
129+
return getLastKnownGoodFile(`w`);
92130
}
93-
}
94131

95-
if (process.env.COREPACK_DEFAULT_TO_LATEST === `0`)
96-
return definition.default;
132+
throw err;
133+
});
134+
try {
135+
const lastKnownGood = emptyFile || await getJSONFileContent(lastKnownGoodFile);
136+
const lastKnownGoodForThisPackageManager = getLastKnownGoodFromFileContent(lastKnownGood, packageManager);
137+
if (lastKnownGoodForThisPackageManager)
138+
return lastKnownGoodForThisPackageManager;
97139

98-
const reference = await corepackUtils.fetchLatestStableVersion(definition.fetchLatestFrom);
140+
if (process.env.COREPACK_DEFAULT_TO_LATEST === `0`)
141+
return definition.default;
99142

100-
await this.activatePackageManager({
101-
name: packageManager,
102-
reference,
103-
});
143+
const reference = await corepackUtils.fetchLatestStableVersion(definition.fetchLatestFrom);
144+
145+
await activatePackageManagerFromFileHandle(lastKnownGoodFile, lastKnownGood, {
146+
name: packageManager,
147+
reference,
148+
});
104149

105-
return reference;
150+
return reference;
151+
} finally {
152+
await lastKnownGoodFile.close();
153+
}
106154
}
107155

108156
async activatePackageManager(locator: Locator) {
109-
const lastKnownGoodFile = this.getLastKnownGoodFile();
157+
let emptyFile = false;
158+
const lastKnownGoodFile = await getLastKnownGoodFile(`r+`).catch(err => {
159+
if ((err as NodeError)?.code === `ENOENT`) {
160+
emptyFile = true;
161+
return getLastKnownGoodFile(`w`);
162+
}
110163

111-
let lastKnownGood;
164+
throw err;
165+
});
112166
try {
113-
lastKnownGood = JSON.parse(await fs.promises.readFile(lastKnownGoodFile, `utf8`));
114-
} catch {
115-
// Ignore errors; too bad
167+
await activatePackageManagerFromFileHandle(lastKnownGoodFile, emptyFile || await getJSONFileContent(lastKnownGoodFile), locator);
168+
} finally {
169+
await lastKnownGoodFile.close();
116170
}
117-
118-
if (typeof lastKnownGood !== `object` || lastKnownGood === null)
119-
lastKnownGood = {};
120-
121-
lastKnownGood[locator.name] = locator.reference;
122-
123-
await fs.promises.mkdir(path.dirname(lastKnownGoodFile), {recursive: true});
124-
await fs.promises.writeFile(lastKnownGoodFile, `${JSON.stringify(lastKnownGood, null, 2)}\n`);
125171
}
126172

127173
async ensurePackageManager(locator: Locator) {
@@ -194,8 +240,4 @@ export class Engine {
194240

195241
return {name: finalDescriptor.name, reference: highestVersion[0]};
196242
}
197-
198-
private getLastKnownGoodFile() {
199-
return path.join(folderUtils.getInstallFolder(), `lastKnownGood.json`);
200-
}
201243
}

sources/corepackUtils.ts

+28-2
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import {createHash} from 'crypto';
22
import {once} from 'events';
3+
import {FileHandle} from 'fs/promises';
34
import fs from 'fs';
45
import type {Dir} from 'fs';
56
import Module from 'module';
67
import path from 'path';
78
import semver from 'semver';
89

10+
import * as engine from './Engine';
911
import * as debugUtils from './debugUtils';
1012
import * as folderUtils from './folderUtils';
1113
import * as fsUtils from './fsUtils';
@@ -103,8 +105,8 @@ export async function findInstalledVersion(installTarget: string, descriptor: De
103105
}
104106

105107
export async function installVersion(installTarget: string, locator: Locator, {spec}: {spec: PackageManagerSpec}) {
106-
const {default: tar} = await import(`tar`);
107-
const {version, build} = semver.parse(locator.reference)!;
108+
const locatorReference = semver.parse(locator.reference)!;
109+
const {version, build} = locatorReference;
108110

109111
const installFolder = path.join(installTarget, locator.name, version);
110112
const corepackFile = path.join(installFolder, `.corepack`);
@@ -146,6 +148,7 @@ export async function installVersion(installTarget: string, locator: Locator, {s
146148

147149
let sendTo: any;
148150
if (ext === `.tgz`) {
151+
const {default: tar} = await import(`tar`);
149152
sendTo = tar.x({strip: 1, cwd: tmpFolder});
150153
} else if (ext === `.js`) {
151154
outputFile = path.join(tmpFolder, path.posix.basename(parsedUrl.pathname));
@@ -193,6 +196,29 @@ export async function installVersion(installTarget: string, locator: Locator, {s
193196
}
194197
}
195198

199+
if (process.env.COREPACK_DEFAULT_TO_LATEST !== `0`) {
200+
let lastKnownGoodFile: FileHandle;
201+
try {
202+
lastKnownGoodFile = await engine.getLastKnownGoodFile(`r+`);
203+
const lastKnownGood = await engine.getJSONFileContent(lastKnownGoodFile);
204+
const defaultVersion = engine.getLastKnownGoodFromFileContent(lastKnownGood, locator.name);
205+
if (defaultVersion) {
206+
const currentDefault = semver.parse(defaultVersion)!;
207+
if (currentDefault.major === locatorReference.major && semver.lt(currentDefault, locatorReference)) {
208+
await engine.activatePackageManagerFromFileHandle(lastKnownGoodFile, lastKnownGood, locator);
209+
}
210+
}
211+
} catch (err) {
212+
// ENOENT would mean there are no lastKnownGoodFile, in which case we can ignore.
213+
if ((err as nodeUtils.NodeError)?.code !== `ENOENT`) {
214+
throw err;
215+
}
216+
} finally {
217+
// @ts-expect-error used before assigned
218+
await lastKnownGoodFile?.close();
219+
}
220+
}
221+
196222
debugUtils.log(`Install finished`);
197223

198224
return {

tests/main.test.ts

+46
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,52 @@ for (const [name, version] of testedPackageManagers) {
100100
});
101101
}
102102

103+
it(`should update the Known Good Release only when the major matches`, async () => {
104+
await xfs.writeJsonPromise(ppath.join(corepackHome, `lastKnownGood.json`), {
105+
yarn: `1.0.0`,
106+
});
107+
108+
process.env.COREPACK_DEFAULT_TO_LATEST = `1`;
109+
110+
await xfs.mktempPromise(async cwd => {
111+
await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as Filename), {
112+
packageManager: `yarn@1.22.4+sha224.0d6eecaf4d82ec12566fdd97143794d0f0c317e0d652bd4d1b305430`,
113+
});
114+
115+
await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({
116+
exitCode: 0,
117+
stderr: ``,
118+
stdout: `1.22.4\n`,
119+
});
120+
121+
await xfs.removePromise(ppath.join(cwd, `package.json` as Filename));
122+
123+
await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({
124+
exitCode: 0,
125+
stderr: ``,
126+
stdout: `1.22.4\n`,
127+
});
128+
129+
await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as Filename), {
130+
packageManager: `yarn@2.2.2`,
131+
});
132+
133+
await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({
134+
exitCode: 0,
135+
stderr: ``,
136+
stdout: `2.2.2\n`,
137+
});
138+
139+
await xfs.removePromise(ppath.join(cwd, `package.json` as Filename));
140+
141+
await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({
142+
exitCode: 0,
143+
stderr: ``,
144+
stdout: `1.22.4\n`,
145+
});
146+
});
147+
});
148+
103149
it(`should ignore the packageManager field when found within a node_modules vendor`, async () => {
104150
await xfs.mktempPromise(async cwd => {
105151
await xfs.mkdirPromise(ppath.join(cwd, `node_modules/foo` as PortablePath), {recursive: true});
5.54 KB
Binary file not shown.
2.37 MB
Binary file not shown.
7 Bytes
Binary file not shown.
3.44 MB
Binary file not shown.
7 Bytes
Binary file not shown.

0 commit comments

Comments
 (0)