Luigit
repositories / pi-ext

pi-ext

bugabingas pi extensions

owned by admin

scripts/build/build.ts

Raw
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 `<root>/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 `<distRoot>/<name>`.
 * 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<BuiltPackage> {
	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.
			}
		}
	}
}