* test(#3854): write normalization must preserve tight multi-line lists (failing first) * fix(#3854): no blank before a bullet whose previous line is an indented continuation _normalizeMd's 'separate a list from a preceding paragraph' rule inserted a blank before any bullet whose previous line wasn't a bullet — but an INDENTED CONTINUATION of the previous multi-line item also isn't a bullet. Every .md write (phase.complete in the report, but any write through platformWriteSync) therefore converted tight lists to loose ones: +61 blank lines on the reporter's 1015-line ROADMAP, one before each bullet following a wrapped item. Tight and loose lists render differently, so this was a rendering change plus misleading diff noise; one-shot (idempotent afterwards), which is why integrity checks on headings/content passed. The guard is the mirror image of the after-a-bullet rule two lines below, which already excludes indented next lines. Paragraph→list and heading→list separations — the rule's purpose — are pinned unchanged by the new suite. * fix(#3854): review fold-ins — ceiling tracks next's 281 + this branch's marker (282), header/require nits The ceiling is not ratcheted but must track the tree: origin/next raised it to 281 (sibling branch's marker file); this tree adds one more (shell-command-projection-md-normalize), so 282/282. Also fixes the test header's stale pre-rename filename and hoists the inline require to the file's single import. * chore(#3854): changeset fragment (pr number backfilled after PR creation) * chore(#3854): backfill changeset PR number (4049) --------- Co-authored-by: sim <sim@local>
1302 lines
57 KiB
TypeScript
1302 lines
57 KiB
TypeScript
/**
|
|
* Shell Command Projection Module
|
|
*
|
|
* Tracer-bullet seam for runtime-aware projection of serialized command text
|
|
* that GSD writes into runtime config or prints for copy/paste. This module
|
|
* does NOT execute commands; it only renders command text for external shells
|
|
* and runtimes.
|
|
*
|
|
* ADR-457 build-at-publish: the hand-written bin/lib/shell-command-projection.cjs
|
|
* collapsed to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour
|
|
* from the prior hand-written .cjs; only types are added.
|
|
*/
|
|
|
|
import path from 'node:path';
|
|
import fs from 'node:fs';
|
|
// Use non-destructured namespace import so test-time mock.method(childProcess, 'spawnSync')
|
|
// can intercept calls from this seam — destructured imports capture references
|
|
// at load time and become un-mockable.
|
|
import childProcess from 'node:child_process';
|
|
import { escapeRegex } from './pattern.cjs';
|
|
|
|
/**
|
|
* Convert a filesystem path to POSIX form (forward slashes) by translating the
|
|
* platform-native separator. Single seam for native→POSIX conversion.
|
|
*
|
|
* Prefer this over `p.replace(/\\/g, '/')`: the regex form hardcodes both
|
|
* separators and corrupts POSIX paths containing a literal backslash (a legal
|
|
* filename character). Splitting on `path.sep` only ever touches real
|
|
* separators — a no-op on POSIX, `\`→`/` on Windows.
|
|
*/
|
|
export function toPosixPath(p: string): string {
|
|
return p.split(path.sep).join(path.posix.sep);
|
|
}
|
|
|
|
/**
|
|
* Convert a filesystem path to the platform-native separator form. No-op on
|
|
* POSIX; `/`→`\` on Windows. Prefer this over a
|
|
* `process.platform === 'win32' ? p.replace(/\//g, '\\') : p` ternary.
|
|
*/
|
|
export function toNativePath(p: string): string {
|
|
return p.split(path.posix.sep).join(path.sep);
|
|
}
|
|
|
|
/**
|
|
* Normalize ALL backslashes to forward slashes, unconditionally and independent
|
|
* of the running OS. Use this when emitting a path into a POSIX/bash target
|
|
* (which may differ from the running platform — e.g. generating a Windows config
|
|
* on a Linux runner) or when parsing input whose separators are unpredictable.
|
|
*
|
|
* Contrast `toPosixPath`, which is running-OS-relative (splits on `path.sep`) and
|
|
* is for *this machine's* filesystem paths. Do NOT use `toPosixPath` for
|
|
* target-platform projection — on a Linux runner it would not convert a
|
|
* Windows-target path's backslashes.
|
|
*/
|
|
export function posixNormalize(p: string): string {
|
|
return p.replace(/\\/g, '/');
|
|
}
|
|
|
|
/**
|
|
* #3663 — comparison key for a path that must equal another path regardless
|
|
* of spelling: separator form (forward slashes via posixNormalize), relative
|
|
* segments and trailing separators (path.resolve + strip), and — ONLY on
|
|
* win32's case-insensitive filesystem — letter casing. POSIX stays
|
|
* case-sensitive: differently-cased paths are genuinely different
|
|
* directories there. This module owns the platform-conditional fold so the
|
|
* policy lives at the seam instead of accreting per-call-site copies (the
|
|
* class init.cts's normalizeForCompare/toComparableRaw predate).
|
|
*/
|
|
export function toComparablePathKey(p: string, platform: string = process.platform): string {
|
|
const normalized = posixNormalize(path.resolve(p)).replace(/\/+$/g, '');
|
|
return platform === 'win32' ? normalized.toLowerCase() : normalized;
|
|
}
|
|
|
|
/**
|
|
* Return true when a managed hook command must be prefixed with PowerShell's
|
|
* call operator so a quoted executable token is invokable by the target
|
|
* runtime/shell combination.
|
|
*
|
|
* The `&`/no-`&` decision is keyed on the **effective hook-execution shell**
|
|
* (`opts.hookShell`), not on runtime alone — a single runtime (Claude Code)
|
|
* can host either Git Bash or PowerShell on Windows, and no single static
|
|
* command string is valid in both (#2236):
|
|
* - Git Bash: `"node.exe" "hook.js"` works; `& "node.exe" …` → syntax error.
|
|
* - PowerShell: `& "node.exe" "hook.js"` works; bare `"node.exe" …` →
|
|
* `Unexpected token`.
|
|
*
|
|
* Default is `false` (Git Bash form) for backward compatibility. Set
|
|
* `opts.hookShell = 'powershell'` to emit the PowerShell call-operator form.
|
|
*/
|
|
export function hookCommandNeedsPowerShellCallOperator(opts: { platform?: string; runtime?: string; hookShell?: string } = {}): boolean {
|
|
return opts.hookShell === 'powershell';
|
|
}
|
|
|
|
/**
|
|
* Project a fully-assembled hook command string for the target runtime.
|
|
*/
|
|
export function formatHookCommandForRuntime(command: string, opts: { platform?: string; runtime?: string; hookShell?: string } = {}): string {
|
|
return hookCommandNeedsPowerShellCallOperator(opts) ? `& ${command}` : command;
|
|
}
|
|
|
|
// #166/#580: Claude Code on Windows executes hook command strings inside Git
|
|
// Bash. A `.sh` hook wrapped with an explicit bash.exe path makes bash try to
|
|
// exec bash itself ("C:/.../bash.exe: cannot execute binary file"). Both install
|
|
// paths — global (buildHookCommand) and local (buildLocalShellHookCommand) — must
|
|
// drop the bash runner in this case and emit only the anchored script path.
|
|
// Centralized here so the two paths cannot silently drift apart again: the local
|
|
// path missed this guard and reintroduced the #166/#377 failure (#580).
|
|
export function shellHookOmitsBashRunner({ platform, runtime = 'generic', isShellHook = false }: { platform?: string; runtime?: string; isShellHook?: boolean } = {}): boolean {
|
|
const p = platform ?? process.platform;
|
|
return p === 'win32' && runtime === 'claude' && isShellHook;
|
|
}
|
|
|
|
// Builds the command string for a local-install managed `.sh` hook. Mirrors the
|
|
// global buildHookCommand path but uses the $CLAUDE_PROJECT_DIR-anchored prefix
|
|
// instead of an absolute configDir. On Claude/Windows the bash runner is dropped
|
|
// (see shellHookOmitsBashRunner) and the anchored script path is emitted alone —
|
|
// matching the global path. Elsewhere the resolved bash runner is required; a
|
|
// null runner yields null so callers skip registration instead of emitting a
|
|
// broken hook (#3393).
|
|
export function buildLocalShellHookCommand({ localPrefix, hookFile, bashRunner, runtime = 'generic', platform = process.platform }: {
|
|
localPrefix?: string | null;
|
|
hookFile?: string | null;
|
|
bashRunner?: string | null;
|
|
runtime?: string;
|
|
platform?: string;
|
|
}): string | null {
|
|
if (!localPrefix || !hookFile) return null;
|
|
const scriptPath = `${localPrefix}/hooks/${hookFile}`;
|
|
if (shellHookOmitsBashRunner({ platform, runtime, isShellHook: true })) {
|
|
return formatHookCommandForRuntime(scriptPath, { platform, runtime });
|
|
}
|
|
if (!bashRunner) return null;
|
|
return projectShellCommandText({
|
|
runnerToken: bashRunner,
|
|
argTokens: [scriptPath],
|
|
runtime,
|
|
platform,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Project a managed hook script path token for serialized shell commands.
|
|
* Windows managed hook commands normalize to forward slashes so the same path
|
|
* survives JSON/TOML/config surfaces consistently.
|
|
*/
|
|
export function formatManagedHookScriptToken(scriptPath: string, opts: { platform?: string } = {}): string | null {
|
|
const platform = opts.platform || process.platform;
|
|
if (platform !== 'win32') return null;
|
|
return JSON.stringify(posixNormalize(scriptPath));
|
|
}
|
|
|
|
export function projectLocalHookPrefix({ runtime: _runtime = 'claude', dirName, hookPathStyle }: { runtime?: string; dirName?: string | null; hookPathStyle?: string | null }): string | undefined | null {
|
|
if (!dirName) return dirName;
|
|
// Descriptor-driven (ADR-1239 / #2096): folded from a hardcoded
|
|
// `runtime === 'antigravity'` literal into the runtime's declared
|
|
// `hostBehaviors.hookPathStyle`. Runtimes that always run project hooks
|
|
// with the project dir as cwd (Antigravity today) declare 'raw' and get
|
|
// the bare dirName; every other runtime keeps the $CLAUDE_PROJECT_DIR-
|
|
// anchored prefix. `runtime` itself is now unused here but stays in the
|
|
// signature for call-site/back-compat parity (kept `_`-prefixed to
|
|
// satisfy no-unused-vars).
|
|
return (hookPathStyle === 'raw')
|
|
? dirName
|
|
: `"$CLAUDE_PROJECT_DIR"/${dirName}`;
|
|
}
|
|
|
|
export function projectPortableHookBaseDir({ configDir, homeDir }: { configDir?: string | null; homeDir?: string | null }): string {
|
|
const normalizedConfigDir = posixNormalize(String(configDir || ''));
|
|
const normalizedHome = posixNormalize(String(homeDir || ''));
|
|
if (!normalizedConfigDir || !normalizedHome) return normalizedConfigDir;
|
|
return normalizedConfigDir.startsWith(normalizedHome)
|
|
? '$HOME' + normalizedConfigDir.slice(normalizedHome.length)
|
|
: normalizedConfigDir;
|
|
}
|
|
|
|
export function projectShellCommandText({
|
|
runnerToken,
|
|
argTokens = [],
|
|
runtime = 'generic',
|
|
platform = process.platform,
|
|
hookShell,
|
|
}: {
|
|
runnerToken?: string | null;
|
|
argTokens?: (string | null | undefined)[];
|
|
runtime?: string;
|
|
platform?: string;
|
|
hookShell?: string;
|
|
}): string | null {
|
|
if (!runnerToken) return null;
|
|
const parts = [runnerToken, ...argTokens.filter(Boolean)] as string[];
|
|
return formatHookCommandForRuntime(parts.join(' '), { platform, runtime, hookShell });
|
|
}
|
|
|
|
export function projectManagedHookCommand({ absoluteRunner, scriptPath, runtime = 'generic', platform = process.platform, hookShell }: {
|
|
absoluteRunner?: string | null;
|
|
scriptPath?: string | null;
|
|
runtime?: string;
|
|
platform?: string;
|
|
hookShell?: string;
|
|
}): string | null {
|
|
if (!absoluteRunner || !scriptPath) return null;
|
|
const normalizedScriptPath = platform === 'win32' ? posixNormalize(scriptPath) : scriptPath;
|
|
return projectShellCommandText({
|
|
runnerToken: absoluteRunner,
|
|
argTokens: [JSON.stringify(normalizedScriptPath)],
|
|
runtime,
|
|
platform,
|
|
hookShell,
|
|
});
|
|
}
|
|
|
|
const MANAGED_HOOK_BASENAMES_BY_SURFACE: Record<string, Set<string>> = {
|
|
'settings-json': new Set([
|
|
'gsd-check-update.js',
|
|
'gsd-config-reload.js',
|
|
'gsd-statusline.js',
|
|
'gsd-context-monitor.js',
|
|
'gsd-prompt-guard.js',
|
|
'gsd-read-guard.js',
|
|
'gsd-read-injection-scanner.js',
|
|
'gsd-update-banner.js',
|
|
'gsd-workflow-guard.js',
|
|
// #3662: the three PreToolUse guards are GSD-managed JS hooks (registered
|
|
// only-if-absent by applySettingsJsonHooks) but sat outside this set, so
|
|
// the #2979 runner rewriter never reached them — the issue's reported
|
|
// mixed state lived on exactly these hooks.
|
|
'gsd-write-guard.js',
|
|
'gsd-agent-isolation-guard.js',
|
|
'gsd-worktree-path-guard.js',
|
|
]),
|
|
'codex-toml': new Set([
|
|
'gsd-check-update.js',
|
|
]),
|
|
};
|
|
|
|
const MANAGED_HOOK_COMMAND_BASENAMES_BY_SURFACE: Record<string, Set<string>> = {
|
|
'settings-json': new Set([
|
|
'gsd-check-update.js',
|
|
'gsd-config-reload.js',
|
|
'gsd-statusline.js',
|
|
'gsd-context-monitor.js',
|
|
'gsd-prompt-guard.js',
|
|
'gsd-read-guard.js',
|
|
'gsd-read-injection-scanner.js',
|
|
'gsd-update-banner.js',
|
|
'gsd-workflow-guard.js',
|
|
'gsd-session-state.sh',
|
|
'gsd-validate-commit.sh',
|
|
'gsd-phase-boundary.sh',
|
|
// #3662: same three guards as MANAGED_HOOK_BASENAMES_BY_SURFACE above —
|
|
// their absence here meant isManagedHookCommand never recognized them, so
|
|
// the settings.json→settings.local.json migration filter (and uninstall
|
|
// cleanup) skipped GSD's own guard entries. Folded-in fix, surfaced by the
|
|
// #3662 issue thread.
|
|
'gsd-write-guard.js',
|
|
'gsd-agent-isolation-guard.js',
|
|
'gsd-worktree-path-guard.js',
|
|
]),
|
|
'codex-toml': new Set([
|
|
'gsd-check-update.js',
|
|
]),
|
|
'codex-hooks-json': new Set([
|
|
'gsd-check-update.js',
|
|
// #3426: Windows .cmd shim for Codex hook — must be treated as managed so
|
|
// reconcileCodexHooksJsonSessionStart can replace stale node-runner commands
|
|
// with the .cmd shim on reinstall (and vice-versa on cross-platform moves).
|
|
'gsd-check-update.cmd',
|
|
// #772: context-monitor is now registered for Codex SubagentStart/Stop/PostToolUse.
|
|
'gsd-context-monitor.js',
|
|
// #772: Windows .cmd shim for gsd-context-monitor — same #3426 pattern.
|
|
'gsd-context-monitor.cmd',
|
|
]),
|
|
};
|
|
|
|
const LEGACY_MANAGED_HOOK_ALIASES_BY_SURFACE: Record<string, Set<string>> = {
|
|
'codex-toml': new Set([
|
|
'gsd-update-check.js',
|
|
]),
|
|
'codex-hooks-json': new Set([
|
|
'gsd-update-check.js',
|
|
]),
|
|
};
|
|
|
|
function managedHookSurfaceSet(surface: string = 'settings-json'): Set<string> {
|
|
return MANAGED_HOOK_BASENAMES_BY_SURFACE[surface] || MANAGED_HOOK_BASENAMES_BY_SURFACE['settings-json'];
|
|
}
|
|
|
|
export function isManagedHookBasename(scriptPathOrBasename: string | null | undefined, opts: { surface?: string } = {}): boolean {
|
|
if (!scriptPathOrBasename) return false;
|
|
const surface = opts.surface || 'settings-json';
|
|
const basename = String(scriptPathOrBasename).split(/[\\/]/).pop() || '';
|
|
return managedHookSurfaceSet(surface).has(basename);
|
|
}
|
|
|
|
function managedHookCommandSurfaceSet(surface: string = 'settings-json', includeLegacyAliases: boolean = false): Set<string> {
|
|
const base = MANAGED_HOOK_COMMAND_BASENAMES_BY_SURFACE[surface]
|
|
|| MANAGED_HOOK_COMMAND_BASENAMES_BY_SURFACE['settings-json'];
|
|
if (!includeLegacyAliases) return base;
|
|
const aliases = LEGACY_MANAGED_HOOK_ALIASES_BY_SURFACE[surface];
|
|
if (!aliases || aliases.size === 0) return base;
|
|
return new Set([...base, ...aliases]);
|
|
}
|
|
|
|
export function isManagedHookCommand(commandText: unknown, opts: { surface?: string; includeLegacyAliases?: boolean; configDir?: string; args?: unknown[] } = {}): boolean {
|
|
if (typeof commandText !== 'string') return false;
|
|
const surface = opts.surface || 'settings-json';
|
|
const includeLegacyAliases = opts.includeLegacyAliases === true;
|
|
const managedBasenames = managedHookCommandSurfaceSet(surface, includeLegacyAliases);
|
|
if (!managedBasenames || managedBasenames.size === 0) return false;
|
|
|
|
// args-form check: the managed hook filename may appear in args[] rather than
|
|
// in command when a windowless launcher wraps the Node invocation. (#976)
|
|
// Only treat as managed when an arg basename matches the managed hook set —
|
|
// prevents false-positives for non-GSD entries that happen to share a path segment.
|
|
if (Array.isArray(opts.args) && opts.args.length > 0) {
|
|
for (const arg of opts.args) {
|
|
if (typeof arg !== 'string') continue;
|
|
const argBasename = posixNormalize(arg).split('/').pop() || '';
|
|
if (isManagedHookBasename(argBasename, { surface })) return true;
|
|
}
|
|
}
|
|
|
|
const normalizedCommand = posixNormalize(commandText);
|
|
|
|
if (typeof opts.configDir === 'string' && opts.configDir.length > 0) {
|
|
const normalizedHooksDir = `${posixNormalize(path.join(opts.configDir, 'hooks'))}/`;
|
|
if (!normalizedCommand.includes(normalizedHooksDir)) return false;
|
|
}
|
|
|
|
for (const basename of managedBasenames) {
|
|
const escapedBasename = escapeRegex(basename);
|
|
const pattern = new RegExp(`(^|[\\\\/\\s"'` + '`' + `])${escapedBasename}(?=$|[\\s"'` + '`' + `])`);
|
|
if (pattern.test(normalizedCommand)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Detect a `"$VAR"/rest` anchored hook-script token — a path whose leading
|
|
* shell variable is already double-quoted with the remainder left bare (the
|
|
* shape `projectLocalHookPrefix` emits for local installs, e.g.
|
|
* `"$CLAUDE_PROJECT_DIR"/.claude/hooks/gsd-x.js`). Such a token is ALREADY a
|
|
* valid, correctly-quoted shell argument and must never be re-quoted.
|
|
*/
|
|
const ANCHORED_HOOK_SCRIPT_TOKEN = /^"\$[A-Za-z_][A-Za-z0-9_]*"\//;
|
|
|
|
/**
|
|
* Projection helper for legacy settings.json hook rewrites.
|
|
*
|
|
* Non-Windows keeps the original script token shape when provided (single
|
|
* quote / bareword / quoted), while Windows normalizes to double-quoted
|
|
* forward-slash path tokens for stable cross-shell behavior.
|
|
*
|
|
* #3662: `runnerToken` is the runner the current install would emit — since
|
|
* #3662 that is the runtime-resolving chain token (see
|
|
* buildNodeRunnerChainToken), not a bare absolute path, so re-projected
|
|
* entries converge onto a runner that resolves in every environment sharing
|
|
* the config root.
|
|
*/
|
|
export function projectLegacySettingsHookCommand({
|
|
runnerToken,
|
|
scriptPath,
|
|
scriptToken,
|
|
runtime = 'generic',
|
|
platform = process.platform,
|
|
}: {
|
|
runnerToken?: string | null;
|
|
scriptPath?: string | null;
|
|
scriptToken?: string | null;
|
|
runtime?: string;
|
|
platform?: string;
|
|
}): string | null {
|
|
if (!runnerToken || !scriptPath) return null;
|
|
const normalizedScriptPath = platform === 'win32' ? posixNormalize(scriptPath) : scriptPath;
|
|
// #1693: a script path already carrying a `"$CLAUDE_PROJECT_DIR"`-anchored
|
|
// quoted prefix (local installs) is already a valid shell token — only the
|
|
// variable is quoted, the rest is bare. JSON.stringify-ing it on Windows
|
|
// yields `"\"$CLAUDE_PROJECT_DIR\"/..."` (escaped quotes inside an outer
|
|
// quote); node then receives an argument that *starts* with a `"`, treats it
|
|
// as relative, and dies with MODULE_NOT_FOUND. Emit anchored tokens verbatim;
|
|
// only bare absolute paths (which may contain spaces, e.g. "Program Files")
|
|
// need the JSON.stringify quoting. Scoped to win32: the non-Windows branch
|
|
// already preserves the caller's `scriptToken` (which is the bare anchored
|
|
// token for these inputs), so it never had the double-quote bug.
|
|
const commandScriptToken = platform === 'win32'
|
|
? (ANCHORED_HOOK_SCRIPT_TOKEN.test(normalizedScriptPath)
|
|
? normalizedScriptPath
|
|
: JSON.stringify(normalizedScriptPath))
|
|
: (scriptToken || JSON.stringify(normalizedScriptPath));
|
|
return projectShellCommandText({
|
|
runnerToken,
|
|
argTokens: [commandScriptToken],
|
|
runtime,
|
|
platform,
|
|
});
|
|
}
|
|
|
|
// Implements the TOML v1.0.0 basic-string escaping grammar (toml.md, "Basic
|
|
// strings" section, https://toml.io/en/v1.0.0#string): a basic string must
|
|
// escape the quotation mark, backslash, and control characters other than
|
|
// tab (U+0000-U+0008, U+000A-U+001F, U+007F). Compact escapes are used where
|
|
// TOML defines them (\b \t \n \f \r \" \\); every other character in the
|
|
// required ranges falls back to \uXXXX. See #3118 — an earlier version
|
|
// escaped only backslash and quote, so a raw newline/CR/NUL in a value
|
|
// produced an unparseable config.toml.
|
|
const TOML_COMPACT_ESCAPES: Record<string, string> = {
|
|
'\x08': '\\b',
|
|
'\x09': '\\t',
|
|
'\x0A': '\\n',
|
|
'\x0C': '\\f',
|
|
'\x0D': '\\r',
|
|
};
|
|
|
|
// U+0000-U+0008, U+000A-U+001F, U+007F — control characters other than tab
|
|
// (U+0009), which the grammar permits unescaped.
|
|
const TOML_MUST_ESCAPE_CONTROL_CHARS = /[\x00-\x08\x0A-\x1F\x7F]/g;
|
|
|
|
export function escapeTomlDoubleQuotedString(value: unknown): string {
|
|
return String(value)
|
|
.replace(/\\/g, '\\\\')
|
|
.replace(/"/g, '\\"')
|
|
.replace(TOML_MUST_ESCAPE_CONTROL_CHARS, (ch) => {
|
|
const compact = TOML_COMPACT_ESCAPES[ch];
|
|
if (compact) return compact;
|
|
return `\\u${ch.codePointAt(0)!.toString(16).padStart(4, '0')}`;
|
|
});
|
|
}
|
|
|
|
export function projectCodexHookTomlCommand({ absoluteRunner, scriptPath, platform = process.platform }: {
|
|
absoluteRunner?: string | null;
|
|
scriptPath?: string | null;
|
|
platform?: string;
|
|
}): string | null {
|
|
const command = projectManagedHookCommand({
|
|
absoluteRunner,
|
|
scriptPath,
|
|
runtime: 'codex',
|
|
platform,
|
|
});
|
|
return command === null ? null : escapeTomlDoubleQuotedString(command);
|
|
}
|
|
|
|
export function escapePowerShellSingleQuoted(value: unknown): string {
|
|
return String(value).replace(/'/g, "''");
|
|
}
|
|
|
|
export function escapePosixDoubleQuoted(value: unknown): string {
|
|
return String(value).replace(/[\\$"`]/g, '\\$&');
|
|
}
|
|
|
|
export function escapeSingleQuotedShellLiteral(value: unknown): string {
|
|
return String(value).replace(/'/g, "'\\''");
|
|
}
|
|
|
|
/**
|
|
* The `export PATH="<dir>:$PATH"` line every persistence lane appends, plus the escaped directory
|
|
* token it embeds. One builder because three lanes emit this line: a lane that re-escapes it
|
|
* locally is how #3118 shipped a `$(…)` into ~/.bashrc, where it ran on every new shell. The
|
|
* escaping is for the line's FINAL context — a double-quoted string in an rc file — not for
|
|
* whatever transport (an `echo`, a paste) it passes through on the way there.
|
|
*/
|
|
export function projectPathExportLine(targetDir: unknown): { escapedDir: string; line: string } {
|
|
const escapedDir = escapePosixDoubleQuoted(String(targetDir));
|
|
return { escapedDir, line: `export PATH="${escapedDir}:$PATH"` };
|
|
}
|
|
|
|
interface ShellAction {
|
|
label: string | null;
|
|
shell: string;
|
|
command: string;
|
|
}
|
|
|
|
/**
|
|
* Why a PATH suggestion produced no actions. An empty `shellActions` alone folds two different
|
|
* facts together — "no target directory was given" and "this target directory cannot be
|
|
* expressed as a shell command" — and a caller that cannot tell them apart prints a header with
|
|
* nothing under it (#3118).
|
|
*/
|
|
export const PATH_ACTION_REASON = Object.freeze({
|
|
NO_TARGET_DIR: 'no_target_dir',
|
|
WIN32_RESERVED_QUOTE: 'win32_reserved_quote',
|
|
});
|
|
|
|
export function renderShellActionLines(shellActions: ShellAction[] = []): string[] {
|
|
return shellActions.map((action) => {
|
|
if (!action || !action.command) return '';
|
|
return action.label ? `${action.label}: ${action.command}` : action.command;
|
|
}).filter(Boolean);
|
|
}
|
|
|
|
export function projectPathActionProjection({
|
|
mode = 'repair',
|
|
targetDir,
|
|
platform = process.platform,
|
|
}: {
|
|
mode?: string;
|
|
targetDir?: string | null;
|
|
platform?: string;
|
|
}): { shellActions: ShellAction[]; actionLines: string[]; reason?: string } {
|
|
if (!targetDir) return { shellActions: [], actionLines: [], reason: PATH_ACTION_REASON.NO_TARGET_DIR };
|
|
|
|
// #3118: `"` is reserved on Windows, so a path containing one cannot exist — and it would close
|
|
// cmd's quoted region in the `powershell -Command "…"` lane below, turning the rest into cmd
|
|
// input. There is no correct command to suggest for an impossible path: fail closed rather than
|
|
// emit one whose quoting can be broken.
|
|
if (platform === 'win32' && String(targetDir).includes('"')) return { shellActions: [], actionLines: [], reason: PATH_ACTION_REASON.WIN32_RESERVED_QUOTE };
|
|
|
|
const isWin32 = platform === 'win32';
|
|
|
|
let shellActions: ShellAction[];
|
|
if (isWin32) {
|
|
const psTargetDir = escapePowerShellSingleQuoted(targetDir);
|
|
const bashExportLine = escapeSingleQuotedShellLiteral(
|
|
projectPathExportLine(posixNormalize(String(targetDir))).line,
|
|
);
|
|
shellActions = [
|
|
{
|
|
label: 'PowerShell',
|
|
shell: 'powershell',
|
|
command: `[Environment]::SetEnvironmentVariable('PATH', '${psTargetDir};' + [Environment]::GetEnvironmentVariable('PATH', 'User'), 'User')`,
|
|
},
|
|
{
|
|
label: 'cmd.exe',
|
|
shell: 'cmd',
|
|
command: `powershell -Command "[Environment]::SetEnvironmentVariable('PATH', '${psTargetDir};' + [Environment]::GetEnvironmentVariable('PATH', 'User'), 'User')"`,
|
|
},
|
|
{
|
|
label: 'Git Bash',
|
|
shell: 'bash',
|
|
command: `echo '${bashExportLine}' >> ~/.bashrc`,
|
|
},
|
|
];
|
|
} else if (mode === 'persist') {
|
|
const exportLine = escapeSingleQuotedShellLiteral(projectPathExportLine(targetDir).line);
|
|
const fishTargetDir = escapeSingleQuotedShellLiteral(String(targetDir));
|
|
shellActions = [
|
|
{
|
|
label: 'zsh',
|
|
shell: 'zsh',
|
|
command: `echo '${exportLine}' >> ~/.zshrc`,
|
|
},
|
|
{
|
|
label: 'bash',
|
|
shell: 'bash',
|
|
command: `echo '${exportLine}' >> ~/.bashrc`,
|
|
},
|
|
// #323: fish has no `export`/`$PATH`-list syntax. `fish_add_path` is the
|
|
// fish-native API (>= fish 3.2, 2021) that persists to the universal
|
|
// variable store and de-duplicates. The directory is single-quoted with
|
|
// the same POSIX literal escaping as the zsh/bash siblings — `'\''` is
|
|
// also a valid escaped single quote in fish between quote spans.
|
|
//
|
|
// #3118 review MINOR: a `targetDir` with a leading `-` (e.g. `-v`) is a
|
|
// legal directory name, but fish's argparse-based option scanning
|
|
// treats a leading-dash token as a flag REGARDLESS of quoting, so
|
|
// `fish_add_path '-v'` misparses it and prints "No paths to add, not
|
|
// setting anything." (exit 1) instead of adding the path. `--` is
|
|
// fish's standard end-of-options separator; verified empirically
|
|
// against a real fish 4.8.1 install that `fish_add_path -- '-v'`
|
|
// succeeds where the unseparated form fails.
|
|
{
|
|
label: 'fish',
|
|
shell: 'fish',
|
|
command: `fish_add_path -- '${fishTargetDir}'`,
|
|
},
|
|
];
|
|
} else {
|
|
shellActions = [
|
|
{
|
|
label: null,
|
|
shell: 'posix',
|
|
command: projectPathExportLine(targetDir).line,
|
|
},
|
|
];
|
|
}
|
|
|
|
return {
|
|
shellActions,
|
|
actionLines: renderShellActionLines(shellActions),
|
|
};
|
|
}
|
|
|
|
export function projectPersistentPathExportActions({ targetDir, platform = process.platform }: {
|
|
targetDir?: string | null;
|
|
platform?: string;
|
|
}): { shellActions: ShellAction[]; reason?: string } {
|
|
const projected = projectPathActionProjection({
|
|
mode: 'persist',
|
|
targetDir,
|
|
platform,
|
|
});
|
|
return projected.reason === undefined
|
|
? { shellActions: projected.shellActions }
|
|
: { shellActions: projected.shellActions, reason: projected.reason };
|
|
}
|
|
|
|
|
|
// ─── Subprocess dispatch ──────────────────────────────────────────────────────
|
|
|
|
export interface SpawnResultOutput {
|
|
exitCode: number;
|
|
stdout: string;
|
|
stderr: string;
|
|
signal: NodeJS.Signals | null;
|
|
error: Error | null;
|
|
timedOut: boolean;
|
|
}
|
|
|
|
/**
|
|
* Returns true when a spawn/exec result indicates the subprocess was killed
|
|
* by a timeout, i.e. it never completed and reported a real answer. This is
|
|
* the single shared definition of "did this subprocess time out" — worktree
|
|
* safety (src/worktree-safety.cts) and worktree base-ref detection
|
|
* (src/worktree-base-ref.cts) both call this instead of maintaining their
|
|
* own copies (#3050 — "Generative Fix Divergence").
|
|
*
|
|
* Only `error.code === 'ETIMEDOUT'` is checked. Node.js guarantees this
|
|
* cross-platform when `spawnSync`'s `timeout` option fires. The `signal ===
|
|
* 'SIGTERM'` check some earlier code paired with it is platform-fragile —
|
|
* Windows does not necessarily report SIGTERM the same way — and pairing it
|
|
* in as a REQUIRED conjunct risks a false NEGATIVE (a timeout that silently
|
|
* fails to trip the guard) on that platform. There is no false-positive risk
|
|
* from dropping it: an externally-delivered SIGTERM (not a timeout) leaves
|
|
* `error` null, so `error.code === 'ETIMEDOUT'` alone still won't match it.
|
|
*/
|
|
export function isSpawnTimeout(result: { error?: unknown }): boolean {
|
|
return (result.error as NodeJS.ErrnoException | null | undefined)?.code === 'ETIMEDOUT';
|
|
}
|
|
|
|
function _spawnResult(result: { error?: NodeJS.ErrnoException | null; status?: number | null; stdout?: Buffer | string | null; stderr?: Buffer | string | null; signal?: NodeJS.Signals | null }, program: string): SpawnResultOutput {
|
|
if (result.error && result.error.code === 'ENOENT') {
|
|
return { exitCode: 127, stdout: '', stderr: `${program}: not found`, signal: null, error: result.error, timedOut: false };
|
|
}
|
|
const signal = result.signal ?? null;
|
|
const error = result.error ?? null;
|
|
return {
|
|
exitCode: result.status ?? 1,
|
|
stdout: (result.stdout ?? '').toString().trim(),
|
|
stderr: (result.stderr ?? '').toString().trim(),
|
|
signal,
|
|
error,
|
|
// Reuse the single shared timeout predicate (isSpawnTimeout, below) rather
|
|
// than re-deriving it here — see that function's docstring for why only
|
|
// error.code === 'ETIMEDOUT' is checked (not signal === 'SIGTERM').
|
|
timedOut: isSpawnTimeout({ error }),
|
|
};
|
|
}
|
|
|
|
export function execGit(args: string[], opts: { cwd?: string; env?: Record<string, string>; timeout?: number } = {}): SpawnResultOutput {
|
|
// Non-interactive defaults: a hung credential prompt or terminal-input
|
|
// probe must surface as a timeout, not block the tool forever. Callers
|
|
// can override via opts.env.
|
|
const env = {
|
|
...process.env,
|
|
GIT_TERMINAL_PROMPT: '0',
|
|
GCM_INTERACTIVE: 'never',
|
|
...(opts.env || {}),
|
|
};
|
|
const result = childProcess.spawnSync('git', args, {
|
|
cwd: opts.cwd,
|
|
env,
|
|
encoding: 'utf-8',
|
|
stdio: 'pipe',
|
|
timeout: opts.timeout ?? 10_000,
|
|
windowsHide: true,
|
|
});
|
|
return _spawnResult(result, 'git');
|
|
}
|
|
|
|
export function execNpm(args: string[], opts: { cwd?: string; timeout?: number } = {}): SpawnResultOutput {
|
|
const result = childProcess.spawnSync('npm', args, {
|
|
cwd: opts.cwd,
|
|
shell: process.platform === 'win32',
|
|
encoding: 'utf-8',
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
timeout: opts.timeout ?? 15_000,
|
|
windowsHide: true,
|
|
});
|
|
return _spawnResult(result, 'npm');
|
|
}
|
|
|
|
/**
|
|
* Default PATHEXT when Windows does not supply one. Matches the value
|
|
* `gsd-tools.cjs`'s `resolveSpawnBinary` shipped in #3275; kept identical so the
|
|
* delegation is a behavior-preserving move rather than a redefinition.
|
|
*/
|
|
const DEFAULT_PATHEXT = '.EXE;.CMD;.BAT;.COM';
|
|
|
|
/** Windows extensions that must be mediated through cmd.exe rather than spawned. */
|
|
const CMD_MEDIATED_EXT = /\.(cmd|bat)$/i;
|
|
|
|
function _isFile(candidate: string, requireExecutable = false, platform: string = process.platform): boolean {
|
|
try {
|
|
if (!fs.statSync(candidate).isFile()) return false;
|
|
if (requireExecutable && platform !== 'win32') fs.accessSync(candidate, fs.constants.X_OK);
|
|
return true;
|
|
} catch {
|
|
// Missing, unreadable (EACCES), a broken link, or (when requireExecutable
|
|
// is set) not executable — all mean "not this one".
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Read an environment variable by name, case-insensitively.
|
|
*
|
|
* Windows environment variable names are case-insensitive and conventionally
|
|
* cased `Path` / `ComSpec`, and `process.env` is a case-insensitive proxy that
|
|
* hides the difference. Spreading it (`{ ...process.env, ...opts.env }`, which
|
|
* `execTool` does whenever a caller supplies `opts.env`) produces a PLAIN object
|
|
* that keeps the OS's actual casing and loses the proxy — so an exact-case
|
|
* `env['PATH']` lookup returns undefined there and the PATH scan silently sees
|
|
* nothing. Caught by the Windows CI lane on #3617; the #3445 tests never hit it
|
|
* because they pass uppercase keys explicitly.
|
|
*
|
|
* Exact match wins when present, so a caller who sets the canonical name pays
|
|
* no scan.
|
|
*
|
|
* Exported so callers outside this seam can route an exact-case-sensitive env
|
|
* read through it instead of accessing a non-`process.env` object directly
|
|
* (see `local/no-exact-case-env-access`).
|
|
*/
|
|
export function envGet(env: NodeJS.ProcessEnv, name: string): string | undefined {
|
|
const exact = env[name];
|
|
if (exact !== undefined) return exact;
|
|
const lower = name.toLowerCase();
|
|
for (const key of Object.keys(env)) {
|
|
if (key.toLowerCase() === lower) return env[key];
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/** cmd.exe's own quoting rule inside a `/c` string: a literal quote is doubled. */
|
|
function _cmdQuoteToken(token: string): string {
|
|
return `"${token.replace(/"/g, '""')}"`;
|
|
}
|
|
|
|
/**
|
|
* Build a single verbatim command-line string for `cmd.exe /c` where every
|
|
* token is force-quoted, then wrap the whole thing in one more outer pair.
|
|
* cmd.exe strips exactly one outer quote pair when the string begins with a
|
|
* quote and contains at least two — so the outer wrap disappears and what's
|
|
* left is a sequence of individually-quoted tokens. Force-quoting is the
|
|
* point: an unquoted `a&b` is split by cmd's own metacharacter parsing, but a
|
|
* quoted `"a&b"` is one literal argument.
|
|
*/
|
|
function _buildVerbatimCmdLine(target: string, args: string[]): string {
|
|
return `"${[target, ...args].map(_cmdQuoteToken).join(' ')}"`;
|
|
}
|
|
|
|
/**
|
|
* #3411: resolve a DECLARED command name to the file a spawn can actually start.
|
|
* The single canonical answer to "where is this binary?" for the whole tree.
|
|
*
|
|
* This is the seam `CONTEXT.md` declares as "All OS-facing I/O; single platform
|
|
* seam". Four divergent implementations of this logic existed (#3411): `execNpm`'s
|
|
* `shell:true`, `execTool`'s absence of any handling, `gsd-tools.cjs`'s private
|
|
* scan, and `fallow-runner.cts`'s own candidate array. #3275 folded two of them
|
|
* together in `bin/`; this lifts that resolver into the seam so `bin/` delegates
|
|
* instead of owning a copy.
|
|
*
|
|
* **win32** tries PATHEXT entries ONLY — never the bare name. npm global installs
|
|
* drop an EXTENSIONLESS POSIX sh shim (`...\npm\codex`) next to `codex.CMD`; a
|
|
* bare-name-first scan resolves to it, the cmd.exe mediation gate sees no `.cmd`,
|
|
* and the ENOENT returns unchanged (field-reported on Windows 11 — see #3275).
|
|
* A name that ALREADY carries a PATHEXT-listed extension is tried as-is first, so
|
|
* `foo.exe` resolves to `foo.exe` rather than being probed as `foo.exe.EXE`; a
|
|
* suffix that is not in PATHEXT (`foo.txt`) is not an extension and only feeds the
|
|
* append loop.
|
|
*
|
|
* **POSIX** answers EXISTENCE by scanning PATH for the bare name. `execTool` does
|
|
* NOT consult this on POSIX — the bare name goes to spawnSync unchanged and Node's
|
|
* own PATH search does the work, so macOS/Linux behavior is untouched (#3275
|
|
* acceptance contract).
|
|
*
|
|
* Path-like names (any `/` or `\`) bypass the PATH scan: the name is already an
|
|
* address, so it passes through when it names an existing file.
|
|
*
|
|
* **`opts.prependPaths`** — directories searched BEFORE `env.PATH`, in array
|
|
* order (e.g. a project-local `node_modules/.bin`). Defaults to `[]`, so
|
|
* Phase 1's callers (`execTool`, and `gsd-tools.cjs`'s `resolveSpawnBinary` /
|
|
* `deps.spawn` / `hasBinary`), which set neither new option, are byte-identical
|
|
* to today. The existing per-directory candidate logic (win32 as-is-then-append-
|
|
* PATHEXT; POSIX bare name) applies to prepended directories exactly as it does
|
|
* to `PATH` segments — there is no special-casing.
|
|
*
|
|
* **`opts.requireExecutable`** — when `true` and the platform is not `win32`,
|
|
* a candidate must additionally pass `fs.accessSync(candidate, fs.constants.X_OK)`
|
|
* to count as a match. On `win32` this is a no-op (mode bits do not mean
|
|
* execute on Windows — the same carve-out `fallow-runner`'s prior private
|
|
* resolver already had). Defaults to `false`, so `accessSync` is never called
|
|
* unless a caller opts in. It is opt-in rather than the default because making
|
|
* `X_OK` unconditional would break #3445's suite: those tests stage candidates
|
|
* with plain `fs.writeFileSync` and never set an exec bit (the repo bans
|
|
* `chmod` in tests), so every one of them would resolve to `null` on POSIX.
|
|
*
|
|
* **`opts.pathOverride`** — "search THIS PATH, but read everything else —
|
|
* PATHEXT included — from the ambient environment." When set (`!== undefined`),
|
|
* the PATH search segments come from splitting `pathOverride` on `path.delimiter`
|
|
* instead of from `env.PATH`; `pathOverride: ''` means an explicit EMPTY search
|
|
* path (zero segments), never a fallback to `env.PATH` — use `!== undefined`,
|
|
* not truthiness, to tell "caller supplied a PATH string" apart from "caller
|
|
* supplied nothing". `opts.prependPaths` still comes first. PATHEXT resolution
|
|
* is UNAFFECTED by this option — it still reads from `env` (which defaults to
|
|
* `process.env`) exactly as it does when `pathOverride` is omitted. This exists
|
|
* so a caller that already has its own search path in hand (e.g.
|
|
* `resolveFallowBinary`'s `envPath`) does not have to hand-thread PATHEXT
|
|
* alongside it — a private PATHEXT read is the very shape
|
|
* `local/no-private-binary-resolution` forbids outside this seam.
|
|
*
|
|
* @returns the resolved path, or `null` when nothing matched. Callers fall back to
|
|
* the declared name on `null` so a genuine ENOENT still surfaces (#3086).
|
|
*/
|
|
export function resolveExecutableBinary(
|
|
name: string | null | undefined,
|
|
opts: { platform?: string; env?: NodeJS.ProcessEnv; prependPaths?: string[]; requireExecutable?: boolean; pathOverride?: string } = {},
|
|
): string | null {
|
|
if (!name) return null;
|
|
const requireExecutable = opts.requireExecutable ?? false;
|
|
const platform = opts.platform ?? process.platform;
|
|
if (name.includes('/') || name.includes('\\')) {
|
|
return _isFile(name, requireExecutable, platform) ? name : null;
|
|
}
|
|
const env = opts.env ?? process.env;
|
|
const rawPath = opts.pathOverride !== undefined ? opts.pathOverride : (envGet(env, 'PATH') || '');
|
|
const pathSegments = String(rawPath).split(path.delimiter).filter(Boolean);
|
|
const segments = [...(opts.prependPaths ?? []), ...pathSegments];
|
|
|
|
if (platform !== 'win32') {
|
|
for (const dir of segments) {
|
|
const candidate = path.join(dir, name);
|
|
if (_isFile(candidate, requireExecutable, platform)) return candidate;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const exts = String(envGet(env, 'PATHEXT') || DEFAULT_PATHEXT).split(';').filter(Boolean);
|
|
// A name already ending in a PATHEXT-listed extension is an address, not a stem:
|
|
// probing `foo.exe` as `foo.exe.EXE` would miss the file sitting right there.
|
|
// Compared case-insensitively because PATHEXT casing is not guaranteed.
|
|
const lower = name.toLowerCase();
|
|
const carriesKnownExt = exts.some((ext) => lower.endsWith(ext.toLowerCase()));
|
|
|
|
for (const dir of segments) {
|
|
if (carriesKnownExt) {
|
|
const asIs = path.join(dir, name);
|
|
if (_isFile(asIs, requireExecutable, platform)) return asIs;
|
|
}
|
|
for (const ext of exts) {
|
|
const candidate = path.join(dir, name + ext);
|
|
if (_isFile(candidate, requireExecutable, platform)) return candidate;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* #3411: project a declared `(command, args)` into the pair `spawnSync` can
|
|
* actually execute on this platform.
|
|
*
|
|
* Resolution alone does not make Windows work: `CreateProcess` cannot execute a
|
|
* `.cmd`/`.bat` at all, so the cmd.exe mediation is inseparable from the lookup.
|
|
* Exporting only the resolver would leave every caller to re-derive that half —
|
|
* which is precisely how #3411's four copies accumulated.
|
|
*
|
|
* cmd.exe is invoked as an ordinary program with an EXPLICIT argv array, never via
|
|
* `shell: true`. `shell:true` on Windows is the mechanism behind CVE-2024-27980
|
|
* (argument injection through `.bat`/`.cmd`), and Node 26 deprecates it with an
|
|
* args array (DEP0190) because arguments are concatenated rather than escaped.
|
|
*
|
|
* Mediation fires when the target — the resolved path, or the declared name when
|
|
* resolution found nothing — carries a `.cmd`/`.bat` extension. A BARE name that
|
|
* resolved to nothing is passed through verbatim so the spawn fails with ENOENT;
|
|
* mediating it would turn `{exitCode:127, 'foo: not found'}` into cmd.exe's exit
|
|
* 9009 and silently change the not-found contract callers depend on.
|
|
*
|
|
* POSIX is a strict no-op: the declared command is returned unchanged and the
|
|
* environment is never consulted.
|
|
*
|
|
* The mediated command line is built VERBATIM rather than left to libuv: libuv's
|
|
* `quote_cmd_arg` only force-quotes an argument that contains a space, tab, or
|
|
* quote — it does not know about cmd.exe metacharacters (`&`, `|`, `>`, `<`,
|
|
* `^`, ...) at all, so an argument like `a&calc` reaches cmd.exe unquoted and
|
|
* gets re-parsed as two commands (the CVE-2024-27980 argument-injection class).
|
|
* Node's own CVE-2024-27980 escaping does not help here because it only fires
|
|
* when the spawned FILE itself is a `.bat`/`.cmd` — in this seam the spawned
|
|
* file is `cmd.exe`, not the target. Building the line ourselves and passing
|
|
* `windowsVerbatimArguments: true` (the shape Rust's std uses for the sibling
|
|
* CVE-2024-24576) means every token is force-quoted inside one outer pair, so
|
|
* a metacharacter inside a quoted token can never split the command line.
|
|
*
|
|
* KNOWN LIMIT: `%VAR%` still expands inside a cmd `/c` string, and there is no
|
|
* escape for `%` outside a batch file — an argument containing `%FOO%` is
|
|
* substituted with the environment value regardless of quoting. That is an
|
|
* information-disclosure limit, not arbitrary execution, and it's the same
|
|
* limit Rust's std documents for its own `CommandExt::raw_arg` escape hatch.
|
|
*
|
|
* CALLER CHOICE: this function's return value carries two independently
|
|
* adoptable pieces of information, and a caller may take either, both, or
|
|
* neither. `windowsVerbatimArguments: true` marks the cases where mediation
|
|
* was REQUIRED — the caller MUST adopt `command`+`args` together, since a
|
|
* `.cmd`/`.bat` genuinely cannot be spawned any other way. A merely-resolved
|
|
* `.exe` path (no mediation flag set) is only an OFFER: a caller may decline
|
|
* it and keep spawning the declared name instead, to hold its own observable
|
|
* contract stable. `execTool` (this file, below) is exactly such a caller —
|
|
* it adopts the mediated pair when `windowsVerbatimArguments` is set, but
|
|
* otherwise passes the declared `program`/`args` through untouched.
|
|
*/
|
|
export function projectSpawnInvocation(
|
|
command: string,
|
|
args: string[] = [],
|
|
opts: { platform?: string; env?: NodeJS.ProcessEnv } = {},
|
|
): { command: string; args: string[]; resolved: string | null; windowsVerbatimArguments?: boolean } {
|
|
const platform = opts.platform ?? process.platform;
|
|
if (platform !== 'win32') return { command, args, resolved: null };
|
|
|
|
const env = opts.env ?? process.env;
|
|
const resolved = resolveExecutableBinary(command, { platform, env });
|
|
// Mediate against the resolved path when we have one, else against the declared
|
|
// name. An unresolved name is mediated ONLY when it already declares .cmd/.bat:
|
|
// PATH-only resolution misses a batch file sitting in the current directory,
|
|
// which `cmd.exe /c` still finds — the behavior gsd-tools.cjs shipped before
|
|
// this consolidation, preserved here rather than silently narrowed.
|
|
const target = resolved ?? command;
|
|
if (!CMD_MEDIATED_EXT.test(path.basename(target))) {
|
|
// A BARE name that resolved to nothing is passed through untouched so the
|
|
// spawn fails with ENOENT. Mediating it would turn {exitCode:127,
|
|
// '<name>: not found'} into cmd.exe's exit 9009 and silently change the
|
|
// not-found contract `_spawnResult` and its 53 dependent files rely on.
|
|
return resolved ? { command: resolved, args, resolved } : { command, args, resolved: null };
|
|
}
|
|
// A CR/LF cannot be represented inside a Windows command line at all — cmd.exe
|
|
// treats it as a line terminator, so mediating it would silently truncate the
|
|
// argument rather than pass it through. Fail visibly instead: fall back to the
|
|
// unmediated shape so the spawn either fails with ENOENT (bare unresolved name)
|
|
// or hands the raw string to CreateProcess, whichever the caller was already
|
|
// prepared to see for a non-.cmd/.bat target.
|
|
if (/[\r\n]/.test(target) || args.some((a) => /[\r\n]/.test(a))) {
|
|
return resolved ? { command: resolved, args, resolved } : { command, args, resolved: null };
|
|
}
|
|
return {
|
|
command: String(envGet(env, 'ComSpec') || 'cmd.exe'),
|
|
args: ['/d', '/s', '/c', _buildVerbatimCmdLine(target, args)],
|
|
resolved,
|
|
windowsVerbatimArguments: true,
|
|
};
|
|
}
|
|
|
|
export function execTool(program: string, args: string[], opts: { cwd?: string; env?: Record<string, string>; timeout?: number } = {}): SpawnResultOutput {
|
|
// #3411: Windows cannot spawn a .cmd/.bat at all — CreateProcess refuses it —
|
|
// so those are mediated through cmd.exe. Everything else keeps the DECLARED
|
|
// program name: libuv's CreateProcess path already performs PATH + PATHEXT
|
|
// search, so resolving a .exe here would buy nothing and would change what
|
|
// this seam's 167 dependents observe being spawned. `tests/graphify.test.cjs`
|
|
// pins that contract by spying on spawnSync's first argument. POSIX never
|
|
// reaches the mediation branch at all.
|
|
const spawnEnv = opts.env ? { ...process.env, ...opts.env } : undefined;
|
|
const invocation = projectSpawnInvocation(program, args, { env: spawnEnv ?? process.env });
|
|
const mediated = invocation.windowsVerbatimArguments === true;
|
|
const result = childProcess.spawnSync(mediated ? invocation.command : program, mediated ? invocation.args : args, {
|
|
cwd: opts.cwd,
|
|
env: spawnEnv,
|
|
encoding: 'utf-8',
|
|
stdio: 'pipe',
|
|
timeout: opts.timeout ?? 30_000,
|
|
windowsHide: true,
|
|
...(mediated ? { windowsVerbatimArguments: true } : {}),
|
|
});
|
|
// Stamp the DECLARED name, never the resolved path: `_spawnResult` renders
|
|
// `${program}: not found`, and callers across 53 files match on the string they
|
|
// passed. Resolution must not leak an absolute path into that message.
|
|
return _spawnResult(result, program);
|
|
}
|
|
|
|
/**
|
|
* Result shape for {@link dispatchGsdCommand}. Modeled on the existing
|
|
* `{exitCode,stdout,stderr,signal,error}` seam above, but flattened to the
|
|
* fields callers actually need (never leaks a raw Error/signal — see
|
|
* `timedOut`), per the "Unbounded Subprocesses" contract (CLAUDE.md):
|
|
* degrade to a structured result on timeout/ENOENT, never throw.
|
|
*/
|
|
export interface DispatchGsdCommandResult {
|
|
ok: boolean;
|
|
stdout: string;
|
|
stderr: string;
|
|
code: number | null;
|
|
timedOut: boolean;
|
|
}
|
|
|
|
/**
|
|
* Resolve the absolute path to gsd-tools.cjs relative to THIS module.
|
|
*
|
|
* This file compiles to gsd-core/bin/lib/shell-command-projection.cjs — a
|
|
* sibling of gsd-core/bin/gsd-tools.cjs — so the relative walk-up is stable
|
|
* regardless of install location (global/local/dev-repo layouts all ship
|
|
* gsd-core/bin/ as a unit).
|
|
*/
|
|
export function resolveGsdToolsPath(): string {
|
|
return path.resolve(__dirname, '..', 'gsd-tools.cjs');
|
|
}
|
|
|
|
/**
|
|
* Subprocess-shim dispatch to gsd-tools.cjs (ADR-1239 #2102 Stage 2).
|
|
*
|
|
* No fully-populated in-process command-routing hub exists anywhere in the
|
|
* tree — every `createHub()` caller (cjs-command-router-adapter.cts,
|
|
* phase-command-router.cts, command-routing-hub.cts's own tests) builds a
|
|
* single-family hub for its own narrow purpose. The ONLY dispatch path that
|
|
* covers the FULL family/subcommand surface is the gsd-tools.cjs CLI itself.
|
|
* This mirrors the SUBPROCESS-REUSE precedent already established for the
|
|
* OpenCode/Kilo hook bridge (see .opencode/plugins/gsd-core.js header:
|
|
* "Architecture: SUBPROCESS REUSE ... spawns existing hook scripts as child
|
|
* processes") — the same pattern, applied to command dispatch instead of
|
|
* hook dispatch.
|
|
*
|
|
* Output-flag choice (verified by direct invocation — see #2102 dispatch
|
|
* notes for the sample invocations): always pass `--raw` (undecorated,
|
|
* programmatically-consumable stdout on success) and `--json-errors` (a
|
|
* structured `{ok:false,reason,message}` JSON object on stderr, with a
|
|
* non-zero exit, instead of a free-text "Error: ..." line). Both are global
|
|
* flags accepted by every gsd-tools.cjs family/subcommand, so passing them
|
|
* unconditionally is safe for the full command surface.
|
|
*
|
|
* `family` maps 1:1 onto gsd-tools.cjs's first positional argv token;
|
|
* `subcommand` (when present) onto the second — e.g.
|
|
* `{family:'phase', subcommand:'add'}` → `gsd-tools.cjs phase add`. An empty
|
|
* `subcommand` is omitted entirely (some families, e.g. `config-path`, take
|
|
* no subcommand).
|
|
*
|
|
* NEVER throws. Degrades to `{ ok:false, ... }` on:
|
|
* - a missing/invalid "family" (validated locally, no subprocess spawned)
|
|
* - ENOENT / a missing gsd-tools.cjs (via the injectable `gsdToolsPath`)
|
|
* - a wall-clock timeout (`timedOut:true`, via the shared `isSpawnTimeout`
|
|
* predicate defined above in this file — also used by worktree-safety.cts
|
|
* and worktree-base-ref.cts)
|
|
* - any other unanticipated throw from the underlying spawn (defensive
|
|
* try/catch — execTool itself is spawnSync-based and does not throw).
|
|
*/
|
|
export function dispatchGsdCommand({
|
|
family,
|
|
subcommand,
|
|
args = [],
|
|
cwd,
|
|
timeout = 30_000,
|
|
gsdToolsPath,
|
|
}: {
|
|
family?: string;
|
|
subcommand?: string;
|
|
args?: string[];
|
|
cwd?: string;
|
|
timeout?: number;
|
|
gsdToolsPath?: string;
|
|
} = {}): DispatchGsdCommandResult {
|
|
if (typeof family !== 'string' || family.length === 0) {
|
|
return {
|
|
ok: false,
|
|
stdout: '',
|
|
stderr: 'dispatchGsdCommand requires a non-empty string "family".',
|
|
code: null,
|
|
timedOut: false,
|
|
};
|
|
}
|
|
|
|
const resolvedCwd = cwd || process.cwd();
|
|
const toolsPath = gsdToolsPath || resolveGsdToolsPath();
|
|
const argv = [
|
|
toolsPath,
|
|
family,
|
|
...(subcommand ? [subcommand] : []),
|
|
...(Array.isArray(args) ? args : []),
|
|
'--cwd', resolvedCwd,
|
|
'--raw',
|
|
'--json-errors',
|
|
];
|
|
|
|
let result: SpawnResultOutput;
|
|
try {
|
|
result = execTool(process.execPath, argv, { cwd: resolvedCwd, timeout });
|
|
} catch (e) {
|
|
// Defensive belt-and-suspenders: execTool is spawnSync-based and does not
|
|
// throw today, but a degraded result here keeps this seam's no-throw
|
|
// contract true even under an unanticipated future failure mode.
|
|
return {
|
|
ok: false,
|
|
stdout: '',
|
|
stderr: e instanceof Error ? e.message : String(e),
|
|
code: null,
|
|
timedOut: false,
|
|
};
|
|
}
|
|
|
|
// Delegates to the single shared predicate defined above in this file
|
|
// (#3050 — "Generative Fix Divergence") instead of a local inline copy.
|
|
const timedOut = isSpawnTimeout(result);
|
|
|
|
return {
|
|
ok: result.exitCode === 0 && !timedOut,
|
|
stdout: result.stdout,
|
|
stderr: result.stderr,
|
|
code: result.exitCode,
|
|
timedOut,
|
|
};
|
|
}
|
|
|
|
export function probeTty(opts: { platform?: string } = {}): string | null {
|
|
const platform = opts.platform ?? process.platform;
|
|
if (platform === 'win32') return null;
|
|
try {
|
|
const ttyPath = childProcess.execFileSync('tty', [], {
|
|
encoding: 'utf-8',
|
|
stdio: ['inherit', 'pipe', 'ignore'],
|
|
timeout: 5_000,
|
|
}).trim();
|
|
if (!ttyPath || ttyPath === 'not a tty') return null;
|
|
return ttyPath;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ─── Platform file I/O ────────────────────────────────────────────────────────
|
|
|
|
function _normalizeMd(content: string): string {
|
|
if (!content || typeof content !== 'string') return content;
|
|
let text = content.replace(/\r\n/g, '\n');
|
|
const lines = text.split('\n');
|
|
const result: string[] = [];
|
|
const fenceRegex = /^```/;
|
|
const insideFence = new Array<boolean>(lines.length);
|
|
let fenceOpen = false;
|
|
for (let i = 0; i < lines.length; i++) {
|
|
if (fenceRegex.test(lines[i].trimEnd())) {
|
|
if (fenceOpen) {
|
|
insideFence[i] = false;
|
|
fenceOpen = false;
|
|
} else {
|
|
insideFence[i] = false;
|
|
fenceOpen = true;
|
|
}
|
|
} else {
|
|
insideFence[i] = fenceOpen;
|
|
}
|
|
}
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i];
|
|
const prev = i > 0 ? lines[i - 1] : '';
|
|
const prevTrimmed = prev.trimEnd();
|
|
const trimmed = line.trimEnd();
|
|
const isFenceLine = fenceRegex.test(trimmed);
|
|
if (/^#{1,6}\s/.test(trimmed) && i > 0 && prevTrimmed !== '' && prevTrimmed !== '---') result.push('');
|
|
if (isFenceLine && i > 0 && prevTrimmed !== '' && !insideFence[i] && (i === 0 || !insideFence[i - 1] || isFenceLine)) {
|
|
if (i === 0 || !insideFence[i - 1]) result.push('');
|
|
}
|
|
// #3854: the `!/^\s/.test(prev)` guard mirrors the after-a-bullet rule below —
|
|
// an indented non-bullet line is a CONTINUATION of the previous list item, not a
|
|
// preceding paragraph, so no separating blank may be injected before this bullet
|
|
// (that injection converted every tight multi-line list to a loose one on write).
|
|
if (/^(\s*[-*+]\s|\s*\d+\.\s)/.test(line) && i > 0 && prevTrimmed !== '' && !/^(\s*[-*+]\s|\s*\d+\.\s)/.test(prev) && !/^\s/.test(prev) && prevTrimmed !== '---') result.push('');
|
|
result.push(line);
|
|
if (/^#{1,6}\s/.test(trimmed) && i < lines.length - 1 && (lines[i + 1] ?? '').trimEnd() !== '') result.push('');
|
|
if (/^```\s*$/.test(trimmed) && i > 0 && insideFence[i - 1] && i < lines.length - 1 && (lines[i + 1] ?? '').trimEnd() !== '') result.push('');
|
|
if (/^(\s*[-*+]\s|\s*\d+\.\s)/.test(line) && i < lines.length - 1) {
|
|
const next = lines[i + 1];
|
|
if (next !== undefined && next.trimEnd() !== '' && !/^(\s*[-*+]\s|\s*\d+\.\s)/.test(next) && !/^\s/.test(next)) result.push('');
|
|
}
|
|
}
|
|
text = result.join('\n');
|
|
text = text.replace(/\n{3,}/g, '\n\n');
|
|
text = text.replace(/\n*$/, '\n');
|
|
return text;
|
|
}
|
|
|
|
export function normalizeContent(filePath: string, content: string, opts: { encoding?: BufferEncoding } = {}): { content: string; encoding: BufferEncoding } {
|
|
const encoding = opts.encoding ?? 'utf-8';
|
|
const isMd = path.extname(filePath).toLowerCase() === '.md';
|
|
let normalized: string;
|
|
if (isMd) {
|
|
normalized = _normalizeMd(content);
|
|
} else {
|
|
normalized = (content ?? '').replace(/\r\n/g, '\n').replace(/\n*$/, '\n');
|
|
}
|
|
return { content: normalized, encoding };
|
|
}
|
|
|
|
/**
|
|
* True iff persisting `after` via `platformWriteSync` would land different
|
|
* on-disk bytes than `before` already has (or would have, normalized the
|
|
* same way). `platformWriteSync` runs Markdown normalization (CRLF strip,
|
|
* blank-line-run collapse, single trailing newline) before writing, so a
|
|
* caller comparing raw pre-normalize strings (`after !== before`) can report
|
|
* `true` even when the persisted bytes are byte-identical — e.g. a
|
|
* transform that regenerates a section fresh on every call, including a
|
|
* genuine no-op re-run, in a different-but-equivalent raw shape than the
|
|
* already-normalized on-disk original (#3685 / #3691). Any "did this write
|
|
* change the file?" flag MUST go through this seam (or an equivalent
|
|
* post-write re-read of the actual on-disk bytes) instead of a raw `!==` —
|
|
* do not simplify this back to a direct string comparison.
|
|
*/
|
|
export function contentChangedAfterNormalize(filePath: string, before: string, after: string): boolean {
|
|
return normalizeContent(filePath, after).content !== normalizeContent(filePath, before).content;
|
|
}
|
|
|
|
// Rename errnos that are transient on Windows: a concurrent reader (or an AV
|
|
// scanner / indexer) holding the target open makes renameSync fail briefly.
|
|
// Same idiom as capability-ledger.cts / capability-consent.cts.
|
|
const RENAME_RETRY_ERRNOS = new Set(['EPERM', 'EBUSY', 'EACCES']);
|
|
const RENAME_MAX_ATTEMPTS = 3;
|
|
const RENAME_RETRY_BACKOFF_MS = 50;
|
|
|
|
/** Synchronous best-effort backoff sleep (Atomics.wait — same idiom as io.cts). */
|
|
let _renameSleepBuf: Int32Array | null = null;
|
|
function renameBackoff(): void {
|
|
if (_renameSleepBuf === null) _renameSleepBuf = new Int32Array(new SharedArrayBuffer(4));
|
|
Atomics.wait(_renameSleepBuf, 0, 0, RENAME_RETRY_BACKOFF_MS);
|
|
}
|
|
|
|
/**
|
|
* Atomic publish with bounded retry on transient Windows lock errnos.
|
|
* Returns null on success, or the final error if every attempt failed.
|
|
*/
|
|
function atomicRenameWithRetry(tmpPath: string, filePath: string): NodeJS.ErrnoException | null {
|
|
let renameErr: NodeJS.ErrnoException | null = null;
|
|
for (let attempt = 1; attempt <= RENAME_MAX_ATTEMPTS; attempt++) {
|
|
try {
|
|
fs.renameSync(tmpPath, filePath);
|
|
return null;
|
|
} catch (err) {
|
|
renameErr = err as NodeJS.ErrnoException;
|
|
if (attempt < RENAME_MAX_ATTEMPTS && RENAME_RETRY_ERRNOS.has(renameErr.code ?? '')) {
|
|
renameBackoff();
|
|
continue;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
return renameErr;
|
|
}
|
|
|
|
/**
|
|
* Drop-in replacement for `fs.renameSync(from, to)` that retries the transient
|
|
* Windows lock errnos (EPERM/EBUSY/EACCES — see DEFECT.WINDOWS-FS-OPS) a bounded
|
|
* number of times with a short backoff before rethrowing the final error.
|
|
*
|
|
* Idempotent on POSIX (the transient errnos do not occur), so callers retain
|
|
* identical semantics on macOS/Linux while gaining resilience on Windows where
|
|
* an antivirus scanner, indexer, or concurrent reader may briefly hold the
|
|
* target open. Enforced by local/require-fs-op-fallback (ADR-1703 Phase 6).
|
|
*/
|
|
export function retryRenameSync(fromPath: string, toPath: string): void {
|
|
const err = atomicRenameWithRetry(fromPath, toPath);
|
|
if (err !== null) throw err;
|
|
}
|
|
|
|
export function platformWriteSync(filePath: string, content: string, opts: { encoding?: BufferEncoding } = {}): void {
|
|
const { content: normalized, encoding } = normalizeContent(filePath, content, opts);
|
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
const tmpPath = filePath + '.tmp.' + process.pid;
|
|
|
|
// Step 1: write the sibling tmp file. If THIS fails, nothing was published, so a
|
|
// direct fallback write cannot truncate a concurrent reader of an existing file.
|
|
try {
|
|
fs.writeFileSync(tmpPath, normalized, encoding);
|
|
} catch {
|
|
try { fs.unlinkSync(tmpPath); } catch { /* already gone */ }
|
|
fs.writeFileSync(filePath, normalized, encoding);
|
|
return;
|
|
}
|
|
|
|
// Step 2: atomic publish, retrying transient Windows locks.
|
|
const renameErr = atomicRenameWithRetry(tmpPath, filePath);
|
|
if (renameErr === null) return;
|
|
|
|
try { fs.unlinkSync(tmpPath); } catch { /* already gone */ }
|
|
if (RENAME_RETRY_ERRNOS.has(renameErr.code ?? '')) {
|
|
// A live reader still holds the target open after every retry. A non-atomic
|
|
// direct write here would truncate that reader (the exact corruption this seam
|
|
// exists to prevent), so surface the error instead of falling back.
|
|
throw renameErr;
|
|
}
|
|
// Atomic publish is genuinely impossible here (e.g. EXDEV cross-device move):
|
|
// fall back to a direct write to preserve write availability.
|
|
fs.writeFileSync(filePath, normalized, encoding);
|
|
}
|
|
|
|
export function platformReadSync(filePath: string, opts: { encoding?: BufferEncoding; required?: boolean } = {}): string | null {
|
|
const encoding = opts.encoding ?? 'utf-8';
|
|
try {
|
|
return fs.readFileSync(filePath, encoding);
|
|
} catch (err) {
|
|
const e = err as NodeJS.ErrnoException;
|
|
if (e.code === 'ENOENT') {
|
|
if (opts.required) throw err;
|
|
return null;
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
export function platformEnsureDir(dirPath: string): void {
|
|
fs.mkdirSync(dirPath, { recursive: true });
|
|
}
|