diff --git a/packages/omo-codex/scripts/install-local.test.mjs b/packages/omo-codex/scripts/install-local.test.mjs index a277e46e9..3f3df42a1 100644 --- a/packages/omo-codex/scripts/install-local.test.mjs +++ b/packages/omo-codex/scripts/install-local.test.mjs @@ -7,6 +7,7 @@ import { tmpdir } from "node:os"; import { mkdtemp } from "node:fs/promises"; import { installMarketplaceLocally } from "./install-local.mjs"; +import { linkCachedPluginBins } from "./install/cache.mjs"; async function makeTempDir() { return mkdtemp(join(tmpdir(), "codex-plugins-install-")); @@ -208,3 +209,26 @@ test("#given bad plugin source path #when installing #then rejects traversal", a /local plugin source path must start with \.\//, ); }); + +test("#given Windows platform #when linking cached plugin bins #then writes command shims", async () => { + const root = await makeTempDir(); + const pluginRoot = join(root, "plugin"); + const binDir = join(root, "bin"); + + await mkdir(pluginRoot, { 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"); + + const linked = await linkCachedPluginBins({ binDir, pluginRoot, platform: "win32" }); + + assert.deepEqual(linked, [{ name: "alpha", path: join(binDir, "alpha.cmd"), target: join(pluginRoot, "dist", "cli.js") }]); + const shim = await readFile(join(binDir, "alpha.cmd"), "utf8"); + assert.match(shim, /@echo off/); + assert.match(shim, new RegExp(`node "${join(pluginRoot, "dist", "cli.js").replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}" %\\*`)); +}); diff --git a/packages/omo-codex/scripts/install/cache.mjs b/packages/omo-codex/scripts/install/cache.mjs index 2c0b4892c..210acfe53 100644 --- a/packages/omo-codex/scripts/install/cache.mjs +++ b/packages/omo-codex/scripts/install/cache.mjs @@ -25,18 +25,29 @@ export async function pruneMarketplaceCache({ codexHome, marketplaceName, keepPl } } -export async function linkCachedPluginBins({ binDir, pluginRoot }) { +export async function linkCachedPluginBins({ binDir, pluginRoot, platform = process.platform }) { const binLinks = await discoverPackageBins(pluginRoot); await mkdir(binDir, { recursive: true }); const linked = []; for (const link of binLinks) { - const linkPath = join(binDir, link.name); - await replaceSymlink(linkPath, link.target); + const linkPath = await linkCachedPluginBin(binDir, link, platform); linked.push({ name: link.name, path: linkPath, target: link.target }); } return linked; } +async function linkCachedPluginBin(binDir, link, platform) { + if (platform === "win32") { + const linkPath = join(binDir, `${link.name}.cmd`); + await replaceCommandShim(linkPath, link.target); + return linkPath; + } + + const linkPath = join(binDir, link.name); + await replaceSymlink(linkPath, link.target); + return linkPath; +} + async function maybeRunNpmInstall(cwd, runCommand, args = ["install"]) { if (!(await exists(join(cwd, "package.json")))) return; await runCommand("npm", args, { cwd }); @@ -105,6 +116,23 @@ async function replaceSymlink(linkPath, targetPath) { await symlink(targetPath, linkPath); } +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`); +} + +async function existingNonShim(path) { + try { + const stat = await lstat(path); + return !stat.isFile(); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") return false; + throw error; + } +} + async function existingNonSymlink(path) { try { const stat = await lstat(path);