feat(codex): auto-install git bash via winget on windows

This commit is contained in:
YeonGyu-Kim
2026-05-31 11:00:30 +09:00
parent 021d4e3c97
commit 4fded80f6a
8 changed files with 351 additions and 26 deletions
+71 -1
View File
@@ -2,7 +2,7 @@
/// <reference types="bun-types" />
import { describe, expect, test } from "bun:test"
import { resolveGitBash } from "./git-bash"
import { prepareGitBashForInstall, 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"
@@ -115,4 +115,74 @@ describe("git-bash", () => {
expect(result.installHint).toContain("OMO_CODEX_GIT_BASH_PATH=C:\\path\\to\\bash.exe")
expect(result.installHint).toContain("rerun `bunx omo install --platform=codex`")
})
test("#given Windows without Git Bash and winget is allowed #when preparing #then winget runs and resolver is retried", async () => {
// given
const runCalls: string[] = []
const resolutions = [
{ found: false, checkedPaths: [PROGRAM_FILES_GIT_BASH], installHint: "install hint" } as const,
{ found: true, path: PROGRAM_FILES_GIT_BASH, source: "program-files" } as const,
]
let resolveCallCount = 0
// when
const result = await prepareGitBashForInstall({
platform: "win32",
env: {},
cwd: "C:\\repo",
resolveGitBash: () => resolutions[resolveCallCount++] ?? resolutions[resolutions.length - 1],
runCommand: async (command, args, options) => {
runCalls.push([command, ...args, options.cwd].join(" "))
},
})
// then
expect(runCalls).toEqual(["winget install --id Git.Git -e --source winget C:\\repo"])
expect(resolveCallCount).toBe(2)
expect(result).toEqual({ found: true, path: PROGRAM_FILES_GIT_BASH, source: "program-files" })
})
test("#given Windows without Git Bash and skip env is set #when preparing #then winget is not run and install hint is returned", async () => {
// given
const runCalls: string[] = []
const missingResolution = {
found: false,
checkedPaths: [PROGRAM_FILES_GIT_BASH, PROGRAM_FILES_X86_GIT_BASH],
installHint: "install hint",
} as const
// when
const result = await prepareGitBashForInstall({
platform: "win32",
env: { OMO_CODEX_SKIP_GIT_BASH_AUTO_INSTALL: "1" },
cwd: "C:\\repo",
resolveGitBash: () => missingResolution,
runCommand: async (command, args, options) => {
runCalls.push([command, ...args, options.cwd].join(" "))
},
})
// then
expect(runCalls).toEqual([])
expect(result).toEqual(missingResolution)
})
test("#given non-Windows platform #when preparing #then winget is never called", async () => {
// given
const runCalls: string[] = []
// when
const result = await prepareGitBashForInstall({
platform: "linux",
env: {},
cwd: "/repo",
runCommand: async (command, args, options) => {
runCalls.push([command, ...args, options.cwd].join(" "))
},
})
// then
expect(runCalls).toEqual([])
expect(result).toEqual({ found: true, path: null, source: "not-required" })
})
})
+25
View File
@@ -1,9 +1,12 @@
import { execFileSync } from "node:child_process"
import { existsSync } from "node:fs"
import type { RunCommand } from "./types"
const GIT_BASH_ENV_KEY = "OMO_CODEX_GIT_BASH_PATH"
const SKIP_GIT_BASH_AUTO_INSTALL_ENV_KEY = "OMO_CODEX_SKIP_GIT_BASH_AUTO_INSTALL"
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"
const WINGET_INSTALL_ARGS = ["install", "--id", "Git.Git", "-e", "--source", "winget"] as const
export type GitBashSource = "not-required" | "env" | "program-files" | "program-files-x86" | "path"
@@ -67,6 +70,28 @@ export function resolveGitBashForCurrentProcess(input: {
})
}
export async function prepareGitBashForInstall(input: {
readonly platform: string
readonly env: { readonly [key: string]: string | undefined }
readonly cwd: string
readonly runCommand: RunCommand
readonly resolveGitBash?: () => GitBashResolution
}): Promise<GitBashResolution> {
const resolve = input.resolveGitBash ?? (() => resolveGitBashForCurrentProcess({ platform: input.platform, env: input.env }))
const initialResolution = resolve()
if (input.platform !== "win32" || initialResolution.found) return initialResolution
if (input.env[SKIP_GIT_BASH_AUTO_INSTALL_ENV_KEY] === "1") return initialResolution
try {
await input.runCommand("winget", WINGET_INSTALL_ARGS, { cwd: input.cwd })
} catch (error) {
if (!(error instanceof Error)) throw error
return initialResolution
}
return resolve()
}
function missingGitBash(checkedPaths: readonly string[]): GitBashResolution {
return {
found: false,
@@ -2,16 +2,38 @@
/// <reference types="bun-types" />
import { describe, expect, test } from "bun:test"
import { mkdtemp, readFile, stat, writeFile } from "node:fs/promises"
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { runCodexInstaller } from "./install-codex"
import type { CommandRunOptions } from "./types"
const WINDOWS_GIT_BASH_PATH = "C:\\Program Files\\Git\\bin\\bash.exe"
const LSP_CLI_PATH = join(process.cwd(), "packages", "lsp-tools-mcp", "dist", "cli.js")
async function withBundledLspRuntimeForTest<T>(run: () => Promise<T>): Promise<T> {
let lspCliAlreadyPresent = true
try {
await stat(LSP_CLI_PATH)
} catch (error) {
if (!(error instanceof Error)) throw error
lspCliAlreadyPresent = false
await mkdir(join(process.cwd(), "packages", "lsp-tools-mcp", "dist"), { recursive: true })
await writeFile(LSP_CLI_PATH, "#!/usr/bin/env node\n")
}
try {
return await run()
} finally {
if (!lspCliAlreadyPresent) {
await rm(LSP_CLI_PATH, { force: true })
await rm(join(process.cwd(), "packages", "lsp-tools-mcp", "dist"), { recursive: true, force: true })
}
}
}
describe("install-codex Git Bash preflight", () => {
test("#given Windows without Git Bash #when installing Codex profile #then rejects before marketplace or config mutation", async () => {
test("#given Windows without Git Bash and auto install skip env #when installing Codex profile #then rejects before marketplace or config mutation", async () => {
// given
const codexHome = await mkdtemp(join(tmpdir(), "omo-codex-git-bash-missing-home-"))
const repoRoot = await mkdtemp(join(tmpdir(), "omo-codex-git-bash-missing-repo-"))
@@ -22,6 +44,7 @@ describe("install-codex Git Bash preflight", () => {
codexHome,
repoRoot,
platform: "win32",
env: { OMO_CODEX_SKIP_GIT_BASH_AUTO_INSTALL: "1" },
gitBashResolver: () => ({
found: false,
checkedPaths: [WINDOWS_GIT_BASH_PATH],
@@ -43,20 +66,80 @@ describe("install-codex Git Bash preflight", () => {
await expect(stat(join(codexHome, "config.toml"))).rejects.toThrow()
})
test("#given Windows without Git Bash #when winget succeeds and resolver recovers #then install continues", async () => {
// given
const codexHome = await mkdtemp(join(tmpdir(), "omo-codex-git-bash-auto-install-home-"))
const binDir = await mkdtemp(join(tmpdir(), "omo-codex-git-bash-auto-install-bin-"))
const runCalls: string[] = []
const resolutions = [
{
found: false,
checkedPaths: [WINDOWS_GIT_BASH_PATH],
installHint: "install hint before winget",
} as const,
{
found: true,
path: WINDOWS_GIT_BASH_PATH,
source: "program-files",
} as const,
]
let resolveCallCount = 0
// when
const result = await withBundledLspRuntimeForTest(async () => runCodexInstaller({
codexHome,
binDir,
repoRoot: process.cwd(),
platform: "win32",
gitBashResolver: () => resolutions[resolveCallCount++] ?? resolutions[resolutions.length - 1],
runCommand: async (command: string, args: readonly string[], options: CommandRunOptions) => {
runCalls.push([command, ...args, options.cwd].join(" "))
},
}))
// then
expect(runCalls).toContain(`winget install --id Git.Git -e --source winget ${process.cwd()}`)
expect(resolveCallCount).toBe(2)
expect(result.gitBashPath).toBe(WINDOWS_GIT_BASH_PATH)
})
test("#given non-Windows install #when running installer #then winget is never called", async () => {
// given
const codexHome = await mkdtemp(join(tmpdir(), "omo-codex-git-bash-no-winget-linux-home-"))
const binDir = await mkdtemp(join(tmpdir(), "omo-codex-git-bash-no-winget-linux-bin-"))
const runCalls: string[] = []
// when
const result = await withBundledLspRuntimeForTest(async () => runCodexInstaller({
codexHome,
binDir,
repoRoot: process.cwd(),
platform: "linux",
gitBashResolver: () => ({ found: true, path: WINDOWS_GIT_BASH_PATH, source: "program-files" }),
runCommand: async (command: string, args: readonly string[], options: CommandRunOptions) => {
runCalls.push([command, ...args, options.cwd].join(" "))
},
}))
// then
expect(result.gitBashPath).toBeNull()
expect(runCalls.some((command) => command.startsWith("winget "))).toBe(false)
})
test("#given Windows with Git Bash #when installing Codex profile #then proceeds and reports detected path", async () => {
// given
const codexHome = await mkdtemp(join(tmpdir(), "omo-codex-git-bash-present-home-"))
const binDir = await mkdtemp(join(tmpdir(), "omo-codex-git-bash-present-bin-"))
// when
const result = await runCodexInstaller({
const result = await withBundledLspRuntimeForTest(async () => runCodexInstaller({
codexHome,
binDir,
repoRoot: process.cwd(),
platform: "win32",
gitBashResolver: () => ({ found: true, path: WINDOWS_GIT_BASH_PATH, source: "program-files" }),
runCommand: async () => undefined,
})
}))
// then
expect(result.gitBashPath).toBe(WINDOWS_GIT_BASH_PATH)
@@ -71,14 +154,14 @@ describe("install-codex Git Bash preflight", () => {
await writeFile(gitBashPath, "")
// when
const result = await runCodexInstaller({
const result = await withBundledLspRuntimeForTest(async () => runCodexInstaller({
codexHome,
binDir,
repoRoot: process.cwd(),
platform: "win32",
env: { OMO_CODEX_GIT_BASH_PATH: gitBashPath },
runCommand: async () => undefined,
})
}))
// then
expect(result.gitBashPath).toBe(gitBashPath)
@@ -90,7 +173,7 @@ describe("install-codex Git Bash preflight", () => {
const binDir = await mkdtemp(join(tmpdir(), "omo-codex-git-bash-linux-bin-"))
// when
const result = await runCodexInstaller({
const result = await withBundledLspRuntimeForTest(async () => runCodexInstaller({
codexHome,
binDir,
repoRoot: process.cwd(),
@@ -101,7 +184,7 @@ describe("install-codex Git Bash preflight", () => {
installHint: "should not be used",
}),
runCommand: async () => undefined,
})
}))
// then
expect(result.gitBashPath).toBeNull()
+10 -4
View File
@@ -5,7 +5,7 @@ import { mkdir, writeFile } from "node:fs/promises"
import { installCachedPlugin, linkCachedPluginBins, pruneMarketplaceCache, pruneMarketplacePluginCaches } from "./codex-cache"
import { updateCodexConfig } from "./codex-config-toml"
import { trustedHookStatesForPlugin } from "./codex-hook-trust"
import { resolveGitBashForCurrentProcess } from "./git-bash"
import { prepareGitBashForInstall, resolveGitBashForCurrentProcess } from "./git-bash"
import { linkCachedPluginAgents } from "./link-cached-plugin-agents"
import { readMarketplace, readPluginManifest, resolvePluginSource, validatePathSegment } from "./codex-marketplace"
import { writeInstalledMarketplaceSnapshot, type MarketplaceSnapshotPluginSource } from "./codex-marketplace-snapshot"
@@ -23,9 +23,15 @@ export async function runCodexInstaller(options: CodexInstallOptions = {}): Prom
const runCommand = options.runCommand ?? defaultRunCommand
const log = options.log ?? (() => undefined)
const gitBashResolution = platform === "win32"
? (options.gitBashResolver ?? (() => resolveGitBashForCurrentProcess({ platform, env })))()
: { found: true, path: null, source: "not-required" } as const
const gitBashResolution = await prepareGitBashForInstall({
platform,
env,
cwd: repoRoot,
runCommand,
resolveGitBash: platform === "win32"
? (options.gitBashResolver ?? (() => resolveGitBashForCurrentProcess({ platform, env })))
: undefined,
})
if (!gitBashResolution.found) {
throw new Error(gitBashResolution.installHint)
}