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>
This commit is contained in:
6
.changeset/2002-cli-self-healing-runtime-build.md
Normal file
6
.changeset/2002-cli-self-healing-runtime-build.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
type: Changed
|
||||
pr: 2036
|
||||
---
|
||||
|
||||
**The GSD CLI now self-heals a missing runtime build.** The compiled `gsd-core/bin/lib/*.cjs` modules are gitignored build artifacts (ADR-457) that ship prebuilt in the npm tarball but are absent on a Claude Code plugin-marketplace / git-clone install, which never runs `npm run build:lib`. Previously every command died at load with `Cannot find module './lib/cli-exit.cjs'`. The `gsd-tools` entrypoint now detects the missing output and compiles it once, on demand (lock-guarded so parallel invocations don't race), then proceeds — a single no-op check on the already-built npm path. When TypeScript is genuinely unavailable it prints an actionable `npm install && npm run build:lib` message instead of crashing.
|
||||
@@ -102,6 +102,8 @@ The `gsd-tools` binary (installed as part of the `@opengsd/gsd-core` npm package
|
||||
|
||||
Node.js (`node`) must also be available on your `PATH`. The plugin's always-on guard hooks (wired in `hooks/hooks.json`) are invoked as `node "${CLAUDE_PLUGIN_ROOT}/hooks/<script>"`. Some Claude Code distributions ship as a standalone binary and do not expose a `node` executable on `PATH`; in those environments the plugin's hooks will not run. Verify with `node --version` before relying on the plugin hooks.
|
||||
|
||||
**Runtime build (self-healing).** The runtime CLI's compiled modules under `gsd-core/bin/lib/*.cjs` are build artifacts (ADR-457): they are compiled from `src/*.cts` by `npm run build:lib` and shipped prebuilt in the npm tarball. A plugin-marketplace or git-clone install materializes the repository tree directly and never runs that build step, so those files are initially absent. The CLI heals this automatically: the first `gsd-tools` invocation detects the missing output and compiles it once (using the bundled `typescript` devDependency), then proceeds normally. You may see a one-time `gsd: runtime library not built — compiling once…` notice on stderr; subsequent commands are unaffected. If auto-build cannot run (for example `node_modules` was pruned to production-only and `typescript` is unavailable), the CLI prints an actionable message telling you to run `npm install && npm run build:lib` in the plugin directory.
|
||||
|
||||
#### Claude plugin marketplace discovery (ZCODE and compatible runtimes)
|
||||
|
||||
GSD Core also ships a `.claude-plugin/marketplace.json` marketplace manifest (sibling to `plugin.json`). Runtimes that implement the Claude plugin marketplace contract — such as ZCODE — can discover and install GSD Core from a custom marketplace source without a manual clone:
|
||||
|
||||
246
gsd-core/bin/ensure-runtime-build.cjs
Normal file
246
gsd-core/bin/ensure-runtime-build.cjs
Normal file
@@ -0,0 +1,246 @@
|
||||
'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,
|
||||
};
|
||||
@@ -195,6 +195,24 @@
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// #2002 — self-healing runtime build. The compiled ./lib/*.cjs modules this
|
||||
// entrypoint require()s below are gitignored build artifacts (ADR-457), shipped
|
||||
// prebuilt in the npm tarball. The Claude Code plugin-marketplace channel never
|
||||
// runs `npm run build:lib` or bin/install.js, so on that path they can be
|
||||
// absent and every command dies at module load. Compile them once (lock-guarded,
|
||||
// idempotent, a no-op when already built) before the ./lib requires run.
|
||||
const { ensureRuntimeBuild } = require('./ensure-runtime-build.cjs');
|
||||
try {
|
||||
ensureRuntimeBuild();
|
||||
} catch (bootErr) {
|
||||
process.stderr.write((bootErr && bootErr.message ? bootErr.message : String(bootErr)) + '\n');
|
||||
// Fatal bootstrap failure before the CLI's ExitError/runMain machinery (which
|
||||
// lives in ./lib) is available to load, so a direct exit is the only option.
|
||||
// eslint-disable-next-line n/no-process-exit
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
|
||||
const io = require('./lib/io.cjs');
|
||||
const { error, ERROR_REASON, setJsonErrorMode, output } = io;
|
||||
|
||||
340
tests/ensure-runtime-build.test.cjs
Normal file
340
tests/ensure-runtime-build.test.cjs
Normal file
@@ -0,0 +1,340 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Tests for gsd-core/bin/ensure-runtime-build.cjs — the self-healing runtime
|
||||
* build that compiles the gitignored ./lib/*.cjs artifacts on demand when the
|
||||
* CLI is run from a channel (Claude Code plugin marketplace) that never ran
|
||||
* `npm run build:lib`. See #2002.
|
||||
*
|
||||
* The unit tests inject `spawn` so no real tsc runs; the final integration test
|
||||
* drives the REAL module against a real (tiny) tsc build in a temp package to
|
||||
* prove the end-to-end heal. IO failures are injected by monkeypatching the fs
|
||||
* method (root-independent, OS-independent), never via chmod.
|
||||
*/
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
|
||||
const {
|
||||
ensureRuntimeBuild,
|
||||
resolveTscScript,
|
||||
isBuilt,
|
||||
RuntimeBuildError,
|
||||
SENTINEL,
|
||||
} = require('../gsd-core/bin/ensure-runtime-build.cjs');
|
||||
|
||||
const REPO_ROOT = path.resolve(__dirname, '..');
|
||||
|
||||
/** Fresh isolated package dir (auto-cleaned) with a libDir and optional tsconfig. */
|
||||
function makeEnv(t, { withTsconfig = true, prebuilt = false } = {}) {
|
||||
const dir = createTempDir('gsd-erb-');
|
||||
t.after(() => cleanup(dir));
|
||||
const libDir = path.join(dir, 'gsd-core', 'bin', 'lib');
|
||||
fs.mkdirSync(libDir, { recursive: true });
|
||||
if (withTsconfig) fs.writeFileSync(path.join(dir, 'tsconfig.build.json'), '{}');
|
||||
if (prebuilt) fs.writeFileSync(path.join(libDir, SENTINEL), '// built\n');
|
||||
return { dir, packageRoot: dir, libDir };
|
||||
}
|
||||
|
||||
/** A spawn stub that "builds" by writing the sentinel, then reports success. */
|
||||
function buildingSpawn(libDir, calls) {
|
||||
return (cmd, args, options) => {
|
||||
calls.push({ cmd, args, options });
|
||||
fs.writeFileSync(path.join(libDir, SENTINEL), '// built by stub\n');
|
||||
return { status: 0, stdout: '', stderr: '' };
|
||||
};
|
||||
}
|
||||
|
||||
const silent = () => {};
|
||||
|
||||
test('fast path: already-built lib is a no-op, never spawns tsc', (t) => {
|
||||
const { libDir, packageRoot } = makeEnv(t, { prebuilt: true });
|
||||
const calls = [];
|
||||
const res = ensureRuntimeBuild({
|
||||
libDir,
|
||||
packageRoot,
|
||||
spawn: (...a) => { calls.push(a); return { status: 0 }; },
|
||||
log: silent,
|
||||
});
|
||||
assert.deepEqual(res, { built: true, healed: false });
|
||||
assert.equal(calls.length, 0, 'must not spawn when already built');
|
||||
});
|
||||
|
||||
test('heal path: missing lib is compiled once with the correct tsc invocation', (t) => {
|
||||
const { libDir, packageRoot } = makeEnv(t);
|
||||
const calls = [];
|
||||
const res = ensureRuntimeBuild({
|
||||
libDir,
|
||||
packageRoot,
|
||||
tscScript: '/fake/typescript/bin/tsc',
|
||||
spawn: buildingSpawn(libDir, calls),
|
||||
log: silent,
|
||||
});
|
||||
assert.equal(res.built, true);
|
||||
assert.equal(res.healed, true);
|
||||
assert.equal(calls.length, 1, 'compiles exactly once');
|
||||
const { cmd, args, options } = calls[0];
|
||||
assert.equal(cmd, process.execPath, 'runs `node <tsc>` (portable, no shell/.cmd)');
|
||||
assert.deepEqual(args, [
|
||||
'/fake/typescript/bin/tsc',
|
||||
'-p',
|
||||
path.join(packageRoot, 'tsconfig.build.json'),
|
||||
]);
|
||||
assert.equal(options.cwd, packageRoot);
|
||||
assert.ok(isBuilt(libDir), 'sentinel exists after heal');
|
||||
});
|
||||
|
||||
test('missing tsconfig.build.json throws an actionable RuntimeBuildError', (t) => {
|
||||
const { libDir, packageRoot } = makeEnv(t, { withTsconfig: false });
|
||||
assert.throws(
|
||||
() => ensureRuntimeBuild({ libDir, packageRoot, tscScript: '/fake/tsc', log: silent }),
|
||||
(err) => {
|
||||
assert.ok(err instanceof RuntimeBuildError);
|
||||
assert.match(err.message, /tsconfig\.build\.json not found/);
|
||||
assert.match(err.message, /npm run build:lib/);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('absent TypeScript throws with install guidance and does not spawn', (t) => {
|
||||
const { libDir, packageRoot } = makeEnv(t);
|
||||
const calls = [];
|
||||
assert.throws(
|
||||
() => ensureRuntimeBuild({
|
||||
libDir,
|
||||
packageRoot,
|
||||
tscScript: null, // TypeScript not resolvable
|
||||
spawn: (...a) => { calls.push(a); return { status: 0 }; },
|
||||
log: silent,
|
||||
}),
|
||||
(err) => {
|
||||
assert.ok(err instanceof RuntimeBuildError);
|
||||
assert.match(err.message, /TypeScript is unavailable/);
|
||||
assert.match(err.message, /npm install && npm run build:lib/);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
assert.equal(calls.length, 0, 'never spawns when tsc is absent');
|
||||
});
|
||||
|
||||
test('non-zero tsc exit surfaces the exit code and captured output', (t) => {
|
||||
const { libDir, packageRoot } = makeEnv(t);
|
||||
assert.throws(
|
||||
() => ensureRuntimeBuild({
|
||||
libDir,
|
||||
packageRoot,
|
||||
tscScript: '/fake/tsc',
|
||||
spawn: () => ({ status: 2, stdout: '', stderr: 'src/x.cts(1,1): error TS1005' }),
|
||||
log: silent,
|
||||
}),
|
||||
(err) => {
|
||||
assert.ok(err instanceof RuntimeBuildError);
|
||||
assert.match(err.message, /tsc exit 2/);
|
||||
assert.match(err.message, /error TS1005/);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('tsc reports success but sentinel is still missing → throws', (t) => {
|
||||
const { libDir, packageRoot } = makeEnv(t);
|
||||
assert.throws(
|
||||
() => ensureRuntimeBuild({
|
||||
libDir,
|
||||
packageRoot,
|
||||
tscScript: '/fake/tsc',
|
||||
spawn: () => ({ status: 0 }), // "succeeds" but writes nothing
|
||||
log: silent,
|
||||
}),
|
||||
(err) => {
|
||||
assert.ok(err instanceof RuntimeBuildError);
|
||||
assert.match(err.message, /still missing/);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('the build lock releases after a successful heal', (t) => {
|
||||
const { libDir, packageRoot } = makeEnv(t);
|
||||
ensureRuntimeBuild({
|
||||
libDir,
|
||||
packageRoot,
|
||||
tscScript: '/fake/tsc',
|
||||
spawn: buildingSpawn(libDir, []),
|
||||
log: silent,
|
||||
});
|
||||
assert.ok(!fs.existsSync(path.join(libDir, '.build.lock')), 'lock removed in finally');
|
||||
});
|
||||
|
||||
test('lock contention: waits for a peer build instead of racing tsc', (t) => {
|
||||
const { libDir, packageRoot } = makeEnv(t);
|
||||
fs.mkdirSync(path.join(libDir, '.build.lock')); // a peer "holds" the lock
|
||||
const calls = [];
|
||||
const res = ensureRuntimeBuild({
|
||||
libDir,
|
||||
packageRoot,
|
||||
tscScript: '/fake/tsc',
|
||||
spawn: (...a) => { calls.push(a); return { status: 0 }; },
|
||||
log: silent,
|
||||
pollMs: 1,
|
||||
waitTimeoutMs: 1000,
|
||||
// Peer finishes the build between polls.
|
||||
onPoll: () => fs.writeFileSync(path.join(libDir, SENTINEL), '// peer built\n'),
|
||||
});
|
||||
assert.equal(res.healed, true);
|
||||
assert.equal(res.waited, true);
|
||||
assert.equal(calls.length, 0, 'never launches a competing tsc');
|
||||
});
|
||||
|
||||
test('lock takeover: a crashed peer (stale lock) is taken over and the build runs', (t) => {
|
||||
const { libDir, packageRoot } = makeEnv(t);
|
||||
const lockDir = path.join(libDir, '.build.lock');
|
||||
fs.mkdirSync(lockDir);
|
||||
const calls = [];
|
||||
const res = ensureRuntimeBuild({
|
||||
libDir,
|
||||
packageRoot,
|
||||
tscScript: '/fake/tsc',
|
||||
spawn: buildingSpawn(libDir, calls),
|
||||
log: silent,
|
||||
pollMs: 1,
|
||||
waitTimeoutMs: 0, // give up waiting immediately
|
||||
// Peer vanished without finishing — free the stale lock so we can take over.
|
||||
// eslint-disable-next-line local/no-raw-rmsync-in-tests -- simulating a peer releasing its lock, not temp-dir teardown
|
||||
onPoll: () => fs.rmSync(lockDir, { recursive: true, force: true }),
|
||||
});
|
||||
assert.equal(res.healed, true);
|
||||
assert.equal(calls.length, 1, 'takes over and builds once');
|
||||
});
|
||||
|
||||
test('lock never frees before timeout → throws a lock-held RuntimeBuildError', (t) => {
|
||||
const { libDir, packageRoot } = makeEnv(t);
|
||||
fs.mkdirSync(path.join(libDir, '.build.lock')); // stuck peer, never released
|
||||
assert.throws(
|
||||
() => ensureRuntimeBuild({
|
||||
libDir,
|
||||
packageRoot,
|
||||
tscScript: '/fake/tsc',
|
||||
spawn: buildingSpawn(libDir, []),
|
||||
log: silent,
|
||||
pollMs: 1,
|
||||
waitTimeoutMs: 0,
|
||||
}),
|
||||
(err) => {
|
||||
assert.ok(err instanceof RuntimeBuildError);
|
||||
assert.match(err.message, /did not complete within 0ms/);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('IO fault on lock acquisition propagates (injected via fs monkeypatch, not chmod)', (t) => {
|
||||
const { libDir, packageRoot } = makeEnv(t);
|
||||
const lockDir = path.join(libDir, '.build.lock');
|
||||
const orig = fs.mkdirSync;
|
||||
fs.mkdirSync = (target, opts) => {
|
||||
if (target === lockDir) {
|
||||
const e = new Error('injected EPERM');
|
||||
e.code = 'EPERM';
|
||||
throw e;
|
||||
}
|
||||
return orig(target, opts);
|
||||
};
|
||||
try {
|
||||
assert.throws(
|
||||
() => ensureRuntimeBuild({
|
||||
libDir,
|
||||
packageRoot,
|
||||
tscScript: '/fake/tsc',
|
||||
spawn: buildingSpawn(libDir, []),
|
||||
log: silent,
|
||||
}),
|
||||
(err) => {
|
||||
// Non-EEXIST lock errors must NOT be swallowed as "peer holds lock".
|
||||
assert.equal(err.code, 'EPERM');
|
||||
return true;
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
fs.mkdirSync = orig;
|
||||
}
|
||||
});
|
||||
|
||||
test('heal clears a stale tsc incremental cache before compiling (forces full emit)', (t) => {
|
||||
const { libDir, packageRoot } = makeEnv(t);
|
||||
const buildInfo = path.join(packageRoot, 'tsconfig.build.tsbuildinfo');
|
||||
fs.writeFileSync(buildInfo, '{"stale":true}'); // leftover from a partial build
|
||||
ensureRuntimeBuild({
|
||||
libDir,
|
||||
packageRoot,
|
||||
tscScript: '/fake/tsc',
|
||||
spawn: buildingSpawn(libDir, []),
|
||||
log: silent,
|
||||
});
|
||||
assert.ok(!fs.existsSync(buildInfo), 'stale .tsbuildinfo removed so tsc re-emits');
|
||||
});
|
||||
|
||||
test('resolveTscScript finds the real TypeScript under the repo root', () => {
|
||||
const tsc = resolveTscScript(REPO_ROOT);
|
||||
assert.ok(tsc && tsc.includes('typescript'), 'resolves the installed typescript/bin/tsc');
|
||||
assert.ok(fs.existsSync(tsc), 'resolved tsc script exists on disk');
|
||||
});
|
||||
|
||||
test('integration: real tsc heals a missing lib end-to-end (subprocess)', (t) => {
|
||||
// Build a tiny package that mirrors the real layout: a src/*.cts that tsc
|
||||
// compiles to gsd-core/bin/lib/cli-exit.cjs. Then invoke the REAL module from
|
||||
// a subprocess with lib absent and assert it self-heals and the require works.
|
||||
const dir = createTempDir('gsd-erb-int-');
|
||||
t.after(() => cleanup(dir));
|
||||
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
|
||||
fs.mkdirSync(path.join(dir, 'gsd-core', 'bin', 'lib'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'tsconfig.build.json'),
|
||||
JSON.stringify({
|
||||
compilerOptions: {
|
||||
rootDir: 'src',
|
||||
outDir: 'gsd-core/bin/lib',
|
||||
module: 'nodenext',
|
||||
moduleResolution: 'nodenext',
|
||||
target: 'ES2022',
|
||||
noEmitOnError: true,
|
||||
},
|
||||
include: ['src/**/*.cts'],
|
||||
}),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'src', 'cli-exit.cts'),
|
||||
'export const runMain = (): string => "ok";\n',
|
||||
);
|
||||
|
||||
// Resolve the REAL tsc and pass it explicitly, rather than symlinking
|
||||
// node_modules into the temp package — directory-symlink creation is a
|
||||
// privileged operation on Windows and would fail on CI runners. resolveTscScript
|
||||
// itself is covered by its own unit test; here we just need a real tsc to run.
|
||||
const realTsc = resolveTscScript(REPO_ROOT);
|
||||
assert.ok(realTsc, 'real typescript resolvable for the integration build');
|
||||
const driver = path.join(dir, 'driver.cjs');
|
||||
fs.writeFileSync(
|
||||
driver,
|
||||
`const { ensureRuntimeBuild } = require(${JSON.stringify(
|
||||
path.join(REPO_ROOT, 'gsd-core', 'bin', 'ensure-runtime-build.cjs'),
|
||||
)});\n` +
|
||||
`const libDir = ${JSON.stringify(path.join(dir, 'gsd-core', 'bin', 'lib'))};\n` +
|
||||
`const packageRoot = ${JSON.stringify(dir)};\n` +
|
||||
`const tscScript = ${JSON.stringify(realTsc)};\n` +
|
||||
'const r = ensureRuntimeBuild({ libDir, packageRoot, tscScript, log: () => {} });\n' +
|
||||
'const { runMain } = require(require("path").join(libDir, "cli-exit.cjs"));\n' +
|
||||
'process.stdout.write(JSON.stringify({ healed: r.healed, run: runMain() }));\n',
|
||||
);
|
||||
|
||||
assert.ok(!isBuilt(path.join(dir, 'gsd-core', 'bin', 'lib')), 'lib starts empty');
|
||||
const out = execFileSync(process.execPath, [driver], { encoding: 'utf8', cwd: dir });
|
||||
const parsed = JSON.parse(out);
|
||||
assert.equal(parsed.healed, true, 'self-healed the missing build');
|
||||
assert.equal(parsed.run, 'ok', 'the freshly-compiled cli-exit.cjs loads and runs');
|
||||
});
|
||||
@@ -37,7 +37,8 @@
|
||||
"agents/gsd-verifier.md": "224e8df2d8fd1d95",
|
||||
"gsd-core/VERSION": "ef0deccd81a6723c",
|
||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||
"gsd-core/bin/gsd-tools.cjs": "e839e52071d5baa1",
|
||||
"gsd-core/bin/ensure-runtime-build.cjs": "ea841e2865248e74",
|
||||
"gsd-core/bin/gsd-tools.cjs": "747defe620cb8ae6",
|
||||
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json": "c55b99e5d82f69fb",
|
||||
|
||||
@@ -107,7 +107,8 @@
|
||||
"commands/gsd-workstreams.md": "52ab9c585d3a00f3",
|
||||
"gsd-core/VERSION": "ef0deccd81a6723c",
|
||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||
"gsd-core/bin/gsd-tools.cjs": "e504d3b82da834ae",
|
||||
"gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62",
|
||||
"gsd-core/bin/gsd-tools.cjs": "770e0b1c1f08ef47",
|
||||
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json": "c55b99e5d82f69fb",
|
||||
|
||||
@@ -36,7 +36,8 @@
|
||||
"agents/gsd-verifier.md": "2271174b5aa20e31",
|
||||
"gsd-core/VERSION": "ef0deccd81a6723c",
|
||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||
"gsd-core/bin/gsd-tools.cjs": "e504d3b82da834ae",
|
||||
"gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62",
|
||||
"gsd-core/bin/gsd-tools.cjs": "770e0b1c1f08ef47",
|
||||
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json": "c55b99e5d82f69fb",
|
||||
|
||||
@@ -40,7 +40,8 @@
|
||||
"agents/gsd-verifier.md": "75912e9cadd83eb3",
|
||||
"gsd-core/VERSION": "ef0deccd81a6723c",
|
||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||
"gsd-core/bin/gsd-tools.cjs": "e504d3b82da834ae",
|
||||
"gsd-core/bin/ensure-runtime-build.cjs": "476aa24e8c4f03cf",
|
||||
"gsd-core/bin/gsd-tools.cjs": "13640341aa311ae3",
|
||||
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json": "c55b99e5d82f69fb",
|
||||
|
||||
@@ -107,7 +107,8 @@
|
||||
"commands/gsd-workstreams.md": "884b6c8d648422c7",
|
||||
"gsd-core/VERSION": "ef0deccd81a6723c",
|
||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||
"gsd-core/bin/gsd-tools.cjs": "e504d3b82da834ae",
|
||||
"gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62",
|
||||
"gsd-core/bin/gsd-tools.cjs": "770e0b1c1f08ef47",
|
||||
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json": "c55b99e5d82f69fb",
|
||||
|
||||
@@ -72,7 +72,8 @@
|
||||
"config.toml": "a34316b9ac61a620",
|
||||
"gsd-core/VERSION": "ef0deccd81a6723c",
|
||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||
"gsd-core/bin/gsd-tools.cjs": "e504d3b82da834ae",
|
||||
"gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62",
|
||||
"gsd-core/bin/gsd-tools.cjs": "770e0b1c1f08ef47",
|
||||
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json": "c55b99e5d82f69fb",
|
||||
|
||||
@@ -38,7 +38,8 @@
|
||||
"copilot-instructions.md": "1fb04111759f1645",
|
||||
"gsd-core/VERSION": "ef0deccd81a6723c",
|
||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||
"gsd-core/bin/gsd-tools.cjs": "e839e52071d5baa1",
|
||||
"gsd-core/bin/ensure-runtime-build.cjs": "ea841e2865248e74",
|
||||
"gsd-core/bin/gsd-tools.cjs": "747defe620cb8ae6",
|
||||
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json": "c55b99e5d82f69fb",
|
||||
|
||||
@@ -107,7 +107,8 @@
|
||||
"commands/gsd-workstreams.md": "112660ceb663c750",
|
||||
"gsd-core/VERSION": "ef0deccd81a6723c",
|
||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||
"gsd-core/bin/gsd-tools.cjs": "e839e52071d5baa1",
|
||||
"gsd-core/bin/ensure-runtime-build.cjs": "2525f1ae8b086828",
|
||||
"gsd-core/bin/gsd-tools.cjs": "887e2cb9ebdb609d",
|
||||
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json": "c55b99e5d82f69fb",
|
||||
|
||||
@@ -37,7 +37,8 @@
|
||||
"agents/gsd-verifier.md": "82d3e9015ed04017",
|
||||
"gsd-core/VERSION": "ef0deccd81a6723c",
|
||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||
"gsd-core/bin/gsd-tools.cjs": "e504d3b82da834ae",
|
||||
"gsd-core/bin/ensure-runtime-build.cjs": "3a3409215044af9f",
|
||||
"gsd-core/bin/gsd-tools.cjs": "cbdd5da1b973c6ae",
|
||||
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json": "c55b99e5d82f69fb",
|
||||
|
||||
@@ -107,7 +107,8 @@
|
||||
"command/gsd-workstreams.md": "5e57eed1881c3891",
|
||||
"gsd-core/VERSION": "ef0deccd81a6723c",
|
||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||
"gsd-core/bin/gsd-tools.cjs": "e504d3b82da834ae",
|
||||
"gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62",
|
||||
"gsd-core/bin/gsd-tools.cjs": "770e0b1c1f08ef47",
|
||||
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json": "c55b99e5d82f69fb",
|
||||
|
||||
@@ -73,7 +73,8 @@
|
||||
"agents/subagents/gsd-verifier.yaml": "2d2bd6b37626f382",
|
||||
"gsd-core/VERSION": "ef0deccd81a6723c",
|
||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||
"gsd-core/bin/gsd-tools.cjs": "e504d3b82da834ae",
|
||||
"gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62",
|
||||
"gsd-core/bin/gsd-tools.cjs": "770e0b1c1f08ef47",
|
||||
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json": "c55b99e5d82f69fb",
|
||||
|
||||
@@ -107,7 +107,8 @@
|
||||
"command/gsd-workstreams.md": "5e57eed1881c3891",
|
||||
"gsd-core/VERSION": "ef0deccd81a6723c",
|
||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||
"gsd-core/bin/gsd-tools.cjs": "e504d3b82da834ae",
|
||||
"gsd-core/bin/ensure-runtime-build.cjs": "51bc64467ab30f62",
|
||||
"gsd-core/bin/gsd-tools.cjs": "770e0b1c1f08ef47",
|
||||
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json": "c55b99e5d82f69fb",
|
||||
|
||||
@@ -37,7 +37,8 @@
|
||||
"agents/gsd-verifier.md": "45c450b6d7cc5f0d",
|
||||
"gsd-core/VERSION": "ef0deccd81a6723c",
|
||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||
"gsd-core/bin/gsd-tools.cjs": "e504d3b82da834ae",
|
||||
"gsd-core/bin/ensure-runtime-build.cjs": "6e98d76e955e35a2",
|
||||
"gsd-core/bin/gsd-tools.cjs": "1118403709188168",
|
||||
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json": "c55b99e5d82f69fb",
|
||||
|
||||
@@ -37,7 +37,8 @@
|
||||
"agents/gsd-verifier.md": "99e95e235aacffbf",
|
||||
"gsd-core/VERSION": "ef0deccd81a6723c",
|
||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||
"gsd-core/bin/gsd-tools.cjs": "e839e52071d5baa1",
|
||||
"gsd-core/bin/ensure-runtime-build.cjs": "de4627dff103d527",
|
||||
"gsd-core/bin/gsd-tools.cjs": "fa4db2ce1f77ef99",
|
||||
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json": "c55b99e5d82f69fb",
|
||||
|
||||
@@ -37,7 +37,8 @@
|
||||
"agents/gsd-verifier.md": "a07b00b9c5b7b338",
|
||||
"gsd-core/VERSION": "ef0deccd81a6723c",
|
||||
"gsd-core/bin/check-latest-version.cjs": "e4a224058c8f4d74",
|
||||
"gsd-core/bin/gsd-tools.cjs": "e839e52071d5baa1",
|
||||
"gsd-core/bin/ensure-runtime-build.cjs": "5636ca0b726871b2",
|
||||
"gsd-core/bin/gsd-tools.cjs": "92e0f7b7eb55af51",
|
||||
"gsd-core/bin/gsd_run": "62d9b647ede212e6",
|
||||
"gsd-core/bin/shared/config-defaults.manifest.json": "517e6a7c1e9f4f16",
|
||||
"gsd-core/bin/shared/config-schema.manifest.json": "c55b99e5d82f69fb",
|
||||
|
||||
Reference in New Issue
Block a user