A git installed DURING first-run setup stayed invisible (spawn git ENOENT on project creation; onboarding indicator stuck on missing) because the server's PATH snapshot predates the install. New resolver probes well-known install locations on PATH ENOENT with cache invalidation, wired into project git init/workspace detection, the onboarding git status probe, and the dashboard clone route. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
77 lines
2.8 KiB
TypeScript
77 lines
2.8 KiB
TypeScript
/*
|
|
FNXC:Onboarding 2026-07-18-03:20:
|
|
Field report: a user installed git DURING first-time setup, but project
|
|
creation kept failing with spawn git ENOENT until Fusion was restarted. The
|
|
running server's PATH snapshot predates the install (on Windows the installer
|
|
updates the registry PATH, which running processes never see), so bare
|
|
execFile("git", ...) cannot find a just-installed git. Resolve git once via
|
|
PATH and, on ENOENT, fall back to the platform's well-known install
|
|
locations; cache the winner and invalidate the cache on a later ENOENT so an
|
|
install or uninstall mid-session is picked up without a restart.
|
|
*/
|
|
|
|
import { execFile } from "node:child_process";
|
|
import { existsSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { promisify } from "node:util";
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
let cachedGitBinary: string | null = null;
|
|
|
|
/** Well-known absolute git locations checked when PATH resolution fails. */
|
|
export function wellKnownGitBinaryPaths(
|
|
platform: NodeJS.Platform = process.platform,
|
|
env: NodeJS.ProcessEnv = process.env,
|
|
): string[] {
|
|
if (platform === "win32") {
|
|
const roots = [
|
|
env.ProgramFiles,
|
|
env["ProgramFiles(x86)"],
|
|
env.LOCALAPPDATA ? join(env.LOCALAPPDATA, "Programs") : undefined,
|
|
].filter((root): root is string => Boolean(root));
|
|
return roots.map((root) => join(root, "Git", "cmd", "git.exe"));
|
|
}
|
|
if (platform === "darwin") {
|
|
return ["/opt/homebrew/bin/git", "/usr/local/bin/git", "/usr/bin/git"];
|
|
}
|
|
return ["/usr/bin/git", "/usr/local/bin/git"];
|
|
}
|
|
|
|
/** True when the error is a spawn-level "binary not found". */
|
|
export function isSpawnGitEnoent(error: unknown): boolean {
|
|
return Boolean(error && typeof error === "object" && (error as { code?: unknown }).code === "ENOENT");
|
|
}
|
|
|
|
/**
|
|
* Resolve the git binary to spawn: "git" (PATH) when it works, otherwise the
|
|
* first existing well-known install location. Returns "git" as the final
|
|
* fallback so callers surface the ordinary ENOENT when git is truly absent.
|
|
*/
|
|
export async function resolveGitBinary(): Promise<string> {
|
|
if (cachedGitBinary) return cachedGitBinary;
|
|
try {
|
|
await execFileAsync("git", ["--version"], { timeout: 5_000, windowsHide: true });
|
|
cachedGitBinary = "git";
|
|
return cachedGitBinary;
|
|
} catch (error) {
|
|
if (!isSpawnGitEnoent(error)) {
|
|
// git exists but errored (e.g. timeout) — PATH resolution itself works.
|
|
cachedGitBinary = "git";
|
|
return cachedGitBinary;
|
|
}
|
|
}
|
|
for (const candidate of wellKnownGitBinaryPaths()) {
|
|
if (existsSync(candidate)) {
|
|
cachedGitBinary = candidate;
|
|
return cachedGitBinary;
|
|
}
|
|
}
|
|
return "git";
|
|
}
|
|
|
|
/** Drop the cached resolution (call on a later ENOENT so installs/uninstalls mid-session are re-probed). */
|
|
export function invalidateGitBinaryCache(): void {
|
|
cachedGitBinary = null;
|
|
}
|