Luigit
repositories / pi-ext

pi-ext

bugabingas pi extensions

owned by admin

scripts/build/spec.ts

Raw
import { existsSync, readFileSync, statSync } from "node:fs";
import { builtinModules } from "node:module";
import { basename, isAbsolute, join, relative } from "node:path";

/** Bare specifiers that stay external in every generated package. */
export const PI_EXTERNAL_PATTERNS: readonly RegExp[] = [
	/^node:/,
	/^@earendil-works\//,
	/^@mariozechner\//,
	/^typebox(?:\/|$)/,
	/^@sinclair\/typebox(?:\/|$)/,
];

const BUILTINS = new Set(builtinModules);

export interface ExtensionPackageJson {
	name: string;
	description?: string;
	license?: string;
	keywords?: string[];
	dependencies?: Record<string, string>;
	peerDependencies?: Record<string, string>;
	pi?: Partial<
		Record<"extensions" | "skills" | "prompts" | "themes", string[]>
	>;
	"pi-ext"?: {
		entries?: string[];
		resources?: string[];
		/** Package-relative destination → `<dependency>/<file>` copied from a bundled dependency. */
		assets?: Record<string, string>;
		externals?: Record<string, string>;
	};
}

export interface ExtensionBuildSpec {
	/** Directory name under `extensions/`. */
	name: string;
	/** Absolute source directory. */
	dir: string;
	pkg: ExtensionPackageJson;
	/** Rolldown entry name → source file relative to `dir`. */
	entries: Record<string, string>;
	/** Files or directories copied verbatim into the package, relative to `dir`. */
	resources: string[];
	/** Package-relative destination → absolute source file taken from a dependency. */
	assets: Record<string, string>;
	/** Third-party packages kept external with a recorded incompatibility reason. */
	externals: Record<string, string>;
}

function fail(name: string, message: string): never {
	throw new Error(`${name}: ${message}`);
}

function safeRelative(
	name: string,
	dir: string,
	value: unknown,
	label: string,
) {
	if (typeof value !== "string" || !value)
		fail(name, `${label} must be a relative path`);
	if (isAbsolute(value) || value.includes("\\") || value.includes("\0"))
		fail(name, `${label} must be a relative slash-separated path`);
	const path = join(dir, value);
	const rel = relative(dir, path);
	if (rel === "" || rel.startsWith("..") || isAbsolute(rel))
		fail(name, `${label} must stay inside the extension`);
	if (!existsSync(path)) fail(name, `${label} ${value} does not exist`);
	return value
		.split("/")
		.filter((part) => part !== ".")
		.join("/");
}

function findDependencyFile(
	name: string,
	dir: string,
	specifier: string,
): string {
	const [scopeOrName, ...rest] = specifier.split("/");
	const packageName = specifier.startsWith("@")
		? `${scopeOrName}/${rest.shift() ?? ""}`
		: (scopeOrName ?? "");
	const file = rest.join("/");
	if (!packageName || !file)
		fail(name, `pi-ext.assets source ${specifier} must be <package>/<file>`);
	let current = dir;
	while (true) {
		const candidate = join(current, "node_modules", packageName, file);
		if (existsSync(candidate) && statSync(candidate).isFile()) return candidate;
		const parent = join(current, "..");
		if (relative(parent, current) === "" || parent === current) break;
		current = parent;
		if (current === "/" || /^[A-Za-z]:\\?$/u.test(current)) {
			const last = join(current, "node_modules", packageName, file);
			if (existsSync(last) && statSync(last).isFile()) return last;
			break;
		}
	}
	return fail(name, `pi-ext.assets source ${specifier} is not installed`);
}

function entryName(file: string): string {
	return file.replace(/\.[cm]?[jt]s$/u, "");
}

export function readBuildSpec(dir: string): ExtensionBuildSpec {
	const name = basename(dir);
	const pkgPath = join(dir, "package.json");
	if (!existsSync(pkgPath)) fail(name, "missing package.json");
	const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as ExtensionPackageJson;
	const declared = pkg["pi-ext"] ?? {};
	const entries: Record<string, string> = {};
	const piEntries = pkg.pi?.extensions?.length
		? pkg.pi.extensions
		: ["index.ts"];
	for (const entry of piEntries) {
		const file = safeRelative(name, dir, entry, "pi.extensions entry");
		if (!statSync(join(dir, file)).isFile())
			fail(name, `pi.extensions entry ${file} must be a file`);
		entries[entryName(file)] = file;
	}
	for (const entry of declared.entries ?? []) {
		const file = safeRelative(name, dir, entry, "pi-ext.entries entry");
		if (!statSync(join(dir, file)).isFile())
			fail(name, `pi-ext.entries entry ${file} must be a file`);
		const key = entryName(file);
		if (key in entries) fail(name, `duplicate entry ${file}`);
		entries[key] = file;
	}
	const resources = (declared.resources ?? []).map((resource) =>
		safeRelative(name, dir, resource, "pi-ext.resources entry"),
	);
	const assets: Record<string, string> = {};
	for (const [destination, source] of Object.entries(declared.assets ?? {})) {
		if (
			typeof destination !== "string" ||
			!destination ||
			isAbsolute(destination) ||
			destination.split("/").some((part) => !part || part === "..")
		)
			fail(
				name,
				`pi-ext.assets destination ${destination} must be a relative path`,
			);
		if (typeof source !== "string")
			fail(name, `pi-ext.assets.${destination} must name <package>/<file>`);
		assets[destination] = findDependencyFile(name, dir, source);
	}
	const externals = declared.externals ?? {};
	for (const [dep, reason] of Object.entries(externals)) {
		if (typeof reason !== "string" || !reason.trim())
			fail(name, `pi-ext.externals.${dep} needs an incompatibility reason`);
		if (!pkg.dependencies?.[dep])
			fail(name, `pi-ext.externals.${dep} is not a declared dependency`);
	}
	return { name, dir, pkg, entries, resources, assets, externals };
}

export function packageOf(specifier: string): string {
	return specifier.startsWith("@")
		? specifier.split("/").slice(0, 2).join("/")
		: (specifier.split("/")[0] ?? specifier);
}

/** True when a bare specifier stays outside the generated bundle. */
export function isExternal(
	specifier: string,
	externals: Record<string, string>,
): boolean {
	if (specifier.startsWith(".") || isAbsolute(specifier)) return false;
	if (BUILTINS.has(specifier)) return true;
	if (PI_EXTERNAL_PATTERNS.some((pattern) => pattern.test(specifier)))
		return true;
	return packageOf(specifier) in externals;
}