The compiled gsd-core/bin/lib/*.cjs modules are gitignored build artifacts (ADR-457) shipped prebuilt in the npm tarball. A Claude Code plugin-marketplace / git-clone install materializes the repo tree directly and never runs `npm run build:lib`, so those ~148 files are absent and every CLI command dies at load with `Cannot find module './lib/cli-exit.cjs'`. gsd-tools.cjs now calls ensureRuntimeBuild() before its ./lib requires: a new committed bin helper that compiles the tree once on demand (lock-guarded, incremental-cache-cleared, portable `node <tsc>` invocation) when the sentinel cli-exit.cjs is missing, and surfaces an actionable error when TypeScript is unavailable. The already-built npm path is a single fs.existsSync no-op. ADR-457 (gitignored, build-at-publish) is preserved; no release-pipeline change. Verified against a simulated marketplace checkout (148 build outputs removed): the CLI self-heals and the result is byte-identical to `npm run build:lib`. Closes #2002 Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
247 lines
9.3 KiB
JavaScript
247 lines
9.3 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* Self-healing runtime build (#2002).
|
|
*
|
|
* The GSD runtime CLI (gsd-tools.cjs) require()s ~150 compiled modules from
|
|
* ./lib/*.cjs. Per ADR-457 ("build-at-publish") those are build artifacts:
|
|
* compiled from src/*.cts by `npm run build:lib` (tsc -p tsconfig.build.json),
|
|
* gitignored, and shipped prebuilt in the npm tarball via the prepack /
|
|
* prepublishOnly lifecycle scripts.
|
|
*
|
|
* The Claude Code plugin-marketplace channel does NOT go through `npm publish`
|
|
* or bin/install.js. Claude Code materializes the git tag tree into its plugin
|
|
* cache and at most runs `npm install --ignore-scripts`, so neither `prepare`
|
|
* nor `build:lib` ever fires. The compiled ./lib/*.cjs therefore never exist on
|
|
* that path and every CLI command dies at module load with
|
|
* `Cannot find module './lib/cli-exit.cjs'`.
|
|
*
|
|
* This module heals that: before gsd-tools.cjs require()s ./lib, if the compiled
|
|
* output is absent it invokes tsc once — lock-guarded so the many parallel
|
|
* gsd-tools shell-outs a workflow performs do not race the build — then lets the
|
|
* requires proceed. On the npm path the artifacts already exist, so the common
|
|
* case is a single fs.existsSync check and a no-op.
|
|
*
|
|
* Deliberately depends on nothing under ./lib (that tree is precisely what may
|
|
* be missing) — only on Node built-ins.
|
|
*/
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { spawnSync } = require('child_process');
|
|
|
|
// The first module gsd-tools.cjs require()s, and a gitignored build artifact
|
|
// (so it is absent on a raw git-tag checkout even though ~11 other bin/lib/*.cjs
|
|
// are git-tracked). tsconfig.build.json sets noEmitOnError:true, so tsc is
|
|
// all-or-nothing: if this file exists, the whole tree was emitted.
|
|
const SENTINEL = 'cli-exit.cjs';
|
|
|
|
/** Directory holding the compiled ./lib/*.cjs, relative to this file. */
|
|
function defaultLibDir() {
|
|
return path.join(__dirname, 'lib');
|
|
}
|
|
|
|
/**
|
|
* Package root (holds tsconfig.build.json + node_modules). This file lives at
|
|
* <root>/gsd-core/bin/ensure-runtime-build.cjs, so the root is two levels up —
|
|
* true for both the dev repo layout and the marketplace plugin-cache checkout
|
|
* (…/<version>/gsd-core/bin/ensure-runtime-build.cjs).
|
|
*/
|
|
function defaultPackageRoot() {
|
|
return path.resolve(__dirname, '..', '..');
|
|
}
|
|
|
|
/**
|
|
* Resolve the tsc entry SCRIPT (not the .bin/tsc shim). We run it as
|
|
* `node <tsc.js>` so behaviour is identical on POSIX and Windows and does not
|
|
* depend on a shell or on the .cmd shim. Returns null when TypeScript is not
|
|
* installed under packageRoot.
|
|
*/
|
|
function resolveTscScript(packageRoot) {
|
|
try {
|
|
return require.resolve('typescript/bin/tsc', { paths: [packageRoot] });
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function isBuilt(libDir) {
|
|
return fs.existsSync(path.join(libDir, SENTINEL));
|
|
}
|
|
|
|
/** Synchronous sleep with no dependencies, portable across platforms. */
|
|
function sleepSync(ms) {
|
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, Math.max(0, ms));
|
|
}
|
|
|
|
/**
|
|
* Raised when the runtime library is missing and cannot be auto-built. The
|
|
* message is user-facing and actionable; callers print `.message` (not a stack).
|
|
*/
|
|
class RuntimeBuildError extends Error {
|
|
constructor(message) {
|
|
super(message);
|
|
this.name = 'RuntimeBuildError';
|
|
this.code = 'GSD_RUNTIME_BUILD_FAILED';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Ensure the compiled ./lib/*.cjs exist, building them once if absent.
|
|
*
|
|
* Idempotent and safe to call concurrently from many processes. When another
|
|
* process holds the build lock, this one waits for the build to finish rather
|
|
* than launching a competing tsc.
|
|
*
|
|
* @param {object} [opts]
|
|
* @param {string} [opts.libDir] Override the compiled-output directory.
|
|
* @param {string} [opts.packageRoot] Override the tsconfig/node_modules root.
|
|
* @param {string|null} [opts.tscScript] Override tsc resolution (null = "absent").
|
|
* @param {function} [opts.spawn] Override spawnSync (for tests).
|
|
* @param {function} [opts.log] Override the progress logger.
|
|
* @param {number} [opts.waitTimeoutMs] Max wait for a peer build (default 120s).
|
|
* @param {number} [opts.pollMs] Poll interval while waiting (default 100ms).
|
|
* @param {function} [opts.onPoll] Test hook invoked before each wait-poll.
|
|
* @returns {{built: true, healed: boolean, waited?: boolean}}
|
|
* @throws {RuntimeBuildError} when the library is absent and cannot be built.
|
|
*/
|
|
function ensureRuntimeBuild(opts = {}) {
|
|
const libDir = opts.libDir || defaultLibDir();
|
|
const packageRoot = opts.packageRoot || defaultPackageRoot();
|
|
const spawn = opts.spawn || spawnSync;
|
|
const log = opts.log || ((m) => process.stderr.write(m + '\n'));
|
|
const waitTimeoutMs = opts.waitTimeoutMs != null ? opts.waitTimeoutMs : 120000;
|
|
const pollMs = opts.pollMs != null ? opts.pollMs : 100;
|
|
|
|
// Fast path: already built (the npm-registry install and every subsequent run).
|
|
if (isBuilt(libDir)) return { built: true, healed: false };
|
|
|
|
const tsconfig = path.join(packageRoot, 'tsconfig.build.json');
|
|
if (!fs.existsSync(tsconfig)) {
|
|
throw new RuntimeBuildError(
|
|
'GSD runtime library is not built and cannot be auto-built: ' +
|
|
`${tsconfig} not found. Run \`npm run build:lib\` in the gsd-core package.`,
|
|
);
|
|
}
|
|
|
|
const tscScript =
|
|
opts.tscScript !== undefined ? opts.tscScript : resolveTscScript(packageRoot);
|
|
if (!tscScript) {
|
|
throw new RuntimeBuildError(
|
|
`GSD runtime library is not built (missing ${path.join(libDir, SENTINEL)}) ` +
|
|
'and TypeScript is unavailable to build it. Run ' +
|
|
`\`npm install && npm run build:lib\` in the gsd-core package (${packageRoot}).`,
|
|
);
|
|
}
|
|
|
|
// libDir may not exist yet on a totally fresh tree; create it so the lock and
|
|
// tsc output have a home.
|
|
fs.mkdirSync(libDir, { recursive: true });
|
|
const lockDir = path.join(libDir, '.build.lock');
|
|
|
|
let haveLock = acquireLock(lockDir);
|
|
if (!haveLock) {
|
|
// A peer process is building. Wait for the sentinel rather than racing tsc.
|
|
const deadline = Date.now() + waitTimeoutMs;
|
|
while (!isBuilt(libDir)) {
|
|
if (typeof opts.onPoll === 'function') opts.onPoll();
|
|
if (isBuilt(libDir)) break;
|
|
if (Date.now() > deadline) break; // peer wedged/crashed — take over below
|
|
sleepSync(pollMs);
|
|
}
|
|
if (isBuilt(libDir)) return { built: true, healed: true, waited: true };
|
|
// Peer never finished; try to take over the (stale) lock.
|
|
haveLock = acquireLock(lockDir);
|
|
if (!haveLock && !isBuilt(libDir)) {
|
|
throw new RuntimeBuildError(
|
|
`GSD runtime build did not complete within ${waitTimeoutMs}ms and the ` +
|
|
`build lock (${lockDir}) is held by another process. Remove it and run ` +
|
|
'`npm run build:lib` if this persists.',
|
|
);
|
|
}
|
|
if (isBuilt(libDir)) return { built: true, healed: true, waited: true };
|
|
}
|
|
|
|
try {
|
|
log('gsd: runtime library not built — compiling once (tsc -p tsconfig.build.json)…');
|
|
// Heal only runs when the build is genuinely absent/broken, so force a full
|
|
// emit: a stale incremental cache from a partial build would make tsc report
|
|
// success without re-emitting the (still-missing) sentinel.
|
|
forceFullEmit(packageRoot, tsconfig);
|
|
const res = spawn(process.execPath, [tscScript, '-p', tsconfig], {
|
|
cwd: packageRoot,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
encoding: 'utf8',
|
|
});
|
|
if (!res || res.status !== 0) {
|
|
const detail = ((res && (res.stderr || res.stdout)) || '').toString().trim();
|
|
throw new RuntimeBuildError(
|
|
`GSD runtime build failed (tsc exit ${res ? res.status : 'unknown'}). ` +
|
|
`Run \`npm run build:lib\` in ${packageRoot} to see the error.` +
|
|
(detail ? `\n${detail}` : ''),
|
|
);
|
|
}
|
|
} finally {
|
|
releaseLock(lockDir);
|
|
}
|
|
|
|
if (!isBuilt(libDir)) {
|
|
throw new RuntimeBuildError(
|
|
`GSD runtime build ran but ${path.join(libDir, SENTINEL)} is still missing. ` +
|
|
`Run \`npm run build:lib\` in ${packageRoot}.`,
|
|
);
|
|
}
|
|
return { built: true, healed: true };
|
|
}
|
|
|
|
/**
|
|
* Delete tsc's incremental build cache so the heal re-emits every module. The
|
|
* cache path defaults to tsconfig.build.tsbuildinfo (per tsconfig.build.json)
|
|
* but is read from the config when overridden. Best-effort: a missing or
|
|
* unreadable config just falls back to the default and a missing cache is fine.
|
|
*/
|
|
function forceFullEmit(packageRoot, tsconfigPath) {
|
|
let rel = 'tsconfig.build.tsbuildinfo';
|
|
try {
|
|
const cfg = JSON.parse(fs.readFileSync(tsconfigPath, 'utf8'));
|
|
if (cfg && cfg.compilerOptions && cfg.compilerOptions.tsBuildInfoFile) {
|
|
rel = cfg.compilerOptions.tsBuildInfoFile;
|
|
}
|
|
} catch {
|
|
/* unreadable/JSONC config — use the default cache path */
|
|
}
|
|
try {
|
|
fs.rmSync(path.resolve(packageRoot, rel), { force: true });
|
|
} catch {
|
|
/* best effort */
|
|
}
|
|
}
|
|
|
|
/** Atomically acquire the build lock. Returns true on success. */
|
|
function acquireLock(lockDir) {
|
|
try {
|
|
fs.mkdirSync(lockDir); // atomic — throws EEXIST if a peer holds it
|
|
return true;
|
|
} catch (e) {
|
|
if (e && e.code === 'EEXIST') return false;
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
/** Best-effort lock release. */
|
|
function releaseLock(lockDir) {
|
|
try {
|
|
fs.rmSync(lockDir, { recursive: true, force: true });
|
|
} catch {
|
|
/* best effort — a leftover lock is recovered by the wait-then-takeover path */
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
ensureRuntimeBuild,
|
|
resolveTscScript,
|
|
isBuilt,
|
|
RuntimeBuildError,
|
|
SENTINEL,
|
|
};
|