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 index 81c1256ff..774cd051a 100644 --- a/packages/omo-codex/scripts/install-local-git-bash-preflight.test.mjs +++ b/packages/omo-codex/scripts/install-local-git-bash-preflight.test.mjs @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { mkdtemp, readFile, stat, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; @@ -7,8 +7,21 @@ import test from "node:test"; import { installMarketplaceLocally } from "./install-local.mjs"; const windowsGitBashPath = "C:\\Program Files\\Git\\bin\\bash.exe"; +const lspCliPath = join(process.cwd(), "packages", "lsp-tools-mcp", "dist", "cli.js"); -test("#given Windows without Git Bash #when installing local marketplace #then rejects before marketplace or config mutation", async () => { +async function withBundledLspRuntimeForTest(run) { + try { + await stat(lspCliPath); + } catch (error) { + if (!(error instanceof Error)) throw error; + await mkdir(join(process.cwd(), "packages", "lsp-tools-mcp", "dist"), { recursive: true }); + await writeFile(lspCliPath, "#!/usr/bin/env node\n"); + } + + return run(); +} + +test("#given Windows without Git Bash and auto install skip env #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 = []; @@ -18,6 +31,7 @@ test("#given Windows without Git Bash #when installing local marketplace #then r repoRoot, codexHome, platform: "win32", + env: { OMO_CODEX_SKIP_GIT_BASH_AUTO_INSTALL: "1" }, gitBashResolver: () => ({ found: false, checkedPaths: [windowsGitBashPath], @@ -39,8 +53,50 @@ test("#given Windows without Git Bash #when installing local marketplace #then r await assert.rejects(stat(join(codexHome, "config.toml")), /ENOENT/); }); +test("#given Windows without Git Bash #when winget succeeds and resolver recovers #then install continues", async () => { + const runCalls = []; + const resolutions = [ + { found: false, checkedPaths: [windowsGitBashPath], installHint: "install hint before winget" }, + { found: true, path: windowsGitBashPath, source: "program-files" }, + ]; + let resolveCallCount = 0; + + const result = await withBundledLspRuntimeForTest(async () => installMarketplaceLocally({ + repoRoot: process.cwd(), + codexHome: await mkdtemp(join(tmpdir(), "omo-codex-script-git-bash-auto-home-")), + binDir: await mkdtemp(join(tmpdir(), "omo-codex-script-git-bash-auto-bin-")), + platform: "win32", + gitBashResolver: () => resolutions[resolveCallCount++] ?? resolutions[resolutions.length - 1], + runCommand: async (command, args, options) => { + runCalls.push([command, ...args, options.cwd].join(" ")); + }, + log: () => {}, + })); + + assert.equal(resolveCallCount, 2); + assert.match(runCalls.join("\n"), /^winget install --id Git\.Git -e --source winget /m); + assert.equal(result.gitBashPath, windowsGitBashPath); +}); + +test("#given non-Windows install #when running installer #then winget is never called", async () => { + const runCalls = []; + const result = await withBundledLspRuntimeForTest(async () => installMarketplaceLocally({ + repoRoot: process.cwd(), + codexHome: await mkdtemp(join(tmpdir(), "omo-codex-script-git-bash-no-winget-home-")), + binDir: await mkdtemp(join(tmpdir(), "omo-codex-script-git-bash-no-winget-bin-")), + platform: "linux", + runCommand: async (command, args, options) => { + runCalls.push([command, ...args, options.cwd].join(" ")); + }, + log: () => {}, + })); + + assert.equal(result.gitBashPath, null); + assert.equal(runCalls.some((command) => command.startsWith("winget ")), false); +}); + test("#given Windows env override resolves Git Bash #when installing local marketplace #then install continues", async () => { - const result = await installMarketplaceLocally({ + const result = await withBundledLspRuntimeForTest(async () => 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-")), @@ -48,7 +104,7 @@ test("#given Windows env override resolves Git Bash #when installing local marke gitBashResolver: () => ({ found: true, path: windowsGitBashPath, source: "env" }), runCommand: async () => {}, log: () => {}, - }); + })); assert.equal(result.gitBashPath, windowsGitBashPath); assert.equal(result.installed.length, 1); @@ -59,7 +115,7 @@ test("#given Windows env override in installer options #when no custom resolver const gitBashPath = join(await mkdtemp(join(tmpdir(), "omo-codex-script-git-bash-env-")), "bash.exe"); await writeFile(gitBashPath, ""); - const result = await installMarketplaceLocally({ + const result = await withBundledLspRuntimeForTest(async () => installMarketplaceLocally({ repoRoot: process.cwd(), codexHome, binDir: await mkdtemp(join(tmpdir(), "omo-codex-script-git-bash-env-bin-")), @@ -67,14 +123,14 @@ test("#given Windows env override in installer options #when no custom resolver 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({ + const result = await withBundledLspRuntimeForTest(async () => installMarketplaceLocally({ repoRoot: process.cwd(), codexHome, binDir: await mkdtemp(join(tmpdir(), "omo-codex-script-git-bash-linux-bin-")), @@ -82,7 +138,7 @@ test("#given non-Windows local install #when resolver would fail #then installer 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 6510a6bcf..01533ea9c 100644 --- a/packages/omo-codex/scripts/install-local.mjs +++ b/packages/omo-codex/scripts/install-local.mjs @@ -21,7 +21,7 @@ import { resolvePluginSource, validatePathSegment, } from "./install/marketplace.mjs"; -import { resolveGitBashForCurrentProcess } from "./install/git-bash.mjs"; +import { prepareGitBashForInstall, 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]; @@ -46,9 +46,15 @@ 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" }; + 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); } diff --git a/packages/omo-codex/scripts/install/git-bash.mjs b/packages/omo-codex/scripts/install/git-bash.mjs index 2a21d8297..406ce74d0 100644 --- a/packages/omo-codex/scripts/install/git-bash.mjs +++ b/packages/omo-codex/scripts/install/git-bash.mjs @@ -2,8 +2,10 @@ import { execFileSync } from "node:child_process"; import { existsSync } from "node:fs"; 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"]; export function resolveGitBash({ platform, env, exists, where }) { if (platform !== "win32") return { found: true, path: null, source: "not-required" }; @@ -43,6 +45,23 @@ export function resolveGitBashForCurrentProcess(options = {}) { }); } +export async function prepareGitBashForInstall(options) { + const resolveGitBashWithDefaults = options.resolveGitBash + ?? (() => resolveGitBashForCurrentProcess({ platform: options.platform, env: options.env })); + const initialResolution = resolveGitBashWithDefaults(); + if (options.platform !== "win32" || initialResolution.found) return initialResolution; + if (options.env[SKIP_GIT_BASH_AUTO_INSTALL_ENV_KEY] === "1") return initialResolution; + + try { + await options.runCommand("winget", WINGET_INSTALL_ARGS, { cwd: options.cwd }); + } catch (error) { + if (!(error instanceof Error)) throw error; + return initialResolution; + } + + return resolveGitBashWithDefaults(); +} + function missingGitBash(checkedPaths) { return { found: false, diff --git a/packages/omo-codex/scripts/install/git-bash.test.mjs b/packages/omo-codex/scripts/install/git-bash.test.mjs index 45b7c9639..ff79f47b0 100644 --- a/packages/omo-codex/scripts/install/git-bash.test.mjs +++ b/packages/omo-codex/scripts/install/git-bash.test.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { resolveGitBash } from "./git-bash.mjs"; +import { prepareGitBashForInstall, resolveGitBash } from "./git-bash.mjs"; const programFilesGitBash = "C:\\Program Files\\Git\\bin\\bash.exe"; const programFilesX86GitBash = "C:\\Program Files (x86)\\Git\\bin\\bash.exe"; @@ -68,3 +68,63 @@ test("#given Windows without Git Bash #when resolving #then returns install guid assert.match(result.installHint, /winget install --id Git\.Git -e --source winget/); assert.match(result.installHint, /rerun `bunx omo install --platform=codex`/); }); + +test("#given Windows without Git Bash and winget is allowed #when preparing #then winget runs and resolver retries", async () => { + const runCalls = []; + const resolutions = [ + { found: false, checkedPaths: [programFilesGitBash], installHint: "install hint" }, + { found: true, path: programFilesGitBash, source: "program-files" }, + ]; + let resolveCallCount = 0; + + 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(" ")); + }, + }); + + assert.deepEqual(runCalls, ["winget install --id Git.Git -e --source winget C:\\repo"]); + assert.equal(resolveCallCount, 2); + assert.deepEqual(result, { found: true, path: programFilesGitBash, source: "program-files" }); +}); + +test("#given Windows without Git Bash and skip env is set #when preparing #then winget is not run and install hint remains", async () => { + const runCalls = []; + const missingResolution = { + found: false, + checkedPaths: [programFilesGitBash, programFilesX86GitBash], + installHint: "install hint", + }; + + 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(" ")); + }, + }); + + assert.deepEqual(runCalls, []); + assert.deepEqual(result, missingResolution); +}); + +test("#given non-Windows platform #when preparing #then winget is never called", async () => { + const runCalls = []; + const result = await prepareGitBashForInstall({ + platform: "linux", + env: {}, + cwd: "/repo", + runCommand: async (command, args, options) => { + runCalls.push([command, ...args, options.cwd].join(" ")); + }, + }); + + assert.deepEqual(runCalls, []); + assert.deepEqual(result, { found: true, path: null, source: "not-required" }); +}); diff --git a/src/cli/install-codex/git-bash.test.ts b/src/cli/install-codex/git-bash.test.ts index 5407052fd..2688def90 100644 --- a/src/cli/install-codex/git-bash.test.ts +++ b/src/cli/install-codex/git-bash.test.ts @@ -2,7 +2,7 @@ /// 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" }) + }) }) diff --git a/src/cli/install-codex/git-bash.ts b/src/cli/install-codex/git-bash.ts index f0d75af25..80a01a026 100644 --- a/src/cli/install-codex/git-bash.ts +++ b/src/cli/install-codex/git-bash.ts @@ -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 { + 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, 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 index 627bbbcd9..4f4982e6b 100644 --- a/src/cli/install-codex/install-codex-git-bash-preflight.test.ts +++ b/src/cli/install-codex/install-codex-git-bash-preflight.test.ts @@ -2,16 +2,38 @@ /// 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(run: () => Promise): Promise { + 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() diff --git a/src/cli/install-codex/install-codex.ts b/src/cli/install-codex/install-codex.ts index 7d4728b7f..92ae3cc9e 100644 --- a/src/cli/install-codex/install-codex.ts +++ b/src/cli/install-codex/install-codex.ts @@ -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) }