diff --git a/src/cli/install-codex/codex-config-shell-settings.test.ts b/src/cli/install-codex/codex-config-shell-settings.test.ts new file mode 100644 index 000000000..aca736d2c --- /dev/null +++ b/src/cli/install-codex/codex-config-shell-settings.test.ts @@ -0,0 +1,31 @@ +/// +/// + +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") + }) +}) diff --git a/src/cli/install-codex/install-codex-git-bash-preflight.test.ts b/src/cli/install-codex/install-codex-git-bash-preflight.test.ts new file mode 100644 index 000000000..627bbbcd9 --- /dev/null +++ b/src/cli/install-codex/install-codex-git-bash-preflight.test.ts @@ -0,0 +1,110 @@ +/// +/// + +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]") + }) +}) diff --git a/src/cli/install-codex/install-codex.ts b/src/cli/install-codex/install-codex.ts index 4ea48ef57..7d4728b7f 100644 --- a/src/cli/install-codex/install-codex.ts +++ b/src/cli/install-codex/install-codex.ts @@ -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 { - 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, } } diff --git a/src/cli/install-codex/types.ts b/src/cli/install-codex/types.ts index 5254a2b43..c5be93be1 100644 --- a/src/cli/install-codex/types.ts +++ b/src/cli/install-codex/types.ts @@ -56,10 +56,29 @@ export type RunCommand = ( options: CommandRunOptions, ) => Promise +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 }