Files
NetMesh/electron/bridges/ai/shellUtils.cjs
zhaolei 3c72efcb7f
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

1001 lines
34 KiB
JavaScript

/**
* Shell utility functions shared across AI bridge modules.
*
* Provides ANSI stripping, URL extraction, CLI resolution, path helpers,
* stream chunk serialization, and cached shell environment resolution.
*/
"use strict";
const { execFile, execFileSync } = require("node:child_process");
const { existsSync, readFileSync, statSync } = require("node:fs");
const path = require("node:path");
// ── ANSI / URL regexes ──
const ANSI_ESCAPE_REGEX = /\u001B\[[0-?]*[ -/]*[@-~]/g;
const ANSI_OSC_REGEX = /\u001B\][^\u0007]*(?:\u0007|\u001B\\)/g;
const URL_CANDIDATE_REGEX = /https?:\/\/[^\s]+/g;
const WINDOWS_RUNNABLE_EXTENSIONS = [".exe", ".cmd", ".bat", ".com"];
const MAX_PROMPT_TRACK_TAIL = 4096;
function execFileAsync(command, args, options) {
return new Promise((resolve, reject) => {
execFile(command, args, options, (error, stdout, stderr) => {
if (error) {
error.stdout = stdout;
error.stderr = stderr;
reject(error);
return;
}
resolve({ stdout, stderr });
});
});
}
// ── ANSI stripping ──
function stripAnsi(input) {
return String(input || "").replace(ANSI_OSC_REGEX, "").replace(ANSI_ESCAPE_REGEX, "");
}
// ── Synthetic command echo ──
//
// The agent's typed command is not echoed by the PTY as-is (the wrapper
// line is filtered out in preload), so exec bridges emit a synthetic echo
// for the user to see. xterm.js treats a bare \n as "move down, keep
// column", which renders multi-line commands as a staircase. Normalize
// every line break to \r\n so each line starts at column 0.
function formatSyntheticEcho(command) {
return `${String(command ?? "").replace(/\r?\n/g, "\r\n")}\r\n`;
}
// Default PowerShell prompt (e.g. `PS C:\Users\alice>`, `PS>`,
// `PS /home/alice>`). Anchored so command output that merely starts with
// `PS` (e.g. `PSO>`) doesn't match. The `\S` after `\s+` rejects literal
// `"PS >"` (which the default prompt never emits) so a script that prints
// such a line can't trick prompt-driven shell-kind selection.
const POWERSHELL_PROMPT_PATTERN = /^PS(?:\s+\S.*)?>$/;
// Default cmd.exe prompt (e.g. `C:\>`, `C:\Users\alice>`, `D:\data>`).
// Drive letter + optional path + `>`. Rejects `C: >` (space before `>`) and
// PowerShell's `PS C:\...>` (handled by POWERSHELL_PROMPT_PATTERN first).
const CMD_PROMPT_PATTERN = /^[A-Za-z]:(?:\\[^<>"|]*)?>$/;
// Classic `user@host:...$` / `user@host:...#` login prompt (bash/zsh in WSL,
// remote Linux, etc.). Intentionally narrow so custom / fish prompts do not
// flip a Windows DefaultShell soft hint.
const POSIX_PROMPT_PATTERN = /^[^\s@]+@[^\s:]+(?::[^\n\r]*)?[#$]$/;
function isDefaultPowerShellPromptLine(line) {
return POWERSHELL_PROMPT_PATTERN.test(String(line || ""));
}
function isDefaultCmdPromptLine(line) {
return CMD_PROMPT_PATTERN.test(String(line || "").replace(/\s+$/, ""));
}
function isDefaultPosixPromptLine(line) {
return POSIX_PROMPT_PATTERN.test(String(line || "").replace(/\s+$/, ""));
}
function extractTrailingIdlePrompt(output) {
// Treat `\r` as a line break, not as a stripped character: PSReadLine /
// ConPTY repaints emit bare `\r` to redraw the current line, and we
// want only the redrawn line to be considered, not the concatenation
// of every overwritten frame.
const normalized = stripAnsi(output).replace(/\r/g, "\n");
if (!normalized || normalized.endsWith("\n")) return "";
const lastLine = normalized.split("\n").pop() || "";
const rightTrimmed = lastLine.replace(/\s+$/, "");
if (!rightTrimmed) return "";
if (isDefaultPowerShellPromptLine(rightTrimmed)) {
return lastLine;
}
if (isDefaultPosixPromptLine(rightTrimmed)) {
return lastLine;
}
if (isDefaultCmdPromptLine(rightTrimmed)) {
return lastLine;
}
return "";
}
// bash and csh/tcsh print a banner to the terminal right before exiting due to
// the shell's TMOUT idle-timeout setting ("timed out waiting for input:
// auto-logout" / "auto-logout"). That exit is a clean shell exit — numeric
// code, no signal — so it is indistinguishable from a user-typed `exit` by
// exit code alone (verified: bash auto-logout exits 0). The banner is the only
// reliable discriminator, letting the SSH bridge keep the tab open for
// reconnect instead of auto-closing it (#1062, regression of #977).
const IDLE_AUTO_LOGOUT_PATTERN = /(?:timed out waiting for input:\s*)?auto-?logout$/i;
function looksLikeIdleAutoLogout(outputTail) {
if (typeof outputTail !== "string" || !outputTail) return false;
// The shell prints this banner on its own line as the very last thing before
// it exits, so anchor on the final non-empty line rather than a loose
// substring. Otherwise unrelated output that merely mentions "auto-logout"
// (e.g. `grep auto-logout /etc/profile`) followed by an intentional `exit`
// would be misclassified as a timeout and wrongly keep the tab open.
const lines = stripAnsi(outputTail.slice(-512)).replace(/\r/g, "\n").split("\n");
for (let i = lines.length - 1; i >= 0; i--) {
// Drop control bytes (e.g. the BEL bash rings before the banner) and trim.
const line = lines[i].replace(/[\x00-\x1f\x7f]/g, "").trim();
if (!line) continue;
return IDLE_AUTO_LOGOUT_PATTERN.test(line);
}
return false;
}
function trackSessionIdlePrompt(session, chunk) {
if (!session || typeof chunk !== "string" || !chunk) return "";
const nextTail = `${session._promptTrackTail || ""}${chunk}`.slice(-MAX_PROMPT_TRACK_TAIL);
session._promptTrackTail = nextTail;
const prompt = extractTrailingIdlePrompt(nextTail);
if (prompt) {
session.lastIdlePrompt = prompt;
session.lastIdlePromptAt = Date.now();
}
return prompt;
}
// Return `session.lastIdlePrompt` only if the PTY's recent rolling tail
// still ends with it. The cached prompt is updated only when
// extractTrailingIdlePrompt recognizes a known shape (PowerShell, cmd.exe,
// or `user@host[:path][#$]`); a remote shell switch into another shell, an
// oh-my-posh / starship / custom PS1, or any unrecognized prompt would
// otherwise leave a stale value behind, which `resolveEffectiveShellKind`
// would then keep using to coerce future commands into a PowerShell
// wrapper. By re-checking the live tail we self-correct: if the visible
// last line no longer matches the cached prompt, the prompt is treated
// as expired and downstream wrapper selection / suffix matching falls
// back to `shellKind` alone.
function getFreshIdlePrompt(session) {
if (!session) return "";
const cached = session.lastIdlePrompt;
if (!cached) return "";
const tail = session._promptTrackTail;
if (typeof tail !== "string" || !tail) return "";
const normalizedTail = stripAnsi(tail).replace(/\r/g, "\n");
const normalizedCached = stripAnsi(cached).replace(/\r/g, "\n");
if (!normalizedCached) return "";
return normalizedTail.endsWith(normalizedCached) ? cached : "";
}
// ── URL helpers ──
function isLocalhostHostname(hostname) {
const normalized = String(hostname || "").trim().toLowerCase();
return (
normalized === "localhost" ||
normalized === "127.0.0.1" ||
normalized === "::1" ||
normalized === "[::1]" ||
normalized.endsWith(".localhost")
);
}
function extractFirstNonLocalhostUrl(output) {
const { URL } = require("node:url");
const matches = stripAnsi(output).match(URL_CANDIDATE_REGEX);
if (!matches) return null;
for (const match of matches) {
try {
const parsedUrl = new URL(match.trim().replace(/[),.;!?]+$/, ""));
if (!isLocalhostHostname(parsedUrl.hostname)) {
return parsedUrl.toString();
}
} catch {
// Ignore invalid URL candidates.
}
}
return null;
}
// ── CLI / path helpers ──
function normalizeCliPathForPlatform(filePath) {
const normalized = String(filePath || "").trim();
if (!normalized) return null;
if (process.platform !== "win32") {
// Reject directories (e.g. /Applications/Codex.app) — must be a file
try {
if (existsSync(normalized) && statSync(normalized).isFile()) return normalized;
} catch { /* stat failed */ }
return null;
}
const ext = path.extname(normalized).toLowerCase();
if (ext) {
return existsSync(normalized) ? normalized : null;
}
// Windows npm globals often contain both a POSIX shim (`codex`) and the
// actual runnable wrapper (`codex.cmd`). Prefer the wrapper when present.
for (const suffix of WINDOWS_RUNNABLE_EXTENSIONS) {
const candidate = `${normalized}${suffix}`;
if (existsSync(candidate)) {
return candidate;
}
}
return existsSync(normalized) ? normalized : null;
}
function shouldUseShellForCommand(command) {
if (process.platform !== "win32") return false;
const normalized = String(command || "").trim().toLowerCase();
return normalized.endsWith(".cmd") || normalized.endsWith(".bat");
}
function quoteWindowsShellArg(value) {
const arg = String(value ?? "");
if (!arg) return "\"\"";
return `"${arg.replace(/"/g, '\\"')}"`;
}
function buildWindowsShellCommandLine(command, args) {
return [command, ...(args || [])].map(quoteWindowsShellArg).join(" ");
}
function resolveWindowsShimToNativeExe(command, platform = process.platform) {
if (platform !== "win32") return null;
const normalized = String(command || "").trim();
if (!normalized) return null;
const ext = path.extname(normalized).toLowerCase();
if (ext !== ".cmd" && ext !== ".bat") return null;
if (!existsSync(normalized)) return null;
try {
const contents = readFileSync(normalized, "utf8");
const shimDir = path.dirname(normalized);
// Match patterns like: "%~dp0\..\node_modules\@anthropic-ai\claude-code\bin\claude.exe" %*
// or: "%~dp0\..\@openai\codex\bin\codex.exe"
const exeRefs = [...contents.matchAll(/"%~dp0\\([^"]+\.exe)"/gi)];
for (const [, relativePath] of exeRefs) {
const candidate = path.resolve(shimDir, relativePath.replace(/\\/g, "/"));
if (existsSync(candidate)) return candidate;
}
} catch {}
return null;
}
function prepareCommandForSpawn(command, args, options = {}) {
const spawnArgs = Array.isArray(args) ? args : [];
if (!shouldUseShellForCommand(command)) {
return { command, args: spawnArgs, shell: false };
}
// Cursor's Windows installer .cmd launches node.exe + index.js. Unwrapping
// to the first quoted .exe would drop the script and run node with CLI args.
if (options.unwrapNativeExe !== false) {
const nativeExePath = resolveWindowsShimToNativeExe(command);
if (nativeExePath) {
return { command: nativeExePath, args: spawnArgs, shell: false };
}
}
return {
command: buildWindowsShellCommandLine(command, spawnArgs),
args: [],
shell: true,
};
}
function resolveClaudeCodeExecutableForSdk(claudeExecutablePath, platform = process.platform) {
const normalized = String(claudeExecutablePath || "").trim();
if (!normalized) return null;
if (platform !== "win32") return normalized;
const ext = path.extname(normalized).toLowerCase();
if (ext && ext !== ".cmd" && ext !== ".bat") return normalized;
const baseDir = path.dirname(normalized);
const packageCliPath = path.join(baseDir, "node_modules", "@anthropic-ai", "claude-code", "cli.js");
if (existsSync(packageCliPath)) {
return packageCliPath;
}
// Native binary check: Claude Code >= 2.1.169 ships as native exe with no cli.js
const nativeExeCandidates = [
path.join(baseDir, "node_modules", "@anthropic-ai", "claude-code", "bin", "claude.exe"),
path.join(baseDir, "..", "node_modules", "@anthropic-ai", "claude-code", "bin", "claude.exe"),
];
for (const exePath of nativeExeCandidates) {
if (existsSync(exePath)) return exePath;
}
const shimCandidates = [normalized];
if (!ext) {
shimCandidates.push(`${normalized}.cmd`, `${normalized}.bat`);
}
for (const shimPath of shimCandidates) {
try {
if (!existsSync(shimPath)) continue;
const contents = readFileSync(shimPath, "utf8");
if (!/node_modules[\\/]+@anthropic-ai[\\/]+claude-code[\\/]+cli\.js/i.test(contents)) {
continue;
}
if (existsSync(packageCliPath)) {
return packageCliPath;
}
} catch {
// Fall back to the original executable path below.
}
}
return normalized;
}
function normalizeClaudeCodeExecutableEnvForSdk(env, platform = process.platform) {
if (!env?.CLAUDE_CODE_EXECUTABLE) return env;
const resolved = resolveClaudeCodeExecutableForSdk(env.CLAUDE_CODE_EXECUTABLE, platform);
if (!resolved || resolved === env.CLAUDE_CODE_EXECUTABLE) return env;
return {
...env,
CLAUDE_CODE_EXECUTABLE: resolved,
};
}
const CODEX_WIN32_PLATFORM_PACKAGES = {
x64: { triple: "x86_64-pc-windows-msvc", package: "@openai/codex-win32-x64" },
arm64: { triple: "aarch64-pc-windows-msvc", package: "@openai/codex-win32-arm64" },
};
function resolveCodexNativeExecutableWin32(moduleSearchDirs, arch = process.arch) {
const archKey = arch === "arm64" ? "arm64" : "x64";
const { triple, package: platformPackage } = CODEX_WIN32_PLATFORM_PACKAGES[archKey];
for (const dir of moduleSearchDirs) {
if (!dir) continue;
const candidates = [
path.join(dir, "node_modules", platformPackage, "vendor", triple, "bin", "codex.exe"),
path.join(dir, "node_modules", platformPackage, "vendor", triple, "codex", "codex.exe"),
];
for (const candidate of candidates) {
if (existsSync(candidate)) {
return candidate;
}
}
}
return null;
}
function getNvmdHomeFromShimDir(shimDir) {
const normalized = String(shimDir || "").trim();
if (!normalized) return null;
if (path.basename(normalized).toLowerCase() !== "bin") return null;
const home = path.dirname(normalized);
// nvm-desktop / nvmd-command layout: $NVMD_HOME/{bin,versions,default,packages.json}
if (
existsSync(path.join(home, "versions")) ||
existsSync(path.join(home, "packages.json")) ||
existsSync(path.join(home, "default"))
) {
return home;
}
return null;
}
function readNvmdDefaultVersion(nvmdHome) {
try {
const raw = readFileSync(path.join(nvmdHome, "default"), "utf8").trim();
return raw || null;
} catch {
return null;
}
}
function readNvmdPackageVersions(nvmdHome, packageBinName) {
try {
const raw = readFileSync(path.join(nvmdHome, "packages.json"), "utf8");
const data = JSON.parse(raw);
const versions = data && data[packageBinName];
if (!Array.isArray(versions)) return [];
return versions.map((v) => String(v || "").trim()).filter(Boolean);
} catch {
return [];
}
}
function getNvmdVersionsDirectory(nvmdHome) {
try {
const raw = readFileSync(path.join(nvmdHome, "setting.json"), "utf8");
const data = JSON.parse(raw);
const custom = data && typeof data.directory === "string" ? data.directory.trim() : "";
if (custom) return custom;
} catch {
// Fall back to the default versions/ directory.
}
return path.join(nvmdHome, "versions");
}
function getNvmdCodexVersionRoots(nvmdHome) {
if (!nvmdHome) return [];
const versionsDir = getNvmdVersionsDirectory(nvmdHome);
const candidates = [
...readNvmdPackageVersions(nvmdHome, "codex").reverse(),
readNvmdDefaultVersion(nvmdHome),
].filter(Boolean);
const roots = [];
const seen = new Set();
for (const version of candidates) {
const root = path.join(versionsDir, version);
if (seen.has(root) || !existsSync(root)) continue;
seen.add(root);
roots.push(root);
}
return roots;
}
function getCodexNativeSearchDirsForShim(shimDir) {
const dirs = [shimDir];
const parentDir = path.dirname(shimDir);
if (
path.basename(shimDir).toLowerCase() === ".bin" &&
path.basename(parentDir).toLowerCase() === "node_modules"
) {
dirs.push(path.dirname(parentDir));
}
dirs.push(path.join(shimDir, "node_modules", "@openai", "codex"));
// nvm-desktop installs global CLIs under $NVMD_HOME/versions/<ver>/, while
// $NVMD_HOME/bin/codex{.cmd,.exe} are only nvmd router shims. Expand search
// into the active/recorded Node version roots so the SDK can spawn the real
// native codex.exe (codexPathOverride) instead of falling back to bundled
// optional deps that Netcatty deliberately does not ship.
const nvmdHome = getNvmdHomeFromShimDir(shimDir);
for (const versionRoot of getNvmdCodexVersionRoots(nvmdHome)) {
dirs.push(versionRoot);
dirs.push(path.join(versionRoot, "node_modules", "@openai", "codex"));
}
return dirs;
}
function getCodexNativePathDirsWin32(nativeExecutablePath) {
const normalized = String(nativeExecutablePath || "").trim();
if (!normalized || path.basename(normalized).toLowerCase() !== "codex.exe") {
return [];
}
const executableDir = path.dirname(normalized);
const packageRoot = path.dirname(executableDir);
const dirs = [];
if (path.basename(executableDir).toLowerCase() === "bin") {
dirs.push(path.join(packageRoot, "codex-path"));
} else if (path.basename(executableDir).toLowerCase() === "codex") {
dirs.push(path.join(packageRoot, "path"));
}
return dirs.filter((dir) => existsSync(dir));
}
function getPathEnvKey(env, platform = process.platform) {
if (platform !== "win32") return "PATH";
const keys = Object.keys(env || {}).filter((key) => key.toLowerCase() === "path");
return keys.includes("Path") ? "Path" : keys.at(-1) || "PATH";
}
function addCodexExecutableEnvForSdk(env, codexExecutablePath, platform = process.platform) {
if (platform !== "win32" || !codexExecutablePath) return env;
const pathDirs = getCodexNativePathDirsWin32(codexExecutablePath);
if (pathDirs.length === 0) return env;
const nextEnv = { ...(env || {}) };
const pathKey = getPathEnvKey(nextEnv, platform);
for (const key of Object.keys(nextEnv)) {
if (key.toLowerCase() === "path" && key !== pathKey) {
delete nextEnv[key];
}
}
const delimiter = platform === "win32" ? ";" : path.delimiter;
const existingEntries = String(nextEnv[pathKey] || "")
.split(delimiter)
.filter((entry) => entry && !pathDirs.includes(entry));
nextEnv[pathKey] = [...pathDirs, ...existingEntries].join(delimiter);
return nextEnv;
}
function resolveCodexExecutableForSdk(codexExecutablePath, platform = process.platform) {
const normalized = String(codexExecutablePath || "").trim();
if (!normalized) return null;
if (platform !== "win32") return normalized;
const ext = path.extname(normalized).toLowerCase();
const baseDir = path.dirname(normalized);
const moduleSearchDirs = getCodexNativeSearchDirsForShim(baseDir);
const nvmdHome = getNvmdHomeFromShimDir(baseDir);
// nvmd's Windows package shim is a copy of nvmd.exe named codex.exe. Prefer
// the real native binary under versions/<ver>/ when that layout is present.
if (ext === ".exe") {
if (nvmdHome) {
const nativeExe = resolveCodexNativeExecutableWin32(moduleSearchDirs);
if (nativeExe) return nativeExe;
}
return normalized;
}
if (ext === ".js" && /[\\/]codex\.js$/i.test(normalized)) {
const codexPackageRoot = path.dirname(path.dirname(normalized));
const globalPrefix = path.resolve(codexPackageRoot, "..", "..", "..");
const nativeExe = resolveCodexNativeExecutableWin32([
globalPrefix,
codexPackageRoot,
...moduleSearchDirs,
]);
if (nativeExe) return nativeExe;
}
if (ext && ext !== ".cmd" && ext !== ".bat" && ext !== ".ps1") {
return normalized;
}
const nativeExe = resolveCodexNativeExecutableWin32(moduleSearchDirs);
if (nativeExe) return nativeExe;
const shimCandidates = [normalized];
if (!ext) {
shimCandidates.push(`${normalized}.cmd`, `${normalized}.bat`);
}
for (const shimPath of shimCandidates) {
try {
if (!existsSync(shimPath)) continue;
const contents = readFileSync(shimPath, "utf8");
if (!/@openai[\\/]codex[\\/]bin[\\/]codex\.js/i.test(contents)) {
continue;
}
const resolved = resolveCodexNativeExecutableWin32(moduleSearchDirs);
if (resolved) return resolved;
} catch {
// Fall back to the original executable path below.
}
}
return ext === ".cmd" || ext === ".bat" || ext === ".ps1" ? null : normalized;
}
function resolveCodebuddyExecutableForSdk(codebuddyExecutablePath, platform = process.platform) {
const normalized = String(codebuddyExecutablePath || "").trim();
if (!normalized) return null;
if (platform !== "win32") return normalized;
const ext = path.extname(normalized).toLowerCase();
// A native exe or an explicit .js entry can be launched by the Agent SDK as-is.
if (ext === ".exe" || ext === ".js") return normalized;
// Any other concrete, non-shim extension: leave it untouched.
if (ext && ext !== ".cmd" && ext !== ".bat" && ext !== ".ps1") return normalized;
// Windows npm globals expose `codebuddy.cmd` / `codebuddy.ps1` shims (and an
// extensionless POSIX shim). The Agent SDK launches the CLI through `node`
// (electron-as-node in a packaged app), which cannot parse a batch/POSIX shim
// as JavaScript — the spawned process exits immediately and the SDK surfaces
// "CLI process stdout closed unexpectedly". Resolve the shim to the package's
// real `bin/codebuddy` JS entry so the SDK runs it exactly as on macOS/Linux.
const baseDir = path.dirname(normalized);
const packageRoots = [
path.join(baseDir, "node_modules", "@tencent-ai", "codebuddy-code"),
path.join(baseDir, "..", "node_modules", "@tencent-ai", "codebuddy-code"),
];
for (const root of packageRoots) {
const binJs = path.join(root, "bin", "codebuddy");
if (existsSync(binJs)) return binJs;
}
// Fall back to parsing the shim for the bin/codebuddy path it references.
const shimCandidates = [normalized];
if (!ext) shimCandidates.push(`${normalized}.cmd`, `${normalized}.bat`);
for (const shimPath of shimCandidates) {
try {
if (!existsSync(shimPath)) continue;
const contents = readFileSync(shimPath, "utf8");
const match = contents.match(/([^"\s]*codebuddy-code[\\/]bin[\\/]codebuddy)/i);
if (match) {
const ref = match[1].replace(/^%~dp0[\\/]?/i, "").replace(/[\\/]+/g, path.sep);
const binJs = path.isAbsolute(ref) ? ref : path.resolve(path.dirname(shimPath), ref);
if (existsSync(binJs)) return binJs;
}
} catch {
// Try the next shim candidate.
}
}
// Could not locate the JS entry — return null so the caller falls back to the
// SDK's bundled CLI rather than handing `node` an unrunnable shim.
return ext === ".cmd" || ext === ".bat" || ext === ".ps1" ? null : normalized;
}
function resolveSdkBinPath(command, shellEnv, platform = process.platform) {
const raw = resolveCliFromPath(command, shellEnv);
if (!raw) return null;
if (platform !== "win32") return raw;
if (command === "codex") {
return resolveCodexExecutableForSdk(raw, platform);
}
if (command === "claude") {
return resolveClaudeCodeExecutableForSdk(raw, platform);
}
return raw;
}
async function resolveSdkBinPathAsync(command, shellEnv, platform = process.platform) {
const raw = await resolveCliFromPathAsync(command, shellEnv);
if (!raw) return null;
if (platform !== "win32") return raw;
if (command === "codex") {
return resolveCodexExecutableForSdk(raw, platform);
}
if (command === "claude") {
return resolveClaudeCodeExecutableForSdk(raw, platform);
}
return raw;
}
function resolveCliFromPath(command, shellEnv) {
// Validate command: only allow valid binary names (alphanumeric, hyphens, underscores, dots)
if (!command || !/^[a-zA-Z0-9._-]+$/.test(command)) {
return null;
}
if (shellEnv) {
try {
const whichCmd = process.platform === "win32" ? "where" : "which";
const resolved = execFileSync(whichCmd, [command], {
encoding: "utf8",
timeout: 3000,
stdio: ["pipe", "pipe", "pipe"],
env: shellEnv,
}).trim();
for (const candidate of resolved.split(/\r?\n/)) {
const normalized = normalizeCliPathForPlatform(candidate);
if (normalized) return normalized;
}
} catch {
// Not found on PATH
}
}
return null;
}
async function resolveCliFromPathAsync(command, shellEnv) {
// Validate command: only allow valid binary names (alphanumeric, hyphens, underscores, dots)
if (!command || !/^[a-zA-Z0-9._-]+$/.test(command)) {
return null;
}
if (shellEnv) {
try {
const whichCmd = process.platform === "win32" ? "where" : "which";
const { stdout } = await execFileAsync(whichCmd, [command], {
encoding: "utf8",
timeout: 3000,
env: shellEnv,
});
const resolved = String(stdout || "").trim();
for (const candidate of resolved.split(/\r?\n/)) {
const normalized = normalizeCliPathForPlatform(candidate);
if (normalized) return normalized;
}
} catch {
// Not found on PATH
}
}
return null;
}
function toUnpackedAsarPath(filePath) {
const unpackedPath = filePath.replace(/app\.asar([\\/])/, "app.asar.unpacked$1");
if (unpackedPath !== filePath && existsSync(unpackedPath)) {
return unpackedPath;
}
return filePath;
}
function isPlausibleCliVersionOutput(value) {
const line = stripAnsi(String(value || "")).trim().split(/\r?\n/)[0]?.trim() || "";
if (!line) return false;
if (/^(?:file|node):\/\//i.test(line)) return false;
if (/^\s*at\s+/i.test(line)) return false;
if (/\b(?:Error|TypeError|ReferenceError|SyntaxError|ERR_[A-Z_]+)\b/.test(line)) return false;
return /(?:^|[^\d])v?\d+(?:\.\d+){1,3}(?:[-+][0-9A-Za-z.-]+)?(?:$|[^\d])/.test(line);
}
// ── Shell environment (cached) ──
let _cachedShellEnv = null;
let _shellEnvPromise = null;
let _shellEnvGeneration = 0;
/**
* Run the user's login shell once to print its PATH. Used as a fallback when
* the main `-ilc env` capture in getShellEnv fails (layer-0 fix-path).
*/
function defaultRunLoginShellPath() {
let shell = process.env.SHELL || "/bin/zsh";
if (!path.isAbsolute(shell) || !existsSync(shell)) {
shell = "/bin/zsh";
}
return execFileSync(shell, ["-ilc", 'echo -n "$PATH"'], {
encoding: "utf8",
timeout: 4000,
stdio: ["ignore", "pipe", "ignore"],
env: { ...process.env, HOME: process.env.HOME || "" },
});
}
async function defaultRunLoginShellPathAsync() {
let shell = process.env.SHELL || "/bin/zsh";
if (!path.isAbsolute(shell) || !existsSync(shell)) {
shell = "/bin/zsh";
}
const { stdout } = await execFileAsync(shell, ["-ilc", 'echo -n "$PATH"'], {
encoding: "utf8",
timeout: 4000,
env: { ...process.env, HOME: process.env.HOME || "" },
});
return stdout;
}
/**
* Union a login-shell PATH ahead of basePath and de-duplicate, so a GUI launch
* (Finder/Dock) with a stripped PATH still discovers user-installed CLIs.
* Returns basePath unchanged on win32 or if the login-shell probe fails.
*/
function mergeLoginShellPath({
basePath,
runLoginShellPath = defaultRunLoginShellPath,
platform = process.platform,
delimiter = path.delimiter,
}) {
if (platform === "win32") return basePath;
let shellPath = "";
try {
shellPath = String(runLoginShellPath() || "").trim();
} catch {
return basePath;
}
if (!shellPath) return basePath;
const seen = new Set();
const out = [];
for (const part of [...shellPath.split(delimiter), ...String(basePath || "").split(delimiter)]) {
const p = part.trim();
if (p && !seen.has(p)) { seen.add(p); out.push(p); }
}
return out.join(delimiter);
}
// ── Windows live PATH refresh ──
//
// A GUI-launched Electron process freezes process.env at launch. When a CLI is
// installed *after* Netcatty starts (its installer appends to the user/system
// PATH in the registry), a freshly opened cmd/PowerShell sees it but Netcatty
// does not — and clicking "Refresh" can't help, because process.env never
// changes for the life of the process. So on Windows we re-read the authoritative
// PATH from the registry (the value a brand-new shell would inherit) and merge it
// with the in-process PATH. This mirrors the login-shell PATH probe used on
// macOS/Linux and fixes CLIs (e.g. CodeBuddy) that "work in cmd" but don't scan.
function parseRegQueryPath(stdout) {
// `reg query` prints e.g.: " Path REG_EXPAND_SZ C:\\a;C:\\b"
for (const line of String(stdout || "").split(/\r?\n/)) {
const match = line.match(/^\s*Path\s+REG_(?:EXPAND_)?SZ\s+(.*\S)\s*$/i);
if (match) return match[1];
}
return "";
}
function expandWindowsEnvRefs(value, env = process.env) {
return String(value || "").replace(/%([^%]+)%/g, (whole, name) => {
const key = Object.keys(env).find((k) => k.toLowerCase() === String(name).toLowerCase());
return key && typeof env[key] === "string" ? env[key] : whole;
});
}
function mergeWindowsPath(...pathStrings) {
const seen = new Set();
const out = [];
for (const str of pathStrings) {
for (const part of String(str || "").split(";")) {
const trimmed = part.trim().replace(/^"|"$/g, "");
if (!trimmed) continue;
const dedupeKey = trimmed.toLowerCase().replace(/[\\/]+$/, "");
if (seen.has(dedupeKey)) continue;
seen.add(dedupeKey);
out.push(trimmed);
}
}
return out.join(";");
}
function getWindowsKnownCliPathDirs(env = process.env) {
const dirs = [];
if (env.APPDATA) dirs.push(path.join(env.APPDATA, "npm"));
if (env.LOCALAPPDATA) {
dirs.push(path.join(env.LOCALAPPDATA, "pnpm"));
dirs.push(path.join(env.LOCALAPPDATA, "Yarn", "bin"));
}
return dirs.filter((dir) => existsSync(dir));
}
async function readWindowsRegistryPath({ exec = execFileAsync, env = process.env } = {}) {
const hives = [
"HKCU\\Environment",
"HKLM\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment",
];
const parts = [];
for (const hive of hives) {
try {
const { stdout } = await exec("reg", ["query", hive, "/v", "Path"], {
encoding: "utf8",
timeout: 3000,
});
const raw = parseRegQueryPath(stdout);
if (raw) parts.push(expandWindowsEnvRefs(raw, env));
} catch {
// Hive unreadable / value missing — skip and rely on other sources.
}
}
return parts.join(";");
}
async function getShellEnv() {
if (_cachedShellEnv) return _cachedShellEnv;
if (_shellEnvPromise) return _shellEnvPromise;
const generation = _shellEnvGeneration;
_shellEnvPromise = (async () => {
const home = process.env.HOME || "";
const extraPaths = [
`${home}/.local/bin`,
`${home}/.npm-global/bin`,
"/usr/local/bin",
"/opt/homebrew/bin",
];
if (process.platform === "win32") {
// Re-read the live PATH from the registry so CLIs installed after launch
// (e.g. CodeBuddy) are discoverable without restarting Netcatty, then fold
// in well-known npm/pnpm/yarn global bin dirs as a belt-and-suspenders.
let registryPath = "";
try {
registryPath = await readWindowsRegistryPath();
} catch {
registryPath = "";
}
const knownDirs = getWindowsKnownCliPathDirs().join(path.delimiter);
const nextEnv = {
...process.env,
PATH: mergeWindowsPath(registryPath, knownDirs, process.env.PATH || ""),
};
if (generation === _shellEnvGeneration) {
_cachedShellEnv = nextEnv;
}
return nextEnv;
}
// On macOS/Linux, spawn a login shell to capture the real environment.
try {
let shell = process.env.SHELL || "/bin/zsh";
if (!path.isAbsolute(shell) || !existsSync(shell)) {
shell = "/bin/zsh";
}
const { stdout: envOutput } = await execFileAsync(shell, ['-ilc', 'env'], {
encoding: "utf8",
timeout: 10000,
env: { ...process.env, HOME: home },
});
const envMap = {};
for (const line of envOutput.split("\n")) {
const idx = line.indexOf("=");
if (idx > 0) {
envMap[line.slice(0, idx)] = line.slice(idx + 1);
}
}
const shellPath = envMap.PATH || "";
const mergedPath = [...extraPaths, shellPath, process.env.PATH || ""].join(path.delimiter);
// Layer-0 fix-path: front-load + de-duplicate the login-shell PATH we just
// captured (reuse the `-ilc env` result above — no second shell spawn).
const nextEnv = {
...envMap,
...process.env,
PATH: mergeLoginShellPath({ basePath: mergedPath, runLoginShellPath: () => shellPath }),
};
if (generation === _shellEnvGeneration) {
_cachedShellEnv = nextEnv;
}
return nextEnv;
} catch {
// `-ilc env` failed — try a lighter login-shell PATH probe as a fallback so
// GUI-launch PATH stripping still doesn't break CLI discovery (layer-0).
const basePath = [...extraPaths, process.env.PATH || ""].join(path.delimiter);
let loginShellPath = "";
try {
loginShellPath = await defaultRunLoginShellPathAsync();
} catch {
loginShellPath = "";
}
const nextEnv = {
...process.env,
PATH: mergeLoginShellPath({
basePath,
runLoginShellPath: () => loginShellPath,
}),
};
if (generation === _shellEnvGeneration) {
_cachedShellEnv = nextEnv;
}
return nextEnv;
}
})().finally(() => {
if (generation === _shellEnvGeneration) {
_shellEnvPromise = null;
}
});
return _shellEnvPromise;
}
/**
* Drop the shell-env cache so the next getShellEnv() call re-spawns the
* login shell. Useful when the user has just exported a new variable in
* their rc file and clicks "Refresh Status" without restarting the app.
*/
function invalidateShellEnvCache() {
_shellEnvGeneration += 1;
_cachedShellEnv = null;
_shellEnvPromise = null;
}
module.exports = {
stripAnsi,
formatSyntheticEcho,
extractTrailingIdlePrompt,
getFreshIdlePrompt,
isDefaultPowerShellPromptLine,
isDefaultCmdPromptLine,
isDefaultPosixPromptLine,
trackSessionIdlePrompt,
looksLikeIdleAutoLogout,
isLocalhostHostname,
extractFirstNonLocalhostUrl,
normalizeCliPathForPlatform,
shouldUseShellForCommand,
quoteWindowsShellArg,
buildWindowsShellCommandLine,
prepareCommandForSpawn,
resolveWindowsShimToNativeExe,
resolveClaudeCodeExecutableForSdk,
normalizeClaudeCodeExecutableEnvForSdk,
resolveCodexExecutableForSdk,
addCodexExecutableEnvForSdk,
resolveCodebuddyExecutableForSdk,
resolveSdkBinPath,
resolveSdkBinPathAsync,
resolveCliFromPath,
resolveCliFromPathAsync,
toUnpackedAsarPath,
isPlausibleCliVersionOutput,
mergeLoginShellPath,
parseRegQueryPath,
expandWindowsEnvRefs,
mergeWindowsPath,
readWindowsRegistryPath,
getShellEnv,
invalidateShellEnvCache,
};