import { spawnSync } from "node:child_process"; import { randomUUID } from "node:crypto"; import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmdirSync, rmSync, writeFileSync, } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { bundleBrowserEntries, bundleExtension, bundleNodeEntries, } from "./bundle.ts"; import { generatePackageJson } from "./manifest.ts"; import { publishPackage } from "./publish.ts"; import { readBuildSpec } from "./spec.ts"; export const ROOT: string = resolve( dirname(fileURLToPath(import.meta.url)), "../..", ); export const DIST_ROOT: string = 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[]; } /** Exact installed version of `dep`, resolved from `startDir` up the node_modules tree. */ function resolveInstalledVersion( startDir: string, dep: string, ): string | undefined { let current = startDir; while (true) { const manifest = join(current, "node_modules", dep, "package.json"); if (existsSync(manifest)) return ( JSON.parse(readFileSync(manifest, "utf8")) as { version?: string } ).version; const parent = dirname(current); if (parent === current) return undefined; current = parent; } } export function allExtensionNames(root: string = 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: string = 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 args = [ "install", "--omit=dev", "--legacy-peer-deps", "--ignore-scripts", // Local file dependencies must be packed, not symlinked outside the package. "--install-links", "--no-audit", "--no-fund", "--no-package-lock", "--prefer-offline", "--loglevel=error", ]; const windows = process.platform === "win32"; const result = spawnSync( windows ? `npm.cmd ${args.join(" ")}` : "npm", windows ? [] : args, { cwd: packageDir, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], // Windows .cmd shims require cmd.exe; the command contains only fixed literals. shell: windows, }, ); if (result.status !== 0) throw new Error( `npm install of external dependencies failed:\n${result.error?.message || result.stderr || result.stdout || `exit ${result.status}`}`, ); } /** * 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 { // Browser E2E entries ship into dev `dist/` only; release packages // stay free of test surface. const effectiveSpec = options.release ? spec : { ...spec, entries: { ...spec.entries, ...spec.testEntries } }; const bundled = await bundleExtension(effectiveSpec, staging, { release: options.release, }); await bundleNodeEntries(spec, staging, { release: options.release }); await bundleBrowserEntries(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 }); cpSync(source, target, { recursive: true, dereference: true }); } // Pin each external to the exact version resolved in the workspace lock, so a // relocated package installs what dev and CI tested instead of re-resolving a // floating range. // Consume-time externals keep their source range (e.g. `*`) so the // consumer's install resolves the right per-platform build; baked externals // pin the exact version resolved in the workspace lock. const externalVersions = spec.consumeExternals ? {} : Object.fromEntries( Object.keys(spec.externals).map((dep) => [ dep, resolveInstalledVersion(spec.dir, dep), ]), ); writeFileSync( join(staging, "package.json"), `${JSON.stringify(generatePackageJson(spec, rootVersion(root), externalVersions), null, "\t")}\n`, ); if (Object.keys(spec.externals).length && !spec.consumeExternals) 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. } } } }