873 lines
30 KiB
JavaScript
873 lines
30 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, "");
|
|
}
|
|
|
|
// 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.*)?>$/;
|
|
|
|
function isDefaultPowerShellPromptLine(line) {
|
|
return POWERSHELL_PROMPT_PATTERN.test(String(line || ""));
|
|
}
|
|
|
|
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 (/^[^\s@]+@[^\s:]+(?::[^\n\r]*)?[#$]$/.test(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 or
|
|
// `user@host[:path][#$]`); a remote shell switch into cmd.exe, 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) {
|
|
const spawnArgs = Array.isArray(args) ? args : [];
|
|
if (!shouldUseShellForCommand(command)) {
|
|
return { command, args: spawnArgs, shell: 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 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"));
|
|
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();
|
|
if (ext === ".exe") return normalized;
|
|
|
|
const baseDir = path.dirname(normalized);
|
|
const moduleSearchDirs = getCodexNativeSearchDirsForShim(baseDir);
|
|
|
|
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,
|
|
extractTrailingIdlePrompt,
|
|
getFreshIdlePrompt,
|
|
isDefaultPowerShellPromptLine,
|
|
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,
|
|
};
|