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 {
	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 `<root>/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 `<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 {
		// Browser E2E entries ship into dev `dist/<name>` 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.
			}
		}
	}
}