fix: bootstrap linuxbrew for skills
This commit is contained in:
@@ -56,6 +56,7 @@
|
|||||||
- Docs: add group chat participation guidance to the AGENTS template.
|
- 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).
|
- 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.
|
- 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: migrate key handling to the updated pi-tui Key matcher API.
|
||||||
- TUI: add `/elev` alias for `/elevated`.
|
- TUI: add `/elev` alias for `/elevated`.
|
||||||
- Logging: redact sensitive tokens in verbose tool summaries by default (configurable patterns).
|
- 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 path from "node:path";
|
||||||
|
|
||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
|
import { resolveBrewExecutable } from "../infra/brew.js";
|
||||||
import { runCommandWithTimeout } from "../process/exec.js";
|
import { runCommandWithTimeout } from "../process/exec.js";
|
||||||
import { resolveUserPath } from "../utils.js";
|
import { resolveUserPath } from "../utils.js";
|
||||||
import {
|
import {
|
||||||
@@ -129,9 +130,13 @@ function buildInstallCommand(
|
|||||||
|
|
||||||
async function resolveBrewBinDir(
|
async function resolveBrewBinDir(
|
||||||
timeoutMs: number,
|
timeoutMs: number,
|
||||||
|
brewExe?: string,
|
||||||
): Promise<string | undefined> {
|
): Promise<string | undefined> {
|
||||||
if (!hasBinary("brew")) return undefined;
|
const exe =
|
||||||
const prefixResult = await runCommandWithTimeout(["brew", "--prefix"], {
|
brewExe ?? (hasBinary("brew") ? "brew" : resolveBrewExecutable());
|
||||||
|
if (!exe) return undefined;
|
||||||
|
|
||||||
|
const prefixResult = await runCommandWithTimeout([exe, "--prefix"], {
|
||||||
timeoutMs: Math.min(timeoutMs, 30_000),
|
timeoutMs: Math.min(timeoutMs, 30_000),
|
||||||
});
|
});
|
||||||
if (prefixResult.code === 0) {
|
if (prefixResult.code === 0) {
|
||||||
@@ -194,7 +199,9 @@ export async function installSkill(
|
|||||||
code: null,
|
code: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (spec.kind === "brew" && !hasBinary("brew")) {
|
|
||||||
|
const brewExe = hasBinary("brew") ? "brew" : resolveBrewExecutable();
|
||||||
|
if (spec.kind === "brew" && !brewExe) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
message: "brew not installed",
|
message: "brew not installed",
|
||||||
@@ -204,9 +211,9 @@ export async function installSkill(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (spec.kind === "uv" && !hasBinary("uv")) {
|
if (spec.kind === "uv" && !hasBinary("uv")) {
|
||||||
if (hasBinary("brew")) {
|
if (brewExe) {
|
||||||
const brewResult = await runCommandWithTimeout(
|
const brewResult = await runCommandWithTimeout(
|
||||||
["brew", "install", "uv"],
|
[brewExe, "install", "uv"],
|
||||||
{
|
{
|
||||||
timeoutMs,
|
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 (spec.kind === "go" && !hasBinary("go")) {
|
||||||
if (hasBinary("brew")) {
|
if (brewExe) {
|
||||||
const brewResult = await runCommandWithTimeout(
|
const brewResult = await runCommandWithTimeout(
|
||||||
["brew", "install", "go"],
|
[brewExe, "install", "go"],
|
||||||
{
|
{
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
},
|
},
|
||||||
@@ -269,8 +280,8 @@ export async function installSkill(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let env: NodeJS.ProcessEnv | undefined;
|
let env: NodeJS.ProcessEnv | undefined;
|
||||||
if (spec.kind === "go" && hasBinary("brew")) {
|
if (spec.kind === "go" && brewExe) {
|
||||||
const brewBin = await resolveBrewBinDir(timeoutMs);
|
const brewBin = await resolveBrewBinDir(timeoutMs, brewExe);
|
||||||
if (brewBin) env = { GOBIN: brewBin };
|
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 });
|
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 os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { resolveBrewPathDirs } from "./brew.js";
|
||||||
|
|
||||||
type EnsureClawdbotPathOpts = {
|
type EnsureClawdbotPathOpts = {
|
||||||
execPath?: string;
|
execPath?: string;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
@@ -75,6 +77,8 @@ function candidateBinDirs(opts: EnsureClawdbotPathOpts): string[] {
|
|||||||
const miseShims = path.join(miseDataDir, "shims");
|
const miseShims = path.join(miseDataDir, "shims");
|
||||||
if (isDirectory(miseShims)) candidates.push(miseShims);
|
if (isDirectory(miseShims)) candidates.push(miseShims);
|
||||||
|
|
||||||
|
candidates.push(...resolveBrewPathDirs({ homeDir }));
|
||||||
|
|
||||||
// Common global install locations (macOS first).
|
// Common global install locations (macOS first).
|
||||||
if (platform === "darwin") {
|
if (platform === "darwin") {
|
||||||
candidates.push(path.join(homeDir, "Library", "pnpm"));
|
candidates.push(path.join(homeDir, "Library", "pnpm"));
|
||||||
|
|||||||
Reference in New Issue
Block a user