| 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",
|
| auth: [".claude/.credentials.json", ".config/claude/credentials.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: {},
|
| },
|
| devin: {
|
| defaultCommand: "devin",
|
| envBinKey: "CLI_DEVIN_BIN",
|
| requiresBinary: true,
|
|
|
| healthcheckTimeoutMs: 12000,
|
| paths: {
|
|
|
|
|
| get config() {
|
| return isWindows()
|
| ? path.join(
|
| process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"),
|
| "devin",
|
| "config.json"
|
| )
|
| : path.join(os.homedir(), ".config", "devin", "config.json");
|
| },
|
| },
|
| },
|
| 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",
|
| },
|
| },
|
| hermes: {
|
|
|
| defaultCommand: "hermes",
|
| envBinKey: "CLI_HERMES_BIN",
|
| requiresBinary: false,
|
| healthcheckTimeoutMs: 4000,
|
| paths: {
|
| config: ".config/hermes/config.json",
|
| },
|
| },
|
| "hermes-agent": {
|
|
|
| defaultCommand: "hermes",
|
| envBinKey: "CLI_HERMES_BIN",
|
| requiresBinary: true,
|
| healthcheckTimeoutMs: 4000,
|
| paths: {
|
| config: ".hermes/config.yaml",
|
| },
|
| },
|
| amp: {
|
| defaultCommand: "amp",
|
| envBinKey: "CLI_AMP_BIN",
|
| requiresBinary: true,
|
| healthcheckTimeoutMs: 12000,
|
| paths: {},
|
| },
|
| qoder: {
|
| defaultCommand: "qodercli",
|
| envBinKey: "CLI_QODER_BIN",
|
| requiresBinary: true,
|
| healthcheckTimeoutMs: 12000,
|
| paths: {
|
| config: ".qoder/settings.json",
|
| auth: ".qoder/auth.json",
|
| },
|
| },
|
| qwen: {
|
| defaultCommand: "qwen",
|
| envBinKey: "CLI_QWEN_BIN",
|
| requiresBinary: true,
|
| healthcheckTimeoutMs: 12000,
|
| paths: {
|
| settings: ".qwen/settings.json",
|
| env: ".qwen/.env",
|
| },
|
| },
|
| "gemini-cli": {
|
| defaultCommand: "gemini",
|
| envBinKey: "CLI_GEMINI_BIN",
|
| requiresBinary: true,
|
| healthcheckTimeoutMs: 8000,
|
| paths: {
|
| auth: ".gemini/oauth_creds.json",
|
| accounts: ".gemini/google_accounts.json",
|
| settings: ".gemini/settings.json",
|
| },
|
| },
|
|
|
| forge: {
|
| defaultCommand: "forge",
|
| envBinKey: "CLI_FORGE_BIN",
|
| requiresBinary: true,
|
| healthcheckTimeoutMs: 8000,
|
| paths: {
|
| config: ".forge/config.toml",
|
| },
|
| },
|
| jcode: {
|
| defaultCommand: "jcode",
|
| envBinKey: "CLI_JCODE_BIN",
|
| requiresBinary: true,
|
| healthcheckTimeoutMs: 8000,
|
| paths: {
|
| config: ".jcode/config.json",
|
| },
|
| },
|
| "deepseek-tui": {
|
| defaultCommand: "deepseek-tui",
|
| envBinKey: "CLI_DEEPSEEK_TUI_BIN",
|
| requiresBinary: true,
|
| healthcheckTimeoutMs: 8000,
|
| paths: {
|
| config: ".config/deepseek-tui/config.toml",
|
| },
|
| },
|
| smelt: {
|
| defaultCommand: "smelt",
|
| envBinKey: "CLI_SMELT_BIN",
|
| requiresBinary: true,
|
| healthcheckTimeoutMs: 8000,
|
| paths: {
|
| config: ".smelt/config.json",
|
| },
|
| },
|
| pi: {
|
| defaultCommand: "pi",
|
| envBinKey: "CLI_PI_BIN",
|
| requiresBinary: true,
|
| healthcheckTimeoutMs: 8000,
|
| paths: {
|
| config: ".pi/config.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());
|
| };
|
|
|
| export const shouldUseShellForCommand = (command: string): boolean => {
|
| if (!isWindows()) return false;
|
|
|
|
|
|
|
|
|
| return /\.(?:cmd|bat)$/i.test(command);
|
| };
|
|
|
| const runProcess = (
|
| command: string,
|
| args: string[],
|
| {
|
| env,
|
| timeoutMs = 3000,
|
| useShell = shouldUseShellForCommand(command),
|
| }: {
|
| env?: Record<string, string | undefined>;
|
| timeoutMs?: number;
|
| useShell?: boolean;
|
| } = {}
|
| ): Promise<any> =>
|
| new Promise((resolve) => {
|
|
|
|
|
| if (/[;&|`$<>\n\r]/.test(command)) {
|
| resolve({ ok: false, stdout: "", stderr: "rejected: unsafe command path", exitCode: -1 });
|
| return;
|
| }
|
|
|
| let stdout = "";
|
| let stderr = "";
|
| let timedOut = false;
|
| let settled = false;
|
|
|
|
|
|
|
|
|
|
|
| const child = spawn(command, args, {
|
| env,
|
| stdio: ["ignore", "pipe", "pipe"],
|
|
|
|
|
|
|
| ...(useShell ? { 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.cmd", "qodercli"],
|
| ["qodercli.exe", "qodercli"],
|
| ],
|
| devin: [
|
| ["devin.exe", "devin"],
|
| ["devin.cmd", "devin"],
|
| ],
|
| };
|
|
|
| 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"));
|
| }
|
|
|
|
|
| if (toolId === "devin" && localAppData) {
|
| paths.push(path.join(localAppData, "devin", "cli", "bin", "devin.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));
|
| }
|
|
|
|
|
| if (toolId === "devin") {
|
| paths.push(path.join(home, ".local", "share", "devin", "bin", "devin"));
|
| paths.push(path.join(home, ".devin", "bin", "devin"));
|
| }
|
| }
|
| }
|
|
|
| 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();
|
| const currentPath = env.PATH || env.Path || "";
|
|
|
|
|
|
|
| if (extraPaths.length > 0) {
|
| const mergedPath = [...extraPaths, currentPath].filter(Boolean).join(path.delimiter);
|
| env.PATH = mergedPath;
|
| if (isWindows()) {
|
| env.Path = mergedPath;
|
| }
|
| } else if (isWindows() && currentPath) {
|
| env.PATH = currentPath;
|
| env.Path = currentPath;
|
| }
|
| 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.exe", [command], {
|
| env,
|
| timeoutMs: 3000,
|
| useShell: false,
|
| });
|
| 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: normalizeMsys2Path(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);
|
|
|
|
|
|
|
|
|
|
|
| let isWithinExpected = false;
|
| for (const parent of EXPECTED_PARENT_PATHS) {
|
| if (isPathWithin(realPath, parent)) {
|
| isWithinExpected = true;
|
| break;
|
| }
|
|
|
| try {
|
| const resolvedParent = await fs.realpath(parent);
|
| if (isPathWithin(realPath, resolvedParent)) {
|
| isWithinExpected = true;
|
| break;
|
| }
|
| } catch {
|
|
|
| }
|
| }
|
|
|
| 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,
|
| };
|
| }
|
|
|
| if (result.installed && result.reason === "not_executable") {
|
| return {
|
| command: commands[0],
|
| installed: true,
|
| commandPath: result.commandPath,
|
| reason: "not_executable",
|
| };
|
| }
|
|
|
| if (result.reason && result.reason !== "not_found") {
|
| return { command: commands[0], ...result };
|
| }
|
| }
|
| }
|
|
|
|
|
| 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 || 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,
|
| };
|
|
|
| if (isWindows() && minimalEnv.PATH) {
|
| minimalEnv.Path = minimalEnv.PATH;
|
| }
|
|
|
| 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]) => {
|
| let resolvedPath = "";
|
| if (Array.isArray(relativePath)) {
|
|
|
| resolvedPath = path.join(home, relativePath[0]);
|
| for (const p of relativePath) {
|
| const candidate = path.join(home, p);
|
| if (fsSync.existsSync(candidate)) {
|
| resolvedPath = candidate;
|
| break;
|
| }
|
| }
|
| } else {
|
| resolvedPath = path.join(home, relativePath as string);
|
| }
|
| return [key, resolvedPath];
|
| })
|
| );
|
| };
|
|
|
| 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);
|
|
|