mirror of
https://github.com/mksglu/context-mode.git
synced 2026-09-19 03:27:16 +08:00
fix(executor): hide Windows console + drop .sh extension for shell exec (#384)
On Windows, ctx_execute(language: 'shell', ...) had two problems:
1. Silent output - child_process.spawn without windowsHide:true creates
a visible console window that intercepts stdout, leaving the MCP
response empty.
2. Git Bash popup - temp script written as 'script.sh' triggers Windows
file association for .sh files. bash.exe opens a visible window over
the user's IDE.
Fix (minimal, two surgical changes):
- spawn(..., { windowsHide: isWin }) via buildSpawnOptions(platform)
- isWin && language === 'shell' ? 'script' : 'script.{ext}' via
buildScriptFilename(language, platform)
Both changes are Windows-gated. Linux/macOS behavior unchanged.
Does NOT change shell invocation semantics (no bash -c wrapper).
Does NOT add SHELL env override. Both deferred - separate features.
Helpers exposed as pure functions for unit testing without mocking
spawn or filesystem.
Closes #384.
Supersedes #385 with smaller surface area.
This commit is contained in:
+40
-15
@@ -13,6 +13,40 @@ import type { ExecResult } from "./types.js";
|
||||
|
||||
const isWin = process.platform === "win32";
|
||||
|
||||
/**
|
||||
* Pure helper: extension map for temp script files per language.
|
||||
* On Windows, shell scripts get NO extension to avoid Windows file-association
|
||||
* for `.sh` (which spawns a visible Git Bash window over the user's IDE).
|
||||
*/
|
||||
const SCRIPT_EXT: Record<Language, string> = {
|
||||
javascript: "js",
|
||||
typescript: "ts",
|
||||
python: "py",
|
||||
shell: "sh",
|
||||
ruby: "rb",
|
||||
go: "go",
|
||||
rust: "rs",
|
||||
php: "php",
|
||||
perl: "pl",
|
||||
r: "R",
|
||||
elixir: "exs",
|
||||
};
|
||||
|
||||
/** Pure helper — exported for unit testing. Returns "script" or "script.<ext>". */
|
||||
export function buildScriptFilename(language: Language, platform: NodeJS.Platform): string {
|
||||
if (platform === "win32" && language === "shell") return "script";
|
||||
return `script.${SCRIPT_EXT[language]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure helper — exported for unit testing. Adds `windowsHide: true` on Windows
|
||||
* to prevent the spawned shell from creating a visible console window that
|
||||
* intercepts stdout (issue #384).
|
||||
*/
|
||||
export function buildSpawnOptions(platform: NodeJS.Platform): { windowsHide: boolean } {
|
||||
return { windowsHide: platform === "win32" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the real OS temp directory, bypassing any TMPDIR env override.
|
||||
* os.tmpdir() reads TMPDIR from the environment, which some shells/tools
|
||||
@@ -138,20 +172,6 @@ export class PolyglotExecutor {
|
||||
}
|
||||
|
||||
#writeScript(tmpDir: string, code: string, language: Language): string {
|
||||
const extMap: Record<Language, string> = {
|
||||
javascript: "js",
|
||||
typescript: "ts",
|
||||
python: "py",
|
||||
shell: "sh",
|
||||
ruby: "rb",
|
||||
go: "go",
|
||||
rust: "rs",
|
||||
php: "php",
|
||||
perl: "pl",
|
||||
r: "R",
|
||||
elixir: "exs",
|
||||
};
|
||||
|
||||
// Go needs a main package wrapper if not present
|
||||
if (language === "go" && !code.includes("package ")) {
|
||||
code = `package main\n\nimport "fmt"\n\nfunc main() {\n${code}\n}\n`;
|
||||
@@ -168,7 +188,7 @@ export class PolyglotExecutor {
|
||||
code = `Path.wildcard(Path.join(${escaped}, "*/ebin"))\n|> Enum.each(&Code.prepend_path/1)\n\n${code}`;
|
||||
}
|
||||
|
||||
const fp = join(tmpDir, `script.${extMap[language]}`);
|
||||
const fp = join(tmpDir, buildScriptFilename(language, process.platform));
|
||||
if (language === "shell") {
|
||||
writeFileSync(fp, code, { encoding: "utf-8", mode: 0o700 });
|
||||
} else {
|
||||
@@ -240,6 +260,11 @@ export class PolyglotExecutor {
|
||||
shell: needsShell,
|
||||
// On Unix, create a new process group so killTree can kill all children
|
||||
detached: !isWin,
|
||||
// Hide the spawned-process console window on Windows. Without this,
|
||||
// child_process.spawn creates a visible window that intercepts stdout,
|
||||
// leaving the MCP response empty and popping a Git Bash terminal over
|
||||
// the user's IDE. Issue #384.
|
||||
...buildSpawnOptions(process.platform),
|
||||
});
|
||||
|
||||
let timedOut = false;
|
||||
|
||||
+35
-1
@@ -3,7 +3,7 @@ import { strict as assert } from "node:assert";
|
||||
import { writeFileSync, mkdirSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { PolyglotExecutor } from "../src/executor.js";
|
||||
import { PolyglotExecutor, buildScriptFilename, buildSpawnOptions } from "../src/executor.js";
|
||||
import {
|
||||
detectRuntimes,
|
||||
buildCommand,
|
||||
@@ -1541,5 +1541,39 @@ describe("Windows Shell Support", () => {
|
||||
assert.ok(Array.isArray(cmd) && cmd.length === 2, `Expected [shell, path], got: ${cmd}`);
|
||||
assert.equal(cmd[1], "/tmp/script.sh");
|
||||
});
|
||||
|
||||
// --- Issue #384: hide Windows console + drop .sh extension for shell ---
|
||||
|
||||
test("buildSpawnOptions: windowsHide=true on Windows", async () => {
|
||||
assert.equal(buildSpawnOptions("win32").windowsHide, true);
|
||||
});
|
||||
|
||||
test("buildSpawnOptions: windowsHide=false on macOS/Linux", async () => {
|
||||
assert.equal(buildSpawnOptions("darwin").windowsHide, false);
|
||||
assert.equal(buildSpawnOptions("linux").windowsHide, false);
|
||||
});
|
||||
|
||||
test("buildScriptFilename: shell on Windows has NO extension (avoid .sh file association)", async () => {
|
||||
assert.equal(buildScriptFilename("shell", "win32"), "script");
|
||||
});
|
||||
|
||||
test("buildScriptFilename: shell on Unix keeps .sh extension", async () => {
|
||||
assert.equal(buildScriptFilename("shell", "darwin"), "script.sh");
|
||||
assert.equal(buildScriptFilename("shell", "linux"), "script.sh");
|
||||
});
|
||||
|
||||
test("buildScriptFilename: non-shell languages keep their extension on Windows", async () => {
|
||||
assert.equal(buildScriptFilename("python", "win32"), "script.py");
|
||||
assert.equal(buildScriptFilename("javascript", "win32"), "script.js");
|
||||
assert.equal(buildScriptFilename("typescript", "win32"), "script.ts");
|
||||
assert.equal(buildScriptFilename("ruby", "win32"), "script.rb");
|
||||
assert.equal(buildScriptFilename("go", "win32"), "script.go");
|
||||
assert.equal(buildScriptFilename("rust", "win32"), "script.rs");
|
||||
});
|
||||
|
||||
test("buildScriptFilename: non-shell languages keep their extension on Unix", async () => {
|
||||
assert.equal(buildScriptFilename("python", "linux"), "script.py");
|
||||
assert.equal(buildScriptFilename("javascript", "darwin"), "script.js");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user