feat(headroom): activate/uninstall extras + fix interpreter detection
- find interpreter next to headroom binary so extras/version read correctly - add on/off toggle to activate [code]/[ml] via proxy restart - add uninstall action + live install log progress + ~1GB confirm modal
This commit is contained in:
@@ -9,7 +9,7 @@ export const HEADROOM_COMPRESSION_EXTRAS = ["code", "ml"];
|
||||
|
||||
// Marker packages that each extra pulls in. Detected from `pip list --format=json`
|
||||
// so one call can answer both the installed version and active extras.
|
||||
const EXTRA_MARKERS = {
|
||||
export const EXTRA_MARKERS = {
|
||||
code: ["tree-sitter", "tree-sitter-language-pack"],
|
||||
ml: ["torch", "huggingface-hub"],
|
||||
};
|
||||
@@ -64,11 +64,33 @@ export function findHeadroomBinary() {
|
||||
}
|
||||
|
||||
// Find a Python interpreter >= 3.10 (headroom-ai requires it). Returns null if none.
|
||||
// On Windows, `python3` and `python` can point at different envs. Prefer the
|
||||
// first candidate that can also see the installed `headroom-ai` package so the
|
||||
// `python3`, `python3.13`, `python` can point at different envs on any OS. Prefer
|
||||
// the interpreter that can also see the installed `headroom-ai` package so the
|
||||
// dashboard probes and install action operate on the same interpreter as the CLI.
|
||||
// Falls back to the first version-eligible candidate when headroom-ai is not yet
|
||||
// installed anywhere (needed for the initial install).
|
||||
// Interpreters to probe, most specific first: the python next to the headroom
|
||||
// binary (guaranteed to have headroom-ai), then full paths from EXTRA_BINS, then
|
||||
// bare names resolved via PATH.
|
||||
function pythonCandidates() {
|
||||
const list = [];
|
||||
const bin = findHeadroomBinary();
|
||||
if (bin) {
|
||||
const dir = path.dirname(bin);
|
||||
const names = IS_WIN ? ["python.exe", "python3.exe"] : ["python3", "python3.13", "python"];
|
||||
for (const n of names) list.push(path.join(dir, n));
|
||||
}
|
||||
for (const dir of EXTRA_BINS) {
|
||||
if (!dir) continue;
|
||||
for (const n of PYTHON_CANDIDATES) list.push(path.join(dir, IS_WIN ? `${n}.exe` : n));
|
||||
}
|
||||
list.push(...PYTHON_CANDIDATES);
|
||||
return list;
|
||||
}
|
||||
|
||||
export function findPython310() {
|
||||
for (const candidate of PYTHON_CANDIDATES) {
|
||||
let fallback = null;
|
||||
for (const candidate of pythonCandidates()) {
|
||||
try {
|
||||
const ver = execSync(`${candidate} --version`, {
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
@@ -79,7 +101,7 @@ export function findPython310() {
|
||||
if (!match) continue;
|
||||
const [major, minor] = [parseInt(match[1], 10), parseInt(match[2], 10)];
|
||||
if (!(major > MIN_VERSION[0] || (major === MIN_VERSION[0] && minor >= MIN_VERSION[1]))) continue;
|
||||
if (!IS_WIN) return candidate;
|
||||
if (!fallback) fallback = candidate;
|
||||
try {
|
||||
execFileSync(candidate, ["-m", "pip", "show", "headroom-ai"], {
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
@@ -89,13 +111,13 @@ export function findPython310() {
|
||||
});
|
||||
return candidate;
|
||||
} catch {
|
||||
// Keep scanning on Windows until an interpreter sees headroom-ai.
|
||||
// Keep scanning until an interpreter that sees headroom-ai is found.
|
||||
}
|
||||
} catch {
|
||||
// candidate not present, try next
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
// Probe whether a Headroom proxy is reachable at the given URL by hitting /health.
|
||||
|
||||
@@ -2,11 +2,12 @@ import fs from "fs";
|
||||
import path from "path";
|
||||
import { spawn } from "child_process";
|
||||
import { DATA_DIR } from "@/lib/dataDir.js";
|
||||
import { findHeadroomBinary, findPython310, HEADROOM_COMPRESSION_EXTRAS, getInstalledHeadroomExtras } from "./detect.js";
|
||||
import { findHeadroomBinary, findPython310, HEADROOM_COMPRESSION_EXTRAS, EXTRA_MARKERS, getInstalledHeadroomExtras } from "./detect.js";
|
||||
|
||||
const HEADROOM_DIR = path.join(DATA_DIR, "headroom");
|
||||
const PID_FILE = path.join(HEADROOM_DIR, "proxy.pid");
|
||||
const LOG_FILE = path.join(HEADROOM_DIR, "proxy.log");
|
||||
const INSTALL_LOG_FILE = path.join(HEADROOM_DIR, "install.log");
|
||||
const DEFAULT_PORT = 8787;
|
||||
const STARTUP_TIMEOUT_MS = 8000;
|
||||
|
||||
@@ -41,7 +42,17 @@ export function getManagedPid() {
|
||||
return pid && isPidAlive(pid) ? pid : null;
|
||||
}
|
||||
|
||||
export async function startHeadroomProxy({ port = DEFAULT_PORT } = {}) {
|
||||
// Build proxy CLI flags for the active compression extras. `[code]` (AST
|
||||
// compression) is off by default in headroom → pass --code-aware to turn it on;
|
||||
// `[ml]` (Kompress) is on by default → pass --disable-kompress to turn it off.
|
||||
function extrasProxyArgs({ codeAware, kompress } = {}) {
|
||||
const args = [];
|
||||
if (codeAware) args.push("--code-aware");
|
||||
if (kompress === false) args.push("--disable-kompress");
|
||||
return args;
|
||||
}
|
||||
|
||||
export async function startHeadroomProxy({ port = DEFAULT_PORT, codeAware = false, kompress = true } = {}) {
|
||||
const safePort = Number(port) > 0 && Number(port) < 65536 ? Number(port) : DEFAULT_PORT;
|
||||
const binary = findHeadroomBinary();
|
||||
if (!binary) {
|
||||
@@ -57,7 +68,8 @@ export async function startHeadroomProxy({ port = DEFAULT_PORT } = {}) {
|
||||
// spawn stdio requires fd numbers, not WriteStream objects.
|
||||
const outFd = fs.openSync(LOG_FILE, "a");
|
||||
|
||||
const child = spawn(binary, ["proxy", "--port", String(safePort)], {
|
||||
const args = ["proxy", "--port", String(safePort), ...extrasProxyArgs({ codeAware, kompress })];
|
||||
const child = spawn(binary, args, {
|
||||
stdio: ["ignore", outFd, outFd],
|
||||
detached: true,
|
||||
windowsHide: true,
|
||||
@@ -118,6 +130,25 @@ export function stopHeadroomProxy() {
|
||||
}
|
||||
}
|
||||
|
||||
// Stop the managed proxy (if any), wait for the pid to die, then start again
|
||||
// with the given flags. Used when toggling active extras that require a restart.
|
||||
export async function restartHeadroomProxy(opts = {}) {
|
||||
const pid = getManagedPid();
|
||||
if (pid) {
|
||||
try { process.kill(pid, "SIGTERM"); } catch { /* already gone */ }
|
||||
// Wait up to ~3s for graceful exit, force-kill if still alive.
|
||||
for (let i = 0; i < 30 && isPidAlive(pid); i++) {
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
if (isPidAlive(pid)) {
|
||||
try { process.kill(pid, "SIGKILL"); } catch { /* already gone */ }
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
}
|
||||
clearPid();
|
||||
}
|
||||
return startHeadroomProxy(opts);
|
||||
}
|
||||
|
||||
export function getHeadroomLogTail(maxLines = 200) {
|
||||
try {
|
||||
if (!fs.existsSync(LOG_FILE)) return "";
|
||||
@@ -153,7 +184,8 @@ export async function installHeadroomExtras(extras = []) {
|
||||
const args = ["-m", "pip", "install", "--upgrade", spec];
|
||||
|
||||
ensureDir();
|
||||
const outFd = fs.openSync(path.join(HEADROOM_DIR, "install.log"), "a");
|
||||
// Truncate ("w") so the log reflects only the current install for live progress.
|
||||
const outFd = fs.openSync(INSTALL_LOG_FILE, "w");
|
||||
const child = spawn(py, args, {
|
||||
stdio: ["ignore", outFd, outFd],
|
||||
windowsHide: true,
|
||||
@@ -175,3 +207,54 @@ export async function installHeadroomExtras(extras = []) {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Uninstall the marker packages that back a single extra (e.g. `ml` → torch,
|
||||
// huggingface-hub). `headroom-ai` base and the `proxy` extra are never removed.
|
||||
export async function uninstallHeadroomExtras(extras = []) {
|
||||
const requested = Array.isArray(extras) ? extras.filter((e) => HEADROOM_COMPRESSION_EXTRAS.includes(e)) : [];
|
||||
const py = findPython310();
|
||||
if (!py) {
|
||||
const err = new Error("Python >= 3.10 not found");
|
||||
err.code = "NO_PYTHON";
|
||||
throw err;
|
||||
}
|
||||
const pkgs = [...new Set(requested.flatMap((e) => EXTRA_MARKERS[e] || []))];
|
||||
if (pkgs.length === 0) {
|
||||
const err = new Error("No valid extras to remove");
|
||||
err.code = "INVALID_EXTRAS";
|
||||
throw err;
|
||||
}
|
||||
const args = ["-m", "pip", "uninstall", "-y", ...pkgs];
|
||||
|
||||
ensureDir();
|
||||
const outFd = fs.openSync(INSTALL_LOG_FILE, "w");
|
||||
const child = spawn(py, args, {
|
||||
stdio: ["ignore", outFd, outFd],
|
||||
windowsHide: true,
|
||||
env: { ...process.env },
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
child.once("error", (e) => { fs.closeSync(outFd); reject(e); });
|
||||
child.once("exit", (code) => {
|
||||
fs.closeSync(outFd);
|
||||
if (code === 0) {
|
||||
const status = getInstalledHeadroomExtras(py);
|
||||
resolve({ success: true, code, removed: pkgs, extras: requested, ...status });
|
||||
} else {
|
||||
const err = new Error(`pip uninstall exited with code=${code} — see headroom/install.log`);
|
||||
err.code = "UNINSTALL_FAILED";
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Read the tail of the install/uninstall log for live progress in the UI.
|
||||
export function getInstallLogTail(maxLines = 15) {
|
||||
try {
|
||||
if (!fs.existsSync(INSTALL_LOG_FILE)) return "";
|
||||
const lines = fs.readFileSync(INSTALL_LOG_FILE, "utf8").split(/\r?\n/).filter(Boolean);
|
||||
return lines.slice(-maxLines).join("\n");
|
||||
} catch { return ""; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user