repositories / pi-ext
pi-ext
bugabingas pi extensions
owned by admin
scripts/build/spec.ts
Rawimport { 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;
}