249 lines
8.5 KiB
JavaScript
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;
|