Luigit
repositories / pi-ext

pi-ext

bugabingas pi extensions

owned by admin

scripts/build/publish.ts

Raw
import {
	copyFileSync,
	existsSync,
	mkdirSync,
	readdirSync,
	readFileSync,
	renameSync,
	rmSync,
	statSync,
	writeFileSync,
} from "node:fs";
import { dirname, join, relative, sep } from "node:path";

export const BUILD_MANIFEST = ".pi-ext-build.json";

interface BuildManifest {
	files: string[];
}

export function listFiles(dir: string, base = dir): string[] {
	const out: string[] = [];
	if (!existsSync(dir)) return out;
	for (const entry of readdirSync(dir, { withFileTypes: true })) {
		const path = join(dir, entry.name);
		if (entry.isDirectory()) out.push(...listFiles(path, base));
		else out.push(relative(base, path).split(sep).join("/"));
	}
	return out.sort();
}

function readManifest(target: string): BuildManifest | undefined {
	try {
		const parsed = JSON.parse(
			readFileSync(join(target, BUILD_MANIFEST), "utf8"),
		) as Partial<BuildManifest>;
		return Array.isArray(parsed.files)
			? { files: parsed.files.filter((f) => typeof f === "string") }
			: undefined;
	} catch {
		return undefined;
	}
}

function replaceFile(source: string, destination: string): void {
	mkdirSync(dirname(destination), { recursive: true });
	const temp = `${destination}.tmp-${process.pid}`;
	copyFileSync(source, temp);
	renameSync(temp, destination);
}

function pruneEmptyDirs(dir: string, root: string): void {
	if (dir === root || !existsSync(dir)) return;
	if (readdirSync(dir).length === 0) {
		rmSync(dir, { recursive: true, force: true });
		pruneEmptyDirs(dirname(dir), root);
	}
}

/**
 * Publish a complete staging directory into the canonical package directory.
 *
 * Non-entry files land first, then entry files replace the previous entries
 * atomically, so a loaded extension keeps a consistent package until the
 * new entrypoint appears. Files from the previous build survive one more
 * generation so in-flight lazy imports can still resolve their chunks; anything
 * older is pruned.
 */
export function publishPackage(
	staging: string,
	target: string,
	entries: readonly string[],
): void {
	const files = listFiles(staging);
	if (!files.length) throw new Error(`nothing staged in ${staging}`);
	if (!existsSync(target)) {
		mkdirSync(dirname(target), { recursive: true });
		writeFileSync(
			join(staging, BUILD_MANIFEST),
			`${JSON.stringify({ files } satisfies BuildManifest, null, "\t")}\n`,
		);
		renameSync(staging, target);
		return;
	}
	if (!statSync(target).isDirectory())
		throw new Error(`${target} is not a directory`);
	const previous = new Set(readManifest(target)?.files ?? listFiles(target));
	const entrySet = new Set(entries);
	const late = files.filter((f) => entrySet.has(f) || f === "package.json");
	const early = files.filter((f) => !late.includes(f));
	for (const file of early)
		replaceFile(join(staging, file), join(target, file));
	for (const file of late.filter((f) => f === "package.json"))
		replaceFile(join(staging, file), join(target, file));
	for (const file of late.filter((f) => f !== "package.json"))
		replaceFile(join(staging, file), join(target, file));
	const keep = new Set([...files, ...previous, BUILD_MANIFEST]);
	for (const file of listFiles(target)) {
		if (keep.has(file)) continue;
		rmSync(join(target, file), { force: true });
		pruneEmptyDirs(dirname(join(target, file)), target);
	}
	writeFileSync(
		join(target, BUILD_MANIFEST),
		`${JSON.stringify({ files } satisfies BuildManifest, null, "\t")}\n`,
	);
	rmSync(staging, { recursive: true, force: true });
}