From f87eceadaeb3a78c8da111e553386d8c45e623e1 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 26 May 2026 11:40:50 +0900 Subject: [PATCH] fix(cli): protect existing Windows Codex shims Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- packages/omo-codex/scripts/install-local.mjs | 3 +- .../omo-codex/scripts/install-local.test.mjs | 25 +++++++++++++++ packages/omo-codex/scripts/install/cache.mjs | 9 ++++-- src/cli/install-codex/codex-cache.test.ts | 32 +++++++++++++++++-- src/cli/install-codex/codex-cache.ts | 9 ++++-- 5 files changed, 71 insertions(+), 7 deletions(-) diff --git a/packages/omo-codex/scripts/install-local.mjs b/packages/omo-codex/scripts/install-local.mjs index a00fa3d61..0065d5a7e 100644 --- a/packages/omo-codex/scripts/install-local.mjs +++ b/packages/omo-codex/scripts/install-local.mjs @@ -18,6 +18,7 @@ export async function installMarketplaceLocally(options = {}) { const repoRoot = resolve(options.repoRoot ?? process.cwd()); const codexHome = resolve(options.codexHome ?? process.env.CODEX_HOME ?? join(homedir(), ".codex")); const binDir = resolve(options.binDir ?? process.env.CODEX_LOCAL_BIN_DIR ?? join(homedir(), ".local", "bin")); + const platform = options.platform ?? process.platform; const runCommand = options.runCommand ?? defaultRunCommand; const log = options.log ?? console.log; const marketplace = await readMarketplace(repoRoot); @@ -43,7 +44,7 @@ export async function installMarketplaceLocally(options = {}) { sourcePath, version, }); - const binLinks = await linkCachedPluginBins({ binDir, pluginRoot: plugin.path }); + const binLinks = await linkCachedPluginBins({ binDir, pluginRoot: plugin.path, platform }); for (const link of binLinks) { log(`Linked ${link.name} -> ${link.target}`); } diff --git a/packages/omo-codex/scripts/install-local.test.mjs b/packages/omo-codex/scripts/install-local.test.mjs index 3f3df42a1..88f55d0bb 100644 --- a/packages/omo-codex/scripts/install-local.test.mjs +++ b/packages/omo-codex/scripts/install-local.test.mjs @@ -100,6 +100,7 @@ test("#given local marketplace #when installing #then copies versioned plugins a repoRoot, codexHome, binDir, + platform: "linux", runCommand: async (command, args, options) => { commands.push([command, args, options.cwd]); }, @@ -232,3 +233,27 @@ test("#given Windows platform #when linking cached plugin bins #then writes comm assert.match(shim, /@echo off/); assert.match(shim, new RegExp(`node "${join(pluginRoot, "dist", "cli.js").replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}" %\\*`)); }); + +test("#given existing custom Windows command shim #when linking bins #then rejects without overwriting", async () => { + const root = await makeTempDir(); + const pluginRoot = join(root, "plugin"); + const binDir = join(root, "bin"); + + await mkdir(pluginRoot, { recursive: true }); + await mkdir(binDir, { recursive: true }); + await writeJson(join(pluginRoot, "package.json"), { + name: "@example/alpha", + bin: { + alpha: "./dist/cli.js", + }, + }); + await mkdir(join(pluginRoot, "dist"), { recursive: true }); + await writeFile(join(pluginRoot, "dist", "cli.js"), "#!/usr/bin/env node\n"); + await writeFile(join(binDir, "alpha.cmd"), "@echo off\r\necho custom\r\n"); + + await assert.rejects( + linkCachedPluginBins({ binDir, pluginRoot, platform: "win32" }), + /already exists and is not a generated command shim/, + ); + assert.match(await readFile(join(binDir, "alpha.cmd"), "utf8"), /echo custom/); +}); diff --git a/packages/omo-codex/scripts/install/cache.mjs b/packages/omo-codex/scripts/install/cache.mjs index 210acfe53..6d795e060 100644 --- a/packages/omo-codex/scripts/install/cache.mjs +++ b/packages/omo-codex/scripts/install/cache.mjs @@ -3,6 +3,8 @@ import { cp, lstat, mkdir, readFile, readdir, readlink, rename, rm, symlink, wri import { exists, isRecord } from "./utils.mjs"; +const COMMAND_SHIM_MARKER = ":: generated by oh-my-openagent Codex installer"; + export async function installCachedPlugin({ codexHome, marketplaceName, name, runCommand, sourcePath, version }) { await maybeRunNpmInstall(sourcePath, runCommand); await maybeRunNpmBuild(sourcePath, runCommand); @@ -120,13 +122,16 @@ async function replaceCommandShim(linkPath, targetPath) { if (await existingNonShim(linkPath)) { throw new Error(`${linkPath} already exists and is not a command shim`); } - await writeFile(linkPath, `@echo off\r\nnode "${targetPath}" %*\r\n`); + await writeFile(linkPath, `@echo off\r\n${COMMAND_SHIM_MARKER}\r\nnode "${targetPath}" %*\r\n`); } async function existingNonShim(path) { try { const stat = await lstat(path); - return !stat.isFile(); + if (!stat.isFile()) return true; + const content = await readFile(path, "utf8"); + if (content.includes(COMMAND_SHIM_MARKER)) return false; + throw new Error(`${path} already exists and is not a generated command shim`); } catch (error) { if (error instanceof Error && "code" in error && error.code === "ENOENT") return false; throw error; diff --git a/src/cli/install-codex/codex-cache.test.ts b/src/cli/install-codex/codex-cache.test.ts index 7ff1e11f8..66efe2f3d 100644 --- a/src/cli/install-codex/codex-cache.test.ts +++ b/src/cli/install-codex/codex-cache.test.ts @@ -1,3 +1,6 @@ +/// +/// + import { describe, expect, test } from "bun:test" import { mkdir, mkdtemp, readFile, readlink, writeFile } from "node:fs/promises" import { tmpdir } from "node:os" @@ -35,8 +38,8 @@ describe("codex-cache", () => { await writeFile(join(pluginRoot, "dist", "cli.js"), "#!/usr/bin/env node\n") // when - const first = await linkCachedPluginBins({ binDir, pluginRoot }) - const second = await linkCachedPluginBins({ binDir, pluginRoot }) + const first = await linkCachedPluginBins({ binDir, pluginRoot, platform: "linux" }) + const second = await linkCachedPluginBins({ binDir, pluginRoot, platform: "linux" }) // then expect(first).toHaveLength(1) @@ -64,4 +67,29 @@ describe("codex-cache", () => { expect(commandShim).toContain("@echo off") expect(commandShim).toContain(`node "${join(pluginRoot, "dist", "cli.js")}" %*`) }) + + test("rejects existing non-generated Windows command shims", async () => { + // given + const root = await mkdtemp(join(tmpdir(), "omo-codex-cache-")) + const pluginRoot = join(root, "plugin") + const binDir = join(root, "bin") + await mkdir(pluginRoot, { recursive: true }) + await mkdir(binDir, { recursive: true }) + await writeFile(join(pluginRoot, "package.json"), JSON.stringify({ name: "@scope/omo", bin: { "omo-hook": "dist/cli.js" } })) + await mkdir(join(pluginRoot, "dist"), { recursive: true }) + await writeFile(join(pluginRoot, "dist", "cli.js"), "#!/usr/bin/env node\n") + await writeFile(join(binDir, "omo-hook.cmd"), "@echo off\r\necho custom\r\n") + + // when + let rejected = false + try { + await linkCachedPluginBins({ binDir, pluginRoot, platform: "win32" }) + } catch (error) { + rejected = error instanceof Error && error.message.includes("already exists and is not a generated command shim") + } + + // then + expect(rejected).toBe(true) + expect(await readFile(join(binDir, "omo-hook.cmd"), "utf8")).toContain("echo custom") + }) }) diff --git a/src/cli/install-codex/codex-cache.ts b/src/cli/install-codex/codex-cache.ts index 082b20606..ce35bf840 100644 --- a/src/cli/install-codex/codex-cache.ts +++ b/src/cli/install-codex/codex-cache.ts @@ -4,6 +4,8 @@ import type { InstalledPlugin, RunCommand } from "./types" type LinkPlatform = NodeJS.Platform +const COMMAND_SHIM_MARKER = ":: generated by oh-my-openagent Codex installer" + export async function installCachedPlugin(input: { readonly codexHome: string readonly marketplaceName: string @@ -163,13 +165,16 @@ async function replaceSymlink(linkPath: string, targetPath: string): Promise { if (await existingNonShim(linkPath)) throw new Error(`${linkPath} already exists and is not a command shim`) - await writeFile(linkPath, `@echo off\r\nnode "${targetPath}" %*\r\n`) + await writeFile(linkPath, `@echo off\r\n${COMMAND_SHIM_MARKER}\r\nnode "${targetPath}" %*\r\n`) } async function existingNonShim(path: string): Promise { try { const stat = await lstat(path) - return !stat.isFile() + if (!stat.isFile()) return true + const content = await readFile(path, "utf8") + if (content.includes(COMMAND_SHIM_MARKER)) return false + throw new Error(`${path} already exists and is not a generated command shim`) } catch (error) { if (isNodeErrorWithCode(error) && error.code === "ENOENT") return false throw error