fix: bootstrap linuxbrew for skills
This commit is contained in:
@@ -56,6 +56,7 @@
|
||||
- Docs: add group chat participation guidance to the AGENTS template.
|
||||
- Gmail: stop restart loop when `gog gmail watch serve` fails to bind (address already in use).
|
||||
- Linux: auto-attempt lingering during onboarding (try without sudo, fallback to sudo) and prompt on install/restart to keep the gateway alive after logout/idle. Thanks @tobiasbischoff for PR #237.
|
||||
- Skills: add Linuxbrew paths to gateway PATH bootstrap so the Skills UI can run brew installers under systemd/minimal environments.
|
||||
- TUI: migrate key handling to the updated pi-tui Key matcher API.
|
||||
- TUI: add `/elev` alias for `/elevated`.
|
||||
- Logging: redact sensitive tokens in verbose tool summaries by default (configurable patterns).
|
||||
|
||||
@@ -2,6 +2,7 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { resolveBrewExecutable } from "../infra/brew.js";
|
||||
import { runCommandWithTimeout } from "../process/exec.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import {
|
||||
@@ -129,9 +130,13 @@ function buildInstallCommand(
|
||||
|
||||
async function resolveBrewBinDir(
|
||||
timeoutMs: number,
|
||||
brewExe?: string,
|
||||
): Promise<string | undefined> {
|
||||
if (!hasBinary("brew")) return undefined;
|
||||
const prefixResult = await runCommandWithTimeout(["brew", "--prefix"], {
|
||||
const exe =
|
||||
brewExe ?? (hasBinary("brew") ? "brew" : resolveBrewExecutable());
|
||||
if (!exe) return undefined;
|
||||
|
||||
const prefixResult = await runCommandWithTimeout([exe, "--prefix"], {
|
||||
timeoutMs: Math.min(timeoutMs, 30_000),
|
||||
});
|
||||
if (prefixResult.code === 0) {
|
||||
@@ -194,7 +199,9 @@ export async function installSkill(
|
||||
code: null,
|
||||
};
|
||||
}
|
||||
if (spec.kind === "brew" && !hasBinary("brew")) {
|
||||
|
||||
const brewExe = hasBinary("brew") ? "brew" : resolveBrewExecutable();
|
||||
if (spec.kind === "brew" && !brewExe) {
|
||||
return {
|
||||
ok: false,
|
||||
message: "brew not installed",
|
||||
@@ -204,9 +211,9 @@ export async function installSkill(
|
||||
};
|
||||
}
|
||||
if (spec.kind === "uv" && !hasBinary("uv")) {
|
||||
if (hasBinary("brew")) {
|
||||
if (brewExe) {
|
||||
const brewResult = await runCommandWithTimeout(
|
||||
["brew", "install", "uv"],
|
||||
[brewExe, "install", "uv"],
|
||||
{
|
||||
timeoutMs,
|
||||
},
|
||||
@@ -240,10 +247,14 @@ export async function installSkill(
|
||||
};
|
||||
}
|
||||
|
||||
if (spec.kind === "brew" && brewExe && command.argv[0] === "brew") {
|
||||
command.argv[0] = brewExe;
|
||||
}
|
||||
|
||||
if (spec.kind === "go" && !hasBinary("go")) {
|
||||
if (hasBinary("brew")) {
|
||||
if (brewExe) {
|
||||
const brewResult = await runCommandWithTimeout(
|
||||
["brew", "install", "go"],
|
||||
[brewExe, "install", "go"],
|
||||
{
|
||||
timeoutMs,
|
||||
},
|
||||
@@ -269,8 +280,8 @@ export async function installSkill(
|
||||
}
|
||||
|
||||
let env: NodeJS.ProcessEnv | undefined;
|
||||
if (spec.kind === "go" && hasBinary("brew")) {
|
||||
const brewBin = await resolveBrewBinDir(timeoutMs);
|
||||
if (spec.kind === "go" && brewExe) {
|
||||
const brewBin = await resolveBrewBinDir(timeoutMs, brewExe);
|
||||
if (brewBin) env = { GOBIN: brewBin };
|
||||
}
|
||||
|
||||
|
||||
59
src/infra/brew.test.ts
Normal file
59
src/infra/brew.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { resolveBrewExecutable, resolveBrewPathDirs } from "./brew.js";
|
||||
|
||||
describe("brew helpers", () => {
|
||||
it("resolves brew from ~/.linuxbrew/bin when executable exists", async () => {
|
||||
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-brew-"));
|
||||
try {
|
||||
const homebrewBin = path.join(tmp, ".linuxbrew", "bin");
|
||||
await fs.mkdir(homebrewBin, { recursive: true });
|
||||
const brewPath = path.join(homebrewBin, "brew");
|
||||
await fs.writeFile(brewPath, "#!/bin/sh\necho ok\n", "utf-8");
|
||||
await fs.chmod(brewPath, 0o755);
|
||||
|
||||
const env: NodeJS.ProcessEnv = {};
|
||||
expect(resolveBrewExecutable({ homeDir: tmp, env })).toBe(brewPath);
|
||||
} finally {
|
||||
await fs.rm(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("prefers HOMEBREW_PREFIX/bin/brew when present", async () => {
|
||||
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-brew-"));
|
||||
try {
|
||||
const prefix = path.join(tmp, "prefix");
|
||||
const prefixBin = path.join(prefix, "bin");
|
||||
await fs.mkdir(prefixBin, { recursive: true });
|
||||
const prefixBrew = path.join(prefixBin, "brew");
|
||||
await fs.writeFile(prefixBrew, "#!/bin/sh\necho ok\n", "utf-8");
|
||||
await fs.chmod(prefixBrew, 0o755);
|
||||
|
||||
const homebrewBin = path.join(tmp, ".linuxbrew", "bin");
|
||||
await fs.mkdir(homebrewBin, { recursive: true });
|
||||
const homebrewBrew = path.join(homebrewBin, "brew");
|
||||
await fs.writeFile(homebrewBrew, "#!/bin/sh\necho ok\n", "utf-8");
|
||||
await fs.chmod(homebrewBrew, 0o755);
|
||||
|
||||
const env: NodeJS.ProcessEnv = { HOMEBREW_PREFIX: prefix };
|
||||
expect(resolveBrewExecutable({ homeDir: tmp, env })).toBe(prefixBrew);
|
||||
} finally {
|
||||
await fs.rm(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("includes Linuxbrew bin/sbin in path candidates", () => {
|
||||
const env: NodeJS.ProcessEnv = { HOMEBREW_PREFIX: "/custom/prefix" };
|
||||
const dirs = resolveBrewPathDirs({ homeDir: "/home/test", env });
|
||||
expect(dirs).toContain("/custom/prefix/bin");
|
||||
expect(dirs).toContain("/custom/prefix/sbin");
|
||||
expect(dirs).toContain("/home/linuxbrew/.linuxbrew/bin");
|
||||
expect(dirs).toContain("/home/linuxbrew/.linuxbrew/sbin");
|
||||
expect(dirs).toContain("/home/test/.linuxbrew/bin");
|
||||
expect(dirs).toContain("/home/test/.linuxbrew/sbin");
|
||||
});
|
||||
});
|
||||
71
src/infra/brew.ts
Normal file
71
src/infra/brew.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
function isExecutable(filePath: string): boolean {
|
||||
try {
|
||||
fs.accessSync(filePath, fs.constants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePathValue(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
export function resolveBrewPathDirs(opts?: {
|
||||
homeDir?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): string[] {
|
||||
const homeDir = opts?.homeDir ?? os.homedir();
|
||||
const env = opts?.env ?? process.env;
|
||||
|
||||
const dirs: string[] = [];
|
||||
const prefix = normalizePathValue(env.HOMEBREW_PREFIX);
|
||||
if (prefix) {
|
||||
dirs.push(path.join(prefix, "bin"), path.join(prefix, "sbin"));
|
||||
}
|
||||
|
||||
// Linuxbrew defaults.
|
||||
dirs.push("/home/linuxbrew/.linuxbrew/bin", "/home/linuxbrew/.linuxbrew/sbin");
|
||||
dirs.push(path.join(homeDir, ".linuxbrew", "bin"));
|
||||
dirs.push(path.join(homeDir, ".linuxbrew", "sbin"));
|
||||
|
||||
// macOS defaults (also used by some Linux setups).
|
||||
dirs.push("/opt/homebrew/bin", "/usr/local/bin");
|
||||
|
||||
return dirs;
|
||||
}
|
||||
|
||||
export function resolveBrewExecutable(opts?: {
|
||||
homeDir?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): string | undefined {
|
||||
const homeDir = opts?.homeDir ?? os.homedir();
|
||||
const env = opts?.env ?? process.env;
|
||||
|
||||
const candidates: string[] = [];
|
||||
|
||||
const brewFile = normalizePathValue(env.HOMEBREW_BREW_FILE);
|
||||
if (brewFile) candidates.push(brewFile);
|
||||
|
||||
const prefix = normalizePathValue(env.HOMEBREW_PREFIX);
|
||||
if (prefix) candidates.push(path.join(prefix, "bin", "brew"));
|
||||
|
||||
// Linuxbrew defaults.
|
||||
candidates.push("/home/linuxbrew/.linuxbrew/bin/brew");
|
||||
candidates.push(path.join(homeDir, ".linuxbrew", "bin", "brew"));
|
||||
|
||||
// macOS defaults.
|
||||
candidates.push("/opt/homebrew/bin/brew", "/usr/local/bin/brew");
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (isExecutable(candidate)) return candidate;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
@@ -111,4 +111,52 @@ describe("ensureClawdbotCliOnPath", () => {
|
||||
await fs.rm(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("prepends Linuxbrew dirs when present", async () => {
|
||||
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-path-"));
|
||||
const originalPath = process.env.PATH;
|
||||
const originalFlag = process.env.CLAWDBOT_PATH_BOOTSTRAPPED;
|
||||
const originalHomebrewPrefix = process.env.HOMEBREW_PREFIX;
|
||||
const originalHomebrewBrewFile = process.env.HOMEBREW_BREW_FILE;
|
||||
const originalXdgBinHome = process.env.XDG_BIN_HOME;
|
||||
try {
|
||||
const execDir = path.join(tmp, "exec");
|
||||
await fs.mkdir(execDir, { recursive: true });
|
||||
|
||||
const linuxbrewBin = path.join(tmp, ".linuxbrew", "bin");
|
||||
const linuxbrewSbin = path.join(tmp, ".linuxbrew", "sbin");
|
||||
await fs.mkdir(linuxbrewBin, { recursive: true });
|
||||
await fs.mkdir(linuxbrewSbin, { recursive: true });
|
||||
|
||||
process.env.PATH = "/usr/bin";
|
||||
delete process.env.CLAWDBOT_PATH_BOOTSTRAPPED;
|
||||
delete process.env.HOMEBREW_PREFIX;
|
||||
delete process.env.HOMEBREW_BREW_FILE;
|
||||
delete process.env.XDG_BIN_HOME;
|
||||
|
||||
ensureClawdbotCliOnPath({
|
||||
execPath: path.join(execDir, "node"),
|
||||
cwd: tmp,
|
||||
homeDir: tmp,
|
||||
platform: "linux",
|
||||
});
|
||||
|
||||
const updated = process.env.PATH ?? "";
|
||||
const parts = updated.split(path.delimiter);
|
||||
expect(parts[0]).toBe(linuxbrewBin);
|
||||
expect(parts[1]).toBe(linuxbrewSbin);
|
||||
} finally {
|
||||
process.env.PATH = originalPath;
|
||||
if (originalFlag === undefined)
|
||||
delete process.env.CLAWDBOT_PATH_BOOTSTRAPPED;
|
||||
else process.env.CLAWDBOT_PATH_BOOTSTRAPPED = originalFlag;
|
||||
if (originalHomebrewPrefix === undefined) delete process.env.HOMEBREW_PREFIX;
|
||||
else process.env.HOMEBREW_PREFIX = originalHomebrewPrefix;
|
||||
if (originalHomebrewBrewFile === undefined) delete process.env.HOMEBREW_BREW_FILE;
|
||||
else process.env.HOMEBREW_BREW_FILE = originalHomebrewBrewFile;
|
||||
if (originalXdgBinHome === undefined) delete process.env.XDG_BIN_HOME;
|
||||
else process.env.XDG_BIN_HOME = originalXdgBinHome;
|
||||
await fs.rm(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,8 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { resolveBrewPathDirs } from "./brew.js";
|
||||
|
||||
type EnsureClawdbotPathOpts = {
|
||||
execPath?: string;
|
||||
cwd?: string;
|
||||
@@ -75,6 +77,8 @@ function candidateBinDirs(opts: EnsureClawdbotPathOpts): string[] {
|
||||
const miseShims = path.join(miseDataDir, "shims");
|
||||
if (isDirectory(miseShims)) candidates.push(miseShims);
|
||||
|
||||
candidates.push(...resolveBrewPathDirs({ homeDir }));
|
||||
|
||||
// Common global install locations (macOS first).
|
||||
if (platform === "darwin") {
|
||||
candidates.push(path.join(homeDir, "Library", "pnpm"));
|
||||
|
||||
Reference in New Issue
Block a user