paperclip/scripts/ensure-plugin-build-deps.mjs

249 lines
8.5 KiB
JavaScript

#!/usr/bin/env node
import { spawn } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { createHash, randomUUID } from "node:crypto";
import { setTimeout as sleep } from "node:timers/promises";
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const rootDir = path.resolve(scriptDir, "..");
const tscCliPath = path.join(rootDir, "node_modules", "typescript", "bin", "tsc");
const lockDir = path.join(rootDir, "node_modules", ".cache", "paperclip-plugin-build-deps.lock");
const lockTimeoutMs = 60_000;
const lockPollMs = 100;
const buildTargets = [
{
name: "@paperclipai/shared",
output: path.join(rootDir, "packages/shared/dist/index.js"),
completion: path.join(rootDir, "packages/shared/dist/.paperclip-build-complete"),
sourceDir: path.join(rootDir, "packages/shared/src"),
tsconfig: path.join(rootDir, "packages/shared/tsconfig.json"),
dependencies: [],
},
{
name: "@paperclipai/plugin-sdk",
output: path.join(rootDir, "packages/plugins/sdk/dist/index.js"),
completion: path.join(rootDir, "packages/plugins/sdk/dist/.paperclip-build-complete"),
sourceDir: path.join(rootDir, "packages/plugins/sdk/src"),
tsconfig: path.join(rootDir, "packages/plugins/sdk/tsconfig.json"),
dependencies: [0],
},
];
if (!fs.existsSync(tscCliPath)) {
throw new Error(`TypeScript CLI not found at ${tscCliPath}`);
}
function directoryFingerprint(directory, exclude) {
const hash = createHash("sha256");
function visit(dir) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
const entryPath = path.join(dir, entry.name);
if (entryPath === exclude) continue;
if (entry.isDirectory()) {
visit(entryPath);
} else if (entry.isFile()) {
const content = fs.readFileSync(entryPath);
hash.update(JSON.stringify([path.relative(directory, entryPath), content.length]));
hash.update(content);
}
}
}
visit(directory);
return hash.digest("hex");
}
function sourceFingerprint(target) {
const hash = createHash("sha256");
hash.update(directoryFingerprint(target.sourceDir));
for (const config of [
target.tsconfig,
path.join(path.dirname(target.tsconfig), "package.json"),
path.join(rootDir, "tsconfig.json"),
path.join(rootDir, "tsconfig.base.json"),
path.join(rootDir, "node_modules/typescript/package.json"),
]) {
if (fs.existsSync(config)) hash.update(fs.readFileSync(config));
}
for (const dependency of target.dependencies) hash.update(sourceFingerprint(buildTargets[dependency]));
return hash.digest("hex");
}
function outputFingerprint(target) {
return directoryFingerprint(path.dirname(target.output), target.completion);
}
function needsBuild(target) {
if (!fs.existsSync(target.output)) return true;
try {
const completed = JSON.parse(fs.readFileSync(target.completion, "utf8"));
// Content fingerprints detect partial direct builds even on filesystems
// with coarse timestamps, while identical successful direct builds reuse
// the certified output without another compile.
return completed.sources !== sourceFingerprint(target)
|| completed.outputs !== outputFingerprint(target);
} catch (error) {
if (error.code === "ENOENT" || error instanceof SyntaxError) return true;
throw error;
}
}
function allOutputsCurrent() {
return buildTargets.every((target) => !needsBuild(target));
}
// Publish an already-populated directory so another contender never mistakes a
// newly acquired lock for an abandoned, ownerless lock. Never recursively remove
// the shared path: another process may have acquired it since we last read it.
const ownerFile = `owner-${process.pid}-${randomUUID()}.json`;
let child = null;
let stoppingSignal = null;
let holdsLock = false;
function processAlive(pid) {
if (!Number.isInteger(pid) || pid <= 0) return true;
try {
process.kill(pid, 0);
return true;
} catch (error) {
return error.code !== "ESRCH";
}
}
function removeOwner(file) {
try {
fs.unlinkSync(path.join(lockDir, file));
} catch (error) {
if (error.code === "ENOENT") return;
throw error;
}
try {
fs.rmdirSync(lockDir);
} catch (error) {
if (!["ENOENT", "ENOTEMPTY", "EEXIST"].includes(error.code)) throw error;
}
}
function releaseLock() {
if (!holdsLock) return;
removeOwner(ownerFile);
holdsLock = false;
}
function recoverAbandonedLock() {
try {
const entries = fs.readdirSync(lockDir);
if (entries.length === 0) {
// Older versions wrote no owner. Allow their bounded CLI build to finish
// before reclaiming an empty directory left by interruption or timeout.
if (Date.now() - fs.statSync(lockDir).mtimeMs < 120_000) return;
fs.rmdirSync(lockDir);
} else if (entries.length === 1 && /^owner-.*\.json$/.test(entries[0])) {
const owner = JSON.parse(fs.readFileSync(path.join(lockDir, entries[0]), "utf8"));
if (processAlive(owner.pid) || (owner.childPid && processAlive(owner.childPid))) return;
removeOwner(entries[0]);
} else {
return;
}
console.log("[paperclip] Recovered abandoned workspace build lock.");
} catch (error) {
if (["ENOENT", "ENOTEMPTY", "EEXIST"].includes(error.code) || error instanceof SyntaxError) return;
throw error;
}
}
async function acquireLock() {
fs.mkdirSync(path.dirname(lockDir), { recursive: true });
const candidate = fs.mkdtempSync(`${lockDir}.candidate-`);
fs.writeFileSync(path.join(candidate, ownerFile), JSON.stringify({ pid: process.pid }));
const startedAt = Date.now();
let reportedWait = false;
try {
while (!stoppingSignal) {
// Do not replace a fresh empty lock held by an older script.
recoverAbandonedLock();
if (!fs.existsSync(lockDir)) {
try {
fs.renameSync(candidate, lockDir);
holdsLock = true;
return;
} catch (error) {
if (!["ENOTEMPTY", "EEXIST", "EPERM"].includes(error.code)) throw error;
}
}
if (!reportedWait) {
console.log(`[paperclip] Waiting for another workspace build (${lockDir})...`);
reportedWait = true;
}
if (Date.now() - startedAt >= lockTimeoutMs) {
throw new Error(`Timed out waiting for workspace build lock at ${lockDir}. Another build may still be running.`);
}
await sleep(lockPollMs);
}
} finally {
fs.rmSync(candidate, { recursive: true, force: true });
}
}
async function build(target) {
console.log(`[paperclip] Building ${target.name}...`);
// A hard kill bypasses cleanup. Only a completed compile may restore this
// marker, so recovery never trusts index.js emitted partway through a build.
fs.rmSync(target.completion, { force: true });
const sources = sourceFingerprint(target);
const code = await new Promise((resolve, reject) => {
child = spawn(process.execPath, [tscCliPath, "-p", target.tsconfig], {
cwd: rootDir,
stdio: "inherit",
});
// A hard-killed parent must not let a successor race its surviving compiler.
fs.writeFileSync(path.join(lockDir, ownerFile), JSON.stringify({ pid: process.pid, childPid: child.pid }));
child.once("error", (error) => {
fs.rmSync(target.output, { force: true });
reject(error);
});
child.once("close", (code) => {
child = null;
resolve(code ?? 1);
});
});
// tsc emits index.js before it finishes the package. A failed or interrupted
// compile must not make the next startup accept that partial build as current.
if (code !== 0) fs.rmSync(target.output, { force: true });
else fs.writeFileSync(target.completion, JSON.stringify({ sources, outputs: outputFingerprint(target) }) + "\n");
return code;
}
if (allOutputsCurrent() && !fs.existsSync(lockDir)) {
process.exit(0);
}
// Keep the lock until the compiler has stopped, including when the foreground
// CLI's build timeout terminates this helper.
for (const signal of ["SIGINT", "SIGTERM"]) {
process.on(signal, () => {
stoppingSignal = signal;
child?.kill(signal);
});
}
process.once("exit", releaseLock);
let exitCode = 0;
try {
await acquireLock();
if (holdsLock) {
for (const target of buildTargets) {
if (stoppingSignal) break;
if (!needsBuild(target)) continue;
exitCode = await build(target);
if (exitCode !== 0) break;
}
}
} finally {
releaseLock();
}
process.exitCode = stoppingSignal === "SIGINT" ? 130 : stoppingSignal ? 143 : exitCode;