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