diff --git a/src/core/windows-cmd-launch.ts b/src/core/windows-cmd-launch.ts index c4073f8b5..70ab5637c 100644 --- a/src/core/windows-cmd-launch.ts +++ b/src/core/windows-cmd-launch.ts @@ -1,5 +1,5 @@ -import { accessSync, constants } from "node:fs"; -import { extname, join } from "node:path"; +import { accessSync, constants, readFileSync } from "node:fs"; +import { dirname, extname, join, resolve } from "node:path"; const WINDOWS_CMD_META_CHARS_REGEXP = /([()\][%!^"`<>&|;, *?])/g; const WINDOWS_CMD_EXTENSIONS = new Set([".cmd", ".bat"]); @@ -55,6 +55,10 @@ function getWindowsPathExtensions(env: NodeJS.ProcessEnv): string[] { } function resolveWindowsBinaryExtension(binary: string, env: NodeJS.ProcessEnv): string | null { + return resolveWindowsBinaryPath(binary, env)?.extension ?? null; +} + +function resolveWindowsBinaryPath(binary: string, env: NodeJS.ProcessEnv): { path: string; extension: string } | null { const trimmed = binary.trim(); if (!trimmed) { return null; @@ -62,7 +66,7 @@ function resolveWindowsBinaryExtension(binary: string, env: NodeJS.ProcessEnv): const extension = extname(trimmed); if (extension) { - return extension.toLowerCase(); + return canAccessPath(trimmed) ? { path: trimmed, extension: extension.toLowerCase() } : null; } const pathExtensions = getWindowsPathExtensions(env); @@ -71,7 +75,7 @@ function resolveWindowsBinaryExtension(binary: string, env: NodeJS.ProcessEnv): for (const pathExtension of pathExtensions) { const candidate = `${trimmed}${pathExtension}`; if (canAccessPath(candidate)) { - return pathExtension.toLowerCase(); + return { path: candidate, extension: pathExtension.toLowerCase() }; } } return null; @@ -89,13 +93,39 @@ function resolveWindowsBinaryExtension(binary: string, env: NodeJS.ProcessEnv): for (const pathExtension of pathExtensions) { const candidate = join(pathEntry, `${trimmed}${pathExtension}`); if (canAccessPath(candidate)) { - return pathExtension.toLowerCase(); + return { path: candidate, extension: pathExtension.toLowerCase() }; } } } return null; } +function resolveDp0Path(shimPath: string, value: string): string { + const shimDirectory = dirname(shimPath); + return resolve(value.replace(/^%dp0%([\\/])/i, `${shimDirectory}$1`)); +} + +function extractWindowsNpmShimTarget(shimPath: string): string | null { + let content: string; + try { + content = readFileSync(shimPath, "utf8"); + } catch { + return null; + } + + const directTarget = content.match(/"(%dp0%[\\/][^"]+\.(?:exe|com))"\s+%[*]/i)?.[1]; + if (directTarget) { + return resolveDp0Path(shimPath, directTarget); + } + + const nodeScriptTarget = content.match(/"%_prog%"\s+"(%dp0%[\\/][^"]+\.(?:js|mjs|cjs))"\s+%[*]/i)?.[1]; + if (nodeScriptTarget) { + return resolveDp0Path(shimPath, nodeScriptTarget); + } + + return null; +} + function normalizeWindowsCmdArgument(value: string): string { return value.replaceAll("\r\n", "\n").replaceAll("\r", "\n").replaceAll("\n", "\\n"); } @@ -132,6 +162,37 @@ export function buildWindowsCmdArgsArray(binary: string, args: string[]): string return ["/d", "/s", "/c", `"${shellCommand}"`]; } +export function resolveWindowsNpmShimLaunch( + binary: string, + args: string[], + platform: NodeJS.Platform = process.platform, + env: NodeJS.ProcessEnv = process.env, +): { binary: string; args: string[] } | null { + if (platform !== "win32") { + return null; + } + + const resolved = resolveWindowsBinaryPath(binary, env); + if (!resolved || resolved.extension !== ".cmd") { + return null; + } + + const target = extractWindowsNpmShimTarget(resolved.path); + if (!target) { + return null; + } + + const targetExtension = extname(target).toLowerCase(); + if (targetExtension === ".exe" || targetExtension === ".com") { + return canAccessPath(target) ? { binary: target, args } : null; + } + if (targetExtension === ".js" || targetExtension === ".mjs" || targetExtension === ".cjs") { + return { binary: process.execPath, args: [target, ...args] }; + } + + return null; +} + export function shouldUseWindowsCmdLaunch( binary: string, platform: NodeJS.Platform = process.platform, diff --git a/src/terminal/pty-session.ts b/src/terminal/pty-session.ts index 44171584f..0ba88008a 100644 --- a/src/terminal/pty-session.ts +++ b/src/terminal/pty-session.ts @@ -3,6 +3,7 @@ import * as pty from "node-pty"; import { buildWindowsCmdArgsCommandLine, resolveWindowsComSpec, + resolveWindowsNpmShimLaunch, shouldUseWindowsCmdLaunch, } from "../core/windows-cmd-launch"; @@ -87,9 +88,14 @@ export class PtySession { const normalizedArgs = typeof args === "string" ? [args] : args; const terminalName = env?.TERM?.trim() || process.env.TERM?.trim() || "xterm-256color"; const launchEnv: NodeJS.ProcessEnv = env ? { ...process.env, ...env } : process.env; - const useWindowsShellLaunch = shouldUseWindowsCmdLaunch(binary, process.platform, launchEnv); - const spawnBinary = useWindowsShellLaunch ? resolveWindowsComSpec(launchEnv) : binary; - const spawnArgs = useWindowsShellLaunch ? buildWindowsCmdArgsCommandLine(binary, normalizedArgs) : normalizedArgs; + const windowsShimLaunch = resolveWindowsNpmShimLaunch(binary, normalizedArgs, process.platform, launchEnv); + const useWindowsShellLaunch = + !windowsShimLaunch && shouldUseWindowsCmdLaunch(binary, process.platform, launchEnv); + const spawnBinary = + windowsShimLaunch?.binary ?? (useWindowsShellLaunch ? resolveWindowsComSpec(launchEnv) : binary); + const spawnArgs = + windowsShimLaunch?.args ?? + (useWindowsShellLaunch ? buildWindowsCmdArgsCommandLine(binary, normalizedArgs) : normalizedArgs); const ptyOptions: pty.IPtyForkOptions = { name: terminalName, cwd, diff --git a/test/runtime/core/windows-cmd-launch.test.ts b/test/runtime/core/windows-cmd-launch.test.ts index babf94fd3..a44f1ae7f 100644 --- a/test/runtime/core/windows-cmd-launch.test.ts +++ b/test/runtime/core/windows-cmd-launch.test.ts @@ -1,9 +1,9 @@ -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { shouldUseWindowsCmdLaunch } from "../../../src/core/windows-cmd-launch"; +import { resolveWindowsNpmShimLaunch, shouldUseWindowsCmdLaunch } from "../../../src/core/windows-cmd-launch"; function createWindowsBinary(directory: string, fileName: string): string { const filePath = join(directory, fileName); @@ -99,4 +99,85 @@ describe("shouldUseWindowsCmdLaunch", () => { }), ).toBe(true); }); + + it("resolves npm .cmd shims that forward to a native executable", () => { + const tempDirectory = mkdtempSync(join(tmpdir(), "kanban-win-launch-")); + tempDirectories.push(tempDirectory); + mkdirSync(join(tempDirectory, "node_modules", "@anthropic-ai", "claude-code", "bin"), { recursive: true }); + writeFileSync(join(tempDirectory, "node_modules", "@anthropic-ai", "claude-code", "bin", "claude.exe"), ""); + writeFileSync( + join(tempDirectory, "claude.cmd"), + ["@ECHO off", 'CALL "%dp0%\\node_modules\\@anthropic-ai\\claude-code\\bin\\claude.exe" %*'].join("\n"), + ); + + const launch = resolveWindowsNpmShimLaunch("claude", ["--append-system-prompt", "long prompt"], "win32", { + PATH: tempDirectory, + PATHEXT: ".com;.exe;.bat;.cmd", + }); + + expect(launch).toEqual({ + binary: join(tempDirectory, "node_modules", "@anthropic-ai", "claude-code", "bin", "claude.exe"), + args: ["--append-system-prompt", "long prompt"], + }); + }); + + it("resolves npm .cmd shim %dp0% paths case-insensitively", () => { + const tempDirectory = mkdtempSync(join(tmpdir(), "kanban-win-launch-")); + tempDirectories.push(tempDirectory); + mkdirSync(join(tempDirectory, "node_modules", "@anthropic-ai", "claude-code", "bin"), { recursive: true }); + writeFileSync(join(tempDirectory, "node_modules", "@anthropic-ai", "claude-code", "bin", "claude.exe"), ""); + writeFileSync( + join(tempDirectory, "claude.cmd"), + ["@ECHO off", 'CALL "%DP0%\\node_modules\\@anthropic-ai\\claude-code\\bin\\claude.exe" %*'].join("\n"), + ); + + const launch = resolveWindowsNpmShimLaunch("claude", ["--foo"], "win32", { + PATH: tempDirectory, + PATHEXT: ".com;.exe;.bat;.cmd", + }); + + expect(launch).toEqual({ + binary: join(tempDirectory, "node_modules", "@anthropic-ai", "claude-code", "bin", "claude.exe"), + args: ["--foo"], + }); + }); + + it("falls back when npm .cmd shim target executable is missing", () => { + const tempDirectory = mkdtempSync(join(tmpdir(), "kanban-win-launch-")); + tempDirectories.push(tempDirectory); + writeFileSync( + join(tempDirectory, "claude.cmd"), + ["@ECHO off", 'CALL "%dp0%\\node_modules\\@anthropic-ai\\claude-code\\bin\\claude.exe" %*'].join("\n"), + ); + + const launch = resolveWindowsNpmShimLaunch("claude", ["--foo"], "win32", { + PATH: tempDirectory, + PATHEXT: ".com;.exe;.bat;.cmd", + }); + + expect(launch).toBeNull(); + }); + + it("resolves npm .cmd shims that forward to a Node script", () => { + const tempDirectory = mkdtempSync(join(tmpdir(), "kanban-win-launch-")); + tempDirectories.push(tempDirectory); + writeFileSync( + join(tempDirectory, "codex.cmd"), + [ + "@ECHO off", + 'IF EXIST "%dp0%\\node.exe" SET "_prog=%dp0%\\node.exe"', + 'endLocal & goto #_undefined_# 2>NUL || "%_prog%" "%dp0%\\node_modules\\@openai\\codex\\bin\\codex.js" %*', + ].join("\n"), + ); + + const launch = resolveWindowsNpmShimLaunch("codex", ["--foo"], "win32", { + PATH: tempDirectory, + PATHEXT: ".com;.exe;.bat;.cmd", + }); + + expect(launch).toEqual({ + binary: process.execPath, + args: [join(tempDirectory, "node_modules", "@openai", "codex", "bin", "codex.js"), "--foo"], + }); + }); }); diff --git a/test/runtime/terminal/pty-session.test.ts b/test/runtime/terminal/pty-session.test.ts index 8aef0533f..f1b55e6f1 100644 --- a/test/runtime/terminal/pty-session.test.ts +++ b/test/runtime/terminal/pty-session.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -87,6 +87,7 @@ describe("PtySession", () => { it("launches through cmd shell on Windows", () => { setPlatform("win32"); process.env.ComSpec = "C:\\Windows\\System32\\cmd.exe"; + process.env.PATH = ""; const ptyProcess = createMockPtyProcess(); ptyMocks.spawn.mockReturnValue(ptyProcess); @@ -111,6 +112,7 @@ describe("PtySession", () => { it("does not over-quote bare executables on Windows", () => { setPlatform("win32"); process.env.ComSpec = "C:\\Windows\\System32\\cmd.exe"; + process.env.PATH = ""; const ptyProcess = createMockPtyProcess(); ptyMocks.spawn.mockReturnValue(ptyProcess); @@ -162,6 +164,7 @@ describe("PtySession", () => { it("preserves full prompt text on Windows", () => { setPlatform("win32"); process.env.ComSpec = "C:\\Windows\\System32\\cmd.exe"; + process.env.PATH = ""; const ptyProcess = createMockPtyProcess(); ptyMocks.spawn.mockReturnValue(ptyProcess); @@ -184,6 +187,40 @@ describe("PtySession", () => { expect(cmdArgs).toContain("context"); }); + it("launches npm .cmd shims through their target executable on Windows", () => { + setPlatform("win32"); + const windowsBinDir = mkdtempSync(join(tmpdir(), "kanban-win-shim-")); + const target = join(windowsBinDir, "node_modules", "@anthropic-ai", "claude-code", "bin", "claude.exe"); + mkdirSync(join(windowsBinDir, "node_modules", "@anthropic-ai", "claude-code", "bin"), { recursive: true }); + writeFileSync( + join(windowsBinDir, "claude.cmd"), + '@ECHO off\n"%dp0%\\node_modules\\@anthropic-ai\\claude-code\\bin\\claude.exe" %*', + ); + writeFileSync(target, ""); + const ptyProcess = createMockPtyProcess(); + ptyMocks.spawn.mockReturnValue(ptyProcess); + + try { + PtySession.spawn({ + binary: "claude", + args: ["--append-system-prompt", "x".repeat(9000)], + cwd: "C:/repo", + env: { + PATH: windowsBinDir, + PATHEXT: ".com;.exe;.bat;.cmd", + }, + cols: 120, + rows: 40, + }); + } finally { + rmSync(windowsBinDir, { recursive: true, force: true }); + } + + expect(ptyMocks.spawn).toHaveBeenCalledTimes(1); + expect(ptyMocks.spawn.mock.calls[0]?.[0]).toBe(target); + expect(ptyMocks.spawn.mock.calls[0]?.[1]).toEqual(["--append-system-prompt", "x".repeat(9000)]); + }); + it("does not use cmd shell outside Windows", () => { setPlatform("darwin"); const ptyProcess = createMockPtyProcess();