fix(cli): protect existing Windows Codex shims
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -18,6 +18,7 @@ export async function installMarketplaceLocally(options = {}) {
|
|||||||
const repoRoot = resolve(options.repoRoot ?? process.cwd());
|
const repoRoot = resolve(options.repoRoot ?? process.cwd());
|
||||||
const codexHome = resolve(options.codexHome ?? process.env.CODEX_HOME ?? join(homedir(), ".codex"));
|
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 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 runCommand = options.runCommand ?? defaultRunCommand;
|
||||||
const log = options.log ?? console.log;
|
const log = options.log ?? console.log;
|
||||||
const marketplace = await readMarketplace(repoRoot);
|
const marketplace = await readMarketplace(repoRoot);
|
||||||
@@ -43,7 +44,7 @@ export async function installMarketplaceLocally(options = {}) {
|
|||||||
sourcePath,
|
sourcePath,
|
||||||
version,
|
version,
|
||||||
});
|
});
|
||||||
const binLinks = await linkCachedPluginBins({ binDir, pluginRoot: plugin.path });
|
const binLinks = await linkCachedPluginBins({ binDir, pluginRoot: plugin.path, platform });
|
||||||
for (const link of binLinks) {
|
for (const link of binLinks) {
|
||||||
log(`Linked ${link.name} -> ${link.target}`);
|
log(`Linked ${link.name} -> ${link.target}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ test("#given local marketplace #when installing #then copies versioned plugins a
|
|||||||
repoRoot,
|
repoRoot,
|
||||||
codexHome,
|
codexHome,
|
||||||
binDir,
|
binDir,
|
||||||
|
platform: "linux",
|
||||||
runCommand: async (command, args, options) => {
|
runCommand: async (command, args, options) => {
|
||||||
commands.push([command, args, options.cwd]);
|
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, /@echo off/);
|
||||||
assert.match(shim, new RegExp(`node "${join(pluginRoot, "dist", "cli.js").replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}" %\\*`));
|
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/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { cp, lstat, mkdir, readFile, readdir, readlink, rename, rm, symlink, wri
|
|||||||
|
|
||||||
import { exists, isRecord } from "./utils.mjs";
|
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 }) {
|
export async function installCachedPlugin({ codexHome, marketplaceName, name, runCommand, sourcePath, version }) {
|
||||||
await maybeRunNpmInstall(sourcePath, runCommand);
|
await maybeRunNpmInstall(sourcePath, runCommand);
|
||||||
await maybeRunNpmBuild(sourcePath, runCommand);
|
await maybeRunNpmBuild(sourcePath, runCommand);
|
||||||
@@ -120,13 +122,16 @@ async function replaceCommandShim(linkPath, targetPath) {
|
|||||||
if (await existingNonShim(linkPath)) {
|
if (await existingNonShim(linkPath)) {
|
||||||
throw new Error(`${linkPath} already exists and is not a command shim`);
|
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) {
|
async function existingNonShim(path) {
|
||||||
try {
|
try {
|
||||||
const stat = await lstat(path);
|
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) {
|
} catch (error) {
|
||||||
if (error instanceof Error && "code" in error && error.code === "ENOENT") return false;
|
if (error instanceof Error && "code" in error && error.code === "ENOENT") return false;
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
/// <reference path="../../../bun-test.d.ts" />
|
||||||
|
/// <reference types="bun-types" />
|
||||||
|
|
||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { mkdir, mkdtemp, readFile, readlink, writeFile } from "node:fs/promises"
|
import { mkdir, mkdtemp, readFile, readlink, writeFile } from "node:fs/promises"
|
||||||
import { tmpdir } from "node:os"
|
import { tmpdir } from "node:os"
|
||||||
@@ -35,8 +38,8 @@ describe("codex-cache", () => {
|
|||||||
await writeFile(join(pluginRoot, "dist", "cli.js"), "#!/usr/bin/env node\n")
|
await writeFile(join(pluginRoot, "dist", "cli.js"), "#!/usr/bin/env node\n")
|
||||||
|
|
||||||
// when
|
// when
|
||||||
const first = await linkCachedPluginBins({ binDir, pluginRoot })
|
const first = await linkCachedPluginBins({ binDir, pluginRoot, platform: "linux" })
|
||||||
const second = await linkCachedPluginBins({ binDir, pluginRoot })
|
const second = await linkCachedPluginBins({ binDir, pluginRoot, platform: "linux" })
|
||||||
|
|
||||||
// then
|
// then
|
||||||
expect(first).toHaveLength(1)
|
expect(first).toHaveLength(1)
|
||||||
@@ -64,4 +67,29 @@ describe("codex-cache", () => {
|
|||||||
expect(commandShim).toContain("@echo off")
|
expect(commandShim).toContain("@echo off")
|
||||||
expect(commandShim).toContain(`node "${join(pluginRoot, "dist", "cli.js")}" %*`)
|
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")
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import type { InstalledPlugin, RunCommand } from "./types"
|
|||||||
|
|
||||||
type LinkPlatform = NodeJS.Platform
|
type LinkPlatform = NodeJS.Platform
|
||||||
|
|
||||||
|
const COMMAND_SHIM_MARKER = ":: generated by oh-my-openagent Codex installer"
|
||||||
|
|
||||||
export async function installCachedPlugin(input: {
|
export async function installCachedPlugin(input: {
|
||||||
readonly codexHome: string
|
readonly codexHome: string
|
||||||
readonly marketplaceName: string
|
readonly marketplaceName: string
|
||||||
@@ -163,13 +165,16 @@ async function replaceSymlink(linkPath: string, targetPath: string): Promise<voi
|
|||||||
|
|
||||||
async function replaceCommandShim(linkPath: string, targetPath: string): Promise<void> {
|
async function replaceCommandShim(linkPath: string, targetPath: string): Promise<void> {
|
||||||
if (await existingNonShim(linkPath)) throw new Error(`${linkPath} already exists and is not a command shim`)
|
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<boolean> {
|
async function existingNonShim(path: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const stat = await lstat(path)
|
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) {
|
} catch (error) {
|
||||||
if (isNodeErrorWithCode(error) && error.code === "ENOENT") return false
|
if (isNodeErrorWithCode(error) && error.code === "ENOENT") return false
|
||||||
throw error
|
throw error
|
||||||
|
|||||||
Reference in New Issue
Block a user