feat(codex): require git bash for windows install

Plan: plans/codex-windows-git-bash-profile.md
This commit is contained in:
YeonGyu-Kim
2026-05-31 06:03:44 +09:00
parent 5b9ca5fa26
commit 2996b5775e
4 changed files with 175 additions and 3 deletions
@@ -0,0 +1,31 @@
/// <reference path="../../../bun-test.d.ts" />
/// <reference types="bun-types" />
import { describe, expect, test } from "bun:test"
import { mkdtemp, readFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { updateCodexConfig } from "./codex-config-toml"
describe("codex-config shell settings", () => {
test("#given Codex config update #when Git Bash is required by installer #then no fake shell config is written", async () => {
// given
const root = await mkdtemp(join(tmpdir(), "omo-codex-config-shell-"))
const configPath = join(root, "config.toml")
// when
await updateCodexConfig({
configPath,
repoRoot: "/repo/packages/omo-codex",
marketplaceName: "debug",
marketplaceSource: { sourceType: "local", source: "/repo/packages/omo-codex" },
pluginNames: ["omo"],
})
// then
const content = await readFile(configPath, "utf8")
expect(content).not.toContain("default_shell")
expect(content).not.toContain("shell_path")
expect(content).not.toContain("git_bash")
})
})
@@ -0,0 +1,110 @@
/// <reference path="../../../bun-test.d.ts" />
/// <reference types="bun-types" />
import { describe, expect, test } from "bun:test"
import { mkdtemp, readFile, 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"
describe("install-codex Git Bash preflight", () => {
test("#given Windows without Git Bash #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-"))
const commands: string[] = []
// when
const install = runCodexInstaller({
codexHome,
repoRoot,
platform: "win32",
gitBashResolver: () => ({
found: false,
checkedPaths: [WINDOWS_GIT_BASH_PATH],
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: string, args: readonly string[], options: CommandRunOptions) => {
commands.push([command, ...args, options.cwd].join(" "))
},
})
// then
await expect(install).rejects.toThrow("winget install --id Git.Git -e --source winget")
expect(commands).toEqual([])
await expect(stat(join(codexHome, "config.toml"))).rejects.toThrow()
})
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({
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)
expect(await readFile(join(codexHome, "config.toml"), "utf8")).toContain("[marketplaces.sisyphuslabs]")
})
test("#given Windows env override in installer options #when no custom resolver is provided #then default resolver uses it", async () => {
// given
const codexHome = await mkdtemp(join(tmpdir(), "omo-codex-git-bash-env-home-"))
const binDir = await mkdtemp(join(tmpdir(), "omo-codex-git-bash-env-bin-"))
const gitBashPath = join(await mkdtemp(join(tmpdir(), "omo-codex-git-bash-env-")), "bash.exe")
await writeFile(gitBashPath, "")
// when
const result = await runCodexInstaller({
codexHome,
binDir,
repoRoot: process.cwd(),
platform: "win32",
env: { OMO_CODEX_GIT_BASH_PATH: gitBashPath },
runCommand: async () => undefined,
})
// then
expect(result.gitBashPath).toBe(gitBashPath)
})
test("#given non-Windows install #when Git Bash resolver would fail #then installer keeps existing behavior", async () => {
// given
const codexHome = await mkdtemp(join(tmpdir(), "omo-codex-git-bash-linux-home-"))
const binDir = await mkdtemp(join(tmpdir(), "omo-codex-git-bash-linux-bin-"))
// when
const result = await runCodexInstaller({
codexHome,
binDir,
repoRoot: process.cwd(),
platform: "linux",
gitBashResolver: () => ({
found: false,
checkedPaths: [WINDOWS_GIT_BASH_PATH],
installHint: "should not be used",
}),
runCommand: async () => undefined,
})
// then
expect(result.gitBashPath).toBeNull()
expect(await readFile(join(codexHome, "config.toml"), "utf8")).toContain("[marketplaces.sisyphuslabs]")
})
})
+14 -3
View File
@@ -5,6 +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 { linkCachedPluginAgents } from "./link-cached-plugin-agents"
import { readMarketplace, readPluginManifest, resolvePluginSource, validatePathSegment } from "./codex-marketplace"
import { writeInstalledMarketplaceSnapshot, type MarketplaceSnapshotPluginSource } from "./codex-marketplace-snapshot"
@@ -14,12 +15,21 @@ import type { CodexInstallOptions, CodexInstallResult, CodexMarketplaceSource, I
const SISYPHUS_LEGACY_CACHE_MARKETPLACES = ["lazycodex", "code-yeongyu-codex-plugins"] as const
export async function runCodexInstaller(options: CodexInstallOptions = {}): Promise<CodexInstallResult> {
const repoRoot = resolve(options.repoRoot ?? findRepoRoot({ importerDir: import.meta.dir, env: process.env }))
const codexHome = resolve(options.codexHome ?? process.env.CODEX_HOME ?? join(homedir(), ".codex"))
const binDir = resolveCodexInstallerBinDir({ binDir: options.binDir, codexHome, env: process.env })
const env = options.env ?? process.env
const platform = options.platform ?? process.platform
const repoRoot = resolve(options.repoRoot ?? findRepoRoot({ importerDir: import.meta.dir, env }))
const codexHome = resolve(options.codexHome ?? env.CODEX_HOME ?? join(homedir(), ".codex"))
const binDir = resolveCodexInstallerBinDir({ binDir: options.binDir, codexHome, env })
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
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"),
@@ -125,6 +135,7 @@ export async function runCodexInstaller(options: CodexInstallOptions = {}): Prom
installed,
configPath,
codexHome,
gitBashPath: gitBashResolution.path,
}
}
+20
View File
@@ -56,10 +56,29 @@ export type RunCommand = (
options: CommandRunOptions,
) => Promise<void>
export type CodexInstallPlatform = "aix" | "android" | "darwin" | "freebsd" | "haiku" | "linux" | "openbsd" | "sunos" | "win32" | "cygwin" | "netbsd"
export type GitBashResolution =
| {
readonly found: true
readonly path: string | null
readonly source: "not-required" | "env" | "program-files" | "program-files-x86" | "path"
}
| {
readonly found: false
readonly checkedPaths: readonly string[]
readonly installHint: string
}
export type GitBashResolver = () => GitBashResolution
export interface CodexInstallOptions {
readonly codexHome?: string
readonly binDir?: string
readonly repoRoot?: string
readonly platform?: CodexInstallPlatform
readonly env?: { readonly [key: string]: string | undefined }
readonly gitBashResolver?: GitBashResolver
readonly autonomousPermissions?: boolean
readonly runCommand?: RunCommand
readonly log?: (message: string) => void
@@ -70,4 +89,5 @@ export interface CodexInstallResult {
readonly installed: readonly InstalledPlugin[]
readonly configPath: string
readonly codexHome: string
readonly gitBashPath: string | null
}