import { execFile } from "node:child_process"; import { mkdir, mkdtemp, readdir, readFile, realpath, rm, stat } from "node:fs/promises"; import { homedir, tmpdir } from "node:os"; import path from "node:path"; import process from "node:process"; import { fileURLToPath, pathToFileURL } from "node:url"; import { promisify } from "node:util"; /** * The public module-development package set (ADR 0053), and the release check that packs it. * * node scripts/pack-public-packages.ts --out * * Rebuilds each public package from clean output, packs it with `pnpm pack`, and inspects every * tarball before anything may consume it: only the package's built files, its declarations, and a * `LICENSE`; a manifest with no `workspace:` range, no install-time lifecycle script, the explicit * distribution marker, and the one coordinated version. It publishes nothing. */ const execFileAsync = promisify(execFile); const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); export type PublicPackageRole = "author-api" | "tooling" | "pre-1.0" | "implementation"; export interface PublicPackage { readonly name: string; /** Relative to the repository root. */ readonly directory: string; readonly role: PublicPackageRole; } /** ADR 0053's public set. Every other workspace package is private and never published. */ export const PUBLIC_PACKAGES: readonly PublicPackage[] = Object.freeze([ { directory: "packages/module-sdk", name: "@ekklesia/module-sdk", role: "author-api" }, { directory: "packages/module-database", name: "@ekklesia/module-database", role: "author-api" }, { directory: "packages/module-ui-sdk", name: "@ekklesia/module-ui-sdk", role: "author-api" }, { directory: "packages/design-tokens", name: "@ekklesia/design-tokens", role: "author-api" }, { directory: "packages/module-ui-build", name: "@ekklesia/module-ui-build", role: "tooling" }, { directory: "packages/module-pack", name: "@ekklesia/module-pack", role: "tooling" }, { directory: "packages/typescript-config", name: "@ekklesia/typescript-config", role: "tooling", }, { directory: "packages/ui", name: "@ekklesia/ui", role: "pre-1.0" }, // A dependency of the packages above, never a supported direct module-author dependency. { directory: "packages/contracts", name: "@ekklesia/contracts", role: "implementation" }, ]); /** `ekklesia.distribution` in a public package's manifest: the identity the canonical packager * recognizes an installed Ekklesia package by (`packages/module-pack/src/packages.ts`). */ export const PUBLIC_PACKAGE_DISTRIBUTION = "module-development"; /** Scripts a package manager runs on a consumer's machine when it installs, packs, or publishes. */ const LIFECYCLE_SCRIPTS = Object.freeze([ "preinstall", "install", "postinstall", "prepare", "prepack", "postpack", "prepublish", "prepublishOnly", "publish", "postpublish", ]); /** The fields a consumer's install resolves. `devDependencies` are never installed from a published * package, so a tooling package's test-only use of a private package is not a problem there. */ const INSTALLED_DEPENDENCY_FIELDS = Object.freeze([ "dependencies", "peerDependencies", "optionalDependencies", ]); function isRecord(value: unknown): value is Readonly> { return typeof value === "object" && value !== null && !Array.isArray(value); } /** What is wrong with a packed `package.json`, or nothing. */ export function packedManifestProblems( manifest: Readonly>, expected: { readonly name: string; readonly version: string }, ): string[] { const problems: string[] = []; if (manifest.name !== expected.name) problems.push(`is named ${String(manifest.name)}`); if (manifest.version !== expected.version) { problems.push( `has version ${String(manifest.version)}, not the coordinated ${expected.version}`, ); } if (manifest.private !== undefined) problems.push("declares private"); if (manifest.license !== "AGPL-3.0-or-later") problems.push("does not declare its licence"); if (JSON.stringify(manifest).includes("workspace:")) problems.push("keeps a workspace: range"); const ekklesia = manifest.ekklesia; if (!isRecord(ekklesia) || ekklesia.distribution !== PUBLIC_PACKAGE_DISTRIBUTION) { problems.push("lacks the module-development distribution marker"); } const scripts = isRecord(manifest.scripts) ? manifest.scripts : {}; for (const script of LIFECYCLE_SCRIPTS) { if (Object.hasOwn(scripts, script)) problems.push(`runs a ${script} script`); } const publicNames = new Set(PUBLIC_PACKAGES.map((entry) => entry.name)); for (const field of INSTALLED_DEPENDENCY_FIELDS) { const dependencies = manifest[field]; if (!isRecord(dependencies)) continue; for (const [name, range] of Object.entries(dependencies)) { if (!name.startsWith("@ekklesia/")) continue; if (!publicNames.has(name)) problems.push(`${field} names the private ${name}`); else if (range !== expected.version) { problems.push(`${field} names ${name}@${String(range)}, not ${expected.version}`); } } } return problems; } const BUILT_FILE = /^dist\/(.+?)(?:\.d\.ts|\.js)(?:\.map)?$/u; /** * What is wrong with a tarball's file list, or nothing. Every file is `package.json`, `LICENSE`, or * under one of the manifest's `files` entries; nothing is a source, test, or configuration file; and * every built file has the source it was compiled from, so stale output from a deleted source fails. */ export function tarballFileProblems( files: readonly string[], manifest: Readonly>, hasSource: (relativeWithoutExtension: string) => boolean, ): string[] { const problems: string[] = []; const declared = Array.isArray(manifest.files) ? manifest.files.map(String) : []; if (!files.includes("LICENSE")) problems.push("has no LICENSE"); if (!files.includes("package.json")) problems.push("has no package.json"); for (const file of files) { if (file === "package.json" || file === "LICENSE") continue; if (!declared.some((entry) => file === entry || file.startsWith(`${entry}/`))) { problems.push(`ships ${file}, which its files field does not name`); continue; } if ( /(?:^|\/)(?:src|tests?|node_modules)\//u.test(file) || /\.test\.[cm]?[jt]sx?$/u.test(file) ) { problems.push(`ships ${file}`); continue; } const built = BUILT_FILE.exec(file); if (file.startsWith("dist/") && (built === null || !hasSource(built[1] ?? ""))) { problems.push(`ships ${file}, which no source produces`); } } return problems; } async function readJson(file: string): Promise> { return JSON.parse(await readFile(file, "utf8")) as Record; } async function filesUnder(directory: string): Promise { const entries = await readdir(directory, { recursive: true, withFileTypes: true }); return entries .filter((entry) => entry.isFile()) .map((entry) => path.relative(directory, path.join(entry.parentPath, entry.name)).split(path.sep).join("/"), ) .sort(); } async function sourceExists(packageDirectory: string, relative: string): Promise { for (const extension of [".ts", ".tsx"]) { try { await stat(path.join(packageDirectory, "src", `${relative}${extension}`)); return true; } catch {} } return false; } export interface PackedPackage { readonly name: string; readonly tarball: string; readonly files: number; readonly bytes: number; } async function pnpm(arguments_: readonly string[], cwd: string): Promise { const { stdout } = await execFileAsync("pnpm", [...arguments_], { cwd, maxBuffer: 64 * 1024 * 1024, }); return stdout; } async function packOne( entry: PublicPackage, version: string, outDirectory: string, scratch: string, ): Promise<{ packed: PackedPackage; problems: string[] }> { const directory = path.join(repositoryRoot, entry.directory); const report = JSON.parse( await pnpm(["pack", "--json", "--pack-destination", outDirectory], directory), ) as { filename: string }; const tarball = report.filename; const extracted = path.join(scratch, entry.name.replace("/", "+")); await mkdir(extracted, { recursive: true }); await execFileAsync("tar", ["-xzf", tarball, "-C", extracted]); const root = path.join(extracted, "package"); const files = await filesUnder(root); const manifest = await readJson(path.join(root, "package.json")); const sources = new Map(); for (const file of files) { const built = BUILT_FILE.exec(file)?.[1]; if (built !== undefined && !sources.has(built)) { sources.set(built, await sourceExists(directory, built)); } } const problems = [ ...packedManifestProblems(manifest, { name: entry.name, version }), ...tarballFileProblems(files, manifest, (relative) => sources.get(relative) === true), ]; // Nothing the package says may name the machine or checkout it was packed on. for (const file of files) { const text = (await readFile(path.join(root, file))).toString("latin1"); for (const local of [repositoryRoot, homedir()]) { if (text.includes(local)) problems.push(`${file} names a local path`); } } return { packed: { bytes: (await stat(tarball)).size, files: files.length, name: entry.name, tarball }, problems: [...new Set(problems)], }; } /** Rebuilds the public set from clean output, packs it into `outDirectory`, and inspects it. */ export async function packPublicPackages(outDirectory: string): Promise<{ readonly packed: readonly PackedPackage[]; readonly problems: readonly string[]; }> { const version = String((await readJson(path.join(repositoryRoot, "package.json"))).version); await mkdir(outDirectory, { recursive: true }); const out = await realpath(outDirectory); // Stale output from a deleted source would otherwise ship: the build only ever adds files, and // Turbo's cache would restore whatever an earlier build left behind. for (const entry of PUBLIC_PACKAGES) { await rm(path.join(repositoryRoot, entry.directory, "dist"), { force: true, recursive: true }); } await pnpm( [ "exec", "turbo", "run", "build", "--force", "--output-logs=errors-only", ...PUBLIC_PACKAGES.map((entry) => `--filter=${entry.name}`), ], repositoryRoot, ); const scratch = await mkdtemp(path.join(tmpdir(), "ekklesia-public-packages-")); try { const packed: PackedPackage[] = []; const problems: string[] = []; for (const entry of PUBLIC_PACKAGES) { const result = await packOne(entry, version, out, scratch); packed.push(result.packed); problems.push(...result.problems.map((problem) => `${entry.name} ${problem}.`)); } return { packed, problems }; } finally { await rm(scratch, { force: true, recursive: true }); } } async function main(argv: readonly string[]): Promise { if (argv.length !== 2 || argv[0] !== "--out" || !argv[1]) { process.stderr.write("Usage: node scripts/pack-public-packages.ts --out \n"); return 64; } try { const { packed, problems } = await packPublicPackages(path.resolve(argv[1])); for (const entry of packed) { process.stdout.write( `${path.basename(entry.tarball)}: ${entry.files} files, ${entry.bytes} bytes\n`, ); } if (problems.length > 0) { for (const problem of problems) process.stderr.write(`${problem}\n`); return 1; } process.stdout.write(`Packed and inspected ${packed.length} public packages.\n`); return 0; } catch { process.stderr.write("Packing the public packages failed while building or packing.\n"); return 1; } } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { process.exitCode = await main(process.argv.slice(2)); }