'use strict'; /** * Runtime Hooks Surface Module — hook-surface writer functions extracted from * bin/install.js (ADR-857 phase 5f-1). * * Owns the lifecycle writer functions for hook surfaces managed by GSD on four * runtimes: * Cline: writeClineArtifacts + supporting helpers/constants * Cursor: buildCursorHookEntry, isManagedCursorHookEntry, * reconcileCursorHooksJson, writeCursorHooksJson, removeCursorHooksJson * Copilot: buildCopilotHookConfig, writeCopilotHookConfig * Codex hooks.json: ensureCodexHooksJsonSessionStart, ensureCodexHooksJsonEvent, * reconcileCodexHooksJsonEvent, reconcileCodexHooksJsonSessionStart, * removeCodexHooksJsonEvent, removeCodexHooksJsonSessionStart, * buildCodexHookWindowsShimIR, buildCodexHookBlock, rewriteLegacyCodexHookBlock * Shared: buildHookCommand, rewriteLegacyManagedNodeHookCommands * * BEHAVIOR-PRESERVING RELOCATION: all logic is copied verbatim from * bin/install.js. No behavior change, no descriptor reads, no new IO. * * #2876 (epic #2866 Phase 7): bin/install.js previously re-exported every * symbol from this module, but a repo-wide audit found zero production * consumers of those re-exports — no `require('../bin/install.js'). * writeCursorHooksJson` (or any sibling) exists outside a doc comment * anywhere in the tree. The re-exports were test-suite-only pass-throughs; * tests now require this module directly instead. */ import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; // #2544: the single source of truth for the CommonJS module-type marker. The // two helpers below are thin boolean-returning shims over these — see the // marker section for why this file no longer carries its own copy. import { ensureCommonJsMarker as ensureCommonJsMarkerOwned, removeCommonJsMarker as removeCommonJsMarkerOwned, } from './commonjs-marker.cjs'; import { CURSOR_HOOK_EVENTS, CURSOR_EVENT_SCRIPT_MAP, resolveManagedHookEvents, resolveHookScripts, buildHookBusEntries, } from './host-integration-adapters/imperative-hook-bus.cjs'; // eslint-disable-next-line @typescript-eslint/no-require-imports import shellCmdProjection = require('./shell-command-projection.cjs'); const { isManagedHookBasename, isManagedHookCommand, projectLegacySettingsHookCommand, projectManagedHookCommand, projectPortableHookBaseDir, projectCodexHookTomlCommand, shellHookOmitsBashRunner, escapeTomlDoubleQuotedString, escapePosixDoubleQuoted, resolveExecutableBinary, } = shellCmdProjection as { isManagedHookBasename: (scriptPath: string, opts?: { surface?: string }) => boolean; isManagedHookCommand: (cmd: string | null | undefined, opts?: { surface?: string; includeLegacyAliases?: boolean; configDir?: string }) => boolean; projectLegacySettingsHookCommand: (opts: { runnerToken: string; scriptPath: string; scriptToken: string; runtime: string; platform: string }) => string | null; projectManagedHookCommand: (opts: { absoluteRunner: string; scriptPath: string; runtime: string; platform: string; hookShell?: string }) => string | null; projectPortableHookBaseDir: (opts: { configDir: string; homeDir: string }) => string; projectCodexHookTomlCommand: (opts: { absoluteRunner: string; scriptPath: string; platform: string }) => string; shellHookOmitsBashRunner: (opts: { platform: string; runtime: string; isShellHook: boolean }) => boolean; escapeTomlDoubleQuotedString: (value: unknown) => string; escapePosixDoubleQuoted: (value: unknown) => string; resolveExecutableBinary: (name: string, opts?: { platform?: string; requireExecutable?: boolean }) => string | null; }; // --------------------------------------------------------------------------- // Terminal color constants (mirrors install.js for console output parity) // --------------------------------------------------------------------------- const green = '\x1b[32m'; const yellow = '\x1b[33m'; const reset = '\x1b[0m'; // --------------------------------------------------------------------------- // Codex config.toml constants (subset needed by this module) // --------------------------------------------------------------------------- // --------------------------------------------------------------------------- // Copilot hook constants // --------------------------------------------------------------------------- const GSD_COPILOT_HOOK_FILE = 'gsd-session.json'; const GSD_COPILOT_SESSION_MSG_PRESENT = 'GSD: .planning/STATE.md present - review the current phase and any blockers before acting.'; const GSD_COPILOT_SESSION_MSG_ABSENT = 'GSD: no .planning/ workflow found - run /gsd-new-project to start a tracked workflow.'; const GSD_COPILOT_SESSION_HOOK_BASH = 'if [ -f .planning/STATE.md ]; then ' + `printf '%s' '{"additionalContext":"${GSD_COPILOT_SESSION_MSG_PRESENT}"}'; else ` + `printf '%s' '{"additionalContext":"${GSD_COPILOT_SESSION_MSG_ABSENT}"}'; fi`; const GSD_COPILOT_SESSION_HOOK_PWSH = 'if (Test-Path .planning/STATE.md) ' + `{ '{"additionalContext":"${GSD_COPILOT_SESSION_MSG_PRESENT}"}' } ` + `else { '{"additionalContext":"${GSD_COPILOT_SESSION_MSG_ABSENT}"}' }`; // #2099 UPGRADE 1: multi-event hook bus. Each additional event is a static, // deterministic advisory (no branching/no-op-style, matching sessionStart's // tone) so the emitted hooks/gsd-session.json stays golden-trackable — no // node-runner invocation, no filesystem probing beyond what sessionStart // already does. const GSD_COPILOT_PRE_TOOL_MSG = 'GSD: confirm this tool use is in scope for the active phase before proceeding.'; const GSD_COPILOT_PRE_TOOL_HOOK_BASH = `printf '%s' '{"additionalContext":"${GSD_COPILOT_PRE_TOOL_MSG}"}'`; const GSD_COPILOT_PRE_TOOL_HOOK_PWSH = `'{"additionalContext":"${GSD_COPILOT_PRE_TOOL_MSG}"}'`; const GSD_COPILOT_POST_TOOL_MSG = 'GSD: review the tool result against the active phase before continuing.'; const GSD_COPILOT_POST_TOOL_HOOK_BASH = `printf '%s' '{"additionalContext":"${GSD_COPILOT_POST_TOOL_MSG}"}'`; const GSD_COPILOT_POST_TOOL_HOOK_PWSH = `'{"additionalContext":"${GSD_COPILOT_POST_TOOL_MSG}"}'`; const GSD_COPILOT_PROMPT_SUBMIT_MSG = 'GSD: check this request against .planning/STATE.md scope before acting.'; const GSD_COPILOT_PROMPT_SUBMIT_HOOK_BASH = `printf '%s' '{"additionalContext":"${GSD_COPILOT_PROMPT_SUBMIT_MSG}"}'`; const GSD_COPILOT_PROMPT_SUBMIT_HOOK_PWSH = `'{"additionalContext":"${GSD_COPILOT_PROMPT_SUBMIT_MSG}"}'`; const GSD_COPILOT_SESSION_END_MSG = 'GSD: update .planning/STATE.md with the session outcome before ending.'; const GSD_COPILOT_SESSION_END_HOOK_BASH = `printf '%s' '{"additionalContext":"${GSD_COPILOT_SESSION_END_MSG}"}'`; const GSD_COPILOT_SESSION_END_HOOK_PWSH = `'{"additionalContext":"${GSD_COPILOT_SESSION_END_MSG}"}'`; // --------------------------------------------------------------------------- // Cursor hook constants // --------------------------------------------------------------------------- const GSD_CURSOR_SESSION_HOOK_SCRIPT = 'gsd-cursor-session-start.js'; const GSD_CURSOR_POST_TOOL_HOOK_SCRIPT = 'gsd-cursor-post-tool.js'; const GSD_CURSOR_PRE_TOOL_HOOK_SCRIPT = 'gsd-cursor-pre-tool.js'; const GSD_CURSOR_STOP_HOOK_SCRIPT = 'gsd-cursor-stop.js'; const GSD_CURSOR_SUBAGENT_START_HOOK_SCRIPT = 'gsd-cursor-subagent-start.js'; const GSD_CURSOR_SUBAGENT_STOP_HOOK_SCRIPT = 'gsd-cursor-subagent-stop.js'; const GSD_CURSOR_HOOK_MARKER = 'gsd-managed'; // The full set of Cursor hook events GSD manages — sourced from the adapter // (src/host-integration-adapters/imperative-hook-bus.cts) so the vocabulary // stays closed and first-party. Used by reconcileCursorHooksJson (the // reconciliation scope is always the full set). The install path // (writeCursorHooksJson) resolves a descriptor-driven subset via // resolveManagedHookEvents(opts.managedHookEvents). const CURSOR_MANAGED_EVENTS = CURSOR_HOOK_EVENTS; // --------------------------------------------------------------------------- // Cline / AGENTS.md constants // --------------------------------------------------------------------------- const GSD_AGENTS_MD_MARKER = ''; const GSD_AGENTS_MD_CLOSE_MARKER = ''; // --------------------------------------------------------------------------- // Descriptor-driven runtime title lookup (ADR-1239 / #2092) // --------------------------------------------------------------------------- /** * Console-log label for a runtime, sourced from the capability registry's * `title` field (capabilities//capability.json). Folded from a * hardcoded `runtime === 'qwen' ? 'Qwen Code' : runtime === 'claude' ? * 'Claude Code' : runtime` ternary — cosmetic (log text) only, but resolves * to the same 'Qwen Code' / 'Claude Code' values for those two runtimes. * Falls back to the raw runtime id if the registry can't be loaded or the * runtime has no title. */ function _capabilityTitle(runtime: string): string { try { // eslint-disable-next-line @typescript-eslint/no-require-imports const reg = require('./capability-registry.cjs') as { runtimes?: Record; }; return reg?.runtimes?.[runtime]?.title || runtime; } catch { return runtime; } } // --------------------------------------------------------------------------- // atomicWriteFileSync — shared canonical implementation. // // __atomicWrittenTmps is exported so bin/install.js can merge it into its // _cleanTmpFiles() scan, ensuring that atomic writes performed by this // module (Cursor hooks.json, Codex hooks.json shims) participate in the // same temp-file cleanup as writes performed directly by install.js. // // Every temp path written is recorded in the Set so _cleanTmpFiles() can // scope cleanup to files this installer process actually created, avoiding // accidental deletion of unrelated tools' temp files. // --------------------------------------------------------------------------- let __atomicWriteCounter = 0; // Set — absolute paths of .tmp-- files this process created. const __atomicWrittenTmps: Set = new Set(); // Retry budget for the EEXIST (squatted temp path) branch in atomicWriteFileSync. const MAX_TEMP_FILE_ATTEMPTS = 4; function atomicWriteFileSync(target: string, data: string, options: fs.WriteFileOptions): void { // A pre-existing target's permission bits must survive the rewrite: // rename() swaps the temp file's inode into place, so without an explicit // carry a user-hardened chmod (e.g. 600 on a secrets-bearing settings.json) // would silently reset to the umask default. let priorMode: number | undefined; try { const st = fs.statSync(target); if (st.isFile()) priorMode = st.mode & 0o7777; } catch { /* no pre-existing target: default creation mode applies */ } // 'wx' (O_EXCL) refuses to follow a symlink pre-planted at the predictable // temp path and refuses to reuse a foreign file already sitting there; on // EEXIST the write retries under a fresh counter value. const exclusiveOptions: fs.WriteFileOptions = typeof options === 'string' || options == null ? { encoding: options ?? null, flag: 'wx' } : { ...options, flag: 'wx' }; for (let attempt = 0; ; attempt++) { __atomicWriteCounter += 1; const tmp = `${target}.tmp-${process.pid}-${__atomicWriteCounter}`; __atomicWrittenTmps.add(tmp); try { fs.writeFileSync(tmp, data, exclusiveOptions); } catch (e) { if ((e as NodeJS.ErrnoException).code === 'EEXIST') { // The file at tmp is not ours — never rmSync it. if (attempt < MAX_TEMP_FILE_ATTEMPTS) continue; throw e; } try { fs.rmSync(tmp, { force: true }); } catch { /* ignore */ } throw e; } try { // chmod rather than options.mode: open(2) masks mode with the process // umask, chmod applies the preserved bits exactly. if (priorMode !== undefined) fs.chmodSync(tmp, priorMode); shellCmdProjection.retryRenameSync(tmp, target); // Successful rename: the tmp path no longer exists, but leave it in the // Set so _cleanTmpFiles can recognise it as installer-owned if it somehow // lingers (e.g. a rename succeeded but left a stale entry on some FS). } catch (e) { try { fs.rmSync(tmp, { force: true }); } catch { /* ignore */ } throw e; } return; } } // --------------------------------------------------------------------------- // CommonJS package.json marker for staged .js hook scripts (#2717) // // Node resolves the nearest package.json walking up from a .js file. When a // runtime's config root (e.g. ~/.cursor, ~/.codeium/windsurf, ~/.codex) — or any // parent — declares {"type":"module"}, Node loads GSD's staged CommonJS hook // scripts as ESM and every require() fails with "require is not defined", // silently disabling that runtime's lifecycle hooks. // // installSharedHooksBundle writes this marker for the 12 runtimes that go // through the shared hooks bundle, but cursor/windsurf (skipSharedHooksInstall) // and codex (the !isCodex gate) stage their .js hooks via the dedicated paths // below and never reached it. These helpers decouple the marker write from the // shared bundle so any code path that stages .js hooks can ensure the marker // lands in the SAME directory as the scripts (#2717). // // The marker content is byte-identical to installSharedHooksBundle's // (bin/install.js installSharedHooksBundle): {"type":"commonjs"}\n. // // #2544: the two helpers below no longer carry their own copy of the write and // remove rules — they DELEGATE to src/commonjs-marker.cts, which #2544 makes the // single place both rules are enforced. Keeping a second copy here was not // merely redundant; the copies had drifted apart on exactly the two properties // that matter: // // - ownership probe: `fs.existsSync` FOLLOWS symlinks and reports `false` for // a DANGLING one, so a dangling `package.json` symlink classified as absent // and the write below followed the link outside the directory GSD owns. // `classifyMarker` uses `lstat` + `isFile()`, so a symlink or a directory at // the marker path is classified `foreign` and left strictly alone. // - create: a plain `writeFileSync` leaves the classify->write window open. // `ensureCommonJsMarker` creates with `flag: 'wx'` (O_EXCL), so anything // that appears at the path in between fails with EEXIST instead of being // followed or overwritten. // // The exported signatures are unchanged (both still return a boolean), so every // caller and the #2717 tests are unaffected. // --------------------------------------------------------------------------- /** * Ensure a `package.json` forcing CommonJS mode exists in `dir` (the directory * holding GSD-staged `.js` hook scripts). Idempotent: a no-op if the marker is * already present with GSD's content. Never clobbers a distinct user-authored * package.json (it leaves such a file in place; the user owns it). * * @param dir - absolute path to the directory holding the staged .js hooks * @returns `true` if GSD's marker is present after the call (written or already there) */ function ensureCommonJsMarker(dir: string): boolean { // 'written' | 'unchanged' -> the marker is ours and present. // 'preserved-foreign' -> a file GSD does not own is there; left untouched. // 'failed' -> environmental (EACCES/EROFS/ENOSPC); best-effort. const outcome = ensureCommonJsMarkerOwned(dir); return outcome === 'written' || outcome === 'unchanged'; } /** * Remove the CommonJS marker from `dir` on uninstall — but ONLY if it carries * GSD's exact marker content. A user-authored package.json is never deleted. * * @param dir - absolute path to the directory that held the staged .js hooks * @returns `true` if a GSD-owned marker was removed */ function removeCommonJsMarkerIfGsdOwned(dir: string): boolean { return removeCommonJsMarkerOwned(dir); } // --------------------------------------------------------------------------- // parseTomlValue + findMultilineBasicStringClose // (needed by rewriteLegacyCodexHookBlock — pure TOML helpers, no state) // --------------------------------------------------------------------------- function findMultilineBasicStringClose(line: string, startIndex: number): number { let i = startIndex; while (i < line.length) { if (line.startsWith('"""', i) && (i === 0 || line[i - 1] !== '\\')) { return i; } i += 1; } return -1; } function parseTomlValue(text: string, i: number): { value: unknown; end: number } { // Skip leading whitespace. while (i < text.length && (text[i] === ' ' || text[i] === '\t')) { i += 1; } if (i >= text.length) { throw new Error('expected value, got end of input'); } const ch = text[i]; // Basic string if (ch === '"') { if (text.startsWith('"""', i)) { const close = findMultilineBasicStringClose(text, i + 3); if (close === -1) { throw new Error('unterminated multi-line basic string'); } const raw = text.slice(i + 3, close); return { value: raw.replace(/^\r?\n/, ''), end: close + 3 }; } let j = i + 1; let out = ''; while (j < text.length) { const c = text[j]; if (c === '\\') { const next = text[j + 1]; if (next === 'n') { out += '\n'; j += 2; continue; } if (next === 't') { out += '\t'; j += 2; continue; } if (next === 'r') { out += '\r'; j += 2; continue; } if (next === '\\') { out += '\\'; j += 2; continue; } if (next === '"') { out += '"'; j += 2; continue; } if (next === '/') { out += '/'; j += 2; continue; } out += next === undefined ? '' : next; j += 2; continue; } if (c === '"') { return { value: out, end: j + 1 }; } out += c; j += 1; } throw new Error('unterminated basic string'); } // Literal string if (ch === '\'') { if (text.startsWith("'''", i)) { const close = text.indexOf("'''", i + 3); if (close === -1) throw new Error('unterminated multi-line literal string'); return { value: text.slice(i + 3, close).replace(/^\r?\n/, ''), end: close + 3 }; } const close = text.indexOf('\'', i + 1); if (close === -1) throw new Error('unterminated literal string'); return { value: text.slice(i + 1, close), end: close + 1 }; } // Boolean if (text.startsWith('true', i)) return { value: true, end: i + 4 }; if (text.startsWith('false', i)) return { value: false, end: i + 5 }; // Number (integer or float, simplified) const numMatch = text.slice(i).match(/^[+-]?(?:0x[0-9a-fA-F_]+|0o[0-7_]+|0b[01_]+|[0-9][0-9_]*(?:\.[0-9_]+)?(?:[eE][+-]?[0-9_]+)?|inf|nan)/); if (numMatch) { const raw = numMatch[0]; const cleaned = raw.replace(/_/g, ''); const num = Number(cleaned); return { value: isNaN(num) ? cleaned : num, end: i + raw.length }; } // Datetime (simplified passthrough) const dtMatch = text.slice(i).match(/^\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?)?/); if (dtMatch) { return { value: dtMatch[0], end: i + dtMatch[0].length }; } throw new Error(`parseTomlValue: unexpected character '${ch}' at position ${i}`); } // --------------------------------------------------------------------------- // normalizeNodePath / resolveNodeRunner / resolveBashRunner // (needed by buildHookCommand — verbatim from install.js) // --------------------------------------------------------------------------- interface NodeNormOpts { env?: NodeJS.ProcessEnv; existsSync?: (p: string) => boolean; /** * #3662: the process path to normalize instead of `process.execPath`. * Production callers omit it; tests use it to simulate an install baked by * a different environment (a foreign absolute node path). */ execPath?: string; } /** * Normalize a directory that will be joined with `/…` — posix separators, no * trailing slash. `FNM_DIR=/custom/fnm/` would otherwise bake * `/custom/fnm//aliases/default/bin/node` (#3704 review). Cosmetic — `existsSync` * resolves the doubled separator and every shell collapses it — but the value is * written into a user's settings.json and read by humans. * * Shared by both fnm branches deliberately: they build the same alias paths from * different roots, and a trim applied to only one is a difference with no reason * behind it. */ function normalizeRootDir(dir: string): string { return shellCmdProjection.posixNormalize(dir).replace(/\/+$/, ''); } function normalizeNodePath(execPath: string, opts?: NodeNormOpts): string { if (!execPath) return execPath; const env = (opts && opts.env) || process.env; const existsSync = (opts && opts.existsSync) || fs.existsSync; const normalizedForMatch = shellCmdProjection.posixNormalize(execPath); // #977: fnm's Windows shim IS `process.execPath` there — Windows does not // realpath through it — so this branch stays. #3704 adds `(?:bin\/)?`: fnm's // POSIX shim is `/bin/node`, so the original pattern could not match // it even when a caller hands one in explicitly (#3662's `execPath` option // exists to do exactly that, normalizing a path from another environment). if (/\/fnm_multishells\/[0-9]+_[0-9]+\/(?:bin\/)?node(\.exe)?$/i.test(normalizedForMatch)) { const candidates: string[] = []; if (env.FNM_DIR) { const fnmRoot = normalizeRootDir(env.FNM_DIR); candidates.push(`${fnmRoot}/aliases/default/node.exe`); candidates.push(`${fnmRoot}/aliases/default/bin/node`); } const appdata = shellCmdProjection.envGet(env, 'APPDATA'); if (appdata) { candidates.push(`${normalizeRootDir(appdata)}/fnm/aliases/default/node.exe`); } for (const candidate of candidates) { if (candidate && existsSync(candidate)) return candidate; } return execPath; } // #3704: fnm pins a concrete version at // /node-versions//installation/bin/node (Windows: // .../installation/node.exe). On macOS/Linux this — not the shim above — is what // `process.execPath` reports, because Node realpaths through // `fnm_multishells`. So the shim branch above is unreachable on POSIX and the // raw versioned path was baked into every managed hook: `fnm uninstall `, // or fnm's own pruning, then 404s every hook — the ephemeral-path failure #977 // exists to prevent, and the same one #1619 (mise) and #2185 (Homebrew) fixed // for their managers by matching the VERSIONED path rather than a shim. // // The stable alias is /aliases/default/..., which fnm repoints on // `fnm default`. Derive from execPath first (#2185's rule — the path // IS the install location, and FNM_DIR may be unset or point at a different // install), keeping the env as a secondary candidate. Rewrite only when the // alias exists; otherwise fall through to the raw execPath, exactly like the // mise and volta branches, so a rewrite never turns a stale-but-working pin // into an immediately broken one. const fnmVersioned = normalizedForMatch.match( /^(.*)\/node-versions\/[^/]+\/installation\/(?:bin\/)?node(\.exe)?$/i, ); if (fnmVersioned) { const isExe = Boolean(fnmVersioned[2]); const roots = [fnmVersioned[1]]; if (env.FNM_DIR) roots.push(normalizeRootDir(env.FNM_DIR)); const fnmAliasCandidates: string[] = []; for (const root of roots) { // Probe the spelling matching the input first, then the other — a layout // is one or the other, and guessing wrong would skip a real alias. const leaf = isExe ? ['node.exe', 'bin/node'] : ['bin/node', 'node.exe']; for (const tail of leaf) fnmAliasCandidates.push(`${root}/aliases/default/${tail}`); } for (const candidate of fnmAliasCandidates) { if (existsSync(candidate)) return candidate; } } // Homebrew (macOS Intel /usr/local, Apple Silicon /opt/homebrew, Linuxbrew // /home/linuxbrew/.linuxbrew, and any custom HOMEBREW_PREFIX) pins node at // /Cellar/node(<@ver>)?//bin/node, then deletes prior versions on // `brew upgrade node`. Rewrite to the stable /bin/node symlink, which // survives the upgrade. Derive from the path itself (more reliable // than HOMEBREW_PREFIX env — the path IS the install location) so every layout // is covered by one branch instead of one per known prefix (#2185). // // #4137: rewrite only when the symlink exists; otherwise fall through to the // raw execPath, exactly like the mise and volta branches. A keg-only/versioned // formula (node@24 installed but never `brew link`ed) has no /bin/node // at all, so the unconditional rewrite handed every managed hook a path that // fails at invocation — a rewrite must never turn a working keg path into an // immediately broken one. const homebrewMatch = normalizedForMatch.match( /^(.+)\/Cellar\/node(@\d+)?\/[^/]+\/bin\/node(\.exe)?$/i, ); if (homebrewMatch) { const homebrewStable = `${homebrewMatch[1]}/bin/node${homebrewMatch[3] || ''}`; if (existsSync(homebrewStable)) return homebrewStable; } // mise pins a concrete node version at /installs/node//bin/node // (Windows: /installs/node//node.exe). Node realpaths // process.execPath to that versioned path, and `mise up` prunes old versions, // so a baked hook command 404s after any node bump — the same ephemeral-path // failure #977 fixed for fnm. The stable alias is the sibling shim // (/shims/node), which always resolves to the active version, like the // Homebrew symlink survives `brew upgrade node`. Derive from execPath // so a custom MISE_DATA_DIR layout still works, and only rewrite when the shim // exists — otherwise fall back to the raw execPath unchanged. const miseMatch = normalizedForMatch.match( /^(.*)\/installs\/node\/[^/]+\/(?:bin\/)?node(\.exe)?$/, ); if (miseMatch) { const shim = `${miseMatch[1]}/shims/node${miseMatch[2] || ''}`; if (existsSync(shim)) return shim; } // volta pins a concrete node image at /tools/image/node//bin/node // (Windows: /tools/image/node//node.exe — volta's own layout // puts node.exe at the image root, no bin/). `volta uninstall node@` prunes // that image, so a baked hook command 404s — the same ephemeral-path failure // #977 fixed for fnm and #1619 for mise. The stable alias is the shim // /bin/node, a symlink to volta-shim that always resolves to the // active pin. Derive from execPath rather than the env so a custom // VOLTA_HOME and the Windows %LOCALAPPDATA%\Volta default both work (#2185's // reasoning), and only rewrite when the shim exists — otherwise fall back to // the raw execPath unchanged. const voltaMatch = normalizedForMatch.match( /^(.*)\/tools\/image\/node\/[^/]+\/(?:bin\/)?node(\.exe)?$/, ); if (voltaMatch) { const shim = `${voltaMatch[1]}/bin/node${voltaMatch[2] || ''}`; if (existsSync(shim)) return shim; } return execPath; } function resolveNodeRunner(opts?: NodeNormOpts): string | null { const execPath = (opts && opts.execPath) || (typeof process.execPath === 'string' ? process.execPath : ''); if (!execPath) return null; const stablePath = normalizeNodePath(execPath, opts); return JSON.stringify(shellCmdProjection.posixNormalize(stablePath)); } /** * #3662 — the runtime-resolving node runner token for managed JS hooks. * * A bake-time absolute runner (`resolveNodeRunner`) only works in the * environment that ran the installer; a config root shared across * environments (the `--portable-hooks` scenario, or any settings.json under a * mounted `$HOME`) carries a path that 404s with exit 127 everywhere else. * This token is a POSIX `sh` command substitution that resolves node at * hook-fire time, trying IN ORDER: * * 1. the baked installer path (absolute — keeps the #2979/#3002/#3017/#3022 * minimal-PATH guarantee: where the baked path exists it still wins, * under any PATH, GUI launch included); * 2. `command -v node` (quoted — one word even with spaces in the result); * 3. the well-known stable layouts (`/usr/local/bin/node`, `/usr/bin/node`). * * The FIRST executable candidate wins; if none resolves the substitution * yields an empty word and the hook fails exactly as a stale absolute path * does today — no bare `node` token is ever emitted or depended on. * * One shape for every platform: emitted hook commands execute via POSIX `sh` * (Claude-on-win32 runs Git Bash per #166/#580; `hookCommandNeedsPowerShellCallOperator` * is an unused opt-in), and the baked path is posixNormalize'd before escaping * (escapePosixDoubleQuoted — the Shell Command Projection seam owns quoting). * The portable resolver script (hooks/gsd-node-runner.sh) resolves through a * SUPERSET of this candidate list — keep the two lists consistent. */ function buildNodeRunnerChainToken(opts?: NodeNormOpts): string | null { const execPath = (opts && opts.execPath) || (typeof process.execPath === 'string' ? process.execPath : ''); if (!execPath) return null; const stablePath = shellCmdProjection.posixNormalize(normalizeNodePath(execPath, opts)); const baked = escapePosixDoubleQuoted(stablePath); // Absolute candidates only (leading / or a win32 drive letter): a relative // `command -v node` hit (legal under a relative PATH entry) must never // promote repo-cwd content into the runner slot. The gate uses parameter // expansion + [ ] — deliberately NO `case` (its `)` terminates the command // substitution under macOS's stock bash 3.2 /bin/sh, breaking the hook). // \${…} below stays a literal shell parameter expansion, not TS interpolation. return `"$(for n in "${baked}" "$(command -v node)" /usr/local/bin/node /usr/bin/node; do [ -x "$n" ] && { [ "\${n#/}" != "$n" ] || [ "\${n#?:}" != "$n" ]; } && printf '%s' "$n" && break; done)"`; } /** * #3662 — the install-time node path as a shell-safe QUOTED token, carrying * the same double-quote escaping as the chain token (`escapePosixDoubleQuoted` * — $ ` " \), NOT bare JSON quoting. The portable resolver's first argument * is executed by the host shell before the resolver sees argv, so a path * containing shell metacharacters must arrive escaped. */ function buildBakedNodeToken(opts?: NodeNormOpts): string | null { const execPath = (opts && opts.execPath) || (typeof process.execPath === 'string' ? process.execPath : ''); if (!execPath) return null; const stablePath = shellCmdProjection.posixNormalize(normalizeNodePath(execPath, opts)); return `"${escapePosixDoubleQuoted(stablePath)}"`; } /** * #3662 — basename of the portable node resolver staged into the install's * hooks/ directory. Under `--portable-hooks`, managed JS hook commands route * through it (`bash "/gsd-node-runner.sh" "" "