Files
get-shit-done/scripts/build-hooks.js
Tom Boucher c4d3fe62a5 fix(install): require persistent SDK reachability before reporting ready (#3231) (#3249)
* test: reproduce false GSD SDK ready signals on Linux (#3231)

* fix(install): require persistent SDK reachability before reporting ready (#3231)

* changeset: pr=3249 for #3231

* fix(install): filter _npx from login-shell PATH probe (CR finding 1)

Apply filterNpxFromPath() to the getUserShellPath() result before passing
it to isGsdSdkOnPath(), mirroring the same filtering already applied to
process.env.PATH. Without this, a transient _npx entry in the login-shell
PATH can falsely satisfy the cross-shell reachability check and reintroduce
the false-ready condition this PR fixes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(test): unconditional legacy-shim replacement assertion (CR finding 2)

Replace readFileSync+includes source-grep check with isLegacyGsdSdkShim()
and add an else branch asserting that when sdkReady is false, a warning/error
was emitted. Previously the sdkReady===false path had no assertion at all,
allowing the test to pass without verifying any postcondition.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test: replace text-grep assertions with structured ones (CR finding 2 + nitpick)

Finding 2: restructure the legacy-shim replacement assertion to branch on
isLegacyGsdSdkShim() state (a behavioral fact) rather than console output,
and add an unconditional postcondition for both branches.

Nitpick 3 (4 locations):
- lines 149-153: replace /GSD SDK ready/.test(combined) with
  isGsdSdkOnPath(filterNpxFromPath(PATH)) === false
- lines 167-169, 185-189: split filterNpxFromPath result into segments array
  and use array.includes() instead of string.includes() on the raw PATH string
- lines 375-377: replace /GSD SDK ready/.test(combined) with
  fs.existsSync(shimPath) + isGsdSdkOnPath(filterNpxFromPath(localBin))

All 8 tests pass. lint-no-source-grep: 0 violations.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(build-hooks): per-PID staging dir eliminates concurrent-cleanup TOCTOU race

When multiple test before() hooks spawned build-hooks.js concurrently
(--test-concurrency=4), a race existed: Process A would finish all copies,
call rmdirSync('.dist-staging/') in cleanup, then Process B — still in its
copy loop — would call copyFileSync(src, '.dist-staging/hook.pid.ts') and
get ENOENT because the staging directory was gone.

On macOS/Linux, copyFileSync reports the SOURCE path in ENOENT errors when
the destination directory is missing, making the failure appear to be a
missing source file (hooks/gsd-statusline.js) rather than a missing
destination directory. This misled the diagnosis.

Fix: make STAGE_DIR per-PID ('.dist-staging-<pid>/') so each builder owns
its own staging directory. No other process touches it, eliminating all
contention on staging-dir creation and cleanup. Update .gitignore to match
the new 'hooks/.dist-staging-*/' glob.

Reproduces as: CI test matrix (macos-24, ubuntu-22, ubuntu-24) all failing
with ENOENT on hooks/gsd-statusline.js in bug-2136 before() hook. The new
test file added in this PR (bug-3231) shifts the concurrency schedule just
enough to expose the race on every CI run.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test: assert on captured console output, not tautological PATH state (CR finding)

The two discarded `captureConsole()` return values in the bug-3231 test
were flagged by CodeRabbit as tautological assertions. Fix:

- Test 1 (transient _npx PATH): capture stdout/stderr and assert the
  installer does NOT emit "GSD SDK ready" (the false-positive the PR
  fixes), and that it does emit some diagnostic output instead.

- Test 3 (clean install): capture stdout/stderr and assert the installer
  DOES emit "GSD SDK ready" after successfully self-linking into a
  persistent PATH dir — confirming the positive path works correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 09:39:33 -04:00

188 lines
7.4 KiB
JavaScript

#!/usr/bin/env node
/**
* Copy GSD hooks to dist for installation.
* Validates JavaScript syntax before copying to prevent shipping broken hooks.
* See #1107, #1109, #1125, #1161 — a duplicate const declaration shipped
* in dist and caused PostToolUse hook errors for all users.
*/
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const HOOKS_DIR = path.join(__dirname, '..', 'hooks');
const DIST_DIR = path.join(HOOKS_DIR, 'dist');
// Per-process staging directory for atomic writes. Using process.pid in the
// name eliminates all contention between concurrent builders: each process
// owns its own staging dir and never races with another builder's cleanup.
// Lives under hooks/ so it shares a filesystem with DIST_DIR (POSIX
// rename(2) is only atomic within the same filesystem) but is NOT inside
// DIST_DIR — so readers that readdirSync(DIST_DIR) (e.g. bin/install.js,
// install-hooks-copy tests) never observe a transient ".tmp" sibling.
// The parent pattern hooks/.dist-staging-*/ is gitignored.
const STAGE_DIR = path.join(HOOKS_DIR, `.dist-staging-${process.pid}`);
// Hooks to copy (pure Node.js, no bundling needed)
const HOOKS_TO_COPY = [
'gsd-check-update-worker.js',
'gsd-check-update.js',
'gsd-context-monitor.js',
'gsd-prompt-guard.js',
'gsd-read-guard.js',
'gsd-read-injection-scanner.js',
'gsd-statusline.js',
'gsd-update-banner.js',
'gsd-workflow-guard.js',
// Community hooks (bash, opt-in via .planning/config.json hooks.community)
'gsd-session-state.sh',
'gsd-validate-commit.sh',
'gsd-phase-boundary.sh'
];
// Sync millisecond sleep using Atomics.wait on a throwaway SharedArrayBuffer.
// Used between Windows rename retries; this script is sync end-to-end so
// setTimeout would not work. Total worst-case backoff across MAX_ATTEMPTS
// is bounded (~400ms) — acceptable for a one-shot build script.
function sleepSync(ms) {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
}
/**
* Atomic-replace via fs.renameSync, with Windows-only retry and fallback.
*
* POSIX rename(2) atomically replaces dest even when readers hold open
* handles on it. Windows MoveFileEx (which fs.renameSync uses with
* MOVEFILE_REPLACE_EXISTING) cannot — it throws EPERM/EBUSY when another
* process has the destination open. Concurrent install.js readers and
* antivirus scanners are the realistic triggers; both release handles
* within milliseconds, so a short backoff resolves the race. After
* retries are exhausted, fall back to copy-then-unlink (re-introduces
* the truncate-then-write race for this single file but keeps the build
* moving rather than crashing). If even copy fails because dest is hard-
* locked, log a non-fatal warning and leave the prior dest in place — a
* subsequent build invocation will retry from a fresh state.
*/
function renameAtomicWithRetry(stagedDest, dest, hook) {
if (process.platform !== 'win32') {
fs.renameSync(stagedDest, dest);
return;
}
const BACKOFFS_MS = [10, 30, 90, 270];
for (let attempt = 0; attempt <= BACKOFFS_MS.length; attempt++) {
try {
fs.renameSync(stagedDest, dest);
return;
} catch (e) {
const transient = e && (e.code === 'EPERM' || e.code === 'EBUSY');
if (!transient) throw e;
if (attempt < BACKOFFS_MS.length) {
sleepSync(BACKOFFS_MS[attempt]);
continue;
}
// Retries exhausted; fall back to copy-then-unlink.
try {
fs.copyFileSync(stagedDest, dest);
try { fs.unlinkSync(stagedDest); } catch (_) { /* tolerate */ }
console.warn(`\x1b[33m! ${hook}: rename failed (${e.code}) after ${BACKOFFS_MS.length} retries; used copy-fallback\x1b[0m`);
return;
} catch (fallbackErr) {
try { fs.unlinkSync(stagedDest); } catch (_) { /* tolerate */ }
console.warn(`\x1b[33m! ${hook}: rename + copy fallback both failed (${e.code}${fallbackErr.code || fallbackErr.message}); leaving prior dest in place\x1b[0m`);
return;
}
}
}
}
/**
* Validate JavaScript syntax without executing the file.
* Catches SyntaxError (duplicate const, missing brackets, etc.)
* before the hook gets shipped to users.
*/
function validateSyntax(filePath) {
const content = fs.readFileSync(filePath, 'utf8');
try {
// Use vm.compileFunction to check syntax without executing
new vm.Script(content, { filename: path.basename(filePath) });
return null; // No error
} catch (e) {
if (e instanceof SyntaxError) {
return e.message;
}
throw e;
}
}
function build() {
// Ensure dist and staging directories exist (staging is a sibling of dist
// used to make writes atomic — see STAGE_DIR comment above).
if (!fs.existsSync(DIST_DIR)) {
fs.mkdirSync(DIST_DIR, { recursive: true });
}
if (!fs.existsSync(STAGE_DIR)) {
fs.mkdirSync(STAGE_DIR, { recursive: true });
}
let hasErrors = false;
// Copy hooks to dist with syntax validation
for (const hook of HOOKS_TO_COPY) {
const src = path.join(HOOKS_DIR, hook);
const dest = path.join(DIST_DIR, hook);
if (!fs.existsSync(src)) {
console.warn(`Warning: ${hook} not found, skipping`);
continue;
}
// Validate JS syntax before copying (.sh files skip — not Node.js)
if (hook.endsWith('.js')) {
const syntaxError = validateSyntax(src);
if (syntaxError) {
console.error(`\x1b[31m✗ ${hook}: SyntaxError — ${syntaxError}\x1b[0m`);
hasErrors = true;
continue;
}
}
console.log(`\x1b[32m✓\x1b[0m Copying ${hook}...`);
// Atomic write: copy to a per-process staging file in the per-PID sibling
// STAGE_DIR (same filesystem as DIST_DIR so rename(2) is atomic), then
// rename into place. Multiple test files invoke this script concurrently
// from their before() hooks; fs.copyFileSync truncates then writes the
// destination — readers (install.js subprocesses spawned by parallel
// install tests) can observe the dest empty or partial mid-write,
// producing flaky failures such as bug-2136 part 4 where installed .sh
// hooks lacked their "# gsd-hook-version:" header. POSIX rename(2)
// makes the swap atomic so readers see either the old file or the new
// file. The staging file lives outside DIST_DIR so readdirSync(DIST_DIR)
// (in install.js and tests) never observes a transient ".tmp" sibling.
// Each process uses its own STAGE_DIR (keyed by PID) so concurrent
// builders never race on staging-dir creation or cleanup.
const stagedDest = path.join(STAGE_DIR, `${hook}.${Date.now()}`);
fs.copyFileSync(src, stagedDest);
// Preserve executable bit for shell scripts before rename so the
// installed file is executable from the very first observation.
if (hook.endsWith('.sh')) {
try { fs.chmodSync(stagedDest, 0o755); } catch (e) { /* Windows */ }
}
renameAtomicWithRetry(stagedDest, dest, hook);
}
// Best-effort cleanup of this process's own staging dir. Since STAGE_DIR
// is per-PID (`.dist-staging-<pid>/`), no other builder touches it — so
// rmSync with recursive:true is safe and leaves no race window.
try {
fs.rmSync(STAGE_DIR, { recursive: true, force: true });
} catch (e) { /* tolerate ENOENT if the dir was never created (e.g. all hooks skipped) */ }
if (hasErrors) {
console.error('\n\x1b[31mBuild failed: fix syntax errors above before publishing.\x1b[0m');
process.exit(1);
}
console.log('\nBuild complete.');
}
build();