| import fs from "fs/promises"; |
| import fsSync from "fs"; |
| import os from "os"; |
| import path from "path"; |
| import { spawn, execFileSync } from "child_process"; |
|
|
| const VALID_RUNTIME_MODES = new Set(["auto", "host", "container"]); |
| const FALSE_VALUES = new Set(["0", "false", "no", "off"]); |
|
|
| const CLI_TOOLS: Record<string, any> = { |
| claude: { |
| defaultCommand: "claude", |
| envBinKey: "CLI_CLAUDE_BIN", |
| requiresBinary: true, |
| healthcheckTimeoutMs: 4000, |
| paths: { |
| settings: ".claude/settings.json", |
| }, |
| }, |
| codex: { |
| defaultCommand: "codex", |
| envBinKey: "CLI_CODEX_BIN", |
| requiresBinary: true, |
| healthcheckTimeoutMs: 4000, |
| paths: { |
| config: ".codex/config.toml", |
| auth: ".codex/auth.json", |
| }, |
| }, |
| droid: { |
| defaultCommand: "droid", |
| envBinKey: "CLI_DROID_BIN", |
| requiresBinary: true, |
| |
| healthcheckTimeoutMs: 8000, |
| paths: { |
| settings: ".factory/settings.json", |
| }, |
| }, |
| openclaw: { |
| defaultCommand: "openclaw", |
| envBinKey: "CLI_OPENCLAW_BIN", |
| requiresBinary: true, |
| |
| healthcheckTimeoutMs: 15000, |
| paths: { |
| settings: ".openclaw/openclaw.json", |
| }, |
| }, |
| cursor: { |
| defaultCommands: ["agent", "cursor"], |
| envBinKey: "CLI_CURSOR_BIN", |
| requiresBinary: true, |
| |
| healthcheckTimeoutMs: 15000, |
| paths: { |
| config: ".cursor/cli-config.json", |
| auth: ".config/cursor/auth.json", |
| state: ".cursor/agent-cli-state.json", |
| }, |
| }, |
| windsurf: { |
| defaultCommand: null, |
| envBinKey: "CLI_WINDSURF_BIN", |
| requiresBinary: false, |
| healthcheckTimeoutMs: 4000, |
| paths: {}, |
| }, |
| cline: { |
| defaultCommand: "cline", |
| envBinKey: "CLI_CLINE_BIN", |
| requiresBinary: true, |
| |
| healthcheckTimeoutMs: 12000, |
| paths: { |
| globalState: ".cline/data/globalState.json", |
| secrets: ".cline/data/secrets.json", |
| }, |
| }, |
| kilo: { |
| defaultCommand: "kilocode", |
| envBinKey: "CLI_KILO_BIN", |
| requiresBinary: true, |
| |
| |
| |
| healthcheckTimeoutMs: 15000, |
| paths: { |
| auth: ".local/share/kilo/auth.json", |
| }, |
| }, |
| continue: { |
| defaultCommand: null, |
| envBinKey: "CLI_CONTINUE_BIN", |
| requiresBinary: false, |
| |
| healthcheckTimeoutMs: 15000, |
| paths: { |
| settings: ".continue/config.json", |
| }, |
| }, |
| opencode: { |
| defaultCommand: "opencode", |
| envBinKey: "CLI_OPENCODE_BIN", |
| requiresBinary: true, |
| |
| healthcheckTimeoutMs: 15000, |
| paths: { |
| config: ".config/opencode/opencode.json", |
| }, |
| }, |
| qoder: { |
| defaultCommand: "qodercli", |
| envBinKey: "CLI_QODER_BIN", |
| requiresBinary: true, |
| healthcheckTimeoutMs: 12000, |
| paths: { |
| config: ".qoder/settings.json", |
| auth: ".qoder/auth.json", |
| }, |
| }, |
| }; |
|
|
| const isWindows = () => process.platform === "win32"; |
|
|
| |
| |
| |
| |
| |
| |
| const normalizeMsys2Path = (p: string): string => { |
| if (!p || !isWindows()) return p; |
| |
| const msys2Match = p.match(/^\/([a-zA-Z])\/(.+)$/); |
| if (msys2Match) { |
| const drive = msys2Match[1].toUpperCase(); |
| const rest = msys2Match[2].replace(/\//g, "\\"); |
| return `${drive}:\\${rest}`; |
| } |
| return p; |
| }; |
|
|
| const parseBoolean = (value: unknown, defaultValue = true) => { |
| if (value == null || value === "") return defaultValue; |
| return !FALSE_VALUES.has(String(value).trim().toLowerCase()); |
| }; |
|
|
| const runProcess = ( |
| command: string, |
| args: string[], |
| { env, timeoutMs = 3000 }: { env?: Record<string, string | undefined>; timeoutMs?: number } = {} |
| ): Promise<any> => |
| new Promise((resolve) => { |
| let stdout = ""; |
| let stderr = ""; |
| let timedOut = false; |
| let settled = false; |
|
|
| const child = spawn(command, args, { |
| env, |
| stdio: ["ignore", "pipe", "pipe"], |
| |
| |
| |
| ...(isWindows() ? { shell: true } : {}), |
| }); |
| const timer = setTimeout(() => { |
| timedOut = true; |
| child.kill("SIGKILL"); |
| }, timeoutMs); |
|
|
| const done = (result: any) => { |
| if (settled) return; |
| settled = true; |
| clearTimeout(timer); |
| resolve(result); |
| }; |
|
|
| child.stdout.on("data", (chunk) => { |
| stdout += chunk.toString(); |
| }); |
|
|
| child.stderr.on("data", (chunk) => { |
| stderr += chunk.toString(); |
| }); |
|
|
| child.on("error", (error) => { |
| done({ |
| ok: false, |
| code: null, |
| stdout: stdout.trim(), |
| stderr: stderr.trim(), |
| timedOut, |
| error: error?.message || "spawn_error", |
| }); |
| }); |
|
|
| child.on("close", (code) => { |
| done({ |
| ok: !timedOut && code === 0, |
| code, |
| stdout: stdout.trim(), |
| stderr: stderr.trim(), |
| timedOut, |
| error: timedOut ? "timeout" : null, |
| }); |
| }); |
| }); |
|
|
| const getRuntimeMode = () => { |
| const mode = String(process.env.CLI_MODE || "auto") |
| .trim() |
| .toLowerCase(); |
| return VALID_RUNTIME_MODES.has(mode) ? mode : "auto"; |
| }; |
|
|
| |
| |
| |
| |
| |
| const DANGEROUS_PATH_CHARS = ["&", "|", ";", "<", ">", "(", ")", "`", "$", "^", "%", "!"]; |
|
|
| |
| |
| |
| |
| |
| const isPathWithin = (childPath: string, parentPath: string): boolean => { |
| |
| const normalize = (p: string) => path.normalize(p).toLowerCase().replace(/\\/g, "/"); |
| const normalizedChild = normalize(childPath); |
| const normalizedParent = normalize(parentPath); |
|
|
| if (normalizedChild === normalizedParent) return true; |
|
|
| |
| const parentWithSep = normalizedParent.endsWith("/") ? normalizedParent : normalizedParent + "/"; |
|
|
| return normalizedChild.startsWith(parentWithSep); |
| }; |
|
|
| const isSafePath = (execPath: string): boolean => { |
| if (!execPath || !path.isAbsolute(execPath)) return false; |
| if (DANGEROUS_PATH_CHARS.some((c) => execPath.includes(c))) return false; |
| |
| return true; |
| }; |
|
|
| |
| |
| |
| |
| |
| const validateEnvPath = (value: string | undefined, allowedParents: string[]): string => { |
| if (!value) return ""; |
| const trimmed = value.trim(); |
|
|
| |
| if (!path.isAbsolute(trimmed)) return ""; |
|
|
| |
| if (DANGEROUS_PATH_CHARS.some((c) => trimmed.includes(c))) return ""; |
|
|
| |
| const normalized = path.normalize(trimmed); |
| if (normalized.includes("..")) return ""; |
|
|
| |
| if (allowedParents.length > 0) { |
| const withinAllowed = allowedParents.some((parent) => isPathWithin(normalized, parent)); |
| if (!withinAllowed) return ""; |
| } |
|
|
| return normalized; |
| }; |
|
|
| |
| |
| |
| |
| let _npmGlobalPrefix: string | undefined; |
| const getNpmGlobalPrefix = (): string => { |
| if (_npmGlobalPrefix !== undefined) return _npmGlobalPrefix; |
|
|
| const envPrefix = String(process.env.npm_config_prefix || "").trim(); |
| if (envPrefix && path.isAbsolute(envPrefix)) { |
| _npmGlobalPrefix = envPrefix; |
| return _npmGlobalPrefix; |
| } |
|
|
| try { |
| const result = execFileSync("npm", ["config", "get", "prefix"], { |
| timeout: 5000, |
| encoding: "utf8", |
| stdio: ["ignore", "pipe", "ignore"], |
| ...(isWindows() ? { shell: true } : {}), |
| }); |
| const prefix = result.trim(); |
| if ( |
| prefix && |
| path.isAbsolute(prefix) && |
| !DANGEROUS_PATH_CHARS.some((c) => prefix.includes(c)) |
| ) { |
| _npmGlobalPrefix = prefix; |
| return _npmGlobalPrefix; |
| } |
| } catch {} |
|
|
| _npmGlobalPrefix = ""; |
| return _npmGlobalPrefix; |
| }; |
|
|
| |
| |
| |
| |
| const getExpectedParentPaths = (): string[] => { |
| const home = os.homedir(); |
| const userProfile = process.env.USERPROFILE || home; |
|
|
| const validatedAppData = validateEnvPath(process.env.APPDATA, [home, userProfile]); |
| const validatedLocalAppData = validateEnvPath(process.env.LOCALAPPDATA, [ |
| path.join(home, "AppData", "Local"), |
| path.join(userProfile, "AppData", "Local"), |
| userProfile, |
| ]); |
| const validatedProgramFiles = validateEnvPath(process.env.ProgramFiles, [ |
| "C:\\Program Files", |
| "C:\\Program Files (x86)", |
| ]); |
| const validatedProgramFilesX86 = validateEnvPath(process.env["ProgramFiles(x86)"], [ |
| "C:\\Program Files", |
| "C:\\Program Files (x86)", |
| ]); |
|
|
| const npmPrefix = getNpmGlobalPrefix(); |
|
|
| |
| const userBinPaths = [path.join(home, "bin"), path.join(home, ".local", "bin")]; |
|
|
| return [ |
| home, |
| ...userBinPaths, |
| userProfile, |
| validatedAppData, |
| validatedLocalAppData, |
| validatedProgramFiles, |
| validatedProgramFilesX86, |
| npmPrefix, |
| ].filter(Boolean); |
| }; |
|
|
| |
| const EXPECTED_PARENT_PATHS = getExpectedParentPaths(); |
|
|
| const getExtraPaths = () => |
| String(process.env.CLI_EXTRA_PATHS || "") |
| .split(path.delimiter) |
| .map((segment) => segment.trim()) |
| .filter(Boolean) |
| .filter((p) => { |
| |
| if (!path.isAbsolute(p)) return false; |
| |
| if (DANGEROUS_PATH_CHARS.some((c) => p.includes(c))) return false; |
| |
| if (path.normalize(p).includes("..")) return false; |
| return true; |
| }); |
|
|
| |
| |
| |
| |
| |
| const getKnownToolPaths = (toolId: string): string[] => { |
| const home = os.homedir(); |
| const paths: string[] = []; |
|
|
| const npmPrefix = getNpmGlobalPrefix(); |
| const nvmNodePath = getNvmNodePath(); |
|
|
| const toolBins: Record<string, [string, string][]> = { |
| claude: [ |
| ["claude.cmd", "claude"], |
| ["claude.exe", "claude"], |
| ], |
| codex: [["codex.cmd", "codex"]], |
| droid: [ |
| ["droid.cmd", "droid"], |
| ["droid.exe", "droid"], |
| ], |
| openclaw: [["openclaw.cmd", "openclaw"]], |
| cursor: [ |
| ["agent.cmd", "agent"], |
| ["cursor.cmd", "cursor"], |
| ], |
| cline: [["cline.cmd", "cline"]], |
| kilo: [["kilocode.cmd", "kilocode"]], |
| opencode: [["opencode.cmd", "opencode"]], |
| qoder: [["qodercli.exe", "qodercli"]], |
| }; |
|
|
| const bins = toolBins[toolId] || []; |
|
|
| if (isWindows()) { |
| const userProfile = process.env.USERPROFILE || home; |
| const appData = validateEnvPath(process.env.APPDATA, [home, userProfile]); |
| const localAppData = validateEnvPath(process.env.LOCALAPPDATA, [ |
| path.join(home, "AppData", "Local"), |
| path.join(userProfile, "AppData", "Local"), |
| userProfile, |
| ]); |
|
|
| if (toolId === "claude") { |
| paths.push(path.join(home, ".local", "bin", "claude.exe")); |
| if (localAppData) { |
| paths.push(path.join(localAppData, "Programs", "Claude", "claude.exe")); |
| paths.push(path.join(localAppData, "claude-code", "claude.exe")); |
| } |
| } |
|
|
| if (toolId === "droid") { |
| paths.push(path.join(home, "bin", "droid.exe")); |
| } |
|
|
| for (const [winName] of bins) { |
| if (npmPrefix) paths.push(path.join(npmPrefix, winName)); |
| if (appData) { |
| const appDataPath = path.join(appData, "npm", winName); |
| if ( |
| !npmPrefix || |
| path.normalize(appDataPath) !== path.normalize(path.join(npmPrefix, winName)) |
| ) { |
| paths.push(appDataPath); |
| } |
| } |
| if (nvmNodePath) paths.push(path.join(nvmNodePath, winName)); |
| } |
| } else { |
| for (const [, posixName] of bins) { |
| const nodeBinDir = path.dirname(process.execPath); |
| paths.push(path.join(nodeBinDir, posixName)); |
|
|
| if (npmPrefix) { |
| paths.push(path.join(npmPrefix, "bin", posixName)); |
| } |
|
|
| paths.push(path.join(home, ".local", "bin", posixName)); |
| |
| if (fsSync.existsSync("/usr/local/bin")) { |
| paths.push(path.join("/usr", "local", "bin", posixName)); |
| } |
| if (fsSync.existsSync("/usr/bin")) { |
| paths.push(path.join("/usr", "bin", posixName)); |
| } |
|
|
| if (toolId === "opencode") { |
| paths.push(path.join(home, ".opencode", "bin", posixName)); |
| } |
| if (toolId === "claude") { |
| paths.push(path.join(home, ".claude", "bin", posixName)); |
| } |
| } |
| } |
|
|
| return paths; |
| }; |
|
|
| |
| |
| |
| |
| const getNvmNodePath = (): string | null => { |
| |
| if (process.execPath.toLowerCase().includes("nvm")) { |
| return path.dirname(process.execPath); |
| } |
|
|
| return null; |
| }; |
|
|
| const getLookupEnv = () => { |
| const env = { ...process.env }; |
| const extraPaths = getExtraPaths(); |
|
|
| |
| |
| if (extraPaths.length > 0) { |
| env.PATH = [...extraPaths, env.PATH || ""].filter(Boolean).join(path.delimiter); |
| } |
| return env; |
| }; |
|
|
| const resolveToolCommands = (toolId: string): string[] => { |
| const tool = CLI_TOOLS[toolId]; |
| if (!tool) return []; |
| const envCommand = String(process.env[tool.envBinKey] || "").trim(); |
| if (envCommand) return [envCommand]; |
| if (Array.isArray(tool.defaultCommands) && tool.defaultCommands.length > 0) { |
| return tool.defaultCommands.filter(Boolean); |
| } |
| return tool.defaultCommand ? [tool.defaultCommand] : []; |
| }; |
|
|
| const checkExplicitPath = async (commandPath: string) => { |
| |
| if (!isSafePath(commandPath)) { |
| return { installed: false, commandPath: null, reason: "unsafe_path" }; |
| } |
|
|
| try { |
| await fs.access(commandPath, fs.constants.F_OK); |
| } catch { |
| return { installed: false, commandPath: null, reason: "not_found" }; |
| } |
|
|
| try { |
| await fs.access(commandPath, fs.constants.X_OK); |
| return { installed: true, commandPath, reason: null }; |
| } catch { |
| return { installed: true, commandPath, reason: "not_executable" }; |
| } |
| }; |
|
|
| const locateCommand = async (command: string, env: Record<string, string | undefined>) => { |
| if (!command) { |
| return { installed: false, commandPath: null, reason: "missing_command" }; |
| } |
|
|
| if (command.includes("/") || command.includes("\\")) { |
| return checkExplicitPath(command); |
| } |
|
|
| if (isWindows()) { |
| const located = await runProcess("where", [command], { env, timeoutMs: 3000 }); |
| if (located.ok && located.stdout) { |
| |
| |
| |
| const lines = located.stdout |
| .split(/\r?\n/) |
| .map((l) => l.trim()) |
| .filter(Boolean); |
| if (lines.length === 0) { |
| return { installed: false, commandPath: null, reason: "not_found" }; |
| } |
| const winExt = /\.(cmd|exe|bat|com)$/i; |
| const preferred = lines.find((l) => winExt.test(l)) || lines[0]; |
| return { installed: true, commandPath: preferred, reason: null }; |
| } |
| return { installed: false, commandPath: null, reason: "not_found" }; |
| } |
|
|
| const located = await runProcess("sh", ["-c", 'command -v -- "$1"', "sh", command], { |
| env, |
| timeoutMs: 3000, |
| }); |
| if (located.ok && located.stdout) { |
| return { installed: true, commandPath: command, reason: null }; |
| } |
| return { installed: false, commandPath: null, reason: "not_found" }; |
| }; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const checkKnownPath = async (commandPath: string) => { |
| if (!path.isAbsolute(commandPath)) { |
| return { installed: false, commandPath: null, reason: "not_absolute" }; |
| } |
|
|
| if (!isSafePath(commandPath)) { |
| return { installed: false, commandPath: null, reason: "unsafe_path" }; |
| } |
|
|
| try { |
| |
| const realPath = await fs.realpath(commandPath); |
|
|
| |
| |
| const isWithinExpected = EXPECTED_PARENT_PATHS.some((parent) => isPathWithin(realPath, parent)); |
|
|
| if (!isWithinExpected) { |
| return { installed: false, commandPath: null, reason: "symlink_escape" }; |
| } |
|
|
| |
| const stat = await fs.stat(realPath); |
| if (!stat.isFile()) { |
| return { installed: false, commandPath: null, reason: "not_file" }; |
| } |
|
|
| |
| |
| |
| |
| if (stat.size < 30 || stat.size > 350 * 1024 * 1024) { |
| return { installed: false, commandPath: null, reason: "suspicious_size" }; |
| } |
| } catch (error) { |
| const errorCode = (error as NodeJS.ErrnoException).code; |
| if (errorCode === "ENOENT") { |
| return { installed: false, commandPath: null, reason: "not_found" }; |
| } |
| if (errorCode === "EINVAL") { |
| return { installed: false, commandPath: null, reason: "invalid_path" }; |
| } |
| return { installed: false, commandPath: null, reason: "access_error" }; |
| } |
|
|
| try { |
| await fs.access(commandPath, fs.constants.X_OK); |
| return { installed: true, commandPath, reason: null }; |
| } catch { |
| return { installed: true, commandPath, reason: "not_executable" }; |
| } |
| }; |
|
|
| const locateCommandCandidate = async ( |
| commands: string[], |
| env: Record<string, string | undefined>, |
| toolId?: string |
| ) => { |
| if (!Array.isArray(commands) || commands.length === 0) { |
| return { command: null, installed: false, commandPath: null, reason: "missing_command" }; |
| } |
|
|
| |
| |
| if (toolId) { |
| const knownPaths = getKnownToolPaths(toolId); |
| for (const knownPath of knownPaths) { |
| const result = await checkKnownPath(knownPath); |
| if (result.installed && result.reason === null) { |
| return { |
| command: commands[0], |
| installed: true, |
| commandPath: result.commandPath, |
| reason: null, |
| }; |
| } |
| } |
| } |
|
|
| |
| for (const command of commands) { |
| const located = await locateCommand(command, env); |
| if (located.installed || located.reason !== "not_found") { |
| return { command, ...located }; |
| } |
| } |
|
|
| return { command: commands[0], installed: false, commandPath: null, reason: "not_found" }; |
| }; |
|
|
| const checkRunnable = async ( |
| commandPath: string, |
| env: Record<string, string | undefined>, |
| timeoutMs = 4000 |
| ) => { |
| |
| const minimalEnv: Record<string, string | undefined> = { |
| PATH: env.PATH, |
| HOME: env.HOME || env.USERPROFILE, |
| USERPROFILE: env.USERPROFILE, |
| APPDATA: env.APPDATA, |
| LOCALAPPDATA: env.LOCALAPPDATA, |
| TEMP: env.TEMP, |
| TMP: env.TMP, |
| SystemRoot: env.SystemRoot, |
| ComSpec: env.ComSpec, |
| PATHEXT: env.PATHEXT, |
| }; |
|
|
| for (const args of [["--version"], ["-v"]]) { |
| const result = await runProcess(commandPath, args, { env: minimalEnv, timeoutMs }); |
| |
| if (result.ok && result.stdout.length > 0 && result.stdout.length < 4096) { |
| return { runnable: true, reason: null, version: result.stdout.trim() }; |
| } |
| } |
| return { runnable: false, reason: "healthcheck_failed" }; |
| }; |
|
|
| export const isCliConfigWriteAllowed = () => |
| parseBoolean(process.env.CLI_ALLOW_CONFIG_WRITES, true); |
|
|
| export const ensureCliConfigWriteAllowed = () => { |
| if (isCliConfigWriteAllowed()) return null; |
| return "CLI config writes are disabled (CLI_ALLOW_CONFIG_WRITES=false)"; |
| }; |
|
|
| export const getCliConfigHome = () => { |
| const override = String(process.env.CLI_CONFIG_HOME || "").trim(); |
| if (!override) return os.homedir(); |
|
|
| |
| if (!path.isAbsolute(override)) return os.homedir(); |
|
|
| |
| if (DANGEROUS_PATH_CHARS.some((c) => override.includes(c))) return os.homedir(); |
|
|
| |
| if (path.normalize(override).includes("..")) return os.homedir(); |
|
|
| |
| const home = os.homedir(); |
| const normalized = path.normalize(override); |
| if (!isPathWithin(normalized, home)) { |
| return home; |
| } |
|
|
| return normalized; |
| }; |
|
|
| export const resolveOpencodeConfigDir = ( |
| platform = process.platform, |
| env: NodeJS.ProcessEnv = process.env, |
| homeDir = os.homedir() |
| ) => { |
| const isWin = platform === "win32"; |
| if (isWin) { |
| const appData = String(env.APPDATA || "").trim(); |
| return appData || path.join(homeDir, "AppData", "Roaming"); |
| } |
|
|
| const xdgConfigHome = String(env.XDG_CONFIG_HOME || "").trim(); |
| return xdgConfigHome || path.join(homeDir, ".config"); |
| }; |
|
|
| export const resolveOpencodeConfigPath = ( |
| platform = process.platform, |
| env: NodeJS.ProcessEnv = process.env, |
| homeDir = os.homedir() |
| ) => path.join(resolveOpencodeConfigDir(platform, env, homeDir), "opencode", "opencode.json"); |
|
|
| export const getOpenCodeConfigPath = () => resolveOpencodeConfigPath(); |
|
|
| export const getCliConfigPaths = (toolId: string) => { |
| const tool = CLI_TOOLS[toolId]; |
| if (!tool) return null; |
|
|
| if (toolId === "opencode") { |
| return { |
| config: getOpenCodeConfigPath(), |
| }; |
| } |
|
|
| const home = getCliConfigHome(); |
| return Object.fromEntries( |
| Object.entries(tool.paths).map(([key, relativePath]) => [ |
| key, |
| path.join(home, relativePath as string), |
| ]) |
| ); |
| }; |
|
|
| export const getCliPrimaryConfigPath = (toolId: string) => { |
| const paths = getCliConfigPaths(toolId); |
| if (!paths) return null; |
| const firstKey = Object.keys(paths)[0]; |
| return firstKey ? paths[firstKey] : null; |
| }; |
|
|
| export const getCliRuntimeStatus = async (toolId: string) => { |
| const tool = CLI_TOOLS[toolId]; |
| const runtimeMode = getRuntimeMode(); |
| if (!tool) { |
| return { |
| installed: false, |
| runnable: false, |
| command: null, |
| commandPath: null, |
| reason: "unknown_tool", |
| runtimeMode, |
| requiresBinary: false, |
| }; |
| } |
|
|
| const env = getLookupEnv(); |
| const commands = resolveToolCommands(toolId); |
| const requiresBinary = tool.requiresBinary !== false; |
|
|
| if (!requiresBinary && commands.length === 0) { |
| return { |
| installed: true, |
| runnable: true, |
| command: null, |
| commandPath: null, |
| reason: "not_required", |
| runtimeMode, |
| requiresBinary, |
| }; |
| } |
|
|
| const envCommand = String(process.env[tool.envBinKey] || "").trim(); |
| const hasEnvOverride = !!envCommand; |
|
|
| const located = await locateCommandCandidate(commands, env, hasEnvOverride ? undefined : toolId); |
| const command = located.command; |
|
|
| if (!located.installed) { |
| return { |
| installed: false, |
| runnable: false, |
| command, |
| commandPath: null, |
| reason: located.reason || "not_found", |
| runtimeMode, |
| requiresBinary, |
| }; |
| } |
|
|
| if (located.reason === "not_executable") { |
| return { |
| installed: true, |
| runnable: false, |
| command, |
| commandPath: located.commandPath, |
| reason: "not_executable", |
| runtimeMode, |
| requiresBinary, |
| }; |
| } |
|
|
| const healthcheck = await checkRunnable( |
| located.commandPath, |
| env, |
| Number(tool.healthcheckTimeoutMs || 4000) |
| ); |
| return { |
| installed: true, |
| runnable: healthcheck.runnable, |
| command, |
| commandPath: located.commandPath, |
| reason: healthcheck.reason, |
| runtimeMode, |
| requiresBinary, |
| }; |
| }; |
|
|
| export const CLI_TOOL_IDS = Object.keys(CLI_TOOLS); |
|
|