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; peerDependencies?: Record; pi?: Partial< Record<"extensions" | "skills" | "prompts" | "themes", string[]> >; "pi-ext"?: { entries?: string[]; resources?: string[]; /** Package-relative destination → `/` copied from a bundled dependency. */ assets?: Record; externals?: Record; }; } 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; /** 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; /** Third-party packages kept external with a recorded incompatibility reason. */ externals: Record; } 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 /`); 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 = {}; 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 = {}; 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 /`); 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, ): 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; }