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
51 changes: 51 additions & 0 deletions config.json
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,57 @@
"install"
]
}
},
">=12.0.0": {
"url": "https://registry.npmjs.org/pnpm/-/pnpm-{}.tgz",
"bin": {
"pnpm": "pnpm",
"pnpx": "pnpx"
},
"nativePackages": {
"win32-x64": {
"package": "@pnpm/exe.win32-x64",
"bin": "pnpm.exe"
},
"win32-arm64": {
"package": "@pnpm/exe.win32-arm64",
"bin": "pnpm.exe"
},
"darwin-x64": {
"package": "@pnpm/exe.darwin-x64",
"bin": "pnpm"
},
"darwin-arm64": {
"package": "@pnpm/exe.darwin-arm64",
"bin": "pnpm"
},
"linux-x64": {
"package": "@pnpm/exe.linux-x64",
"bin": "pnpm"
},
"linux-arm64": {
"package": "@pnpm/exe.linux-arm64",
"bin": "pnpm"
},
"linux-x64-musl": {
"package": "@pnpm/exe.linux-x64-musl",
"bin": "pnpm"
},
"linux-arm64-musl": {
"package": "@pnpm/exe.linux-arm64-musl",
"bin": "pnpm"
}
},
"registry": {
"type": "npm",
"package": "pnpm"
},
"commands": {
"use": [
"pnpm",
"install"
]
}
}
}
},
Expand Down
184 changes: 176 additions & 8 deletions sources/corepackUtils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import {spawn} from 'child_process';
import {UsageError} from 'clipanion';
import {createHash} from 'crypto';
import {once} from 'events';
import fs from 'fs';
Expand Down Expand Up @@ -205,6 +207,129 @@ async function download(installTarget: string, url: string, algo: string, binPat
};
}

interface DiagnosticReport {
header?: {
glibcVersionRuntime?: string;
};
}

// `excludeNetwork` is missing from the `@types/node` release we depend on.
type ProcessReport = NodeJS.ProcessReport & {excludeNetwork: boolean};

function detectLinuxLibcFamily(): `glibc` | `musl` | null {
if (process.platform !== `linux`)
return null;

const processReport = process.report as ProcessReport | undefined;
if (processReport == null)
return null;

// Gathering the network interfaces is the slowest part of generating a report,
// and we only care about the header: https://github.com/lovell/detect-libc/pull/21
const {excludeNetwork} = processReport;
processReport.excludeNetwork = true;

try {
// glibc builds expose `glibcVersionRuntime`; musl builds leave it unset.
const report = processReport.getReport() as DiagnosticReport;
return report.header?.glibcVersionRuntime ? `glibc` : `musl`;
} catch {
return null;
} finally {
processReport.excludeNetwork = excludeNetwork;
}
}

function getBinNames(bin: BinSpec | BinList): Array<string> {
return Array.isArray(bin) ? bin : Object.keys(bin);
}

/**
* Whether all the binaries recorded for an install are present on disk.
*/
async function isNativeInstallIntact(installFolder: string, bin: BinSpec | BinList): Promise<boolean> {
if (!isValidBinSpec(bin))
return false;

try {
await Promise.all(Object.values(bin).map(target => fs.promises.access(path.join(installFolder, target))));
return true;
} catch {
return false;
}
}

