From 37346a86f7accec7532298fe7dad33cf8a0feba6 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 31 May 2026 06:03:52 +0900 Subject: [PATCH] feat(codex): mirror git bash preflight locally Plan: plans/codex-windows-git-bash-profile.md --- .../install-local-git-bash-preflight.test.mjs | 89 +++++++++++++++++++ packages/omo-codex/scripts/install-local.mjs | 9 +- .../omo-codex/scripts/install/git-bash.mjs | 80 +++++++++++++++++ .../scripts/install/git-bash.test.mjs | 70 +++++++++++++++ 4 files changed, 247 insertions(+), 1 deletion(-) create mode 100644 packages/omo-codex/scripts/install-local-git-bash-preflight.test.mjs create mode 100644 packages/omo-codex/scripts/install/git-bash.mjs create mode 100644 packages/omo-codex/scripts/install/git-bash.test.mjs diff --git a/packages/omo-codex/scripts/install-local-git-bash-preflight.test.mjs b/packages/omo-codex/scripts/install-local-git-bash-preflight.test.mjs new file mode 100644 index 000000000..81c1256ff --- /dev/null +++ b/packages/omo-codex/scripts/install-local-git-bash-preflight.test.mjs @@ -0,0 +1,89 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { installMarketplaceLocally } from "./install-local.mjs"; + +const windowsGitBashPath = "C:\\Program Files\\Git\\bin\\bash.exe"; + +test("#given Windows without Git Bash #when installing local marketplace #then rejects before marketplace or config mutation", async () => { + const repoRoot = await mkdtemp(join(tmpdir(), "omo-codex-script-git-bash-missing-repo-")); + const codexHome = await mkdtemp(join(tmpdir(), "omo-codex-script-git-bash-missing-home-")); + const commands = []; + + await assert.rejects( + installMarketplaceLocally({ + repoRoot, + codexHome, + platform: "win32", + gitBashResolver: () => ({ + found: false, + checkedPaths: [windowsGitBashPath], + installHint: [ + "Git Bash is required.", + "winget install --id Git.Git -e --source winget", + "OMO_CODEX_GIT_BASH_PATH=C:\\path\\to\\bash.exe", + "rerun `bunx omo install --platform=codex`", + ].join("\n"), + }), + runCommand: async (command, args, options) => { + commands.push([command, ...args, options.cwd].join(" ")); + }, + log: () => {}, + }), + /winget install --id Git\.Git -e --source winget/, + ); + assert.deepEqual(commands, []); + await assert.rejects(stat(join(codexHome, "config.toml")), /ENOENT/); +}); + +test("#given Windows env override resolves Git Bash #when installing local marketplace #then install continues", async () => { + const result = await installMarketplaceLocally({ + repoRoot: process.cwd(), + codexHome: await mkdtemp(join(tmpdir(), "omo-codex-script-git-bash-home-")), + binDir: await mkdtemp(join(tmpdir(), "omo-codex-script-git-bash-bin-")), + platform: "win32", + gitBashResolver: () => ({ found: true, path: windowsGitBashPath, source: "env" }), + runCommand: async () => {}, + log: () => {}, + }); + + assert.equal(result.gitBashPath, windowsGitBashPath); + assert.equal(result.installed.length, 1); +}); + +test("#given Windows env override in installer options #when no custom resolver is provided #then default resolver uses it", async () => { + const codexHome = await mkdtemp(join(tmpdir(), "omo-codex-script-git-bash-env-home-")); + const gitBashPath = join(await mkdtemp(join(tmpdir(), "omo-codex-script-git-bash-env-")), "bash.exe"); + await writeFile(gitBashPath, ""); + + const result = await installMarketplaceLocally({ + repoRoot: process.cwd(), + codexHome, + binDir: await mkdtemp(join(tmpdir(), "omo-codex-script-git-bash-env-bin-")), + platform: "win32", + env: { OMO_CODEX_GIT_BASH_PATH: gitBashPath }, + runCommand: async () => {}, + log: () => {}, + }); + + assert.equal(result.gitBashPath, gitBashPath); +}); + +test("#given non-Windows local install #when resolver would fail #then installer keeps existing behavior", async () => { + const codexHome = await mkdtemp(join(tmpdir(), "omo-codex-script-git-bash-linux-home-")); + const result = await installMarketplaceLocally({ + repoRoot: process.cwd(), + codexHome, + binDir: await mkdtemp(join(tmpdir(), "omo-codex-script-git-bash-linux-bin-")), + platform: "linux", + gitBashResolver: () => ({ found: false, checkedPaths: [windowsGitBashPath], installHint: "should not be used" }), + runCommand: async () => {}, + log: () => {}, + }); + + assert.equal(result.gitBashPath, null); + assert.match(await readFile(join(codexHome, "config.toml"), "utf8"), /\[marketplaces\.sisyphuslabs\]/); +}); diff --git a/packages/omo-codex/scripts/install-local.mjs b/packages/omo-codex/scripts/install-local.mjs index 470ffecbc..6510a6bcf 100644 --- a/packages/omo-codex/scripts/install-local.mjs +++ b/packages/omo-codex/scripts/install-local.mjs @@ -21,6 +21,7 @@ import { resolvePluginSource, validatePathSegment, } from "./install/marketplace.mjs"; +import { resolveGitBashForCurrentProcess } from "./install/git-bash.mjs"; const LEGACY_CODEX_PLUGIN_MARKETPLACE = ["code", "yeongyu", "codex", "plugins"].join("-"); const SISYPHUS_LEGACY_CACHE_MARKETPLACES = ["lazycodex", LEGACY_CODEX_PLUGIN_MARKETPLACE]; @@ -45,6 +46,12 @@ export async function installMarketplaceLocally(options = {}) { const platform = options.platform ?? process.platform; const runCommand = options.runCommand ?? defaultRunCommand; const log = options.log ?? console.log; + const gitBashResolution = platform === "win32" + ? (options.gitBashResolver ?? (() => resolveGitBashForCurrentProcess({ platform, env })))() + : { found: true, path: null, source: "not-required" }; + if (!gitBashResolution.found) { + throw new Error(gitBashResolution.installHint); + } const codexPackageRoot = join(repoRoot, "packages", "omo-codex"); const marketplace = await readMarketplace(repoRoot, { marketplacePath: join(codexPackageRoot, "marketplace.json"), @@ -128,7 +135,7 @@ export async function installMarketplaceLocally(options = {}) { log(`Installed ${plugin.name}@${marketplace.name} -> ${plugin.path}`); } - return { marketplaceName: marketplace.name, installed }; + return { marketplaceName: marketplace.name, installed, gitBashPath: gitBashResolution.path }; } function agentNameFromToml(fileName) { diff --git a/packages/omo-codex/scripts/install/git-bash.mjs b/packages/omo-codex/scripts/install/git-bash.mjs new file mode 100644 index 000000000..2a21d8297 --- /dev/null +++ b/packages/omo-codex/scripts/install/git-bash.mjs @@ -0,0 +1,80 @@ +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 function resolveGitBash({ platform, env, exists, where }) { + if (platform !== "win32") return { found: true, path: null, source: "not-required" }; + + const checkedPaths = []; + const envPath = nonEmptyEnvValue(env, GIT_BASH_ENV_KEY); + if (envPath !== undefined) { + checkedPaths.push(envPath); + if (isBashExePath(envPath) && 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" }, + ]) { + checkedPaths.push(candidate.path); + if (exists(candidate.path)) return { found: true, path: candidate.path, source: candidate.source }; + } + + for (const pathCandidate of where("bash")) { + const candidate = pathCandidate.trim(); + if (candidate.length === 0) continue; + checkedPaths.push(candidate); + if (isBashExePath(candidate) && exists(candidate)) return { found: true, path: candidate, source: "path" }; + } + + return missingGitBash(checkedPaths); +} + +export function resolveGitBashForCurrentProcess(options = {}) { + return resolveGitBash({ + platform: options.platform ?? process.platform, + env: options.env ?? process.env, + exists: existsSync, + where: whereCommand, + }); +} + +function missingGitBash(checkedPaths) { + 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, key) { + const value = env[key]; + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed.length === 0 ? undefined : trimmed; +} + +function isBashExePath(path) { + return path.toLowerCase().endsWith("bash.exe"); +} + +function whereCommand(command) { + 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; + } +} diff --git a/packages/omo-codex/scripts/install/git-bash.test.mjs b/packages/omo-codex/scripts/install/git-bash.test.mjs new file mode 100644 index 000000000..45b7c9639 --- /dev/null +++ b/packages/omo-codex/scripts/install/git-bash.test.mjs @@ -0,0 +1,70 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { resolveGitBash } from "./git-bash.mjs"; + +const programFilesGitBash = "C:\\Program Files\\Git\\bin\\bash.exe"; +const programFilesX86GitBash = "C:\\Program Files (x86)\\Git\\bin\\bash.exe"; + +test("#given non-Windows platform #when resolving Git Bash #then no preflight is required", () => { + const result = resolveGitBash({ + platform: "linux", + env: {}, + exists: () => false, + where: () => [], + }); + + assert.deepEqual(result, { found: true, path: null, source: "not-required" }); +}); + +test("#given Windows env override to bash.exe #when the file exists #then env path wins", () => { + const overridePath = "D:\\Tools\\Git\\bin\\bash.exe"; + const result = resolveGitBash({ + platform: "win32", + env: { OMO_CODEX_GIT_BASH_PATH: overridePath }, + exists: (path) => path === overridePath, + where: () => [programFilesGitBash], + }); + + assert.deepEqual(result, { found: true, path: overridePath, source: "env" }); +}); + +test("#given Windows standard paths are absent and PATH contains bash #when resolving #then uses where bash candidate", () => { + const pathCandidate = "E:\\Git\\bin\\bash.exe"; + const result = resolveGitBash({ + platform: "win32", + env: {}, + exists: (path) => path === pathCandidate, + where: () => ["C:\\Windows\\System32\\bash.exe", pathCandidate], + }); + + assert.deepEqual(result, { found: true, path: pathCandidate, source: "path" }); +}); + +test("#given Windows invalid env override #when resolving #then returns guidance without falling through", () => { + const overridePath = "D:\\Tools\\Git\\bin\\git.exe"; + const result = resolveGitBash({ + platform: "win32", + env: { OMO_CODEX_GIT_BASH_PATH: overridePath }, + exists: () => true, + where: () => [programFilesGitBash], + }); + + assert.equal(result.found, false); + assert.deepEqual(result.checkedPaths, [overridePath]); + assert.match(result.installHint, /OMO_CODEX_GIT_BASH_PATH=C:\\path\\to\\bash\.exe/); +}); + +test("#given Windows without Git Bash #when resolving #then returns install guidance", () => { + const result = resolveGitBash({ + platform: "win32", + env: {}, + exists: () => false, + where: () => [], + }); + + assert.equal(result.found, false); + assert.deepEqual(result.checkedPaths, [programFilesGitBash, programFilesX86GitBash]); + assert.match(result.installHint, /winget install --id Git\.Git -e --source winget/); + assert.match(result.installHint, /rerun `bunx omo install --platform=codex`/); +});