From 47ca8f6bc1d4ccc3969a966db623a20c12c3b009 Mon Sep 17 00:00:00 2001 From: Jonesxq <239089032+Jonesxq@users.noreply.github.com> Date: Thu, 30 Jul 2026 18:11:35 +0800 Subject: [PATCH] fix(cli): execute OpenCode command shims on Windows --- packages/cli/src/lib/opencode-helpers.test.ts | 87 +++++++++++++++++++ packages/cli/src/lib/opencode-helpers.ts | 46 +++++++++- 2 files changed, 131 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/lib/opencode-helpers.test.ts b/packages/cli/src/lib/opencode-helpers.test.ts index a14b37baf..6a68f0675 100644 --- a/packages/cli/src/lib/opencode-helpers.test.ts +++ b/packages/cli/src/lib/opencode-helpers.test.ts @@ -6,6 +6,7 @@ import { detectOpenCodeInstallations } from "./opencode-detect"; import { describeOpenCodeInstallations, getAvailableModels, + getOpenCodeCommandInvocation, getOpenCodeVersion, OPENCODE_VERSION_PROBE_TIMEOUT_MS, } from "./opencode-helpers"; @@ -14,9 +15,15 @@ import { // #196 follow-up: a stock CLI not on PATH must still enumerate). POSIX-only: // the test writes an executable shell stub, which CI runs on Linux/macOS. const isPosix = process.platform !== "win32"; +const originalComSpec = process.env.ComSpec; +const originalPathExpansionProbe = process.env.MC_OPENCODE_TEST_PATH; const tempDirs: string[] = []; afterEach(() => { + if (originalComSpec === undefined) delete process.env.ComSpec; + else process.env.ComSpec = originalComSpec; + if (originalPathExpansionProbe === undefined) delete process.env.MC_OPENCODE_TEST_PATH; + else process.env.MC_OPENCODE_TEST_PATH = originalPathExpansionProbe; for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); }); @@ -39,6 +46,86 @@ function fakeHomeOpencode(body: string): { home: string; bin: string } { return { home, bin }; } +function fakeOpenCodeCommandShim(): string { + const dir = mkdtempSync(join(tmpdir(), "mc %MC_OPENCODE_TEST_PATH% & shim ")); + tempDirs.push(dir); + const shim = join(dir, "opencode.cmd"); + + if (process.platform === "win32") { + writeFileSync( + shim, + [ + "@echo off", + 'if "%~1"=="--version" (', + " echo 1.18.7", + ') else if "%~1"=="models" (', + " echo anthropic/claude-opus-4-8", + " echo openai/gpt-5.5", + ")", + ].join("\r\n"), + ); + } else { + const comSpec = join(dir, "fake-cmd"); + writeFileSync( + comSpec, + '#!/bin/sh\ncase "$5" in\n *--version*) echo "1.18.7" ;;\n *models*) printf "anthropic/claude-opus-4-8\\nopenai/gpt-5.5\\n" ;;\nesac\n', + ); + chmodSync(comSpec, 0o755); + process.env.ComSpec = comSpec; + } + + return shim; +} + +describe("OpenCode command execution", () => { + it("routes cmd and bat shims through ComSpec", () => { + process.env.ComSpec = "custom-cmd.exe"; + + expect(getOpenCodeCommandInvocation("C:\\npm\\opencode.CMD", ["--version"])).toEqual({ + command: "custom-cmd.exe", + args: [ + "/d", + "/s", + "/v:off", + "/c", + '""%MAGIC_CONTEXT_OPENCODE_BINARY%" "--version""', + ], + env: { MAGIC_CONTEXT_OPENCODE_BINARY: "C:\\npm\\opencode.CMD" }, + windowsVerbatimArguments: true, + }); + expect(getOpenCodeCommandInvocation("C:\\npm\\opencode.bat", ["models"])).toEqual({ + command: "custom-cmd.exe", + args: [ + "/d", + "/s", + "/v:off", + "/c", + '""%MAGIC_CONTEXT_OPENCODE_BINARY%" "models""', + ], + env: { MAGIC_CONTEXT_OPENCODE_BINARY: "C:\\npm\\opencode.bat" }, + windowsVerbatimArguments: true, + }); + }); + + it("invokes native executables directly", () => { + expect(getOpenCodeCommandInvocation("/usr/local/bin/opencode", ["--version"])).toEqual({ + command: "/usr/local/bin/opencode", + args: ["--version"], + }); + }); + + it("executes a cmd shim for version and model probes", () => { + process.env.MC_OPENCODE_TEST_PATH = "expanded-to-the-wrong-path"; + const shim = fakeOpenCodeCommandShim(); + + expect(getOpenCodeVersion(shim)).toBe("1.18.7"); + expect(getAvailableModels(shim)).toEqual([ + "anthropic/claude-opus-4-8", + "openai/gpt-5.5", + ]); + }); +}); + describe.if(isPosix)("opencode helpers with a resolved binary path", () => { it("getAvailableModels invokes the given absolute binary", () => { const bin = fakeOpencode( diff --git a/packages/cli/src/lib/opencode-helpers.ts b/packages/cli/src/lib/opencode-helpers.ts index 3e4e69f18..8767da28a 100644 --- a/packages/cli/src/lib/opencode-helpers.ts +++ b/packages/cli/src/lib/opencode-helpers.ts @@ -1,17 +1,59 @@ import { execFileSync, execSync } from "node:child_process"; +import { extname } from "node:path"; import type { OpenCodeInstallation } from "./opencode-detect"; +export interface OpenCodeCommandInvocation { + command: string; + args: string[]; + env?: Record; + windowsVerbatimArguments?: true; +} + +const OPENCODE_BINARY_ENV = "MAGIC_CONTEXT_OPENCODE_BINARY"; + +export function getOpenCodeCommandInvocation( + binary: string, + args: string[], +): OpenCodeCommandInvocation { + const extension = extname(binary).toLowerCase(); + if (extension !== ".cmd" && extension !== ".bat") { + return { command: binary, args }; + } + + const command = process.env.ComSpec?.trim() || process.env.COMSPEC?.trim() || "cmd.exe"; + // cmd.exe needs the /c payload as one outer-quoted command string. Pass the + // binary through the child environment so percent signs in a valid path are + // not treated as another variable expansion. Current callers only supply the + // fixed `--version` and `models` arguments. + const commandLine = [`%${OPENCODE_BINARY_ENV}%`, ...args] + .map((part) => `"${part}"`) + .join(" "); + return { + command, + args: ["/d", "/s", "/v:off", "/c", `"${commandLine}"`], + env: { [OPENCODE_BINARY_ENV]: binary }, + windowsVerbatimArguments: true, + }; +} + /** * Run `opencode `. If a `binary` path is given (an absolute path resolved * for a stock `~/.opencode/bin` install or a version-manager shim that is not on - * PATH), call that exact path via execFile; otherwise fall back to a bare + * PATH), invoke that exact path; otherwise fall back to a bare * `opencode` on PATH. */ function runOpenCode(args: string[], binary?: string | null, timeoutMs?: number): string | null { try { const options = { stdio: "pipe" as const, ...(timeoutMs ? { timeout: timeoutMs } : {}) }; if (binary) { - return execFileSync(binary, args, options).toString().trim(); + const invocation = getOpenCodeCommandInvocation(binary, args); + return execFileSync(invocation.command, invocation.args, { + ...options, + ...(invocation.env ? { env: { ...process.env, ...invocation.env } } : {}), + ...(invocation.windowsVerbatimArguments ? { windowsVerbatimArguments: true } : {}), + }) + .toString() + .trim(); } return execSync(`opencode ${args.join(" ")}`, options) .toString()