feat(codex): mirror git bash preflight locally
Plan: plans/codex-windows-git-bash-profile.md
This commit is contained in:
@@ -0,0 +1,89 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { mkdtemp, readFile, stat, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import { installMarketplaceLocally } from "./install-local.mjs";
|
||||||
|
|
||||||
|
const windowsGitBashPath = "C:\\Program Files\\Git\\bin\\bash.exe";
|
||||||
|
|
||||||
|
test("#given Windows without Git Bash #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 = [];
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
installMarketplaceLocally({
|
||||||
|
repoRoot,
|
||||||
|
codexHome,
|
||||||
|
platform: "win32",
|
||||||
|
gitBashResolver: () => ({
|
||||||
|
found: false,
|
||||||
|
checkedPaths: [windowsGitBashPath],
|
||||||
|
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, args, options) => {
|
||||||
|
commands.push([command, ...args, options.cwd].join(" "));
|
||||||
|
},
|
||||||
|
log: () => {},
|
||||||
|
}),
|
||||||
|
/winget install --id Git\.Git -e --source winget/,
|
||||||
|
);
|
||||||
|
assert.deepEqual(commands, []);
|
||||||
|
await assert.rejects(stat(join(codexHome, "config.toml")), /ENOENT/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("#given Windows env override resolves Git Bash #when installing local marketplace #then install continues", async () => {
|
||||||
|
const result = await 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-")),
|
||||||
|
platform: "win32",
|
||||||
|
gitBashResolver: () => ({ found: true, path: windowsGitBashPath, source: "env" }),
|
||||||
|
runCommand: async () => {},
|
||||||
|
log: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.gitBashPath, windowsGitBashPath);
|
||||||
|
assert.equal(result.installed.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("#given Windows env override in installer options #when no custom resolver is provided #then default resolver uses it", async () => {
|
||||||
|
const codexHome = await mkdtemp(join(tmpdir(), "omo-codex-script-git-bash-env-home-"));
|
||||||
|
const gitBashPath = join(await mkdtemp(join(tmpdir(), "omo-codex-script-git-bash-env-")), "bash.exe");
|
||||||
|
await writeFile(gitBashPath, "");
|
||||||
|
|
||||||
|
const result = await installMarketplaceLocally({
|
||||||
|
repoRoot: process.cwd(),
|
||||||
|
codexHome,
|
||||||
|
binDir: await mkdtemp(join(tmpdir(), "omo-codex-script-git-bash-env-bin-")),
|
||||||
|
platform: "win32",
|
||||||
|
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({
|
||||||
|
repoRoot: process.cwd(),
|
||||||
|
codexHome,
|
||||||
|
binDir: await mkdtemp(join(tmpdir(), "omo-codex-script-git-bash-linux-bin-")),
|
||||||
|
platform: "linux",
|
||||||
|
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\]/);
|
||||||
|
});
|
||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
resolvePluginSource,
|
resolvePluginSource,
|
||||||
validatePathSegment,
|
validatePathSegment,
|
||||||
} from "./install/marketplace.mjs";
|
} from "./install/marketplace.mjs";
|
||||||
|
import { 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];
|
||||||
@@ -45,6 +46,12 @@ 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"
|
||||||
|
? (options.gitBashResolver ?? (() => resolveGitBashForCurrentProcess({ platform, env })))()
|
||||||
|
: { found: true, path: null, source: "not-required" };
|
||||||
|
if (!gitBashResolution.found) {
|
||||||
|
throw new Error(gitBashResolution.installHint);
|
||||||
|
}
|
||||||
const codexPackageRoot = join(repoRoot, "packages", "omo-codex");
|
const codexPackageRoot = join(repoRoot, "packages", "omo-codex");
|
||||||
const marketplace = await readMarketplace(repoRoot, {
|
const marketplace = await readMarketplace(repoRoot, {
|
||||||
marketplacePath: join(codexPackageRoot, "marketplace.json"),
|
marketplacePath: join(codexPackageRoot, "marketplace.json"),
|
||||||
@@ -128,7 +135,7 @@ export async function installMarketplaceLocally(options = {}) {
|
|||||||
log(`Installed ${plugin.name}@${marketplace.name} -> ${plugin.path}`);
|
log(`Installed ${plugin.name}@${marketplace.name} -> ${plugin.path}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { marketplaceName: marketplace.name, installed };
|
return { marketplaceName: marketplace.name, installed, gitBashPath: gitBashResolution.path };
|
||||||
}
|
}
|
||||||
|
|
||||||
function agentNameFromToml(fileName) {
|
function agentNameFromToml(fileName) {
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
|
||||||
|
const GIT_BASH_ENV_KEY = "OMO_CODEX_GIT_BASH_PATH";
|
||||||
|
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";
|
||||||
|
|
||||||
|
export function resolveGitBash({ platform, env, exists, where }) {
|
||||||
|
if (platform !== "win32") return { found: true, path: null, source: "not-required" };
|
||||||
|
|
||||||
|
const checkedPaths = [];
|
||||||
|
const envPath = nonEmptyEnvValue(env, GIT_BASH_ENV_KEY);
|
||||||
|
if (envPath !== undefined) {
|
||||||
|
checkedPaths.push(envPath);
|
||||||
|
if (isBashExePath(envPath) && exists(envPath)) return { found: true, path: envPath, source: "env" };
|
||||||
|
return missingGitBash(checkedPaths);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const candidate of [
|
||||||
|
{ path: PROGRAM_FILES_GIT_BASH, source: "program-files" },
|
||||||
|
{ path: PROGRAM_FILES_X86_GIT_BASH, source: "program-files-x86" },
|
||||||
|
]) {
|
||||||
|
checkedPaths.push(candidate.path);
|
||||||
|
if (exists(candidate.path)) return { found: true, path: candidate.path, source: candidate.source };
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const pathCandidate of where("bash")) {
|
||||||
|
const candidate = pathCandidate.trim();
|
||||||
|
if (candidate.length === 0) continue;
|
||||||
|
checkedPaths.push(candidate);
|
||||||
|
if (isBashExePath(candidate) && exists(candidate)) return { found: true, path: candidate, source: "path" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return missingGitBash(checkedPaths);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveGitBashForCurrentProcess(options = {}) {
|
||||||
|
return resolveGitBash({
|
||||||
|
platform: options.platform ?? process.platform,
|
||||||
|
env: options.env ?? process.env,
|
||||||
|
exists: existsSync,
|
||||||
|
where: whereCommand,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function missingGitBash(checkedPaths) {
|
||||||
|
return {
|
||||||
|
found: false,
|
||||||
|
checkedPaths,
|
||||||
|
installHint: [
|
||||||
|
"Git Bash is required for native Windows Codex profile installs.",
|
||||||
|
"Install it with: winget install --id Git.Git -e --source winget",
|
||||||
|
`For a custom install, set ${GIT_BASH_ENV_KEY}=C:\\path\\to\\bash.exe`,
|
||||||
|
"Then rerun `bunx omo install --platform=codex`.",
|
||||||
|
].join("\n"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function nonEmptyEnvValue(env, key) {
|
||||||
|
const value = env[key];
|
||||||
|
if (typeof value !== "string") return undefined;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed.length === 0 ? undefined : trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBashExePath(path) {
|
||||||
|
return path.toLowerCase().endsWith("bash.exe");
|
||||||
|
}
|
||||||
|
|
||||||
|
function whereCommand(command) {
|
||||||
|
try {
|
||||||
|
return execFileSync("where", [command], { encoding: "utf8" })
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => line.length > 0);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) return [];
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import { resolveGitBash } from "./git-bash.mjs";
|
||||||
|
|
||||||
|
const programFilesGitBash = "C:\\Program Files\\Git\\bin\\bash.exe";
|
||||||
|
const programFilesX86GitBash = "C:\\Program Files (x86)\\Git\\bin\\bash.exe";
|
||||||
|
|
||||||
|
test("#given non-Windows platform #when resolving Git Bash #then no preflight is required", () => {
|
||||||
|
const result = resolveGitBash({
|
||||||
|
platform: "linux",
|
||||||
|
env: {},
|
||||||
|
exists: () => false,
|
||||||
|
where: () => [],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(result, { found: true, path: null, source: "not-required" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("#given Windows env override to bash.exe #when the file exists #then env path wins", () => {
|
||||||
|
const overridePath = "D:\\Tools\\Git\\bin\\bash.exe";
|
||||||
|
const result = resolveGitBash({
|
||||||
|
platform: "win32",
|
||||||
|
env: { OMO_CODEX_GIT_BASH_PATH: overridePath },
|
||||||
|
exists: (path) => path === overridePath,
|
||||||
|
where: () => [programFilesGitBash],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(result, { found: true, path: overridePath, source: "env" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("#given Windows standard paths are absent and PATH contains bash #when resolving #then uses where bash candidate", () => {
|
||||||
|
const pathCandidate = "E:\\Git\\bin\\bash.exe";
|
||||||
|
const result = resolveGitBash({
|
||||||
|
platform: "win32",
|
||||||
|
env: {},
|
||||||
|
exists: (path) => path === pathCandidate,
|
||||||
|
where: () => ["C:\\Windows\\System32\\bash.exe", pathCandidate],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(result, { found: true, path: pathCandidate, source: "path" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("#given Windows invalid env override #when resolving #then returns guidance without falling through", () => {
|
||||||
|
const overridePath = "D:\\Tools\\Git\\bin\\git.exe";
|
||||||
|
const result = resolveGitBash({
|
||||||
|
platform: "win32",
|
||||||
|
env: { OMO_CODEX_GIT_BASH_PATH: overridePath },
|
||||||
|
exists: () => true,
|
||||||
|
where: () => [programFilesGitBash],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.found, false);
|
||||||
|
assert.deepEqual(result.checkedPaths, [overridePath]);
|
||||||
|
assert.match(result.installHint, /OMO_CODEX_GIT_BASH_PATH=C:\\path\\to\\bash\.exe/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("#given Windows without Git Bash #when resolving #then returns install guidance", () => {
|
||||||
|
const result = resolveGitBash({
|
||||||
|
platform: "win32",
|
||||||
|
env: {},
|
||||||
|
exists: () => false,
|
||||||
|
where: () => [],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.found, false);
|
||||||
|
assert.deepEqual(result.checkedPaths, [programFilesGitBash, programFilesX86GitBash]);
|
||||||
|
assert.match(result.installHint, /winget install --id Git\.Git -e --source winget/);
|
||||||
|
assert.match(result.installHint, /rerun `bunx omo install --platform=codex`/);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user