diff --git a/src/agents/skills-install.ts b/src/agents/skills-install.ts index 75028a9a8cc..e6528202ca5 100644 --- a/src/agents/skills-install.ts +++ b/src/agents/skills-install.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import type { OpenClawConfig } from "../config/config.js"; import { resolveBrewExecutable } from "../infra/brew.js"; -import { runCommandWithTimeout } from "../process/exec.js"; +import { runCommandWithTimeout, type CommandOptions } from "../process/exec.js"; import { scanDirectoryWithSummary } from "../security/skill-scanner.js"; import { resolveUserPath } from "../utils.js"; import { installDownloadSpec } from "./skills-install-download.js"; @@ -224,6 +224,209 @@ async function resolveBrewBinDir(timeoutMs: number, brewExe?: string): Promise { + try { + const result = await runCommandWithTimeout(argv, optionsOrTimeout); + return { + code: result.code, + stdout: result.stdout, + stderr: result.stderr, + }; + } catch (err) { + return { + code: null, + stdout: "", + stderr: err instanceof Error ? err.message : String(err), + }; + } +} + +async function runBestEffortCommand( + argv: string[], + optionsOrTimeout: number | CommandOptions, +): Promise { + await runCommandSafely(argv, optionsOrTimeout); +} + +function resolveBrewMissingFailure(spec: SkillInstallSpec): SkillInstallResult { + const formula = spec.formula ?? "this package"; + const hint = + process.platform === "linux" + ? `Homebrew is not installed. Install it from https://brew.sh or install "${formula}" manually using your system package manager (e.g. apt, dnf, pacman).` + : "Homebrew is not installed. Install it from https://brew.sh"; + return createInstallFailure({ message: `brew not installed — ${hint}` }); +} + +async function ensureUvInstalled(params: { + spec: SkillInstallSpec; + brewExe?: string; + timeoutMs: number; +}): Promise { + if (params.spec.kind !== "uv" || hasBinary("uv")) { + return undefined; + } + + if (!params.brewExe) { + return createInstallFailure({ + message: + "uv not installed — install manually: https://docs.astral.sh/uv/getting-started/installation/", + }); + } + + const brewResult = await runCommandSafely([params.brewExe, "install", "uv"], { + timeoutMs: params.timeoutMs, + }); + if (brewResult.code === 0) { + return undefined; + } + + return createInstallFailure({ + message: "Failed to install uv (brew)", + ...brewResult, + }); +} + +async function installGoViaApt(timeoutMs: number): Promise { + const aptInstallArgv = ["apt-get", "install", "-y", "golang-go"]; + const aptUpdateArgv = ["apt-get", "update", "-qq"]; + const aptFailureMessage = + "go not installed — automatic install via apt failed. Install manually: https://go.dev/doc/install"; + + const isRoot = typeof process.getuid === "function" && process.getuid() === 0; + if (isRoot) { + // Best effort: fresh containers often need package indexes populated. + await runBestEffortCommand(aptUpdateArgv, { timeoutMs }); + const aptResult = await runCommandSafely(aptInstallArgv, { timeoutMs }); + if (aptResult.code === 0) { + return undefined; + } + return createInstallFailure({ + message: aptFailureMessage, + ...aptResult, + }); + } + + if (!hasBinary("sudo")) { + return createInstallFailure({ + message: + "go not installed — apt-get is available but sudo is not installed. Install manually: https://go.dev/doc/install", + }); + } + + const sudoCheck = await runCommandSafely(["sudo", "-n", "true"], { + timeoutMs: 5_000, + }); + if (sudoCheck.code !== 0) { + return createInstallFailure({ + message: + "go not installed — apt-get is available but sudo is not usable (missing or requires a password). Install manually: https://go.dev/doc/install", + ...sudoCheck, + }); + } + + // Best effort: fresh containers often need package indexes populated. + await runBestEffortCommand(["sudo", ...aptUpdateArgv], { timeoutMs }); + const aptResult = await runCommandSafely(["sudo", ...aptInstallArgv], { + timeoutMs, + }); + if (aptResult.code === 0) { + return undefined; + } + + return createInstallFailure({ + message: aptFailureMessage, + ...aptResult, + }); +} + +async function ensureGoInstalled(params: { + spec: SkillInstallSpec; + brewExe?: string; + timeoutMs: number; +}): Promise { + if (params.spec.kind !== "go" || hasBinary("go")) { + return undefined; + } + + if (params.brewExe) { + const brewResult = await runCommandSafely([params.brewExe, "install", "go"], { + timeoutMs: params.timeoutMs, + }); + if (brewResult.code === 0) { + return undefined; + } + return createInstallFailure({ + message: "Failed to install go (brew)", + ...brewResult, + }); + } + + if (hasBinary("apt-get")) { + return installGoViaApt(params.timeoutMs); + } + + return createInstallFailure({ + message: "go not installed — install manually: https://go.dev/doc/install", + }); +} + +async function executeInstallCommand(params: { + argv: string[] | null; + timeoutMs: number; + env?: NodeJS.ProcessEnv; +}): Promise { + if (!params.argv || params.argv.length === 0) { + return createInstallFailure({ message: "invalid install command" }); + } + + const result = await runCommandSafely(params.argv, { + timeoutMs: params.timeoutMs, + env: params.env, + }); + if (result.code === 0) { + return createInstallSuccess(result); + } + + return createInstallFailure({ + message: formatInstallFailureMessage(result), + ...result, + }); +} + export async function installSkill(params: SkillInstallRequest): Promise { const timeoutMs = Math.min(Math.max(params.timeoutMs ?? 300_000, 1_000), 900_000); const workspaceDir = resolveUserPath(params.workspaceDir); @@ -275,233 +478,22 @@ export async function installSkill(params: SkillInstallRequest): Promise { - const argv = command.argv; - if (!argv || argv.length === 0) { - return { code: null, stdout: "", stderr: "invalid install command" }; - } - try { - return await runCommandWithTimeout(argv, { - timeoutMs, - env, - }); - } catch (err) { - const stderr = err instanceof Error ? err.message : String(err); - return { code: null, stdout: "", stderr }; - } - })(); - - const success = result.code === 0; - return withWarnings( - { - ok: success, - message: success ? "Installed" : formatInstallFailureMessage(result), - stdout: result.stdout.trim(), - stderr: result.stderr.trim(), - code: result.code, - }, - warnings, - ); + return withWarnings(await executeInstallCommand({ argv, timeoutMs, env }), warnings); } diff --git a/src/agents/skills-status.ts b/src/agents/skills-status.ts index 8a35ac6f27d..28409883ea3 100644 --- a/src/agents/skills-status.ts +++ b/src/agents/skills-status.ts @@ -65,6 +65,7 @@ function selectPreferredInstallSpec( if (install.length === 0) { return undefined; } + const indexed = install.map((spec, index) => ({ spec, index })); const findKind = (kind: SkillInstallSpec["kind"]) => indexed.find((item) => item.spec.kind === kind); @@ -73,38 +74,32 @@ function selectPreferredInstallSpec( const nodeSpec = findKind("node"); const goSpec = findKind("go"); const uvSpec = findKind("uv"); - + const downloadSpec = findKind("download"); const brewAvailable = hasBinary("brew"); - if (prefs.preferBrew && brewAvailable && brewSpec) { - return brewSpec; + // Table-driven preference chain; first match wins. + const pickers: Array<() => { spec: SkillInstallSpec; index: number } | undefined> = [ + () => (prefs.preferBrew && brewAvailable ? brewSpec : undefined), + () => uvSpec, + () => nodeSpec, + // Only prefer brew when available to avoid guaranteed failure on Linux/Docker. + () => (brewAvailable ? brewSpec : undefined), + () => goSpec, + // Prefer download over an unavailable brew spec. + () => downloadSpec, + // Last resort: surface descriptive brew-missing error instead of "no installer found". + () => brewSpec, + () => indexed[0], + ]; + + for (const pick of pickers) { + const selected = pick(); + if (selected) { + return selected; + } } - if (uvSpec) { - return uvSpec; - } - if (nodeSpec) { - return nodeSpec; - } - // Only prefer brew when it is actually installed; otherwise skip to - // alternatives so Linux/Docker environments without brew get a working - // install option instead of a guaranteed failure. - if (brewSpec && brewAvailable) { - return brewSpec; - } - if (goSpec) { - return goSpec; - } - // Prefer download over an unavailable brew spec. - const downloadSpec = findKind("download"); - if (downloadSpec) { - return downloadSpec; - } - // Last resort: return brew spec even without brew so the caller can - // surface a descriptive error rather than "no installer found". - if (brewSpec) { - return brewSpec; - } - return indexed[0]; + + return undefined; } function normalizeInstallOptions(