diff --git a/.github/workflows/get-pnpm.yml b/.github/workflows/get-pnpm.yml new file mode 100644 index 0000000..c50e84f --- /dev/null +++ b/.github/workflows/get-pnpm.yml @@ -0,0 +1,44 @@ +name: get-pnpm package + +on: + - push + - pull_request + +permissions: + contents: read + +jobs: + test: + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - macos-latest + - windows-latest + runtime: + # The oldest supported runtime and the current one. `engines` allows + # Node 22, so it has to be exercised there. + - node@22 + - node@24 + + runs-on: ${{ matrix.os }} + defaults: + run: + working-directory: get-pnpm + + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + # pnpm's own action, installing pnpm and the runtime in one step. Nothing + # to install at the repository root, so its `pnpm install` is left off. + - uses: pnpm/setup@v2 + with: + version: latest + runtime: ${{ matrix.runtime }} + install: false + + - run: pnpm install --frozen-lockfile + - run: pnpm test diff --git a/.github/workflows/npm-keys.yml b/.github/workflows/npm-keys.yml new file mode 100644 index 0000000..6fd80c6 --- /dev/null +++ b/.github/workflows/npm-keys.yml @@ -0,0 +1,60 @@ +name: npm signing keys + +on: + schedule: + # Weekly. A rotation npm has not announced would otherwise surface as a + # failed install for whoever runs the installer next. + - cron: '0 6 * * 1' + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +# One rotation at a time: a manual run alongside the weekly one would otherwise +# race over the same branch. +concurrency: + group: npm-key-rotation + cancel-in-progress: false + +jobs: + refresh: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + # The script only needs a runtime; pnpm comes along with it. + - uses: pnpm/setup@v2 + with: + version: latest + runtime: node@24 + install: false + + - id: refresh + run: | + if node scripts/update-npm-keys.mjs; then + echo "changed=false" >> "$GITHUB_OUTPUT" + else + node scripts/update-npm-keys.mjs --update + echo "changed=true" >> "$GITHUB_OUTPUT" + fi + + - if: steps.refresh.outputs.changed == 'true' + env: + GH_TOKEN: ${{ github.token }} + run: | + # One long-lived branch, force-pushed: a weekly job must not open a + # second pull request for a rotation that is already waiting, and a + # re-run on the same day must not collide with its own branch. + branch=npm-key-rotation + git config user.name 'github-actions[bot]' + git config user.email 'github-actions[bot]@users.noreply.github.com' + git checkout -B "$branch" + git commit -am 'chore: refresh the pinned npm signing keys' + git push --force origin "$branch" + if [ -z "$(gh pr list --head "$branch" --state open --json number --jq '.[].number')" ]; then + gh pr create --head "$branch" --title 'chore: refresh the pinned npm signing keys' --body \ + 'npm rotated its registry signing key. Every installer pins it, so all three files change together. + + The checksums in `SHASUMS256.txt` cover `install.sh` and `install.ps1`, so this needs a re-sign (`./_update.sh`) before merge.' + fi diff --git a/.gitignore b/.gitignore index 4c5f206..7aca952 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ .claude/ +get-pnpm/lib +get-pnpm/node_modules diff --git a/get-pnpm/README.md b/get-pnpm/README.md new file mode 100644 index 0000000..5379a86 --- /dev/null +++ b/get-pnpm/README.md @@ -0,0 +1,76 @@ +# get-pnpm + +> Installs pnpm as a standalone executable + +Installs pnpm the same way [the standalone install script](https://github.com/pnpm/get.pnpm.io) does — a self-contained executable in `PNPM_HOME`, on your `PATH`, with no dependency on Node.js afterwards — for people who would rather not pipe a script into a shell. + +## Usage + +```sh +npx get-pnpm +``` + +Install a specific version, major, or dist-tag: + +```sh +npx get-pnpm 12 # the latest release of pnpm 12 +npx get-pnpm next-12 # the pnpm 12 prerelease lane +npx get-pnpm 11.20.0 # an exact version +``` + +Then open a new terminal, or source the file the installer names in its output. + +## How it works + +1. Resolves the requested version against the `pnpm` dist-tags. +2. Downloads the executable for your platform from the npm registry. +3. Checks npm's signature over the package's checksum, then the download against that checksum. +4. Runs `pnpm setup`, which installs the executable globally and adds `PNPM_HOME` to your `PATH`. + +Step 3 is what makes step 2 worth trusting. The registry publishes both the tarball +and its checksum, so a checksum taken from it proves nothing on its own; npm also +signs `@:` with a key that this package pins, and a +download that fails either check is refused rather than installed. + +The download goes to the registry npm is configured with (`npm_config_registry`), not to GitHub. Registries that require authentication are not supported yet. + +## Environment variables + +| Variable | Description | +| --- | --- | +| `PNPM_VERSION` | Version to install when no argument is given. | +| `PNPM_HOME` | Directory to install pnpm into. | +| `npm_config_registry` | Registry to download pnpm from. | + +## Using it from a program + +`installPnpm` does what the command does: download, verify, then hand over to +`pnpm setup`, which installs globally and edits your `PATH`. A caller that +manages its own directory — a CI action, say — wants `downloadPnpm` instead, +which verifies and places the executable and nothing else: + +```js +import { downloadPnpm } from 'get-pnpm' + +const { version, binPath } = await downloadPnpm({ + versionSpec: 'next-12', + registry: 'https://registry.npmjs.org/', + dest: '/opt/pnpm', +}) +``` + +## Development + +```sh +pnpm install +pnpm test # builds, then runs the tests against the build output +``` + +The published package runs on Node.js 22.13 and newer. The test runner needs +22.18 or newer, which is where Node runs TypeScript without a flag. + +## Other ways to install pnpm + +See [pnpm.io/installation](https://pnpm.io/installation). The shell and PowerShell +installers in this repository do the same job for machines without Node.js, and pin +the same npm signing key. diff --git a/get-pnpm/bin/get-pnpm.js b/get-pnpm/bin/get-pnpm.js new file mode 100644 index 0000000..c13b8ec --- /dev/null +++ b/get-pnpm/bin/get-pnpm.js @@ -0,0 +1,11 @@ +#!/usr/bin/env node +import { runCli } from '../lib/index.js' + +try { + process.exitCode = await runCli(process.argv.slice(2)) +} catch (err) { + // Anything can be thrown, and reading `.message` off a non-Error would either + // print `undefined` or throw again, burying the reason under a stack trace. + console.error(err instanceof Error ? err.message : String(err)) + process.exitCode = 1 +} diff --git a/get-pnpm/package.json b/get-pnpm/package.json new file mode 100644 index 0000000..7db4656 --- /dev/null +++ b/get-pnpm/package.json @@ -0,0 +1,43 @@ +{ + "name": "get-pnpm", + "version": "0.0.1", + "description": "Installs pnpm as a standalone executable", + "keywords": [ + "pnpm", + "install", + "installer" + ], + "license": "MIT", + "funding": "https://opencollective.com/pnpm", + "repository": "https://github.com/pnpm/get.pnpm.io/tree/main/get-pnpm", + "homepage": "https://github.com/pnpm/get.pnpm.io/tree/main/get-pnpm#readme", + "bugs": { + "url": "https://github.com/pnpm/get.pnpm.io/issues" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "exports": { + ".": "./lib/index.js" + }, + "files": [ + "lib", + "!*.map", + "bin" + ], + "bin": { + "get-pnpm": "bin/get-pnpm.js" + }, + "scripts": { + "build": "tsc", + "test": "tsc && node --test test/*.test.ts", + "prepublishOnly": "tsc" + }, + "devDependencies": { + "@types/node": "^24.10.1", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=22.13" + } +} diff --git a/get-pnpm/pnpm-lock.yaml b/get-pnpm/pnpm-lock.yaml new file mode 100644 index 0000000..fd11635 --- /dev/null +++ b/get-pnpm/pnpm-lock.yaml @@ -0,0 +1,39 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@types/node': + specifier: ^24.10.1 + version: 24.13.3 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + +packages: + + '@types/node@24.13.3': + resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + +snapshots: + + '@types/node@24.13.3': + dependencies: + undici-types: 7.18.2 + + typescript@5.9.3: {} + + undici-types@7.18.2: {} diff --git a/get-pnpm/src/extractTarball.ts b/get-pnpm/src/extractTarball.ts new file mode 100644 index 0000000..26d401b --- /dev/null +++ b/get-pnpm/src/extractTarball.ts @@ -0,0 +1,24 @@ +import { spawnSync } from 'node:child_process' +import fs from 'node:fs' + +/** + * Unpacks a package tarball into `dest`, leaving its `package/` root in place. + * + * Shells out to `tar`, which every supported host has: macOS and Linux ship it, + * and Windows has had bsdtar since Windows 10 1803. Member selection and + * `--strip-components` are avoided because busybox tar and bsdtar disagree + * about them. + */ +export function extractTarball (tarball: string, dest: string): void { + fs.mkdirSync(dest, { recursive: true }) + const { error, status, stderr } = spawnSync('tar', ['-xzf', tarball, '-C', dest], { encoding: 'utf8' }) + if (error != null) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + throw new Error('This installer needs the `tar` command, which was not found on your PATH.') + } + throw error + } + if (status !== 0) { + throw new Error(`Could not extract ${tarball}: ${stderr.trim()}`) + } +} diff --git a/get-pnpm/src/index.ts b/get-pnpm/src/index.ts new file mode 100644 index 0000000..2d31c7f --- /dev/null +++ b/get-pnpm/src/index.ts @@ -0,0 +1,238 @@ +import { spawnSync } from 'node:child_process' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +import { extractTarball } from './extractTarball.js' +import { isMusl, platformPackageName } from './platformPackageName.js' +import { downloadTarball, fetchPackument, fetchVersionMeta, registryFromEnv } from './registry.js' +import { resolveVersion } from './resolveVersion.js' +import { type SigningKey, verifyRegistrySignature } from './verifySignature.js' + +export { isMusl, platformPackageName, type Target } from './platformPackageName.js' +export { DEFAULT_REGISTRY, registryFromEnv } from './registry.js' +export { type Packument, resolveVersion } from './resolveVersion.js' +export { type PackageSignature, type SigningKey, verifyRegistrySignature } from './verifySignature.js' + +/** + * The package whose dist-tags name every pnpm release. `@pnpm/exe` carries the + * same versions today, but only `pnpm` is published from v12 onward, so its + * tags are the ones that cannot go stale. + */ +const CLI_PKG_NAME = 'pnpm' + +/** Holds the unpacked tarballs while the installation is assembled beside it. */ +const UNPACK_DIR = '.unpack' + +/** Where the `dist/` tree that ships beside the executable is published. */ +function wrapperPackageName (major: number): string { + return major >= 12 ? CLI_PKG_NAME : '@pnpm/exe' +} + +const USAGE = `Usage: npx get-pnpm [version] + +Installs pnpm as a standalone executable and adds it to your PATH. + +Arguments: + version An exact version (11.20.0), a major (12), or a dist-tag + (latest, next-12). Defaults to $PNPM_VERSION, then "latest". + +Environment variables: + PNPM_VERSION Version to install when no argument is given. + PNPM_HOME Directory to install pnpm into. + npm_config_registry Registry to download pnpm from. +` + +export async function runCli (argv: string[]): Promise { + const positional: string[] = [] + for (const arg of argv) { + if (arg === '--help' || arg === '-h') { + console.log(USAGE) + return 0 + } + if (arg.startsWith('-')) { + throw new Error(`Unknown option "${arg}".\n\n${USAGE}`) + } + positional.push(arg) + } + if (positional.length > 1) { + throw new Error(`Expected at most one version, got ${positional.length}.\n\n${USAGE}`) + } + return installPnpm({ + versionSpec: positional[0] ?? process.env.PNPM_VERSION ?? 'latest', + registry: registryFromEnv(), + }) +} + +/** + * Downloads the pnpm executable and hands over to `pnpm setup`, which installs + * it globally and puts it on the PATH. + * + * Every download is checked against the checksum the registry published for it, + * and that checksum against npm's signature — see `verifyRegistrySignature`. + * + * The temporary directory is assembled to look like the release tarball that + * https://get.pnpm.io/install.sh downloads — the executable next to its `dist/` + * tree — because `pnpm setup` installs that directory as-is. + * + * @returns the exit code of `pnpm setup`. + */ +export async function installPnpm ( + opts: { + versionSpec: string + registry: string + keys?: readonly SigningKey[] + } +): Promise { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pnpm-install-')) + const removeTmpDir = (): void => { + fs.rmSync(tmpDir, { recursive: true, force: true }) + } + // Registered for the duration of the call and no longer: this is also a + // library function, and handlers left behind would accumulate and outlive the + // directory they exist to clean up. + const signals = ['SIGINT', 'SIGTERM', 'SIGHUP'] as const + const onSignal = (): void => { + removeTmpDir() + process.exit(1) + } + for (const signal of signals) { + process.once(signal, onSignal) + } + try { + const { binPath } = await downloadPnpm({ ...opts, dest: tmpDir }) + const { error, status } = spawnSync(binPath, ['setup', '--force'], { stdio: 'inherit' }) + if (error != null) throw error + return status ?? 1 + } finally { + for (const signal of signals) { + process.off(signal, onSignal) + } + removeTmpDir() + } +} + +/** + * Downloads the pnpm executable into `dest`, laid out the way the release + * tarball lays it out — the executable next to the `dist/` tree it loads. + * + * Every download is checked against the checksum the registry published for it, + * and that checksum against npm's signature; see `verifyRegistrySignature`. + * Nothing outside `dest` is touched, so a caller that manages its own PATH — a + * CI action, say — can use this without the global install `installPnpm` does. + * + * @returns the version installed and the path to the executable. + */ +export async function downloadPnpm ( + opts: { + versionSpec: string + registry: string + dest: string + keys?: readonly SigningKey[] + } +): Promise<{ version: string, binPath: string }> { + const packument = await fetchPackument(opts.registry, CLI_PKG_NAME) + const version = resolveVersion(packument, opts.versionSpec) + const major = majorVersion(version) + const platformPkgName = platformPackageName({ + major, + platform: process.platform, + arch: process.arch, + musl: isMusl(), + }) + + const { dest } = opts + fs.mkdirSync(dest, { recursive: true }) + try { + console.log(`==> Downloading pnpm ${version}`) + const executable = process.platform === 'win32' ? 'pnpm.exe' : 'pnpm' + const fetchPackage = verifiedPackageFetcher({ dir: dest, registry: opts.registry, version, keys: opts.keys }) + + // Settled, not `all`: a rejection there would leave the other fetch writing + // into the directory the `finally` below is about to remove. + const [platformResult, wrapperResult] = await Promise.allSettled([ + fetchPackage(platformPkgName), + // v11 was the first release to keep files next to the executable; up to + // v10 the executable is self-contained and ships no `dist/`. + major >= 11 ? fetchPackage(wrapperPackageName(major)) : Promise.resolve(undefined), + ]) + if (platformResult.status === 'rejected') throw platformResult.reason + if (wrapperResult.status === 'rejected') throw wrapperResult.reason + const platformPkg = platformResult.value + const wrapperPkg = wrapperResult.value + + const binPath = path.join(dest, executable) + fs.renameSync(path.join(platformPkg.dir, executable), binPath) + fs.chmodSync(binPath, 0o755) + if (wrapperPkg != null) { + fs.rmSync(path.join(dest, 'dist'), { recursive: true, force: true }) + fs.renameSync(path.join(wrapperPkg.dir, 'dist'), path.join(dest, 'dist')) + writeManifest({ dest, executable, version, wrapperDir: wrapperPkg.dir, major }) + } + return { version, binPath } + } finally { + // `pnpm setup` installs this directory as a package, so nothing may be left + // in it that does not belong in the installation. + fs.rmSync(path.join(dest, UNPACK_DIR), { recursive: true, force: true }) + } +} + +/** + * `pnpm setup` installs the directory as a package, writing a minimal manifest + * when there is none — which is what the release tarball relies on. That + * tarball bundles the runtime dependencies inside `dist/`; the registry copy + * declares them instead, so up to v11 they have to be declared here or the + * install silently loses them (`@reflink/reflink`, and with it copy-on-write + * cloning). From v12 the `dist/` tree is self-contained again, so the manifest + * `setup` writes is left to it. + */ +function writeManifest ( + opts: { dest: string, executable: string, version: string, wrapperDir: string, major: number } +): void { + if (opts.major >= 12) return + const wrapper = JSON.parse(fs.readFileSync(path.join(opts.wrapperDir, 'package.json'), 'utf8')) as { dependencies?: Record } + if (wrapper.dependencies == null) return + fs.writeFileSync(path.join(opts.dest, 'package.json'), JSON.stringify({ + name: '@pnpm/exe', + version: opts.version, + type: 'module', + bin: { pnpm: opts.executable, pn: opts.executable }, + dependencies: wrapper.dependencies, + })) +} + +/** Downloads packages of one version into one directory, verifying each. */ +function verifiedPackageFetcher ( + opts: { dir: string, registry: string, version: string, keys?: readonly SigningKey[] } +): (pkgName: string) => Promise<{ dir: string }> { + return async function fetchPackage (pkgName: string): Promise<{ dir: string }> { + const meta = await fetchVersionMeta(opts.registry, pkgName, opts.version) + if (!meta.dist.integrity) { + throw new Error(`The npm registry published no checksum for ${pkgName}@${opts.version}, so it cannot be verified.`) + } + verifyRegistrySignature({ + name: pkgName, + version: opts.version, + integrity: meta.dist.integrity, + signatures: meta.dist.signatures, + keys: opts.keys, + }) + // Unpack away from the directory being assembled: `pnpm` is both a package + // name and the name of the executable that ends up beside it. + const unpackDir = path.join(opts.dir, UNPACK_DIR, pkgName.replaceAll('/', '-')) + const tarball = `${unpackDir}.tgz` + fs.mkdirSync(path.dirname(tarball), { recursive: true }) + await downloadTarball(meta, tarball) + extractTarball(tarball, unpackDir) + fs.rmSync(tarball) + return { dir: path.join(unpackDir, 'package') } + } +} + +function majorVersion (version: string): number { + const major = Number(version.split('.')[0]) + if (!Number.isInteger(major)) { + throw new Error(`Could not read a major version from "${version}".`) + } + return major +} diff --git a/get-pnpm/src/npmSigningKeys.ts b/get-pnpm/src/npmSigningKeys.ts new file mode 100644 index 0000000..178541c --- /dev/null +++ b/get-pnpm/src/npmSigningKeys.ts @@ -0,0 +1,29 @@ +/* eslint-disable */ +// GENERATED — npm's public registry signing keys, mirrored from +// https://registry.npmjs.org/-/npm/v1/keys +// +// Refresh with: node scripts/update-npm-keys.mjs --update +// A scheduled workflow runs the check, so a rotation arrives as a pull request +// rather than as a failed install. +export const NPM_SIGNING_KEYS = [ + { + "expires": null, + "keyid": "SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U", + "keytype": "ecdsa-sha2-nistp256", + "scheme": "ecdsa-sha2-nistp256", + "key": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEY6Ya7W++7aUPzvMTrezH6Ycx3c+HOKYCcNGybJZSCJq/fd7Qa8uuAKtdIkUQtQiEKERhAmE5lMMJhP8OkDOa2g==" + }, + { + "expires": "2025-01-29T00:00:00.000Z", + "keyid": "SHA256:jl3bwswu80PjjokCgh0o2w5c2U4LhQAE57gj9cz1kzA", + "keytype": "ecdsa-sha2-nistp256", + "scheme": "ecdsa-sha2-nistp256", + "key": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE1Olb3zMAFFxXKHiIkQO5cJ3Yhl5i6UPp+IhuteBJbuHcA5UogKo0EWtlWwW6KSaKoTNEYL7JlCQiVnkhBktUgg==" + } +] as const satisfies ReadonlyArray<{ + expires: string | null + keyid: string + keytype: string + scheme: string + key: string +}> diff --git a/get-pnpm/src/platformPackageName.ts b/get-pnpm/src/platformPackageName.ts new file mode 100644 index 0000000..29dce97 --- /dev/null +++ b/get-pnpm/src/platformPackageName.ts @@ -0,0 +1,71 @@ +export interface Target { + /** Major version of the pnpm release being installed. */ + major: number + /** `process.platform` of the host. */ + platform: string + /** `process.arch` of the host. */ + arch: string + /** Whether the host's libc is musl rather than glibc. */ + musl: boolean +} + +/** + * Name of the npm package that carries the pnpm executable for `target`. + * + * pnpm ships the binary in a per-host package that `@pnpm/exe` lists as an + * optional dependency. The naming scheme changed with the Rust rewrite: v12 + * publishes `@pnpm/exe.-[-musl]`, while v11 and older + * publish `@pnpm/-`. + * + * @throws if pnpm publishes no binary for the host — either because the + * architecture was never supported, or because of the v11-only Intel macOS gap. + */ +export function platformPackageName ({ major, platform, arch, musl }: Target): string { + if (arch !== 'x64' && arch !== 'arm64') { + throw new Error('Sorry! pnpm currently only provides pre-built binaries for x86_64/arm64 architectures.') + } + if (platform !== 'darwin' && platform !== 'linux' && platform !== 'win32') { + throw new Error(`Sorry! pnpm does not provide a pre-built binary for ${platform}.`) + } + if (platform === 'darwin' && arch === 'x64' && major === 11) { + throw new Error(`pnpm v11 does not provide a working binary for Intel macOS (darwin-x64) due to an upstream Node.js SEA bug. + +Install pnpm a different way instead: + npx get-pnpm 12 # pnpm v12 ships an Intel macOS binary + npm install -g pnpm # uses your system Node.js + brew install pnpm # via Homebrew + +More context: https://github.com/pnpm/pnpm/issues/11423`) + } + const linuxMusl = platform === 'linux' && musl + if (major >= 12) { + return `@pnpm/exe.${platform}-${arch}${linuxMusl ? '-musl' : ''}` + } + return `@pnpm/${legacyOsSegment(platform, linuxMusl)}-${arch}` +} + +function legacyOsSegment (platform: string, isMusl: boolean): string { + switch (platform) { + case 'darwin': return 'macos' + case 'win32': return 'win' + default: return isMusl ? 'linuxstatic' : 'linux' + } +} + +/** + * Whether this host's libc is musl rather than glibc. + * + * Probed the way pnpm's own wrappers do it rather than through a dependency: + * glibc builds report a `glibcVersionRuntime`, musl builds leave it unset. + * Keeping this package dependency-free means `npx` fetches one thing before + * pnpm exists. + */ +export function isMusl (): boolean { + if (process.platform !== 'linux') return false + try { + const report = process.report?.getReport() as { header?: { glibcVersionRuntime?: string } } | undefined + return report?.header != null && !report.header.glibcVersionRuntime + } catch { + return false + } +} diff --git a/get-pnpm/src/registry.ts b/get-pnpm/src/registry.ts new file mode 100644 index 0000000..9ad1f3c --- /dev/null +++ b/get-pnpm/src/registry.ts @@ -0,0 +1,111 @@ +import { createHash } from 'node:crypto' +import { createWriteStream } from 'node:fs' +import { pipeline } from 'node:stream/promises' + +import type { Packument } from './resolveVersion.js' +import type { PackageSignature } from './verifySignature.js' + +export interface VersionMeta { + dist: { + tarball: string + integrity?: string + signatures?: PackageSignature[] + } +} + +const ABBREVIATED_PACKUMENT = 'application/vnd.npm.install-v1+json' + +export const DEFAULT_REGISTRY = 'https://registry.npmjs.org/' + +/** The registry npx/npm is configured with, so a mirror stays a mirror. */ +export function registryFromEnv (): string { + const registry = process.env.npm_config_registry ?? process.env.NPM_CONFIG_REGISTRY ?? DEFAULT_REGISTRY + return registry.endsWith('/') ? registry : `${registry}/` +} + +// Node's fetch applies no timeout of its own: a registry that accepts the +// connection and then stalls would hang the install with no output. Metadata is +// small and quick; the tarball budget has to survive a slow connection carrying +// a ~150 MB download. +const METADATA_TIMEOUT_MS = 30_000 +const TARBALL_TIMEOUT_MS = 15 * 60_000 + +export async function fetchPackument (registry: string, pkgName: string): Promise { + return fetchJson(new URL(pkgName, registry), ABBREVIATED_PACKUMENT) +} + +export async function fetchVersionMeta (registry: string, pkgName: string, version: string): Promise { + return fetchJson(new URL(`${pkgName}/${version}`, registry), 'application/json') +} + +/** + * Streams `meta.dist.tarball` to `dest`, verifying the checksum the registry + * published for it. A mismatch removes nothing — the caller discards the whole + * temporary directory. + */ +export async function downloadTarball (meta: VersionMeta, dest: string): Promise { + const response = await request(new URL(meta.dist.tarball), undefined, TARBALL_TIMEOUT_MS) + const [algorithm, expected] = checksum(meta) + const hash = createHash(algorithm) + const body = response.body as unknown as AsyncIterable + await pipeline( + async function * () { + for await (const chunk of body) { + hash.update(chunk) + yield chunk + } + }, + createWriteStream(dest) + ) + const actual = hash.digest('base64') + if (actual !== expected) { + throw new Error(`The download from ${meta.dist.tarball} does not match the checksum the npm registry published for it. Refusing to install.`) + } +} + +/** + * The algorithm and digest to check a tarball against. + * + * `integrity` is an SRI string, which may hold several space-separated entries; + * the first is used. Only the digest that the registry signature covers is + * accepted — `shasum` is SHA-1, and a package without `integrity` has already + * been refused before any download starts. + */ +function checksum (meta: VersionMeta): [algorithm: string, expected: string] { + const entry = meta.dist.integrity?.trim().split(/\s+/)[0] + if (!entry) { + throw new Error(`The registry published no checksum for ${meta.dist.tarball}, so it cannot be verified.`) + } + const separator = entry.indexOf('-') + if (separator === -1) { + throw new Error(`The registry published an unreadable checksum for ${meta.dist.tarball}: ${entry}`) + } + return [entry.slice(0, separator), entry.slice(separator + 1)] +} + +async function fetchJson (url: URL, accept: string): Promise { + const response = await request(url, accept, METADATA_TIMEOUT_MS) + return await response.json() as T +} + +async function request (url: URL, accept: string | undefined, timeoutMs: number): Promise { + let response: Response + try { + response = await fetch(url, { + signal: AbortSignal.timeout(timeoutMs), + ...(accept ? { headers: { accept } } : {}), + }) + } catch (err) { + const reason = (err as Error).name === 'TimeoutError' + ? `timed out after ${Math.round(timeoutMs / 1000)}s` + : (err as Error).message + throw new Error(`Could not reach ${url.href}: ${reason}`, { cause: err }) + } + if (!response.ok) { + throw new Error(`Could not download ${url.href}: ${response.status} ${response.statusText}`) + } + if (response.body == null) { + throw new Error(`Empty response from ${url.href}`) + } + return response +} diff --git a/get-pnpm/src/resolveVersion.ts b/get-pnpm/src/resolveVersion.ts new file mode 100644 index 0000000..dc5025b --- /dev/null +++ b/get-pnpm/src/resolveVersion.ts @@ -0,0 +1,30 @@ +export interface Packument { + 'dist-tags': Record + versions: Record +} + +/** + * Turns what the user asked for into a concrete pnpm version. + * + * `spec` may be a dist-tag (`latest`, `next-12`), an exact version (`11.20.0`, + * with an optional leading `v`), or a bare major (`12`), which picks that + * major's stable release and falls back to its prerelease lane. + * + * Dist-tags win over exact versions, matching `install.ps1`. + * + * @throws if `spec` matches neither a dist-tag nor a published version. + */ +export function resolveVersion (packument: Packument, spec: string): string { + const distTags = packument['dist-tags'] + if (distTags[spec]) return distTags[spec] + + const version = spec.startsWith('v') ? spec.slice(1) : spec + if (packument.versions[version]) return version + + if (/^\d+$/.test(version)) { + const majorTag = distTags[`latest-${version}`] ?? distTags[`next-${version}`] + if (majorTag) return majorTag + } + + throw new Error(`Sorry! pnpm version "${spec}" could not be found. Available tags: ${Object.keys(distTags).sort().join(', ')}`) +} diff --git a/get-pnpm/src/verifySignature.ts b/get-pnpm/src/verifySignature.ts new file mode 100644 index 0000000..af79818 --- /dev/null +++ b/get-pnpm/src/verifySignature.ts @@ -0,0 +1,76 @@ +import { createVerify } from 'node:crypto' + +import { NPM_SIGNING_KEYS } from './npmSigningKeys.js' + +export interface SigningKey { + keyid: string + key: string + expires: string | null +} + +export interface PackageSignature { + keyid: string + sig: string +} + +/** + * Checks the registry's signature over a package's identity and checksum. + * + * The registry serves both the tarball and the checksum, so a checksum taken + * from it proves nothing on its own. This is what makes it worth anything: the + * signature is made with a key the registry publishes but the download host + * cannot mint, and the trusted copy of that key ships inside this package. + * + * @throws if the package is unsigned, signed with a key that isn't trusted or + * has expired, or the signature does not verify. + */ +export function verifyRegistrySignature ( + opts: { + name: string + version: string + integrity: string + signatures?: PackageSignature[] + keys?: readonly SigningKey[] + now?: Date + } +): void { + const pkg = `${opts.name}@${opts.version}` + const signatures = opts.signatures ?? [] + if (signatures.length === 0) { + throw new Error(`${pkg} carries no npm registry signature, so it cannot be verified.`) + } + + // Pick the signature by key rather than by position. A package can carry + // several, in no guaranteed order, and across a rotation one of them can be + // from a key this installer does not pin — which is not a reason to refuse a + // package that another, pinned key also signed. + const keys = opts.keys ?? NPM_SIGNING_KEYS + const match = signatures + .map((signature) => ({ signature, key: keys.find(({ keyid }) => keyid === signature.keyid) })) + .find((candidate) => candidate.key != null) + if (match?.key == null) { + throw new Error(`${pkg} is signed with an unexpected npm key (${signatures.map(({ keyid }) => keyid).join(', ')}). + +If npm has rotated its signing key, this installer needs updating. +Until then, install pnpm another way: https://pnpm.io/installation`) + } + const { signature, key } = match + + if (key.expires != null) { + const expires = new Date(key.expires).getTime() + // An unreadable date must not read as "never expires". + if (Number.isNaN(expires)) { + throw new Error(`${pkg} is signed with an npm key whose expiry date cannot be read (${key.expires}).`) + } + if (expires < (opts.now ?? new Date()).getTime()) { + throw new Error(`${pkg} is signed with an npm key that expired on ${key.expires}.`) + } + } + + // Registry signatures cover the package identity and its content hash. + const message = `${pkg}:${opts.integrity}` + const publicKey = `-----BEGIN PUBLIC KEY-----\n${key.key}\n-----END PUBLIC KEY-----` + if (!createVerify('SHA256').update(message).verify(publicKey, signature.sig, 'base64')) { + throw new Error(`The npm registry signature for ${pkg} is not valid. Refusing to install.`) + } +} diff --git a/get-pnpm/test/install.test.ts b/get-pnpm/test/install.test.ts new file mode 100644 index 0000000..3277147 --- /dev/null +++ b/get-pnpm/test/install.test.ts @@ -0,0 +1,172 @@ +import { spawnSync } from 'node:child_process' +import assert from 'node:assert/strict' +import { createHash, createSign, generateKeyPairSync } from 'node:crypto' +import fs from 'node:fs' +import http from 'node:http' +import os from 'node:os' +import path from 'node:path' +import { after, before, describe, test } from 'node:test' + +import { downloadPnpm, installPnpm } from '../lib/index.js' +import type { SigningKey } from '../lib/verifySignature.js' + +const VERSION = '99.0.0' + +// The pinned key is npm's, so these tests sign with their own and pass it in. +// Nothing served here can satisfy the real key — which is the point of pinning. +const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve: 'prime256v1' }) +const KEYS: SigningKey[] = [{ + keyid: 'SHA256:test-key', + key: publicKey.export({ type: 'spki', format: 'der' }).toString('base64'), + expires: null, +}] + +/** One thing broken per run, to drive each refusal separately. */ +type Mode = 'ok' | 'bad-tarball' | 'bad-signature' | 'unsigned' | 'wrong-keyid' | 'no-integrity' + +let tmpDir: string +let server: http.Server +let registry: string +let mode: Mode = 'ok' +let setupLog: string + +// The fake executable the mock serves is a `#!/bin/sh` script, so the flow that +// runs it is POSIX-only. The verification it exercises is platform-independent. +describe('installPnpm', { skip: process.platform === 'win32' }, () => { + before(async () => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'get-pnpm-test-')) + setupLog = path.join(tmpDir, 'setup.log') + const tarballs = { + // The wrapper carries `dist/`; the platform package carries the executable. + pnpm: packTarball('wrapper', { 'dist/worker.js': 'export {}\n' }), + platform: packTarball('platform', { + pnpm: `#!/bin/sh\necho "$@" > "${setupLog}"\nls "$(dirname "$0")/dist" >> "${setupLog}"\n`, + }), + } + + server = http.createServer((req, res) => { + const url = decodeURIComponent(req.url!) + const json = (body: unknown): void => { + res.writeHead(200, { 'content-type': 'application/json' }) + res.end(JSON.stringify(body)) + } + + if (url === '/pnpm') { + json({ 'dist-tags': { latest: VERSION }, versions: { [VERSION]: {} } }) + return + } + if (url.endsWith(`/${VERSION}`)) { + const kind = url === `/pnpm/${VERSION}` ? 'pnpm' : 'platform' + const name = url.slice(1, url.lastIndexOf('/')) + const bytes = fs.readFileSync(tarballs[kind]) + const integrity = `sha512-${createHash('sha512').update(bytes).digest('base64')}` + // Signing a different checksum than the one served is what a tampered + // checksum looks like: the bytes and the metadata agree, the signature + // does not cover them. + const signed = mode === 'bad-signature' ? `sha512-${'A'.repeat(86)}==` : integrity + json({ + dist: { + tarball: `${registry}${kind}.tgz`, + ...(mode === 'no-integrity' ? {} : { integrity }), + signatures: mode === 'unsigned' + ? [] + : [{ + keyid: mode === 'wrong-keyid' ? 'SHA256:not-pinned' : KEYS[0]!.keyid, + sig: createSign('SHA256').update(`${name}@${VERSION}:${signed}`).sign(privateKey, 'base64'), + }], + }, + }) + return + } + if (url === '/pnpm.tgz' || url === '/platform.tgz') { + const bytes = fs.readFileSync(tarballs[url === '/pnpm.tgz' ? 'pnpm' : 'platform']) + res.writeHead(200, { 'content-type': 'application/octet-stream' }) + res.end(mode === 'bad-tarball' ? Buffer.concat([bytes, Buffer.from('tampered')]) : bytes) + return + } + res.writeHead(404) + res.end() + }) + await new Promise((resolve) => { server.listen(0, '127.0.0.1', resolve) }) + registry = `http://127.0.0.1:${(server.address() as { port: number }).port}/` + }) + + after(async () => { + await new Promise((resolve) => { server.close(() => { resolve() }) }) + fs.rmSync(tmpDir, { recursive: true, force: true }) + }) + + const install = async (): Promise => installPnpm({ versionSpec: 'latest', registry, keys: KEYS }) + + test('runs setup on an executable placed next to the dist it ships with', async () => { + mode = 'ok' + assert.equal(await install(), 0) + assert.equal(fs.readFileSync(setupLog, 'utf8'), 'setup --force\nworker.js\n') + }) + + test('downloadPnpm places the executable without running setup', async () => { + mode = 'ok' + const dest = path.join(tmpDir, 'dest') + fs.rmSync(setupLog, { force: true }) + const { version, binPath } = await downloadPnpm({ versionSpec: 'latest', registry, dest, keys: KEYS }) + + assert.equal(version, VERSION) + assert.equal(binPath, path.join(dest, 'pnpm')) + assert.ok(fs.existsSync(path.join(dest, 'dist', 'worker.js')), 'dist/ is placed beside the executable') + assert.ok(!fs.existsSync(setupLog), 'setup is not run') + // Nothing but the installation: an unpack directory left here would be + // installed too by a caller that hands the directory to `pnpm setup`. No + // manifest either — the fixture is a v12-shaped release, whose `dist/` is + // self-contained. + assert.deepEqual(fs.readdirSync(dest).sort(), ['dist', 'pnpm']) + }) + + test('leaves no temporary directory behind', async () => { + mode = 'ok' + const before = leftoverTmpDirs() + await install() + assert.deepEqual(leftoverTmpDirs(), before) + }) + + test('refuses a tarball that does not match its checksum', async () => { + mode = 'bad-tarball' + await assert.rejects(install, /does not match the checksum/) + }) + + test('refuses a checksum the signature does not cover', async () => { + mode = 'bad-signature' + await assert.rejects(install, /is not valid/) + }) + + test('refuses an unsigned package', async () => { + mode = 'unsigned' + await assert.rejects(install, /carries no npm registry signature/) + }) + + test('refuses a package signed with a key that is not pinned', async () => { + mode = 'wrong-keyid' + await assert.rejects(install, /unexpected npm key/) + }) + + test('refuses a package the registry published no checksum for', async () => { + mode = 'no-integrity' + await assert.rejects(install, /published no checksum/) + }) +}) + +function leftoverTmpDirs (): string[] { + return fs.readdirSync(os.tmpdir()).filter((entry) => entry.startsWith('pnpm-install-')) +} + +function packTarball (name: string, files: Record): string { + const contentDir = path.join(tmpDir, name, 'package') + for (const [filePath, content] of Object.entries(files)) { + const dest = path.join(contentDir, filePath) + fs.mkdirSync(path.dirname(dest), { recursive: true }) + fs.writeFileSync(dest, content, { mode: 0o755 }) + } + const tarball = path.join(tmpDir, `${name}.tgz`) + const { status, stderr } = spawnSync('tar', ['-czf', tarball, '-C', path.join(tmpDir, name), 'package'], { encoding: 'utf8' }) + if (status !== 0) throw new Error(`could not create the ${name} fixture tarball: ${stderr}`) + return tarball +} diff --git a/get-pnpm/test/unit.test.ts b/get-pnpm/test/unit.test.ts new file mode 100644 index 0000000..e5e658a --- /dev/null +++ b/get-pnpm/test/unit.test.ts @@ -0,0 +1,138 @@ +import { createSign, generateKeyPairSync } from 'node:crypto' +import assert from 'node:assert/strict' +import { describe, test } from 'node:test' + +import { platformPackageName, type Target } from '../lib/platformPackageName.js' +import { resolveVersion } from '../lib/resolveVersion.js' +import { type SigningKey, verifyRegistrySignature } from '../lib/verifySignature.js' + +describe('resolveVersion', () => { + const packument = { + 'dist-tags': { latest: '11.20.0', 'latest-10': '10.34.5', 'next-12': '12.0.0-rc.1' }, + versions: { '10.34.5': {}, '11.19.0': {}, '11.20.0': {}, '12.0.0-rc.1': {} }, + } + + test('resolves a dist-tag', () => { + assert.equal(resolveVersion(packument, 'latest'), '11.20.0') + assert.equal(resolveVersion(packument, 'next-12'), '12.0.0-rc.1') + }) + + test('resolves an exact version, with or without a leading v', () => { + assert.equal(resolveVersion(packument, '11.19.0'), '11.19.0') + assert.equal(resolveVersion(packument, 'v11.19.0'), '11.19.0') + }) + + test('resolves a bare major, preferring the stable release', () => { + assert.equal(resolveVersion(packument, '10'), '10.34.5') + assert.equal(resolveVersion(packument, '12'), '12.0.0-rc.1') + }) + + test('lists the available tags when nothing matches', () => { + assert.throws(() => resolveVersion(packument, '9'), /could not be found.*latest, latest-10, next-12/s) + }) +}) + +describe('platformPackageName', () => { + const target = (overrides: Partial): Target => + ({ major: 11, platform: 'linux', arch: 'x64', musl: false, ...overrides }) + + test('v11 and older use the legacy names', () => { + assert.equal(platformPackageName(target({})), '@pnpm/linux-x64') + assert.equal(platformPackageName(target({ musl: true })), '@pnpm/linuxstatic-x64') + assert.equal(platformPackageName(target({ platform: 'darwin', arch: 'arm64' })), '@pnpm/macos-arm64') + assert.equal(platformPackageName(target({ platform: 'win32' })), '@pnpm/win-x64') + }) + + test('v12 and newer use the process.platform-based names', () => { + assert.equal(platformPackageName(target({ major: 12 })), '@pnpm/exe.linux-x64') + assert.equal(platformPackageName(target({ major: 12, musl: true })), '@pnpm/exe.linux-x64-musl') + assert.equal(platformPackageName(target({ major: 12, platform: 'win32', arch: 'arm64' })), '@pnpm/exe.win32-arm64') + }) + + test('the musl suffix is Linux-only', () => { + assert.equal(platformPackageName(target({ platform: 'darwin', arch: 'arm64', musl: true })), '@pnpm/macos-arm64') + }) + + test('rejects hosts pnpm publishes no binary for', () => { + assert.throws(() => platformPackageName(target({ arch: 'ia32' })), /x86_64\/arm64/) + assert.throws(() => platformPackageName(target({ platform: 'freebsd' })), /freebsd/) + }) + + test('points Intel macOS users away from v11, which has no working binary', () => { + assert.throws(() => platformPackageName(target({ platform: 'darwin', arch: 'x64' })), /Intel macOS/s) + assert.equal(platformPackageName(target({ major: 10, platform: 'darwin', arch: 'x64' })), '@pnpm/macos-x64') + }) +}) + +describe('verifyRegistrySignature', () => { + const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve: 'prime256v1' }) + const KEY_ID = 'SHA256:test-key' + const keys: SigningKey[] = [{ + keyid: KEY_ID, + key: publicKey.export({ type: 'spki', format: 'der' }).toString('base64'), + expires: null, + }] + const name = '@pnpm/exe.linux-x64' + const version = '12.0.0' + const integrity = 'sha512-Zm9vYmFy' + const sign = (message: string, key = privateKey): string => createSign('SHA256').update(message).sign(key, 'base64') + const signatures = [{ keyid: KEY_ID, sig: sign(`${name}@${version}:${integrity}`) }] + + test('accepts a signature over the package identity and checksum', () => { + verifyRegistrySignature({ name, version, integrity, signatures, keys }) + }) + + test('rejects a signature made over a different checksum', () => { + assert.throws(() => verifyRegistrySignature({ + name, version, integrity, keys, + signatures: [{ keyid: KEY_ID, sig: sign(`${name}@${version}:sha512-dGFtcGVyZWQ=`) }], + }), /is not valid/) + }) + + test('rejects a signature made over a different package', () => { + assert.throws(() => verifyRegistrySignature({ + name, version, integrity, keys, + signatures: [{ keyid: KEY_ID, sig: sign(`@evil/pkg@${version}:${integrity}`) }], + }), /is not valid/) + }) + + test('rejects a signature made with an untrusted key', () => { + const attacker = generateKeyPairSync('ec', { namedCurve: 'prime256v1' }) + assert.throws(() => verifyRegistrySignature({ + name, version, integrity, keys, + signatures: [{ keyid: KEY_ID, sig: sign(`${name}@${version}:${integrity}`, attacker.privateKey) }], + }), /is not valid/) + }) + + test('rejects a keyid that is not pinned', () => { + assert.throws(() => verifyRegistrySignature({ + name, version, integrity, keys, + signatures: [{ keyid: 'SHA256:someone-else', sig: signatures[0]!.sig }], + }), /unexpected npm key/) + }) + + test('rejects an unsigned package', () => { + assert.throws(() => verifyRegistrySignature({ name, version, integrity, keys }), /carries no npm registry signature/) + assert.throws(() => verifyRegistrySignature({ name, version, integrity, keys, signatures: [] }), /carries no npm registry signature/) + }) + + test('picks the signature made with a pinned key, whatever its position', () => { + const rotated = [ + { keyid: 'SHA256:not-pinned-yet', sig: sign(`${name}@${version}:${integrity}`) }, + ...signatures, + ] + verifyRegistrySignature({ name, version, integrity, signatures: rotated, keys }) + }) + + test('rejects a key whose expiry date cannot be read', () => { + assert.throws(() => verifyRegistrySignature({ + name, version, integrity, signatures, keys: [{ ...keys[0]!, expires: 'not a date' }], + }), /expiry date cannot be read/) + }) + + test('rejects a key that has expired, and accepts one that has not', () => { + const expiring = [{ ...keys[0]!, expires: '2020-01-01T00:00:00.000Z' }] + assert.throws(() => verifyRegistrySignature({ name, version, integrity, signatures, keys: expiring }), /expired on 2020-01-01/) + verifyRegistrySignature({ name, version, integrity, signatures, keys: expiring, now: new Date('2019-01-01') }) + }) +}) diff --git a/get-pnpm/tsconfig.json b/get-pnpm/tsconfig.json new file mode 100644 index 0000000..dabadd9 --- /dev/null +++ b/get-pnpm/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "es2023", + "lib": ["es2023"], + "module": "nodenext", + "moduleResolution": "nodenext", + "types": ["node"], + "outDir": "lib", + "rootDir": "src", + "declaration": true, + "strict": true, + "noUncheckedIndexedAccess": false, + "verbatimModuleSyntax": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +} diff --git a/scripts/update-npm-keys.mjs b/scripts/update-npm-keys.mjs new file mode 100644 index 0000000..daae0c0 --- /dev/null +++ b/scripts/update-npm-keys.mjs @@ -0,0 +1,105 @@ +#!/usr/bin/env node +// Keeps the pinned npm registry signing keys in sync with +// https://registry.npmjs.org/-/npm/v1/keys. +// +// node scripts/update-npm-keys.mjs # check, non-zero on drift +// node scripts/update-npm-keys.mjs --update # rewrite the pinned keys +// +// Three installers pin these: install.sh, install.ps1 and the get-pnpm package. +// They are updated together, because a rotation that reaches only some of them +// leaves the rest refusing to install anything. +// +// The pinned set is exactly what npm advertises. A key npm has withdrawn is not +// kept for verifying older packages: a signature carries no trusted timestamp, +// so a signature made before a withdrawal cannot be told apart from one forged +// after it. These installers only ever fetch current releases, so following +// npm's set costs nothing and keeps withdrawal meaning withdrawal. +// +// The shell and PowerShell installers carry only the current key; the package +// carries every key npm lists, expiry included, and rejects an expired one. +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const KEYS_URL = 'https://registry.npmjs.org/-/npm/v1/keys' +const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..') +const KEY_FIELDS = ['expires', 'keyid', 'keytype', 'scheme', 'key'] + +const JS_KEYS_FILE = path.join(ROOT, 'get-pnpm', 'src', 'npmSigningKeys.ts') +const SH_FILE = path.join(ROOT, 'install.sh') +const PS_FILE = path.join(ROOT, 'install.ps1') + +const update = process.argv.includes('--update') +const npmKeys = await fetchNpmKeys() +const current = npmKeys.find((key) => key.expires == null) ?? npmKeys[0] +if (!current) throw new Error(`No keys in ${KEYS_URL}`) + +const merged = [...npmKeys].sort((a, b) => a.keyid.localeCompare(b.keyid)) + +const changes = [ + { file: JS_KEYS_FILE, next: renderKeysModule(merged) }, + { file: SH_FILE, next: replaceShellKey(fs.readFileSync(SH_FILE, 'utf8'), current) }, + { file: PS_FILE, next: replacePowerShellKey(fs.readFileSync(PS_FILE, 'utf8'), current) }, +].filter(({ file, next }) => fs.readFileSync(file, 'utf8') !== next) + +if (!update) { + if (changes.length === 0) { + console.log(`✓ Pinned npm signing keys are up to date (${merged.length} key(s)).`) + process.exit(0) + } + console.error('✗ Pinned npm signing keys are out of date:') + for (const { file } of changes) console.error(` - ${path.relative(ROOT, file)}`) + console.error('\nRun: node scripts/update-npm-keys.mjs --update') + process.exit(1) +} + +for (const { file, next } of changes) { + fs.writeFileSync(file, next) + console.log(`updated ${path.relative(ROOT, file)}`) +} +console.log(changes.length === 0 ? '✓ Already current.' : `✓ Updated ${changes.length} file(s).`) + +async function fetchNpmKeys () { + const res = await fetch(KEYS_URL) + if (!res.ok) throw new Error(`Failed to fetch ${KEYS_URL}: ${res.status}`) + const body = await res.json() + if (!Array.isArray(body?.keys)) throw new Error(`Unexpected response from ${KEYS_URL}`) + return body.keys.map((key) => Object.fromEntries(KEY_FIELDS.map((f) => [f, key[f] ?? null]))) +} + +function renderKeysModule (keys) { + return `/* eslint-disable */ +// GENERATED — npm's public registry signing keys, mirrored from +// ${KEYS_URL} +// +// Refresh with: node scripts/update-npm-keys.mjs --update +// A scheduled workflow runs the check, so a rotation arrives as a pull request +// rather than as a failed install. +export const NPM_SIGNING_KEYS = ${JSON.stringify(keys, KEY_FIELDS, 2)} as const satisfies ReadonlyArray<{ + expires: string | null + keyid: string + keytype: string + scheme: string + key: string +}> +` +} + +function replaceShellKey (source, key) { + return replaceOnce(replaceOnce(source, /(NPM_SIGNING_KEY_ID=')[^']*(')/, key.keyid, SH_FILE), + /(NPM_SIGNING_KEY=')[^']*(')/, key.key, SH_FILE) +} + +function replacePowerShellKey (source, key) { + return replaceOnce(replaceOnce(source, /(\$NpmSigningKeyId = ')[^']*(')/, key.keyid, PS_FILE), + /(\$NpmSigningKey = ')[^']*(')/, key.key, PS_FILE) +} + +// A pattern that stops matching means the installer was restructured and this +// script would otherwise silently leave a stale key behind. +function replaceOnce (source, pattern, value, file) { + if (!pattern.test(source)) { + throw new Error(`Pattern ${pattern} not found in ${path.relative(ROOT, file)}`) + } + return source.replace(pattern, `$1${value}$2`) +}