singularity-forge/scripts/ensure-workspace-builds.cjs

126 lines
4.3 KiB
JavaScript

#!/usr/bin/env node
/**
* ensure-workspace-builds.cjs
*
* Checks whether workspace packages have been compiled (dist/ exists with
* index.js) and that the build is not stale (no src/ file newer than dist/).
* If any are missing or stale, runs the build for those packages.
*
* Designed for the postinstall hook so that `npm install` in a fresh clone
* produces a working runtime without a manual `npm run build` step. Also
* catches the common case where `git pull` updates package sources but the
* old dist/ remains, causing TypeScript type errors.
*
* Skipped in CI (where the full build pipeline handles this) and when
* installing as an end-user dependency (no packages/ directory).
*/
const { existsSync, statSync, readdirSync } = require("node:fs");
const { resolve, join } = require("node:path");
const { execSync } = require("node:child_process");
/**
* Returns the most recent mtime (ms) of any .ts file under dir, recursively.
* Returns 0 if no .ts files found.
*/
function newestSrcMtime(dir) {
if (!existsSync(dir)) return 0;
let newest = 0;
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (entry.name === "node_modules") continue;
const full = join(dir, entry.name);
if (entry.isDirectory()) {
newest = Math.max(newest, newestSrcMtime(full));
} else if (entry.isFile() && entry.name.endsWith(".ts")) {
newest = Math.max(newest, statSync(full).mtimeMs);
}
}
return newest;
}
/**
* Detects workspace packages whose dist/ is missing or stale.
*
* Missing dist/index.js is always reported (the package won't work at all).
*
* Staleness (src/ newer than dist/) is ONLY checked when a .git directory
* exists at root — indicating a development clone. In npm tarball installs,
* file timestamps are unreliable (npm sets all files to a canonical date,
* but extraction ordering can cause src/ to appear 1-2 seconds newer than
* dist/). Attempting to rebuild in that scenario is dangerous: devDependencies
* (including TypeScript) are not installed, and any globally-installed tsc
* may produce broken output that overwrites the known-good dist/.
*
* @param {string} root Project root directory
* @param {string[]} packages Package directory names to check
* @returns {string[]} Package names that need rebuilding
*/
function detectStalePackages(root, packages) {
const packagesDir = join(root, "packages");
const isDevClone = existsSync(join(root, ".git"));
const stale = [];
for (const pkg of packages) {
const distIndex = join(packagesDir, pkg, "dist", "index.js");
if (!existsSync(distIndex)) {
stale.push(pkg);
continue;
}
// Only check src vs dist timestamps in development clones.
// In npm tarball installs, timestamps are unreliable and rebuilding
// without devDependencies can corrupt the pre-built dist/ (#2877).
if (isDevClone) {
const distMtime = statSync(distIndex).mtimeMs;
const srcMtime = newestSrcMtime(join(packagesDir, pkg, "src"));
if (srcMtime > distMtime) {
stale.push(pkg);
}
}
}
return stale;
}
if (require.main === module) {
const root = resolve(__dirname, "..");
const packagesDir = join(root, "packages");
// Skip if packages/ doesn't exist (published tarball / end-user install)
if (!existsSync(packagesDir)) process.exit(0);
// Skip in CI — the pipeline runs `npm run build` explicitly
if (process.env.CI === "true" || process.env.CI === "1") process.exit(0);
// Workspace packages that need dist/index.js at runtime.
// Order matters: dependencies must build before dependents.
const WORKSPACE_PACKAGES = [
"native",
"pi-tui",
"google-gemini-cli-provider",
"pi-ai",
"pi-agent-core",
"pi-coding-agent",
"rpc-client",
"daemon",
];
const stale = detectStalePackages(root, WORKSPACE_PACKAGES);
if (stale.length === 0) process.exit(0);
process.stderr.write(
` Building ${stale.length} workspace package(s) with stale or missing dist/: ${stale.join(", ")}\n`,
);
for (const pkg of stale) {
const pkgDir = join(packagesDir, pkg);
try {
// execSync is safe here: the command is a hardcoded string, not user input
execSync("npm run build", { cwd: pkgDir, stdio: "pipe" });
process.stderr.write(`${pkg}\n`);
} catch (err) {
process.stderr.write(`${pkg} build failed: ${err.message}\n`);
// Non-fatal — the user can run `npm run build` manually
}
}
}
module.exports = { newestSrcMtime, detectStalePackages };