diff --git a/src/cli/install-codex/git-bash.test.ts b/src/cli/install-codex/git-bash.test.ts new file mode 100644 index 000000000..5407052fd --- /dev/null +++ b/src/cli/install-codex/git-bash.test.ts @@ -0,0 +1,118 @@ +/// +/// + +import { describe, expect, test } from "bun:test" +import { resolveGitBash } from "./git-bash" + +const PROGRAM_FILES_GIT_BASH = "C:\\Program Files\\Git\\bin\\bash.exe" +const PROGRAM_FILES_X86_GIT_BASH = "C:\\Program Files (x86)\\Git\\bin\\bash.exe" + +describe("git-bash", () => { + test("#given non-Windows platform #when resolving Git Bash #then no preflight is required", () => { + // given / when + const result = resolveGitBash({ + platform: "darwin", + env: {}, + exists: () => false, + where: () => [], + }) + + // then + expect(result).toEqual({ found: true, path: null, source: "not-required" }) + }) + + test("#given Windows env override to bash.exe #when the file exists #then env path wins", () => { + // given + const overridePath = "D:\\Tools\\Git\\bin\\bash.exe" + + // when + const result = resolveGitBash({ + platform: "win32", + env: { OMO_CODEX_GIT_BASH_PATH: overridePath }, + exists: (path: string) => path === overridePath, + where: () => [PROGRAM_FILES_GIT_BASH], + }) + + // then + expect(result).toEqual({ found: true, path: overridePath, source: "env" }) + }) + + test("#given Windows env override not pointing to bash.exe #when resolving #then reports invalid override and stops", () => { + // given + const overridePath = "D:\\Tools\\Git\\bin\\git.exe" + + // when + const result = resolveGitBash({ + platform: "win32", + env: { OMO_CODEX_GIT_BASH_PATH: overridePath }, + exists: () => true, + where: () => [PROGRAM_FILES_GIT_BASH], + }) + + // then + expect(result.found).toBe(false) + if (result.found) return + expect(result.checkedPaths).toContain(overridePath) + expect(result.installHint).toContain("OMO_CODEX_GIT_BASH_PATH=C:\\path\\to\\bash.exe") + }) + + test("#given Windows standard 64-bit Git Bash exists #when resolving #then uses Program Files path", () => { + // given / when + const result = resolveGitBash({ + platform: "win32", + env: {}, + exists: (path: string) => path === PROGRAM_FILES_GIT_BASH, + where: () => [], + }) + + // then + expect(result).toEqual({ found: true, path: PROGRAM_FILES_GIT_BASH, source: "program-files" }) + }) + + test("#given Windows standard 32-bit Git Bash exists #when resolving #then uses Program Files x86 path", () => { + // given / when + const result = resolveGitBash({ + platform: "win32", + env: {}, + exists: (path: string) => path === PROGRAM_FILES_X86_GIT_BASH, + where: () => [], + }) + + // then + expect(result).toEqual({ found: true, path: PROGRAM_FILES_X86_GIT_BASH, source: "program-files-x86" }) + }) + + test("#given Windows bash on PATH #when standard paths are missing #then uses where bash candidate", () => { + // given + const pathCandidate = "E:\\Git\\bin\\bash.exe" + + // when + const result = resolveGitBash({ + platform: "win32", + env: {}, + exists: (path: string) => path === pathCandidate, + where: () => ["C:\\Windows\\System32\\bash.exe", pathCandidate], + }) + + // then + expect(result).toEqual({ found: true, path: pathCandidate, source: "path" }) + }) + + test("#given Windows without Git Bash #when resolving #then returns install guidance", () => { + // given / when + const result = resolveGitBash({ + platform: "win32", + env: {}, + exists: () => false, + where: () => [], + }) + + // then + expect(result.found).toBe(false) + if (result.found) return + expect(result.checkedPaths).toEqual([PROGRAM_FILES_GIT_BASH, PROGRAM_FILES_X86_GIT_BASH]) + expect(result.installHint).toContain("winget install --id Git.Git -e --source winget") + expect(result.installHint).toContain("OMO_CODEX_GIT_BASH_PATH=C:\\path\\to\\bash.exe") + expect(result.installHint).toContain("rerun `bunx omo install --platform=codex`") + }) +}) diff --git a/src/cli/install-codex/git-bash.ts b/src/cli/install-codex/git-bash.ts new file mode 100644 index 000000000..f0d75af25 --- /dev/null +++ b/src/cli/install-codex/git-bash.ts @@ -0,0 +1,104 @@ +import { execFileSync } from "node:child_process" +import { existsSync } from "node:fs" + +const GIT_BASH_ENV_KEY = "OMO_CODEX_GIT_BASH_PATH" +const PROGRAM_FILES_GIT_BASH = "C:\\Program Files\\Git\\bin\\bash.exe" +const PROGRAM_FILES_X86_GIT_BASH = "C:\\Program Files (x86)\\Git\\bin\\bash.exe" + +export type GitBashSource = "not-required" | "env" | "program-files" | "program-files-x86" | "path" + +export type GitBashResolution = + | { + readonly found: true + readonly path: string | null + readonly source: GitBashSource + } + | { + readonly found: false + readonly checkedPaths: readonly string[] + readonly installHint: string + } + +export interface GitBashResolverInput { + readonly platform: string + readonly env: { readonly [key: string]: string | undefined } + readonly exists: (path: string) => boolean + readonly where: (command: "bash") => readonly string[] +} + +export function resolveGitBash(input: GitBashResolverInput): GitBashResolution { + if (input.platform !== "win32") return { found: true, path: null, source: "not-required" } + + const checkedPaths: string[] = [] + const envPath = nonEmptyEnvValue(input.env, GIT_BASH_ENV_KEY) + if (envPath !== undefined) { + checkedPaths.push(envPath) + if (isBashExePath(envPath) && input.exists(envPath)) return { found: true, path: envPath, source: "env" } + return missingGitBash(checkedPaths) + } + + for (const candidate of [ + { path: PROGRAM_FILES_GIT_BASH, source: "program-files" }, + { path: PROGRAM_FILES_X86_GIT_BASH, source: "program-files-x86" }, + ] as const) { + checkedPaths.push(candidate.path) + if (input.exists(candidate.path)) return { found: true, path: candidate.path, source: candidate.source } + } + + for (const pathCandidate of input.where("bash")) { + const candidate = pathCandidate.trim() + if (candidate.length === 0) continue + checkedPaths.push(candidate) + if (isBashExePath(candidate) && input.exists(candidate)) return { found: true, path: candidate, source: "path" } + } + + return missingGitBash(checkedPaths) +} + +export function resolveGitBashForCurrentProcess(input: { + readonly platform?: string + readonly env?: { readonly [key: string]: string | undefined } +} = {}): GitBashResolution { + return resolveGitBash({ + platform: input.platform ?? process.platform, + env: input.env ?? process.env, + exists: existsSync, + where: whereCommand, + }) +} + +function missingGitBash(checkedPaths: readonly string[]): GitBashResolution { + return { + found: false, + checkedPaths, + installHint: [ + "Git Bash is required for native Windows Codex profile installs.", + "Install it with: winget install --id Git.Git -e --source winget", + `For a custom install, set ${GIT_BASH_ENV_KEY}=C:\\path\\to\\bash.exe`, + "Then rerun `bunx omo install --platform=codex`.", + ].join("\n"), + } +} + +function nonEmptyEnvValue(env: { readonly [key: string]: string | undefined }, key: string): string | undefined { + const value = env[key] + if (value === undefined) return undefined + const trimmed = value.trim() + return trimmed.length === 0 ? undefined : trimmed +} + +function isBashExePath(path: string): boolean { + return path.toLowerCase().endsWith("bash.exe") +} + +function whereCommand(command: "bash"): readonly string[] { + try { + return execFileSync("where", [command], { encoding: "utf8" }) + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0) + } catch (error) { + if (error instanceof Error) return [] + throw error + } +}