import { createHash } from 'node:crypto'; import { readFileSync, readdirSync } from 'node:fs'; import { join } from 'node:path'; import { gunzipSync } from 'node:zlib'; /** Subset of package.json fields a packument needs. Anything else is opaque. */ export interface PackageJson { name: string; version: string; description?: string; main?: string; bin?: string | Record; dependencies?: Record; devDependencies?: Record; peerDependencies?: Record; optionalDependencies?: Record; engines?: Record; os?: string[]; cpu?: string[]; exports?: unknown; type?: string; scripts?: Record; } export interface TarballMeta { /** Absolute path to the .tgz on disk. */ path: string; /** Filename only (e.g. "celilo-cli-0.7.7.tgz"). */ filename: string; /** Raw bytes of the .tgz, kept in memory so requests don't re-read. */ bytes: Buffer; /** Parsed package.json from inside the tarball. */ pkg: PackageJson; /** sha1 hex of the .tgz bytes (legacy npm field). */ shasum: string; /** sha512 base64 prefixed with "sha512-" (modern npm `integrity` field). */ integrity: string; } /** * Parse a tar stream and yield each file's name and bytes. POSIX tar layout: * a 512-byte header (name in bytes 0..99, size as octal in 124..135), followed * by `ceil(size/512)` data blocks. Empty headers (typeflag 0/'\0') are skipped. * We only need to find one file (`package/package.json`), so the parser stops * walking as soon as the caller breaks out. */ function* iterTarEntries(tar: Buffer): Generator<{ name: string; data: Buffer }> { let offset = 0; while (offset + 512 <= tar.length) { const header = tar.subarray(offset, offset + 512); if (header[0] === 0) break; const rawName = header.subarray(0, 100).toString('utf-8').replace(/\0.*$/, ''); const sizeStr = header.subarray(124, 136).toString('utf-8').replace(/\0.*$/, '').trim(); const size = Number.parseInt(sizeStr, 8) || 0; const dataStart = offset + 512; const dataEnd = dataStart + size; if (rawName) { yield { name: rawName, data: tar.subarray(dataStart, dataEnd) }; } offset = dataStart + Math.ceil(size / 512) * 512; } } export function readPackageJsonFromTarball(tarballPath: string): { bytes: Buffer; pkg: PackageJson; } { const bytes = readFileSync(tarballPath); const tar = gunzipSync(bytes); for (const entry of iterTarEntries(tar)) { if (entry.name === 'package/package.json' || entry.name === './package/package.json') { const pkg = JSON.parse(entry.data.toString('utf-8')) as PackageJson; return { bytes, pkg }; } } throw new Error(`No package/package.json in ${tarballPath}`); } export function computeIntegrity(bytes: Buffer): { shasum: string; integrity: string } { const shasum = createHash('sha1').update(bytes).digest('hex'); const integrity = `sha512-${createHash('sha512').update(bytes).digest('base64')}`; return { shasum, integrity }; } export function loadTarball(tarballPath: string, filename: string): TarballMeta { const { bytes, pkg } = readPackageJsonFromTarball(tarballPath); const { shasum, integrity } = computeIntegrity(bytes); return { path: tarballPath, filename, bytes, pkg, shasum, integrity }; } export function loadTarballsFromDir(dir: string): TarballMeta[] { return readdirSync(dir) .filter((f) => f.endsWith('.tgz')) .map((filename) => loadTarball(join(dir, filename), filename)); } /** * Pick the highest version of a package by naive lexicographic-then-numeric * comparison on the dotted segments. Good enough for monorepo workspace * versions which are always plain `x.y.z`. Pre-releases (`-rc.1`) are not * something this fixture sees in practice — if they ever do, this will * return the pre-release as "highest" because of suffix length, which is * fine for an e2e simulator. */ export function highestVersion(versions: string[]): string { return [...versions].sort((a, b) => { const pa = a.split('.').map((s) => Number.parseInt(s, 10) || 0); const pb = b.split('.').map((s) => Number.parseInt(s, 10) || 0); for (let i = 0; i < Math.max(pa.length, pb.length); i++) { const da = pa[i] ?? 0; const db = pb[i] ?? 0; if (da !== db) return db - da; } return 0; })[0]; } export interface PackumentOptions { /** Public URL the registry advertises in `dist.tarball` URLs. */ registryUrl: string; } export function buildPackument( name: string, versions: TarballMeta[], options: PackumentOptions, ): Record { const versionMap: Record = {}; for (const v of versions) { const tarballUrl = `${options.registryUrl.replace(/\/$/, '')}/${name}/-/${v.filename}`; versionMap[v.pkg.version] = { ...v.pkg, dist: { tarball: tarballUrl, shasum: v.shasum, integrity: v.integrity, }, }; } const latest = highestVersion(versions.map((v) => v.pkg.version)); // The sim serves exactly one build per package (the workspace tarball under // test), so every dist-tag resolves to it. We expose `alpha` alongside // `latest` because the install paths track the @alpha channel during alpha // iteration (`bun add @celilo/cli@alpha`); without an `alpha` tag those // installs 404 inside the install-sh / bootstrap-apt e2e tests. See // feedback_alpha_publishes. return { name, 'dist-tags': { latest, alpha: latest }, versions: versionMap, }; } /** * Index a flat directory of tarballs by package name, regardless of how the * tarball is filenamed on disk. Tarballs that share the same `name` field in * their package.json are grouped together — e.g. `celilo-cli-0.1.0.tgz` and * `celilo-cli-0.2.0.tgz` both end up under `@celilo/cli`. */ export function indexTarballs(tarballs: TarballMeta[]): Map { const byName = new Map(); for (const t of tarballs) { const list = byName.get(t.pkg.name) ?? []; list.push(t); byName.set(t.pkg.name, list); } return byName; }