Skip to content

Commit

Permalink
Merge branch 'main' into merceyz/refactor/fetch
Browse files Browse the repository at this point in the history
  • Loading branch information
merceyz committed Feb 3, 2024
2 parents b9f6795 + b9eea58 commit 21b88cd
Show file tree
Hide file tree
Showing 11 changed files with 893 additions and 45 deletions.
17 changes: 13 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -221,7 +221,16 @@ 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
set to `1` to have the URL shown. By default, when Corepack is called
explicitly (e.g. `corepack pnpm …`), it is set to `0`; when Corepack is called
implicitely (e.g. `pnpm …`), it is set to `1`.
When standard input is a TTY and no CI environment is detected, Corepack will
ask for user input before starting the download.

- `COREPACK_ENABLE_NETWORK` can be set to `0` to prevent Corepack from accessing
the network (in which case you'll be responsible for hydrating the package
Expand Down
2 changes: 2 additions & 0 deletions mkshims.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ async function main() {
const corepackPath = path.join(distDir, `corepack.js`);
fs.writeFileSync(corepackPath, [
`#!/usr/bin/env node`,
`process.env.COREPACK_ENABLE_DOWNLOAD_PROMPT??='0';`,
`require('./lib/corepack.cjs').runMain(process.argv.slice(2));`,
].join(`\n`));
fs.chmodSync(corepackPath, 0o755);
Expand All @@ -32,6 +33,7 @@ async function main() {
const entryPath = path.join(distDir, `${binaryName}.js`);
const entryScript = [
`#!/usr/bin/env node`,
`process.env.COREPACK_ENABLE_DOWNLOAD_PROMPT??='1'`,
`require('./lib/corepack.cjs').runMain(['${binaryName}', ...process.argv.slice(2)]);`,
].join(`\n`);

Expand Down
118 changes: 80 additions & 38 deletions sources/Engine.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<ReturnType<Engine[`ensurePackageManager`]>>;

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<string, string>)[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) {
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -194,8 +240,4 @@ export class Engine {

return {name: finalDescriptor.name, reference: highestVersion[0]};
}

private getLastKnownGoodFile() {
return path.join(folderUtils.getInstallFolder(), `lastKnownGood.json`);
}
}
30 changes: 28 additions & 2 deletions sources/corepackUtils.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import assert from 'assert';
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 {Readable} from 'stream';

import * as engine from './Engine';
import * as debugUtils from './debugUtils';
import {fetch} from './fetchUtils';
import * as folderUtils from './folderUtils';
Expand Down Expand Up @@ -108,8 +110,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`);
Expand Down Expand Up @@ -154,6 +156,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));
Expand Down Expand Up @@ -201,6 +204,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 {
Expand Down
20 changes: 19 additions & 1 deletion sources/fetchUtils.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,29 @@
import {UsageError} from 'clipanion';
import {UsageError} from 'clipanion';
import {once} from 'events';
import {stderr, stdin} from 'process';

export async function fetch(input: string | URL, init?: RequestInit) {
if (process.env.COREPACK_ENABLE_NETWORK === `0`)
throw new UsageError(`Network access disabled by the environment; can't reach ${input}`);

const agent = await getProxyAgent(input);

if (process.env.COREPACK_ENABLE_DOWNLOAD_PROMPT === `1`) {
console.error(`Corepack is about to download ${input}.`);
if (stdin.isTTY && !process.env.CI) {
stderr.write(`\nDo you want to continue? [Y/n] `);
stdin.resume();
const chars = await once(stdin, `data`);
stdin.pause();
if (
chars[0][0] === 0x6e || // n
chars[0][0] === 0x4e // N
) {
throw new UsageError(`Aborted by the user`);
}
}
}

let response;
try {
response = await globalThis.fetch(input, {
Expand Down
64 changes: 64 additions & 0 deletions tests/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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});
Expand Down Expand Up @@ -693,3 +739,21 @@ it(`should support package managers in ESM format`, async () => {
});
});
});

it(`should show a warning on stderr before downloading when enable`, async() => {
await xfs.mktempPromise(async cwd => {
process.env.COREPACK_ENABLE_DOWNLOAD_PROMPT = `1`;
try {
await xfs.writeJsonPromise(ppath.join(cwd, `package.json` as Filename), {
packageManager: `yarn@3.0.0`,
});
await expect(runCli(cwd, [`yarn`, `--version`])).resolves.toMatchObject({
exitCode: 0,
stdout: `3.0.0\n`,
stderr: `Corepack is about to download https://repo.yarnpkg.com/3.0.0/packages/yarnpkg-cli/bin/yarn.js.\n`,
});
} finally {
delete process.env.COREPACK_ENABLE_DOWNLOAD_PROMPT;
}
});
});
Binary file modified tests/nock/8LXMft4IyEWeaqoiynS5FA-1.dat
Binary file not shown.
632 changes: 632 additions & 0 deletions tests/nock/MuEAzZXT77khPx8FpLSF2A-1.dat

Large diffs are not rendered by default.

Binary file added tests/nock/_ssVB5fpNumqL8RMl4TqHw-1.dat
Binary file not shown.
55 changes: 55 additions & 0 deletions tests/nock/_ssVB5fpNumqL8RMl4TqHw-3.dat

Large diffs are not rendered by default.

Binary file modified tests/nock/bNE0FYc3WlnFGzjHaIdf5A-1.dat
Binary file not shown.

0 comments on commit 21b88cd

Please sign in to comment.