/**
* Puts the native executable in place of the placeholders shipped in `tmpFolder`,
* like the install lifecycle script of the package manager would have done.
*/
async function installNativeBinaries(installTarget: string, tmpFolder: string, locator: Locator, version: string, spec: PackageManagerSpec): Promise<BinSpec> {
let platformKey = `${process.platform}-${process.arch}`;
if (detectLinuxLibcFamily() === `musl`)
platformKey += `-musl`;

const nativePackage = spec.nativePackages![platformKey];
if (nativePackage == null)
throw new UsageError(`${locator.name}@${version} does not ship a prebuilt executable for ${platformKey}`);

// The main package pins the exact version of its companion packages in its
// `optionalDependencies`; if we can't read it, assume they share its version.
let nativeVersion = version;
try {
const manifest = JSON.parse(await fs.promises.readFile(path.join(tmpFolder, `package.json`), `utf8`));
nativeVersion = manifest?.optionalDependencies?.[nativePackage.package] ?? version;
} catch {}

const {tarball, signatures, integrity} = await npmRegistryUtils.fetchTarballURLAndSignature(nativePackage.package, nativeVersion);

let url = tarball;
if (process.env.COREPACK_NPM_REGISTRY) {
url = url.replace(
npmRegistryUtils.DEFAULT_NPM_REGISTRY_URL,
() => process.env.COREPACK_NPM_REGISTRY!,
);
}

debugUtils.log(`Downloading native executable package ${nativePackage.package}@${nativeVersion} from ${url}`);
const {tmpFolder: nativeTmpFolder, hash: actualHash} = await download(installTarget, url, `sha512`);

try {
if (!shouldSkipIntegrityCheck()) {
npmRegistryUtils.verifySignature({signatures, integrity, packageName: nativePackage.package, version: nativeVersion});

const expectedHash = Buffer.from(integrity.slice(`sha512-`.length), `base64`).toString(`hex`);
if (actualHash !== expectedHash) {
throw new Error(`Mismatch hashes. Expected ${expectedHash}, got ${actualHash}`);
}
}

const nativeBinPath = path.join(nativeTmpFolder, nativePackage.bin);
const ext = process.platform === `win32` ? `.exe` : ``;

const bin: BinSpec = {};
for (const binName of getBinNames(spec.bin)) {
const target = `${binName}${ext}`;
const destPath = path.join(tmpFolder, target);

// The executable adapts to the name it was invoked under, so the same file
// can replace the placeholder of each bin (e.g. `pnpx` = `pnpm dlx`).
await fs.promises.rm(destPath, {force: true});
try {
await fs.promises.link(nativeBinPath, destPath);
} catch {
await fs.promises.copyFile(nativeBinPath, destPath);
}
await fs.promises.chmod(destPath, 0o755);

bin[binName] = target;
}

return bin;
} finally {
await fs.promises.rm(nativeTmpFolder, {recursive: true, force: true});
}
}

