feat(codex): auto-install git bash via winget on windows
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import assert from "node:assert/strict";
|
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 { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
@@ -7,8 +7,21 @@ import test from "node:test";
|
|||||||
import { installMarketplaceLocally } from "./install-local.mjs";
|
import { installMarketplaceLocally } from "./install-local.mjs";
|
||||||
|
|
||||||
const windowsGitBashPath = "C:\\Program Files\\Git\\bin\\bash.exe";
|
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 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 codexHome = await mkdtemp(join(tmpdir(), "omo-codex-script-git-bash-missing-home-"));
|
||||||
const commands = [];
|
const commands = [];
|
||||||
@@ -18,6 +31,7 @@ test("#given Windows without Git Bash #when installing local marketplace #then r
|
|||||||
repoRoot,
|
repoRoot,
|
||||||
codexHome,
|
codexHome,
|
||||||
platform: "win32",
|
platform: "win32",
|
||||||
|
env: { OMO_CODEX_SKIP_GIT_BASH_AUTO_INSTALL: "1" },
|
||||||
gitBashResolver: () => ({
|
gitBashResolver: () => ({
|
||||||
found: false,
|
found: false,
|
||||||
checkedPaths: [windowsGitBashPath],
|
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/);
|
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 () => {
|
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(),
|
repoRoot: process.cwd(),
|
||||||
codexHome: await mkdtemp(join(tmpdir(), "omo-codex-script-git-bash-home-")),
|
codexHome: await mkdtemp(join(tmpdir(), "omo-codex-script-git-bash-home-")),
|
||||||
binDir: await mkdtemp(join(tmpdir(), "omo-codex-script-git-bash-bin-")),
|
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" }),
|
gitBashResolver: () => ({ found: true, path: windowsGitBashPath, source: "env" }),
|
||||||
runCommand: async () => {},
|
runCommand: async () => {},
|
||||||
log: () => {},
|
log: () => {},
|
||||||
});
|
}));
|
||||||
|
|
||||||
assert.equal(result.gitBashPath, windowsGitBashPath);
|
assert.equal(result.gitBashPath, windowsGitBashPath);
|
||||||
assert.equal(result.installed.length, 1);
|
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");
|
const gitBashPath = join(await mkdtemp(join(tmpdir(), "omo-codex-script-git-bash-env-")), "bash.exe");
|
||||||
await writeFile(gitBashPath, "");
|
await writeFile(gitBashPath, "");
|
||||||
|
|
||||||
const result = await installMarketplaceLocally({
|
const result = await withBundledLspRuntimeForTest(async () => installMarketplaceLocally({
|
||||||
repoRoot: process.cwd(),
|
repoRoot: process.cwd(),
|
||||||
codexHome,
|
codexHome,
|
||||||
binDir: await mkdtemp(join(tmpdir(), "omo-codex-script-git-bash-env-bin-")),
|
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 },
|
env: { OMO_CODEX_GIT_BASH_PATH: gitBashPath },
|
||||||
runCommand: async () => {},
|
runCommand: async () => {},
|
||||||
log: () => {},
|
log: () => {},
|
||||||
});
|
}));
|
||||||
|
|
||||||
assert.equal(result.gitBashPath, gitBashPath);
|
assert.equal(result.gitBashPath, gitBashPath);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("#given non-Windows local install #when resolver would fail #then installer keeps existing behavior", async () => {
|
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 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(),
|
repoRoot: process.cwd(),
|
||||||
codexHome,
|
codexHome,
|
||||||
binDir: await mkdtemp(join(tmpdir(), "omo-codex-script-git-bash-linux-bin-")),
|
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" }),
|
gitBashResolver: () => ({ found: false, checkedPaths: [windowsGitBashPath], installHint: "should not be used" }),
|
||||||
runCommand: async () => {},
|
runCommand: async () => {},
|
||||||
log: () => {},
|
log: () => {},
|
||||||
});
|
}));
|
||||||
|
|
||||||
assert.equal(result.gitBashPath, null);
|
assert.equal(result.gitBashPath, null);
|
||||||
assert.match(await readFile(join(codexHome, "config.toml"), "utf8"), /\[marketplaces\.sisyphuslabs\]/);
|
assert.match(await readFile(join(codexHome, "config.toml"), "utf8"), /\[marketplaces\.sisyphuslabs\]/);
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
resolvePluginSource,
|
resolvePluginSource,
|
||||||
validatePathSegment,
|
validatePathSegment,
|
||||||
} from "./install/marketplace.mjs";
|
} 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 LEGACY_CODEX_PLUGIN_MARKETPLACE = ["code", "yeongyu", "codex", "plugins"].join("-");
|
||||||
const SISYPHUS_LEGACY_CACHE_MARKETPLACES = ["lazycodex", LEGACY_CODEX_PLUGIN_MARKETPLACE];
|
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 platform = options.platform ?? process.platform;
|
||||||
const runCommand = options.runCommand ?? defaultRunCommand;
|
const runCommand = options.runCommand ?? defaultRunCommand;
|
||||||
const log = options.log ?? console.log;
|
const log = options.log ?? console.log;
|
||||||
const gitBashResolution = platform === "win32"
|
const gitBashResolution = await prepareGitBashForInstall({
|
||||||
? (options.gitBashResolver ?? (() => resolveGitBashForCurrentProcess({ platform, env })))()
|
platform,
|
||||||
: { found: true, path: null, source: "not-required" };
|
env,
|
||||||
|
cwd: repoRoot,
|
||||||
|
runCommand,
|
||||||
|
resolveGitBash: platform === "win32"
|
||||||
|
? (options.gitBashResolver ?? (() => resolveGitBashForCurrentProcess({ platform, env })))
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
if (!gitBashResolution.found) {
|
if (!gitBashResolution.found) {
|
||||||
throw new Error(gitBashResolution.installHint);
|
throw new Error(gitBashResolution.installHint);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import { execFileSync } from "node:child_process";
|
|||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
|
|
||||||
const GIT_BASH_ENV_KEY = "OMO_CODEX_GIT_BASH_PATH";
|
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_GIT_BASH = "C:\\Program Files\\Git\\bin\\bash.exe";
|
||||||
const PROGRAM_FILES_X86_GIT_BASH = "C:\\Program Files (x86)\\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 }) {
|
export function resolveGitBash({ platform, env, exists, where }) {
|
||||||
if (platform !== "win32") return { found: true, path: null, source: "not-required" };
|
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) {
|
function missingGitBash(checkedPaths) {
|
||||||
return {
|
return {
|
||||||
found: false,
|
found: false,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import test from "node:test";
|
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 programFilesGitBash = "C:\\Program Files\\Git\\bin\\bash.exe";
|
||||||
const programFilesX86GitBash = "C:\\Program Files (x86)\\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, /winget install --id Git\.Git -e --source winget/);
|
||||||
assert.match(result.installHint, /rerun `bunx omo install --platform=codex`/);
|
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" });
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
/// <reference types="bun-types" />
|
/// <reference types="bun-types" />
|
||||||
|
|
||||||
import { describe, expect, test } from "bun:test"
|
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_GIT_BASH = "C:\\Program Files\\Git\\bin\\bash.exe"
|
||||||
const PROGRAM_FILES_X86_GIT_BASH = "C:\\Program Files (x86)\\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("OMO_CODEX_GIT_BASH_PATH=C:\\path\\to\\bash.exe")
|
||||||
expect(result.installHint).toContain("rerun `bunx omo install --platform=codex`")
|
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" })
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { execFileSync } from "node:child_process"
|
import { execFileSync } from "node:child_process"
|
||||||
import { existsSync } from "node:fs"
|
import { existsSync } from "node:fs"
|
||||||
|
import type { RunCommand } from "./types"
|
||||||
|
|
||||||
const GIT_BASH_ENV_KEY = "OMO_CODEX_GIT_BASH_PATH"
|
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_GIT_BASH = "C:\\Program Files\\Git\\bin\\bash.exe"
|
||||||
const PROGRAM_FILES_X86_GIT_BASH = "C:\\Program Files (x86)\\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"
|
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 {
|
function missingGitBash(checkedPaths: readonly string[]): GitBashResolution {
|
||||||
return {
|
return {
|
||||||
found: false,
|
found: false,
|
||||||
|
|||||||
@@ -2,16 +2,38 @@
|
|||||||
/// <reference types="bun-types" />
|
/// <reference types="bun-types" />
|
||||||
|
|
||||||
import { describe, expect, test } from "bun:test"
|
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 { tmpdir } from "node:os"
|
||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
import { runCodexInstaller } from "./install-codex"
|
import { runCodexInstaller } from "./install-codex"
|
||||||
import type { CommandRunOptions } from "./types"
|
import type { CommandRunOptions } from "./types"
|
||||||
|
|
||||||
const WINDOWS_GIT_BASH_PATH = "C:\\Program Files\\Git\\bin\\bash.exe"
|
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", () => {
|
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
|
// given
|
||||||
const codexHome = await mkdtemp(join(tmpdir(), "omo-codex-git-bash-missing-home-"))
|
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 repoRoot = await mkdtemp(join(tmpdir(), "omo-codex-git-bash-missing-repo-"))
|
||||||
@@ -22,6 +44,7 @@ describe("install-codex Git Bash preflight", () => {
|
|||||||
codexHome,
|
codexHome,
|
||||||
repoRoot,
|
repoRoot,
|
||||||
platform: "win32",
|
platform: "win32",
|
||||||
|
env: { OMO_CODEX_SKIP_GIT_BASH_AUTO_INSTALL: "1" },
|
||||||
gitBashResolver: () => ({
|
gitBashResolver: () => ({
|
||||||
found: false,
|
found: false,
|
||||||
checkedPaths: [WINDOWS_GIT_BASH_PATH],
|
checkedPaths: [WINDOWS_GIT_BASH_PATH],
|
||||||
@@ -43,20 +66,80 @@ describe("install-codex Git Bash preflight", () => {
|
|||||||
await expect(stat(join(codexHome, "config.toml"))).rejects.toThrow()
|
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 () => {
|
test("#given Windows with Git Bash #when installing Codex profile #then proceeds and reports detected path", async () => {
|
||||||
// given
|
// given
|
||||||
const codexHome = await mkdtemp(join(tmpdir(), "omo-codex-git-bash-present-home-"))
|
const codexHome = await mkdtemp(join(tmpdir(), "omo-codex-git-bash-present-home-"))
|
||||||
const binDir = await mkdtemp(join(tmpdir(), "omo-codex-git-bash-present-bin-"))
|
const binDir = await mkdtemp(join(tmpdir(), "omo-codex-git-bash-present-bin-"))
|
||||||
|
|
||||||
// when
|
// when
|
||||||
const result = await runCodexInstaller({
|
const result = await withBundledLspRuntimeForTest(async () => runCodexInstaller({
|
||||||
codexHome,
|
codexHome,
|
||||||
binDir,
|
binDir,
|
||||||
repoRoot: process.cwd(),
|
repoRoot: process.cwd(),
|
||||||
platform: "win32",
|
platform: "win32",
|
||||||
gitBashResolver: () => ({ found: true, path: WINDOWS_GIT_BASH_PATH, source: "program-files" }),
|
gitBashResolver: () => ({ found: true, path: WINDOWS_GIT_BASH_PATH, source: "program-files" }),
|
||||||
runCommand: async () => undefined,
|
runCommand: async () => undefined,
|
||||||
})
|
}))
|
||||||
|
|
||||||
// then
|
// then
|
||||||
expect(result.gitBashPath).toBe(WINDOWS_GIT_BASH_PATH)
|
expect(result.gitBashPath).toBe(WINDOWS_GIT_BASH_PATH)
|
||||||
@@ -71,14 +154,14 @@ describe("install-codex Git Bash preflight", () => {
|
|||||||
await writeFile(gitBashPath, "")
|
await writeFile(gitBashPath, "")
|
||||||
|
|
||||||
// when
|
// when
|
||||||
const result = await runCodexInstaller({
|
const result = await withBundledLspRuntimeForTest(async () => runCodexInstaller({
|
||||||
codexHome,
|
codexHome,
|
||||||
binDir,
|
binDir,
|
||||||
repoRoot: process.cwd(),
|
repoRoot: process.cwd(),
|
||||||
platform: "win32",
|
platform: "win32",
|
||||||
env: { OMO_CODEX_GIT_BASH_PATH: gitBashPath },
|
env: { OMO_CODEX_GIT_BASH_PATH: gitBashPath },
|
||||||
runCommand: async () => undefined,
|
runCommand: async () => undefined,
|
||||||
})
|
}))
|
||||||
|
|
||||||
// then
|
// then
|
||||||
expect(result.gitBashPath).toBe(gitBashPath)
|
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-"))
|
const binDir = await mkdtemp(join(tmpdir(), "omo-codex-git-bash-linux-bin-"))
|
||||||
|
|
||||||
// when
|
// when
|
||||||
const result = await runCodexInstaller({
|
const result = await withBundledLspRuntimeForTest(async () => runCodexInstaller({
|
||||||
codexHome,
|
codexHome,
|
||||||
binDir,
|
binDir,
|
||||||
repoRoot: process.cwd(),
|
repoRoot: process.cwd(),
|
||||||
@@ -101,7 +184,7 @@ describe("install-codex Git Bash preflight", () => {
|
|||||||
installHint: "should not be used",
|
installHint: "should not be used",
|
||||||
}),
|
}),
|
||||||
runCommand: async () => undefined,
|
runCommand: async () => undefined,
|
||||||
})
|
}))
|
||||||
|
|
||||||
// then
|
// then
|
||||||
expect(result.gitBashPath).toBeNull()
|
expect(result.gitBashPath).toBeNull()
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { mkdir, writeFile } from "node:fs/promises"
|
|||||||
import { installCachedPlugin, linkCachedPluginBins, pruneMarketplaceCache, pruneMarketplacePluginCaches } from "./codex-cache"
|
import { installCachedPlugin, linkCachedPluginBins, pruneMarketplaceCache, pruneMarketplacePluginCaches } from "./codex-cache"
|
||||||
import { updateCodexConfig } from "./codex-config-toml"
|
import { updateCodexConfig } from "./codex-config-toml"
|
||||||
import { trustedHookStatesForPlugin } from "./codex-hook-trust"
|
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 { linkCachedPluginAgents } from "./link-cached-plugin-agents"
|
||||||
import { readMarketplace, readPluginManifest, resolvePluginSource, validatePathSegment } from "./codex-marketplace"
|
import { readMarketplace, readPluginManifest, resolvePluginSource, validatePathSegment } from "./codex-marketplace"
|
||||||
import { writeInstalledMarketplaceSnapshot, type MarketplaceSnapshotPluginSource } from "./codex-marketplace-snapshot"
|
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 runCommand = options.runCommand ?? defaultRunCommand
|
||||||
const log = options.log ?? (() => undefined)
|
const log = options.log ?? (() => undefined)
|
||||||
|
|
||||||
const gitBashResolution = platform === "win32"
|
const gitBashResolution = await prepareGitBashForInstall({
|
||||||
? (options.gitBashResolver ?? (() => resolveGitBashForCurrentProcess({ platform, env })))()
|
platform,
|
||||||
: { found: true, path: null, source: "not-required" } as const
|
env,
|
||||||
|
cwd: repoRoot,
|
||||||
|
runCommand,
|
||||||
|
resolveGitBash: platform === "win32"
|
||||||
|
? (options.gitBashResolver ?? (() => resolveGitBashForCurrentProcess({ platform, env })))
|
||||||
|
: undefined,
|
||||||
|
})
|
||||||
if (!gitBashResolution.found) {
|
if (!gitBashResolution.found) {
|
||||||
throw new Error(gitBashResolution.installHint)
|
throw new Error(gitBashResolution.installHint)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user