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 {setTimeout as setTimeoutPromise} from 'timers/promises'; import * as engine from './Engine'; import * as debugUtils from './debugUtils'; import * as folderUtils from './folderUtils'; import * as fsUtils from './fsUtils'; import * as httpUtils from './httpUtils'; import * as nodeUtils from './nodeUtils'; import * as npmRegistryUtils from './npmRegistryUtils'; import {RegistrySpec, Descriptor, Locator, PackageManagerSpec} from './types'; export function getRegistryFromPackageManagerSpec(spec: PackageManagerSpec) { return process.env.COREPACK_NPM_REGISTRY ? spec.npmRegistry ?? spec.registry : spec.registry; } export async function fetchLatestStableVersion(spec: RegistrySpec): Promise { switch (spec.type) { case `npm`: { return await npmRegistryUtils.fetchLatestStableVersion(spec.package); } case `url`: { const data = await httpUtils.fetchAsJson(spec.url); return data[spec.fields.tags].stable; } default: { throw new Error(`Unsupported specification ${JSON.stringify(spec)}`); } } } export async function fetchAvailableTags(spec: RegistrySpec): Promise> { switch (spec.type) { case `npm`: { return await npmRegistryUtils.fetchAvailableTags(spec.package); } case `url`: { const data = await httpUtils.fetchAsJson(spec.url); return data[spec.fields.tags]; } default: { throw new Error(`Unsupported specification ${JSON.stringify(spec)}`); } } } export async function fetchAvailableVersions(spec: RegistrySpec): Promise> { switch (spec.type) { case `npm`: { return await npmRegistryUtils.fetchAvailableVersions(spec.package); } case `url`: { const data = await httpUtils.fetchAsJson(spec.url); const field = data[spec.fields.versions]; return Array.isArray(field) ? field : Object.keys(field); } default: { throw new Error(`Unsupported specification ${JSON.stringify(spec)}`); } } } export async function findInstalledVersion(installTarget: string, descriptor: Descriptor) { const installFolder = path.join(installTarget, descriptor.name); let cacheDirectory: Dir; try { cacheDirectory = await fs.promises.opendir(installFolder); } catch (error) { if ((error as nodeUtils.NodeError).code === `ENOENT`) { return null; } else { throw error; } } const range = new semver.Range(descriptor.range); let bestMatch: string | null = null; let maxSV: semver.SemVer | undefined = undefined; for await (const {name} of cacheDirectory) { // Some dot-folders tend to pop inside directories, especially on OSX if (name.startsWith(`.`)) continue; // If the dirname correspond to an in-range version and is not lower than // the previous best match (or if there is not yet a previous best match), // it's our new best match. // @ts-expect-error TODO: decipher why this produces an error if (range.test(name) && maxSV?.compare(name) !== 1) { bestMatch = name; maxSV = new semver.SemVer(bestMatch); } } return bestMatch; } export function isSupportedPackageManagerDescriptor(descriptor: Descriptor) { return !URL.canParse(descriptor.range); } export function isSupportedPackageManagerLocator(locator: Locator) { return !URL.canParse(locator.reference); } function parseURLReference(locator: Locator) { const {hash, href} = new URL(locator.reference); if (hash) { return { version: encodeURIComponent(href.slice(0, -hash.length)), build: hash.slice(1).split(`.`), }; } return {version: encodeURIComponent(href), build: []}; } export async function installVersion(installTarget: string, locator: Locator, {spec}: {spec: PackageManagerSpec}) { const locatorIsASupportedPackageManager = isSupportedPackageManagerLocator(locator); const locatorReference = locatorIsASupportedPackageManager ? semver.parse(locator.reference)! : parseURLReference(locator); const {version, build} = locatorReference; const installFolder = path.join(installTarget, locator.name, version); try { const corepackFile = path.join(installFolder, `.corepack`); const corepackContent = await fs.promises.readFile(corepackFile, `utf8`); const corepackData = JSON.parse(corepackContent); debugUtils.log(`Reusing ${locator.name}@${locator.reference}`); return { hash: corepackData.hash as string, location: installFolder, bin: corepackData.bin, }; } catch (err) { if ((err as nodeUtils.NodeError)?.code !== `ENOENT`) { throw err; } } let url: string; if (locatorIsASupportedPackageManager) { const defaultNpmRegistryURL = spec.url.replace(`{}`, version); url = process.env.COREPACK_NPM_REGISTRY ? defaultNpmRegistryURL.replace( npmRegistryUtils.DEFAULT_NPM_REGISTRY_URL, () => process.env.COREPACK_NPM_REGISTRY!, ) : defaultNpmRegistryURL; } else { url = decodeURIComponent(version); } // Creating a temporary folder inside the install folder means that we // are sure it'll be in the same drive as the destination, so we can // just move it there atomically once we are done const tmpFolder = folderUtils.getTemporaryFolder(installTarget); debugUtils.log(`Installing ${locator.name}@${version} from ${url} to ${tmpFolder}`); const stream = await httpUtils.fetchUrlStream(url); const parsedUrl = new URL(url); const ext = path.posix.extname(parsedUrl.pathname); let outputFile: string | null = null; 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)); sendTo = fs.createWriteStream(outputFile); } stream.pipe(sendTo); const algo = build[0] ?? `sha256`; const hash = stream.pipe(createHash(algo)); await once(sendTo, `finish`); let bin; if (!locatorIsASupportedPackageManager) { if (ext === `.tgz`) { bin = require(path.join(tmpFolder, `package.json`)).bin; } else if (ext === `.js`) { bin = [locator.name]; } } const actualHash = hash.digest(`hex`); if (build[1] && actualHash !== build[1]) throw new Error(`Mismatch hashes. Expected ${build[1]}, got ${actualHash}`); const serializedHash = `${algo}.${actualHash}`; await fs.promises.writeFile(path.join(tmpFolder, `.corepack`), JSON.stringify({ locator, bin, hash: serializedHash, })); await fs.promises.mkdir(path.dirname(installFolder), {recursive: true}); try { if (process.platform === `win32`) { await renameUnderWindows(tmpFolder, installFolder); } else { await fs.promises.rename(tmpFolder, installFolder); } } catch (err) { if ( (err as nodeUtils.NodeError).code === `ENOTEMPTY` || // On Windows the error code is EPERM so we check if it is a directory ((err as nodeUtils.NodeError).code === `EPERM` && (await fs.promises.stat(installFolder)).isDirectory()) ) { debugUtils.log(`Another instance of corepack installed ${locator.name}@${locator.reference}`); await fsUtils.rimraf(tmpFolder); } else { throw err; } } if (locatorIsASupportedPackageManager && 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)!; const downloadedVersion = locatorReference as semver.SemVer; if (currentDefault.major === downloadedVersion.major && semver.lt(currentDefault, downloadedVersion)) { 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 { location: installFolder, bin, hash: serializedHash, }; } async function renameUnderWindows(oldPath: fs.PathLike, newPath: fs.PathLike) { // Windows malicious file analysis blocks files currently under analysis, so we need to wait for file release const retries = 5; for (let i = 0; i < retries; i++) { try { await fs.promises.rename(oldPath, newPath); break; } catch (err) { if ( ( (err as nodeUtils.NodeError).code === `ENOENT` || (err as nodeUtils.NodeError).code === `EPERM` ) && i < (retries - 1) ) { await setTimeoutPromise(100 * 2 ** i); continue; } else { throw err; } } } } /** * Loads the binary, taking control of the current process. */ export async function runVersion(locator: Locator, installSpec: { location: string, spec: PackageManagerSpec }, binName: string, args: Array): Promise { let binPath: string | null = null; if (Array.isArray(installSpec.spec.bin)) { if (installSpec.spec.bin.some(bin => bin === binName)) { const parsedUrl = new URL(installSpec.spec.url); const ext = path.posix.extname(parsedUrl.pathname); if (ext === `.js`) { binPath = path.join(installSpec.location, path.posix.basename(parsedUrl.pathname)); } } } else { for (const [name, dest] of Object.entries(installSpec.spec.bin)) { if (name === binName) { binPath = path.join(installSpec.location, dest); break; } } } if (!binPath) throw new Error(`Assertion failed: Unable to locate path for bin '${binName}'`); // 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 // [SIGSEGV] if (locator.name !== `npm` || semver.lt(locator.reference, `9.7.0`)) // @ts-expect-error - No types await import(`v8-compile-cache`); // We load the binary into the current process, // while making it think it was spawned. // Non-exhaustive list of requirements: // - 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, ...args, ]; process.execArgv = []; // Unset the mainModule and let Node.js set it when needed. process.mainModule = undefined; // Use nextTick to unwind the stack, and consequently remove Corepack from // the stack trace of the package manager. process.nextTick(Module.runMain, binPath); }