@blundergoat/goat-flow
Version:
AI coding agent harness and local dashboard for Claude Code, OpenAI Codex, Google Antigravity, and GitHub Copilot - setup audits, guardrails, structured skills, deny hooks, and persistent learning loops.
189 lines • 7.38 kB
JavaScript
/**
* Cross-platform invocation helpers for the bundled Bash installer.
*
* `handleInstallCommand` shells out to `workflow/install-goat-flow.sh` via
* `spawnSync("bash", ...)`. On native Windows two things go wrong with the
* naive call:
*
* 1. `getTemplatePath()` and `resolve(".")` return backslash paths. When
* Bash receives them as argv, the backslashes act as shell escapes and
* collapse the path (e.g. `C:\Users\...\install.sh` -> `CUsers...`).
* 2. `bash` on a stock Windows host resolves to `System32\bash.exe` (WSL)
* or the `WindowsApps\bash.exe` proxy first, which does not accept
* Windows-shaped paths and is slow to boot from PowerShell.
*
* This module owns the platform-gated argument shape and the Bash selection
* policy. POSIX behavior is intentionally byte-for-byte unchanged.
*/
import { execFileSync } from "node:child_process";
import { delimiter, dirname, win32 } from "node:path";
/**
* Build the (bash, argv) pair that `spawnSync` should use.
*
* Linux/macOS/WSL (`platform` other than `"win32"`): returns the raw inputs
* with `bash` as the command. The shape matches the historical call so POSIX
* users see no behavioural change.
*
* Native Windows (`platform === "win32"`): forward-slash-normalises the script
* and project paths so Git Bash / MSYS2 receive a valid path, and picks a
* non-WSL `bash.exe` from the supplied candidates.
*
* @param params Installer script, target project, agent, flags, and platform-specific Bash candidates.
* @returns Spawn-ready Bash command and argv, or a CLI-ready error when Windows has no usable Bash.
*/
export function buildInstallerInvocation(params) {
const installerFlags = [...params.installerFlags];
if (params.platform !== "win32") {
return {
ok: true,
bashCommand: "bash",
args: [
params.scriptPath,
params.projectPath,
"--agent",
params.agent,
...installerFlags,
],
};
}
const candidates = params.windowsBashCandidates ?? discoverWindowsBashCandidates();
const selected = pickWindowsBashPath(candidates);
if (!selected) {
return {
ok: false,
error: buildWindowsBashMissingMessage(candidates),
};
}
return {
ok: true,
bashCommand: selected,
args: [
toBashPath(params.scriptPath),
toBashPath(params.projectPath),
"--agent",
params.agent,
...installerFlags,
],
};
}
/**
* Build the exact spawn command for a successful installer invocation.
*
* @param invocation Selected Bash command and installer argv.
* @param baseEnv Environment to inherit; tests may pass a fixed object.
* @returns Command, argv, and PATH-adjusted environment for `spawnSync`.
*/
export function buildInstallerSpawnSpec(invocation, baseEnv = process.env) {
return {
command: invocation.bashCommand,
args: invocation.args,
env: installerSpawnEnv(invocation.bashCommand, baseEnv),
};
}
/**
* Convert a Windows path to a form Bash will not shell-escape.
*
* Drive-letter: `C:\Users\me` -> `C:/Users/me`
* UNC share: `\\srv\share\x` -> `//srv/share/x`
*
* POSIX paths contain no backslashes so the operation is a no-op for them,
* which matters because tests assert that POSIX inputs are byte-identical.
*
* @param shellPath Path argument that will be passed to Bash.
* @returns The same path with Windows backslashes converted to forward slashes.
*/
export function toBashPath(shellPath) {
return shellPath.replace(/\\/g, "/");
}
/** Build installer spawn env; selected Windows Bash paths get PATH precedence without a dynamic command. */
function installerSpawnEnv(bashCommand, baseEnv) {
if (bashCommand === "bash")
return baseEnv;
const existingPath = baseEnv.PATH ?? "";
return {
...baseEnv,
PATH: `${bashCommandDir(bashCommand)}${delimiter}${existingPath}`,
};
}
/** Return dirname using Windows semantics for Windows-shaped Bash executable paths. */
function bashCommandDir(bashCommand) {
if (/^[A-Za-z]:[\\/]/.test(bashCommand) || bashCommand.includes("\\")) {
return win32.dirname(bashCommand);
}
return dirname(bashCommand);
}
/**
* Pick the first Windows `bash.exe` that is not one of the known WSL shims.
*
* We reject by path rather than allowlist Git Bash because users may install
* MSYS2, Cygwin, Scoop, or Chocolatey distributions whose paths are not
* predictable. The two known-bad locations both belong to WSL:
*
* - `C:\Windows\System32\bash.exe` (Windows Subsystem for Linux launcher)
* - `%LOCALAPPDATA%\Microsoft\WindowsApps\bash.exe` (Store-managed WSL proxy)
*
* @param candidates Raw `where bash` output lines or test-injected candidate paths.
* @returns First non-WSL Bash path, or null when every candidate is unusable.
*/
export function pickWindowsBashPath(candidates) {
const cleaned = Array.from(new Set(candidates
.map((candidate) => candidate.trim())
.filter((candidate) => candidate.length > 0)));
if (cleaned.length === 0)
return null;
const accepted = cleaned.filter((candidate) => !isWslBashPath(candidate));
return accepted[0] ?? null;
}
/**
* True if the candidate path matches a known WSL launcher location.
*
* @param candidate Bash executable path from discovery.
* @returns Whether the candidate is a WSL launcher that rejects Windows-shaped installer paths.
*/
export function isWslBashPath(candidate) {
const normalised = candidate.replace(/\//g, "\\").toLowerCase();
return (normalised.includes("\\system32\\bash.exe") ||
normalised.includes("\\windowsapps\\bash.exe"));
}
/** Probe `where bash` for candidate paths; reads PATH and swallows command failures as no candidates. */
function discoverWindowsBashCandidates() {
try {
const output = execFileSync("where", ["bash"], {
encoding: "utf-8",
timeout: 5000,
});
return output
.split(/\r?\n/)
.map((candidate) => candidate.trim())
.filter((candidate) => candidate.length > 0);
}
catch {
return [];
}
}
/**
* Render the actionable error when no usable Bash is found on Windows.
*
* @param candidates Rejected Bash paths from discovery, if any.
* @returns Multi-line CLI error that explains the Git Bash and WSL fallback options.
*/
export function buildWindowsBashMissingMessage(candidates) {
const rejected = candidates
.map((candidate) => candidate.trim())
.filter((candidate) => candidate.length > 0);
const lines = [
"Install requires a Windows-compatible Bash, but none was found.",
];
if (rejected.length > 0) {
lines.push("Detected candidates (all rejected as WSL launchers):");
for (const candidate of rejected) {
lines.push(` - ${candidate}`);
}
}
else {
lines.push("`where bash` returned no candidates.");
}
lines.push("Install Git for Windows (https://git-scm.com/download/win) and re-run from", "PowerShell or CMD, or run the command from inside WSL using /mnt/c/... paths.");
return lines.join("\n");
}
//# sourceMappingURL=install-invocation.js.map