export async function installVersion(installTarget: string, locator: Locator, {spec}: {spec: PackageManagerSpec}): Promise<InstallSpec> {
const locatorIsASupportedPackageManager = isSupportedPackageManagerLocator(locator);
const locatorReference = locatorIsASupportedPackageManager ? semverParse(locator.reference)! : parseURLReference(locator);
Expand All @@ -218,13 +343,20 @@ export async function installVersion(installTarget: string, locator: Locator, {s

const corepackData = JSON.parse(corepackContent);

debugUtils.log(`Reusing ${locator.name}@${locator.reference} found in ${installFolder}`);
if (locatorIsASupportedPackageManager && spec.nativePackages != null && !await isNativeInstallIntact(installFolder, corepackData.bin)) {
// Older Corepack releases didn't fetch the native executable, and recorded
// bins that don't exist; such installs have to be done anew.
debugUtils.log(`Discarding incomplete install of ${locator.name}@${locator.reference} found in ${installFolder}`);
await fs.promises.rm(installFolder, {recursive: true, force: true});
} else {
debugUtils.log(`Reusing ${locator.name}@${locator.reference} found in ${installFolder}`);

return {
hash: corepackData.hash as string,
location: installFolder,
bin: corepackData.bin,
};
return {
hash: corepackData.hash as string,
location: installFolder,
bin: corepackData.bin,
};
}
} catch (err) {
if (nodeUtils.isNodeError(err) && err.code !== `ENOENT`) {
throw err;
Expand Down Expand Up @@ -308,6 +440,9 @@ export async function installVersion(installTarget: string, locator: Locator, {s
if (build[1] && actualHash !== build[1])
throw new Error(`Mismatch hashes. Expected ${build[1]}, got ${actualHash}`);

if (locatorIsASupportedPackageManager && spec.nativePackages != null)
bin = await installNativeBinaries(installTarget, tmpFolder, locator, version, spec);

const serializedHash = `${algo}.${actualHash}`;

await fs.promises.writeFile(path.join(tmpFolder, `.corepack`), JSON.stringify({
Expand Down Expand Up @@ -409,6 +544,13 @@ export async function runVersion(locator: Locator, installSpec: InstallSpec & {s
if (!binPath)
throw new Error(`Assertion failed: Unable to locate path for bin '${binName}'`);

process.env.COREPACK_ROOT = path.dirname(require.resolve(`corepack/package.json`));

if (installSpec.spec.nativePackages != null) {
await runNativeVersion(binPath, args);
return;
}

if (!Module.enableCompileCache) {
// Node.js segfaults when using npm@>=9.7.0 and v8-compile-cache
// $ docker run -it node:20.3.0-slim corepack npm@9.7.1 --version
Expand All @@ -426,8 +568,6 @@ export async function runVersion(locator: Locator, installSpec: InstallSpec & {s
// - Yarn uses process.argv[1] to determine its own path: https://github.com/yarnpkg/berry/blob/0da258120fc266b06f42aed67e4227e81a2a900f/packages/yarnpkg-cli/sources/main.ts#L80
// - pnpm uses `require.main == null` to determine its own version: https://github.com/pnpm/pnpm/blob/e2866dee92991e979b2b0e960ddf5a74f6845d90/packages/cli-meta/src/index.ts#L14

process.env.COREPACK_ROOT = path.dirname(require.resolve(`corepack/package.json`));

process.argv = [
process.execPath,
binPath,
Expand All @@ -447,6 +587,34 @@ export async function runVersion(locator: Locator, installSpec: InstallSpec & {s
}
}

/**
* Spawns a native executable, which cannot be loaded into the current process.
*/
async function runNativeVersion(binPath: string, args: Array<string>): Promise<void> {
const child = spawn(binPath, args, {stdio: `inherit`});

// Ctrl+C is delivered to the whole foreground process group, so the child gets
// it on its own; we only have to stay alive until it's done handling it. Other
// signals sent to Corepack are forwarded.
const onSigint = () => {};
const forwardSignal = (signal: NodeJS.Signals) => {
child.kill(signal);
};

process.on(`SIGINT`, onSigint);
process.on(`SIGTERM`, forwardSignal);

const [exitCode, signal] = await once(child, `exit`) as [number | null, NodeJS.Signals | null];

process.off(`SIGINT`, onSigint);
process.off(`SIGTERM`, forwardSignal);

if (signal != null)
process.kill(process.pid, signal);

process.exitCode = exitCode ?? 1;
}

export function shouldSkipIntegrityCheck() {
return process.env.COREPACK_INTEGRITY_KEYS === ``
|| process.env.COREPACK_INTEGRITY_KEYS === `0`;
Expand Down
12 changes: 12 additions & 0 deletions sources/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ export type RegistrySpec =
| NpmRegistrySpec
| UrlRegistrySpec;

export interface NativePackageSpec {
package: string;
bin: string;
}

/**
* Defines how the package manager is meant to be downloaded and accessed.
*/
Expand All @@ -44,6 +49,13 @@ export interface PackageManagerSpec {
bin: BinSpec | BinList;
registry: RegistrySpec;
npmRegistry?: NpmRegistrySpec;
/**
* Set when the package manager is distributed as a native executable, which
* `url` doesn't contain: it must be fetched from a companion package instead.
* Keys are `${process.platform}-${process.arch}`, with a `-musl` suffix on
* Linux systems using musl libc.
*/
nativePackages?: {[platformKey: string]: NativePackageSpec};
commands?: {
use?: Array<string>;
};
Expand Down
Loading