Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 66 additions & 5 deletions src/core/windows-cmd-launch.ts
Original file line number Diff line number Diff line change
@@ -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"]);
Expand Down Expand Up @@ -55,14 +55,18 @@ 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;
}

const extension = extname(trimmed);
if (extension) {
return extension.toLowerCase();
return canAccessPath(trimmed) ? { path: trimmed, extension: extension.toLowerCase() } : null;
}

const pathExtensions = getWindowsPathExtensions(env);
Expand All @@ -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;
Expand All @@ -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`));
}
Comment thread
anst-dev marked this conversation as resolved.

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");
}
Expand Down Expand Up @@ -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;
}
Comment thread
anst-dev marked this conversation as resolved.
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,
Expand Down
12 changes: 9 additions & 3 deletions src/terminal/pty-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as pty from "node-pty";
import {
buildWindowsCmdArgsCommandLine,
resolveWindowsComSpec,
resolveWindowsNpmShimLaunch,
shouldUseWindowsCmdLaunch,
} from "../core/windows-cmd-launch";

Expand Down Expand Up @@ -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,
Expand Down
85 changes: 83 additions & 2 deletions test/runtime/core/windows-cmd-launch.test.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand Down Expand Up @@ -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"],
});
});
});
39 changes: 38 additions & 1 deletion test/runtime/terminal/pty-session.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);

Expand All @@ -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);

Expand Down Expand Up @@ -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);

Expand All @@ -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();
Expand Down