import { spawnSync } from "node:child_process"; import { randomUUID } from "node:crypto"; import { copyFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmdirSync, rmSync, writeFileSync, } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { bundleExtension } from "./bundle.ts"; import { generatePackageJson } from "./manifest.ts"; import { publishPackage } from "./publish.ts"; import { readBuildSpec } from "./spec.ts"; export const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); export const DIST_ROOT = join(ROOT, "dist"); export interface BuildExtensionOptions { /** Repository root; defaults to this checkout. */ root?: string; /** Canonical package parent; defaults to `/dist`. */ distRoot?: string; release?: boolean; } export interface BuiltPackage { name: string; dir: string; entries: string[]; } export function allExtensionNames(root = ROOT): string[] { return readdirSync(join(root, "extensions"), { withFileTypes: true }) .filter( (entry) => entry.isDirectory() && existsSync(join(root, "extensions", entry.name, "package.json")), ) .map((entry) => entry.name) .sort(); } export function rootVersion(root = ROOT): string { const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as { version?: string; }; if (!pkg.version) throw new Error("root package.json has no version"); return pkg.version; } /** * Externals declared with an incompatibility reason ship as a real dependency * tree inside the package, so a relocated copy resolves them without the * workspace. Peer dependencies stay Pi-provided and are never installed. */ function installExternals(packageDir: string): void { const result = spawnSync( process.platform === "win32" ? "npm.cmd" : "npm", [ "install", "--omit=dev", "--legacy-peer-deps", "--ignore-scripts", "--no-audit", "--no-fund", "--no-package-lock", "--prefer-offline", "--loglevel=error", ], { cwd: packageDir, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }, ); if (result.status !== 0) throw new Error( `npm install of external dependencies failed:\n${result.stderr || result.stdout}`, ); } /** * Build one extension into `/`. * Everything is produced in a staging directory first; the canonical package is * touched only once the staging content is complete. */ export async function buildExtension( name: string, options: BuildExtensionOptions = {}, ): Promise { const root = options.root ?? ROOT; const distRoot = options.distRoot ?? join(root, "dist"); const spec = readBuildSpec(join(root, "extensions", name)); const staging = join(distRoot, ".staging", `${name}-${randomUUID()}`); mkdirSync(staging, { recursive: true }); try { const bundled = await bundleExtension(spec, staging, { release: options.release, }); for (const resource of spec.resources) { const destination = join(staging, resource); mkdirSync(dirname(destination), { recursive: true }); cpSync(join(spec.dir, resource), destination, { recursive: true, dereference: true, }); } for (const [destination, source] of Object.entries(spec.assets)) { const target = join(staging, destination); mkdirSync(dirname(target), { recursive: true }); copyFileSync(source, target); } writeFileSync( join(staging, "package.json"), `${JSON.stringify(generatePackageJson(spec, rootVersion(root)), null, "\t")}\n`, ); if (Object.keys(spec.externals).length) installExternals(staging); const target = join(distRoot, name); publishPackage(staging, target, bundled.entries); return { name, dir: target, entries: bundled.entries }; } catch (error) { rmSync(staging, { recursive: true, force: true }); throw error; } finally { const stagingRoot = join(distRoot, ".staging"); if (existsSync(stagingRoot)) { try { rmdirSync(stagingRoot); } catch { // Another build is still staging. } } } }