feat(dashboard): build/link local fn and npm restore from System panel

Add Command Center System controls for source/dev hosts to build the
standalone fn binary and install it as the default PATH binary, switch
back to the global npm install, and force-check for published updates.
Build jobs stream into the shared job log and scroll that view into focus.
This commit is contained in:
gsxdsm
2026-07-15 10:07:20 -07:00
parent a67c2763af
commit 4d78f9641b
7 changed files with 1000 additions and 15 deletions

View File

@@ -11526,10 +11526,15 @@ in-place restart, rebuild jobs with streamed output, engine/agent restarts,
plugin reload, and the host-process log viewer.
*/
/*
FNXC:SystemPanelFnBinary 2026-07-15-09:54:
Job snapshots cover rebuild scopes and the fn-binary link-local / use-global
actions that stream into the same System panel log viewer.
*/
export interface SystemRebuildJobSnapshot {
id: string;
kind: "rebuild";
scope: "app" | "full" | "plugins";
kind: "rebuild" | "fn-binary";
scope: "app" | "full" | "plugins" | "link-local" | "use-global";
restartAfter: boolean;
status: "running" | "succeeded" | "failed";
startedAt: number;
@@ -11554,6 +11559,10 @@ export interface SystemInfoResponse {
supervised: boolean;
restartSupported: boolean;
rebuildSupported: boolean;
/** True when the host is a Fusion source checkout (dev) — can build & link local fn. */
fnBinaryLinkLocalSupported?: boolean;
/** Always true when the route is wired; UI may still disable while a job runs. */
fnBinaryUseGlobalSupported?: boolean;
sourceWorkspaceRoot?: string;
logsSupported: boolean;
engineAvailable: boolean;
@@ -11596,6 +11605,19 @@ export function startSystemRebuild(
});
}
/*
FNXC:SystemPanelFnBinary 2026-07-15-09:54:
Client wrappers for System panel fn-binary actions. Both return a job snapshot
that the panel streams via /system/jobs/:id/stream (same path as rebuild).
*/
export function startFnBinaryLinkLocal(): Promise<SystemRebuildJobSnapshot> {
return api<SystemRebuildJobSnapshot>("/system/fn-binary/link-local", { method: "POST" });
}
export function startFnBinaryUseGlobal(): Promise<SystemRebuildJobSnapshot> {
return api<SystemRebuildJobSnapshot>("/system/fn-binary/use-global", { method: "POST" });
}
export function fetchCurrentSystemRebuild(): Promise<{ job: SystemRebuildJobSnapshot | null }> {
return api<{ job: SystemRebuildJobSnapshot | null }>("/system/rebuild/current");
}

View File

@@ -33,7 +33,26 @@ vi.mock("../../../api/legacy", () => ({
requestSystemRestart: vi.fn().mockResolvedValue({ ok: true }),
restartAllSystemAgents: vi.fn().mockResolvedValue({ ok: true }),
restartSystemEngines: vi.fn().mockResolvedValue({ ok: true }),
startSystemRebuild: vi.fn().mockResolvedValue({ id: "job-1", status: "running", lines: [] }),
startSystemRebuild: vi.fn().mockResolvedValue({ id: "job-1", status: "running", kind: "rebuild", scope: "app", lines: [] }),
startFnBinaryLinkLocal: vi.fn().mockResolvedValue({
id: "job-fn-local",
status: "running",
kind: "fn-binary",
scope: "link-local",
lines: [],
}),
startFnBinaryUseGlobal: vi.fn().mockResolvedValue({
id: "job-fn-global",
status: "running",
kind: "fn-binary",
scope: "use-global",
lines: [],
}),
refreshUpdateCheck: vi.fn().mockResolvedValue({
currentVersion: "0.60.0",
latestVersion: "0.60.0",
updateAvailable: false,
}),
}));
vi.mock("../../../api", () => ({
@@ -70,7 +89,7 @@ function emptyOverviewResponse(path: string) {
return {};
}
function systemInfoFixture() {
function systemInfoFixture(overrides: Record<string, unknown> = {}) {
return {
pid: 12345,
nodeVersion: "v22.0.0",
@@ -78,11 +97,17 @@ function systemInfoFixture() {
arch: "arm64",
sourceCheckout: true,
supervised: true,
restartSupported: true,
rebuildSupported: true,
fnBinaryLinkLocalSupported: true,
fnBinaryUseGlobalSupported: true,
engineAvailable: true,
engineRestartSupported: true,
agentRestartSupported: true,
pluginReloadSupported: true,
logsSupported: true,
activeRebuild: null,
...overrides,
};
}
@@ -357,4 +382,55 @@ describe("SystemControlsArea layout integration", () => {
expect(css).toMatch(/@media\s*\(max-width:\s*768px\)\s*{[\s\S]*\.cc-system-tab\s*{[^}]*gap:\s*var\(--space-lg\);/);
expect(css).not.toMatch(/\.cc-system-tab\s*{[^}]*overflow-y:\s*auto;/s);
});
/*
FNXC:SystemPanelFnBinary 2026-07-15-09:54:
Source/dev hosts expose build-and-link-local; packaged hosts hide it. Use-global
and check-for-updates stay available, and starting a build job surfaces the
shared log viewer.
*/
it("shows fn binary and update controls for a source checkout and scrolls job output into view", async () => {
const scrollIntoView = vi.fn();
const original = Element.prototype.scrollIntoView;
Element.prototype.scrollIntoView = scrollIntoView;
render(<CommandCenter projectId="proj-1" />);
fireEvent.click(screen.getByTestId("command-center-tab-system"));
const linkLocal = await screen.findByTestId("cc-syscontrol-fn-link-local");
const useGlobal = screen.getByTestId("cc-syscontrol-fn-use-global");
const checkUpdates = screen.getByTestId("cc-syscontrol-check-updates");
expect(linkLocal).toBeInTheDocument();
expect(useGlobal).toBeInTheDocument();
expect(checkUpdates).toBeInTheDocument();
fireEvent.click(within(linkLocal).getByRole("button", { name: "Build & link" }));
await waitFor(() => {
expect(screen.getByTestId("cc-system-rebuild-output")).toBeInTheDocument();
});
await waitFor(() => {
expect(scrollIntoView).toHaveBeenCalled();
});
Element.prototype.scrollIntoView = original;
});
it("hides build-and-link-local when the host is not a source checkout", async () => {
mockFetchSystemInfo.mockResolvedValue(
systemInfoFixture({
rebuildSupported: false,
fnBinaryLinkLocalSupported: false,
sourceWorkspaceRoot: undefined,
}),
);
render(<CommandCenter projectId="proj-1" />);
fireEvent.click(screen.getByTestId("command-center-tab-system"));
await screen.findByTestId("cc-system-controls");
expect(screen.queryByTestId("cc-syscontrol-fn-link-local")).not.toBeInTheDocument();
expect(screen.getByTestId("cc-syscontrol-fn-use-global")).toBeInTheDocument();
expect(screen.getByTestId("cc-syscontrol-check-updates")).toBeInTheDocument();
});
});

View File

@@ -1,12 +1,15 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ArrowUpCircle,
Blocks,
Bot,
Bug,
Copy,
Database,
Download,
Hammer,
Link2,
Package,
Power,
RefreshCw,
@@ -19,15 +22,19 @@ import {
fetchCurrentSystemRebuild,
fetchSystemInfo,
fetchSystemLogs,
refreshUpdateCheck,
reloadAllSystemPlugins,
requestSystemRestart,
restartAllSystemAgents,
restartSystemEngines,
startFnBinaryLinkLocal,
startFnBinaryUseGlobal,
startSystemRebuild,
type SystemInfoResponse,
type SystemLogEntryDto,
type SystemRebuildJobLine,
type SystemRebuildJobSnapshot,
type UpdateCheckResponse,
} from "../../../api/legacy";
import { subscribeSse } from "../../../sse-bus";
import type { ToastType } from "../../../hooks/useToast";
@@ -49,6 +56,14 @@ runtime-metrics area. Requirements this encodes:
restart all active agents, backup the database, rebuild+reload plugins,
live server log tail, report a bug (prefilled GitHub issue), and copy a
diagnostics bundle.
FNXC:SystemPanelFnBinary 2026-07-15-09:54:
- "Build & link local fn" (source/dev only): run full workspace build + Bun
compile + install to ~/.local as the default PATH `fn`.
- "Use global npm fn": remove local shims and reinstall runfusion.ai globally.
- "Check for updates": force-refresh the published version probe.
- Any build/job step (rebuild, link-local, use-global) scrolls the panel to
the shared job log viewer so operators see live output without hunting.
*/
const LOG_VIEW_CAP = 500;
@@ -112,6 +127,7 @@ export function SystemControlsArea({ projectId, addToast }: SystemControlsAreaPr
const [job, setJob] = useState<SystemRebuildJobSnapshot | null>(null);
const [jobLines, setJobLines] = useState<SystemRebuildJobLine[]>([]);
const jobOutputRef = useRef<HTMLPreElement | null>(null);
const jobSectionRef = useRef<HTMLDivElement | null>(null);
const [restartPhase, setRestartPhase] = useState<RestartPhase>(null);
const prevPidRef = useRef<number | null>(null);
@@ -120,6 +136,8 @@ export function SystemControlsArea({ projectId, addToast }: SystemControlsAreaPr
const [logEntries, setLogEntries] = useState<SystemLogEntryDto[]>([]);
const logOutputRef = useRef<HTMLDivElement | null>(null);
const [updateCheckResult, setUpdateCheckResult] = useState<UpdateCheckResponse | null>(null);
const loadInfo = useCallback(async () => {
try {
const next = await fetchSystemInfo();
@@ -189,9 +207,15 @@ export function SystemControlsArea({ projectId, addToast }: SystemControlsAreaPr
prevPidRef.current = info?.pid ?? null;
setRestartPhase("waiting");
} else if (snapshot.status === "succeeded") {
toast(t("systemControls.rebuildSucceeded", "Rebuild finished successfully"), "success");
const successMsg =
snapshot.kind === "fn-binary" && snapshot.scope === "link-local"
? t("systemControls.fnLinkLocalSucceeded", "Local fn binary built and linked")
: snapshot.kind === "fn-binary" && snapshot.scope === "use-global"
? t("systemControls.fnUseGlobalSucceeded", "Switched default fn to global npm install")
: t("systemControls.rebuildSucceeded", "Rebuild finished successfully");
toast(successMsg, "success");
} else {
toast(t("systemControls.rebuildFailed", "Rebuild failed — see output for details"), "error");
toast(t("systemControls.rebuildFailed", "Job failed — see output for details"), "error");
}
} catch {
// Ignore malformed stream payloads.
@@ -290,14 +314,85 @@ export function SystemControlsArea({ projectId, addToast }: SystemControlsAreaPr
[busyAction, toast],
);
/*
FNXC:SystemPanelFnBinary 2026-07-15-09:54:
Whenever a build/job starts, scroll the job log section into view so the
operator immediately sees streamed output (rebuild, link-local, use-global).
The effect waits until the job section is mounted (after setJob) so
scrollIntoView has a real target; the inner <pre> still auto-follows new
lines via scrollTop.
*/
useEffect(() => {
if (!job || job.status !== "running") return;
const frame = requestAnimationFrame(() => {
jobSectionRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest" });
});
return () => cancelAnimationFrame(frame);
}, [job?.id, job?.status]);
const adoptJob = useCallback((snapshot: SystemRebuildJobSnapshot) => {
setJobLines([]);
setJob(snapshot);
}, []);
const beginRebuild = useCallback(
(scope: "app" | "full" | "plugins") =>
runAction(`rebuild-${scope}`, async () => {
const snapshot = await startSystemRebuild(scope, info?.restartSupported ?? false);
setJobLines([]);
setJob(snapshot);
adoptJob(snapshot);
}),
[info?.restartSupported, runAction],
[adoptJob, info?.restartSupported, runAction],
);
const beginFnLinkLocal = useCallback(
() =>
runAction("fn-link-local", async () => {
const snapshot = await startFnBinaryLinkLocal();
adoptJob(snapshot);
}),
[adoptJob, runAction],
);
const beginFnUseGlobal = useCallback(
() =>
runAction("fn-use-global", async () => {
const snapshot = await startFnBinaryUseGlobal();
adoptJob(snapshot);
}),
[adoptJob, runAction],
);
const doCheckUpdates = useCallback(
() =>
runAction("check-updates", async () => {
const result = await refreshUpdateCheck();
setUpdateCheckResult(result);
if (result.error) {
toast(result.error, "error");
return;
}
if (result.disabled) {
toast(t("systemControls.updatesDisabled", "Update checks are disabled in global settings"), "warning");
return;
}
if (result.updateAvailable && result.latestVersion) {
toast(
t("systemControls.updateAvailable", "Update available: v{{version}} (current: v{{current}})", {
version: result.latestVersion,
current: result.currentVersion,
}),
"success",
);
return;
}
toast(
t("systemControls.upToDate", "You're up to date (v{{version}})", {
version: result.currentVersion,
}),
"success",
);
}),
[runAction, t, toast],
);
const doRestart = useCallback(
@@ -466,6 +561,13 @@ export function SystemControlsArea({ projectId, addToast }: SystemControlsAreaPr
only confuse operators.
*/
const showRebuildControls = info?.rebuildSupported ?? false;
/*
FNXC:SystemPanelFnBinary 2026-07-15-09:54:
Link-local is HIDDEN unless the server advertises a Fusion source checkout
(dev mode). Use-global and check-for-updates stay visible on packaged installs.
*/
const showFnLinkLocal = info?.fnBinaryLinkLocalSupported ?? info?.rebuildSupported ?? false;
const showFnUseGlobal = info?.fnBinaryUseGlobalSupported !== false;
const rebuildRunning = job?.status === "running";
const controls = useMemo(
@@ -495,6 +597,47 @@ export function SystemControlsArea({ projectId, addToast }: SystemControlsAreaPr
run: () => void beginRebuild("full"),
testId: "cc-syscontrol-rebuild-full",
},
{
key: "fn-link-local",
icon: Link2,
title: t("systemControls.fnLinkLocal", "Build & link local fn"),
description: t(
"systemControls.fnLinkLocalDesc",
"Build the standalone fn binary from this source checkout and install it as your default PATH binary (~/.local).",
),
cta: t("systemControls.fnLinkLocalCta", "Build & link"),
hidden: !showFnLinkLocal,
disabled: rebuildRunning,
run: () => void beginFnLinkLocal(),
testId: "cc-syscontrol-fn-link-local",
},
{
key: "fn-use-global",
icon: Download,
title: t("systemControls.fnUseGlobal", "Use global npm fn"),
description: t(
"systemControls.fnUseGlobalDesc",
"Remove the local-build shims and reinstall the published runfusion.ai package globally.",
),
cta: t("systemControls.fnUseGlobalCta", "Install global"),
hidden: !showFnUseGlobal,
disabled: rebuildRunning,
run: () => void beginFnUseGlobal(),
testId: "cc-syscontrol-fn-use-global",
},
{
key: "check-updates",
icon: ArrowUpCircle,
title: t("systemControls.checkUpdates", "Check for updates"),
description: t(
"systemControls.checkUpdatesDesc",
"Query the registry for a newer published Fusion version.",
),
cta: t("systemControls.checkUpdatesCta", "Check now"),
disabled: false,
run: () => void doCheckUpdates(),
testId: "cc-syscontrol-check-updates",
},
{
key: "restart",
icon: Power,
@@ -594,15 +737,20 @@ export function SystemControlsArea({ projectId, addToast }: SystemControlsAreaPr
},
],
[
beginFnLinkLocal,
beginFnUseGlobal,
beginRebuild,
doAgentsRestart,
doBackup,
doCheckUpdates,
doCopyDiagnostics,
doEngineRestart,
doReloadPlugins,
doReportBug,
doRestart,
info,
showFnLinkLocal,
showFnUseGlobal,
showRebuildControls,
rebuildRunning,
restartDisabledNote,
@@ -618,6 +766,13 @@ export function SystemControlsArea({ projectId, addToast }: SystemControlsAreaPr
: t("systemControls.jobFailed", "Failed")
: null;
const jobTitle =
job?.kind === "fn-binary" && job.scope === "link-local"
? t("systemControls.fnLinkLocalOutput", "Local fn build output")
: job?.kind === "fn-binary" && job.scope === "use-global"
? t("systemControls.fnUseGlobalOutput", "Global npm install output")
: t("systemControls.rebuildOutput", "Rebuild output");
return (
<>
<div className="cc-area-section" data-testid="cc-system-controls">
@@ -669,6 +824,27 @@ export function SystemControlsArea({ projectId, addToast }: SystemControlsAreaPr
</div>
) : null}
{updateCheckResult && !updateCheckResult.error ? (
<div
className={`cc-syscontrols-banner ${updateCheckResult.updateAvailable ? "cc-syscontrols-banner--back" : ""}`}
role="status"
data-testid="cc-system-update-check-result"
>
<span>
{updateCheckResult.disabled
? t("systemControls.updatesDisabled", "Update checks are disabled in global settings")
: updateCheckResult.updateAvailable && updateCheckResult.latestVersion
? t("systemControls.updateAvailable", "Update available: v{{version}} (current: v{{current}})", {
version: updateCheckResult.latestVersion,
current: updateCheckResult.currentVersion,
})
: t("systemControls.upToDate", "You're up to date (v{{version}})", {
version: updateCheckResult.currentVersion,
})}
</span>
</div>
) : null}
<div className="cc-syscontrols-grid">
{controls.filter((control) => !("hidden" in control && control.hidden)).map((control) => (
<div key={control.key} className="card cc-syscontrol-card" data-testid={control.testId}>
@@ -694,9 +870,9 @@ export function SystemControlsArea({ projectId, addToast }: SystemControlsAreaPr
</div>
{job ? (
<div className="cc-area-section" data-testid="cc-system-rebuild-output">
<div ref={jobSectionRef} className="cc-area-section" data-testid="cc-system-rebuild-output">
<div className="cc-area-section-header">
<h3 className="cc-area-section-title">{t("systemControls.rebuildOutput", "Rebuild output")}</h3>
<h3 className="cc-area-section-title">{jobTitle}</h3>
<span className={`cc-syscontrols-job-status cc-syscontrols-job-status--${job.status}`}>
{jobStatusLabel}
</span>

View File

@@ -0,0 +1,78 @@
// @vitest-environment node
import { chmodSync, mkdirSync, mkdtempSync, readlinkSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
installLocalFnBinary,
removeLocalFnShims,
resolveFnBinaryLocalPaths,
} from "../fn-binary-local-install.js";
/*
FNXC:SystemPanelFnBinary 2026-07-15-09:54:
Unit tests for the local fn install layout used by System panel link-local /
use-global actions: co-located client assets, ~/.local/bin shims, and selective
shim removal that leaves unrelated binaries alone.
*/
const tempRoots: string[] = [];
afterEach(() => {
for (const root of tempRoots.splice(0)) {
rmSync(root, { recursive: true, force: true });
}
});
function makeTemp(prefix: string): string {
const root = mkdtempSync(join(tmpdir(), prefix));
tempRoots.push(root);
return root;
}
describe("fn-binary-local-install", () => {
it("installs the binary with co-located client/runtime and links PATH shims", () => {
const home = makeTemp("fn-bin-home-");
const dist = makeTemp("fn-bin-dist-");
const logs: string[] = [];
writeFileSync(join(dist, "fn"), "#!/bin/sh\necho ok\n");
chmodSync(join(dist, "fn"), 0o755);
mkdirSync(join(dist, "client"), { recursive: true });
writeFileSync(join(dist, "client", "index.html"), "<html></html>");
mkdirSync(join(dist, "runtime", "darwin-arm64"), { recursive: true });
writeFileSync(join(dist, "runtime", "darwin-arm64", "pty.node"), "native");
const paths = resolveFnBinaryLocalPaths(home);
installLocalFnBinary(dist, (_stream, text) => logs.push(text), paths);
expect(logs.some((line) => line.includes("Installing binary"))).toBe(true);
expect(readlinkSync(paths.fnShimPath)).toBe(paths.binaryPath);
expect(readlinkSync(paths.fusionShimPath)).toBe(paths.binaryPath);
});
it("removeLocalFnShims only drops shims that point at the local install", () => {
const home = makeTemp("fn-bin-home-rm-");
const dist = makeTemp("fn-bin-dist-rm-");
const logs: string[] = [];
writeFileSync(join(dist, "fn"), "bin");
mkdirSync(join(dist, "client"), { recursive: true });
writeFileSync(join(dist, "client", "index.html"), "<html></html>");
const paths = resolveFnBinaryLocalPaths(home);
installLocalFnBinary(dist, () => {}, paths);
// Unrelated shim in the same bin dir must survive.
const otherShim = join(paths.binDir, "other-tool");
writeFileSync(otherShim, "keep-me");
const result = removeLocalFnShims((_stream, text) => logs.push(text), paths);
expect(result.removed).toEqual(expect.arrayContaining([paths.fnShimPath, paths.fusionShimPath]));
expect(logs.some((line) => line.includes("Removed local shim"))).toBe(true);
// other-tool is a plain file, not inspected as a fusion shim path — still present.
expect(() => readlinkSync(paths.fnShimPath)).toThrow();
});
});

View File

@@ -0,0 +1,390 @@
/*
FNXC:SystemPanelFnBinary 2026-07-15-09:54:
System panel operators need to (1) build the standalone `fn` binary from a Fusion
source checkout and install it as the default PATH binary, and (2) switch back to
the published global npm install. These helpers encode the install layout and
process steps used by POST /system/fn-binary/link-local and
POST /system/fn-binary/use-global so the route can stream every step into the
shared System job log viewer.
*/
import { spawn } from "node:child_process";
import {
chmodSync,
cpSync,
existsSync,
lstatSync,
mkdirSync,
readlinkSync,
rmSync,
symlinkSync,
unlinkSync,
} from "node:fs";
import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { FN_INSTALL_NPM, FN_NPM_PACKAGE } from "@fusion/core";
/** Hard cap on build/install child processes (full workspace + bun compile is long). */
export const FN_BINARY_JOB_MAX_MS = 30 * 60_000;
/** Hard cap on `npm install -g` alone. */
export const FN_BINARY_NPM_MAX_MS = 180_000;
const MAX_OUTPUT_BYTES = 64 * 1024;
export type FnBinaryLogStream = "stdout" | "stderr" | "system";
export type FnBinaryLogFn = (stream: FnBinaryLogStream, text: string) => void;
export interface FnBinaryLocalPaths {
/** `~/.local/share/fusion` — binary + client + runtime co-located here. */
installDir: string;
/** `~/.local/bin` — earlier than Homebrew on typical macOS PATH. */
binDir: string;
binaryPath: string;
fnShimPath: string;
fusionShimPath: string;
}
export function resolveFnBinaryLocalPaths(home = homedir()): FnBinaryLocalPaths {
const installDir = join(home, ".local", "share", "fusion");
const binDir = join(home, ".local", "bin");
return {
installDir,
binDir,
binaryPath: join(installDir, "fn"),
fnShimPath: join(binDir, "fn"),
fusionShimPath: join(binDir, "fusion"),
};
}
export interface ChildRunResult {
exitCode: number | null;
signal: NodeJS.Signals | null;
timedOut: boolean;
stdout: string;
stderr: string;
command: string;
}
/**
* Run a command, streaming line-oriented output through `onLog`. Never throws
* for non-zero exits — caller inspects exitCode. Uses shell only on win32 so
* `.cmd` shims (npm/pnpm/bun) resolve, matching the CLI-binary install path.
*/
export function runStreamingCommand(
command: string,
args: string[],
options: {
cwd?: string;
env?: NodeJS.ProcessEnv;
timeoutMs: number;
onLog: FnBinaryLogFn;
shell?: boolean;
},
): Promise<ChildRunResult> {
const startedAt = Date.now();
const commandLabel = [command, ...args].join(" ");
options.onLog("system", `$ ${commandLabel}`);
return new Promise((resolvePromise) => {
let stdout = "";
let stderr = "";
let timedOut = false;
let partialOut = "";
let partialErr = "";
const flushLines = (target: "stdout" | "stderr", chunk: string): void => {
const bucket = target === "stdout" ? partialOut : partialErr;
const combined = bucket + chunk;
const parts = combined.split(/\r?\n/);
const nextPartial = parts.pop() ?? "";
if (target === "stdout") partialOut = nextPartial;
else partialErr = nextPartial;
for (const line of parts) {
options.onLog(target, line);
if (target === "stdout") {
if (stdout.length < MAX_OUTPUT_BYTES) {
stdout += `${line}\n`.slice(0, MAX_OUTPUT_BYTES - stdout.length);
}
} else if (stderr.length < MAX_OUTPUT_BYTES) {
stderr += `${line}\n`.slice(0, MAX_OUTPUT_BYTES - stderr.length);
}
}
};
const child = spawn(command, args, {
cwd: options.cwd,
env: options.env ?? process.env,
stdio: ["ignore", "pipe", "pipe"],
shell: options.shell ?? process.platform === "win32",
});
const timer = setTimeout(() => {
timedOut = true;
try {
if (process.platform === "win32" && typeof child.pid === "number") {
spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" }).on("error", () => {});
} else {
child.kill("SIGKILL");
}
} catch {
// Best-effort kill only.
}
}, options.timeoutMs);
timer.unref?.();
child.stdout?.on("data", (chunk: Buffer) => flushLines("stdout", chunk.toString("utf8")));
child.stderr?.on("data", (chunk: Buffer) => flushLines("stderr", chunk.toString("utf8")));
child.on("error", (err) => {
clearTimeout(timer);
options.onLog("stderr", err.message);
resolvePromise({
exitCode: null,
signal: null,
timedOut,
stdout,
stderr: stderr || err.message,
command: commandLabel,
});
});
child.on("close", (exitCode, signal) => {
clearTimeout(timer);
if (partialOut) {
options.onLog("stdout", partialOut);
partialOut = "";
}
if (partialErr) {
options.onLog("stderr", partialErr);
partialErr = "";
}
if (timedOut) {
options.onLog("system", `Command timed out after ${Math.round(options.timeoutMs / 1000)}s`);
}
options.onLog(
"system",
`Exit ${exitCode ?? signal ?? "unknown"} (${Date.now() - startedAt}ms)`,
);
resolvePromise({
exitCode,
signal,
timedOut,
stdout,
stderr,
command: commandLabel,
});
});
});
}
/** Prefer an explicit path, then common bun install locations, then PATH. */
export function resolveBunExecutable(): string {
if (process.env.BUN_INSTALL) {
const candidate = join(process.env.BUN_INSTALL, "bin", process.platform === "win32" ? "bun.exe" : "bun");
if (existsSync(candidate)) return candidate;
}
const homeBun = join(homedir(), ".bun", "bin", process.platform === "win32" ? "bun.exe" : "bun");
if (existsSync(homeBun)) return homeBun;
return "bun";
}
/**
* Copy the built standalone binary + co-located client/runtime assets into
* `~/.local/share/fusion` and point `~/.local/bin/{fn,fusion}` at it.
*/
export function installLocalFnBinary(
distDir: string,
onLog: FnBinaryLogFn,
paths: FnBinaryLocalPaths = resolveFnBinaryLocalPaths(),
): void {
const srcBinary = join(distDir, process.platform === "win32" ? "fn.exe" : "fn");
const srcClient = join(distDir, "client");
const srcRuntime = join(distDir, "runtime");
if (!existsSync(srcBinary)) {
throw new Error(`Built binary missing at ${srcBinary}. Did the Bun compile step fail?`);
}
if (!existsSync(srcClient)) {
throw new Error(`Dashboard client assets missing at ${srcClient}.`);
}
mkdirSync(paths.installDir, { recursive: true });
mkdirSync(paths.binDir, { recursive: true });
onLog("system", `Installing binary → ${paths.binaryPath}`);
cpSync(srcBinary, paths.binaryPath);
try {
chmodSync(paths.binaryPath, 0o755);
} catch {
// Windows / restricted FS — ignore.
}
onLog("system", `Installing client assets → ${join(paths.installDir, "client")}`);
rmSync(join(paths.installDir, "client"), { recursive: true, force: true });
cpSync(srcClient, join(paths.installDir, "client"), { recursive: true });
if (existsSync(srcRuntime)) {
onLog("system", `Installing runtime assets → ${join(paths.installDir, "runtime")}`);
rmSync(join(paths.installDir, "runtime"), { recursive: true, force: true });
cpSync(srcRuntime, join(paths.installDir, "runtime"), { recursive: true });
}
for (const shim of [paths.fnShimPath, paths.fusionShimPath]) {
try {
if (existsSync(shim) || isSymlink(shim)) {
unlinkSync(shim);
}
} catch {
// Replace below; a missing prior shim is fine.
}
onLog("system", `Link ${shim} → ${paths.binaryPath}`);
symlinkSync(paths.binaryPath, shim);
}
onLog("system", `Default fn is now ${paths.fnShimPath} (PATH should prefer ~/.local/bin).`);
}
function isSymlink(path: string): boolean {
try {
return lstatSync(path).isSymbolicLink();
} catch {
return false;
}
}
/**
* Remove PATH shims that point at our local install so a later entry (Homebrew
* npm global, etc.) becomes the default again.
*/
export function removeLocalFnShims(
onLog: FnBinaryLogFn,
paths: FnBinaryLocalPaths = resolveFnBinaryLocalPaths(),
): { removed: string[] } {
const removed: string[] = [];
const installReal = resolve(paths.binaryPath);
for (const shim of [paths.fnShimPath, paths.fusionShimPath]) {
try {
if (!existsSync(shim) && !isSymlink(shim)) {
onLog("system", `No shim at ${shim}`);
continue;
}
if (isSymlink(shim)) {
const target = resolve(dirname(shim), readlinkSync(shim));
if (target === installReal || target.startsWith(paths.installDir + "/") || target === paths.installDir) {
unlinkSync(shim);
removed.push(shim);
onLog("system", `Removed local shim ${shim}`);
continue;
}
onLog("system", `Leaving ${shim} (points at ${target}, not the local Fusion install)`);
continue;
}
// Non-symlink binary in ~/.local/bin — only remove if identical path under installDir.
if (resolve(shim) === installReal) {
unlinkSync(shim);
removed.push(shim);
onLog("system", `Removed ${shim}`);
} else {
onLog("system", `Leaving ${shim} (not a local Fusion install shim)`);
}
} catch (err) {
onLog("stderr", `Failed to inspect/remove ${shim}: ${err instanceof Error ? err.message : String(err)}`);
}
}
return { removed };
}
/**
* Full link-local pipeline: workspace full build → bun compile → install under
* ~/.local. Requires a Fusion source checkout root.
*/
export async function runLinkLocalFnBinary(
sourceRoot: string,
onLog: FnBinaryLogFn,
): Promise<{ success: boolean; error?: string }> {
const buildScript = join(sourceRoot, "scripts", "build-workspace.mjs");
const cliBuild = join(sourceRoot, "packages", "cli", "build.ts");
const distDir = join(sourceRoot, "packages", "cli", "dist");
if (!existsSync(buildScript)) {
return { success: false, error: `Build script missing: ${buildScript}` };
}
if (!existsSync(cliBuild)) {
return { success: false, error: `CLI build entry missing: ${cliBuild}` };
}
onLog("system", "Step 1/3 — full workspace package build…");
const build = await runStreamingCommand(process.execPath, [buildScript, "--full"], {
cwd: sourceRoot,
timeoutMs: FN_BINARY_JOB_MAX_MS,
onLog,
shell: false,
env: { ...process.env, FUSION_SKIP_STARTUP_UPDATE_PREFLIGHT: "1", FORCE_COLOR: "0" },
});
if (build.timedOut || build.exitCode !== 0) {
return {
success: false,
error: `Workspace build failed (exit ${build.exitCode ?? build.signal ?? "timeout"})`,
};
}
onLog("system", "Step 2/3 — compile standalone fn binary with Bun…");
const bun = resolveBunExecutable();
const compile = await runStreamingCommand(bun, ["run", cliBuild], {
cwd: sourceRoot,
timeoutMs: FN_BINARY_JOB_MAX_MS,
onLog,
shell: process.platform === "win32",
env: { ...process.env, FORCE_COLOR: "0" },
});
if (compile.timedOut || compile.exitCode !== 0) {
return {
success: false,
error: `Bun compile failed (exit ${compile.exitCode ?? compile.signal ?? "timeout"}). Is bun installed?`,
};
}
onLog("system", "Step 3/3 — install as default local fn…");
try {
installLocalFnBinary(distDir, onLog);
} catch (err) {
return { success: false, error: err instanceof Error ? err.message : String(err) };
}
onLog("system", "Local fn binary is ready. Open a new shell if `which fn` still points at npm.");
return { success: true };
}
/**
* Remove local-build PATH shims, then reinstall the published package globally
* so PATH falls back to the npm global binary.
*/
export async function runUseGlobalFnBinary(
onLog: FnBinaryLogFn,
): Promise<{ success: boolean; error?: string; permissionsHint?: string }> {
onLog("system", "Step 1/2 — remove local-build shims from ~/.local/bin…");
removeLocalFnShims(onLog);
onLog("system", `Step 2/2 — ${FN_INSTALL_NPM}…`);
const install = await runStreamingCommand("npm", ["install", "-g", FN_NPM_PACKAGE], {
timeoutMs: FN_BINARY_NPM_MAX_MS,
onLog,
shell: process.platform === "win32",
});
if (install.timedOut || install.exitCode !== 0) {
const combined = `${install.stdout}\n${install.stderr}`;
const eaccesHit = /EACCES|permission denied|Operation not permitted/i.test(combined);
return {
success: false,
error: `npm install failed (exit ${install.exitCode ?? install.signal ?? "timeout"})`,
permissionsHint: eaccesHit
? "npm reported a permissions error. On macOS/Linux this usually means npm's global prefix needs `sudo` or a fix to your npm prefix (https://docs.npmjs.com/resolving-eacces-permissions-errors-when-installing-packages-globally)."
: undefined,
};
}
onLog("system", "Global npm fn install complete. Verify with `which fn` / `fn --version`.");
return { success: true };
}

View File

@@ -11,6 +11,18 @@ import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
import { request as performRequest } from "../../test-request.js";
import { registerSystemRoutes, __resetSystemJobsForTests } from "../register-system-routes.js";
const mockRunLinkLocalFnBinary = vi.fn();
const mockRunUseGlobalFnBinary = vi.fn();
vi.mock("../../fn-binary-local-install.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../fn-binary-local-install.js")>();
return {
...actual,
runLinkLocalFnBinary: (...args: unknown[]) => mockRunLinkLocalFnBinary(...args),
runUseGlobalFnBinary: (...args: unknown[]) => mockRunUseGlobalFnBinary(...args),
};
});
type App = Parameters<typeof performRequest>[0];
@@ -139,6 +151,16 @@ afterAll(() => {
beforeEach(() => {
__resetSystemJobsForTests();
agentStoreState.agents = [];
mockRunLinkLocalFnBinary.mockReset();
mockRunUseGlobalFnBinary.mockReset();
mockRunLinkLocalFnBinary.mockImplementation(async (_root: string, onLog: (s: string, t: string) => void) => {
onLog("system", "mock-link-local");
return { success: true };
});
mockRunUseGlobalFnBinary.mockImplementation(async (onLog: (s: string, t: string) => void) => {
onLog("system", "mock-use-global");
return { success: true };
});
});
describe("GET /system/info", () => {
@@ -167,10 +189,19 @@ describe("GET /system/info", () => {
const res = await getJson(app, "/api/system/info");
expect(res.body.restartSupported).toBe(true);
expect(res.body.rebuildSupported).toBe(true);
expect(res.body.fnBinaryLinkLocalSupported).toBe(true);
expect(res.body.fnBinaryUseGlobalSupported).toBe(true);
expect(res.body.sourceWorkspaceRoot).toBe("/checkout");
expect(res.body.logsSupported).toBe(true);
expect(res.body.engineAvailable).toBe(true);
});
it("advertises use-global without a source checkout but not link-local", async () => {
const { app } = createApp();
const res = await getJson(app, "/api/system/info");
expect(res.body.fnBinaryLinkLocalSupported).toBe(false);
expect(res.body.fnBinaryUseGlobalSupported).toBe(true);
});
});
describe("POST /system/restart", () => {
@@ -266,6 +297,86 @@ describe("POST /system/rebuild", () => {
});
});
/*
FNXC:SystemPanelFnBinary 2026-07-15-09:54:
Contract tests for System panel fn-binary actions. Link-local is source-gated;
use-global is always available and serializes against rebuild via activeJob.
Helpers are mocked so tests never run a real npm install or workspace build.
*/
describe("POST /system/fn-binary/link-local", () => {
it("409s when not running from a source checkout", async () => {
const { app } = createApp({ options: { systemControl: { supervised: true, requestRestart: vi.fn(() => true) } } });
const res = await postJson(app, "/api/system/fn-binary/link-local");
expect(res.status).toBe(409);
expect(res.body.error).toMatch(/source checkout/i);
expect(mockRunLinkLocalFnBinary).not.toHaveBeenCalled();
});
it("streams a succeeded job when the host is a source checkout", async () => {
const { app } = createApp({
options: {
systemControl: {
supervised: true,
requestRestart: vi.fn(() => true),
sourceWorkspaceRoot: "/Users/dev/fusion",
},
},
});
const started = await postJson(app, "/api/system/fn-binary/link-local");
expect(started.status).toBe(202);
expect(started.body.kind).toBe("fn-binary");
expect(started.body.scope).toBe("link-local");
await vi.waitFor(async () => {
const current = await getJson(app, "/api/system/rebuild/current");
expect(current.body.job?.status).toBe("succeeded");
}, { timeout: 5_000, interval: 50 });
expect(mockRunLinkLocalFnBinary).toHaveBeenCalledWith("/Users/dev/fusion", expect.any(Function));
const current = await getJson(app, "/api/system/rebuild/current");
const texts = current.body.job.lines.map((line: { text: string }) => line.text);
expect(texts).toContain("mock-link-local");
});
});
describe("POST /system/fn-binary/use-global", () => {
it("starts a streaming job without requiring a source checkout", async () => {
const { app } = createApp();
const started = await postJson(app, "/api/system/fn-binary/use-global");
expect(started.status).toBe(202);
expect(started.body.kind).toBe("fn-binary");
expect(started.body.scope).toBe("use-global");
expect(started.body.status).toBe("running");
await vi.waitFor(async () => {
const current = await getJson(app, "/api/system/rebuild/current");
expect(current.body.job?.status).toBe("succeeded");
}, { timeout: 5_000, interval: 50 });
const current = await getJson(app, "/api/system/rebuild/current");
expect(current.body.job.kind).toBe("fn-binary");
expect(current.body.job.scope).toBe("use-global");
expect(Array.isArray(current.body.job.lines)).toBe(true);
expect(current.body.job.lines.some((line: { text: string }) => line.text === "mock-use-global")).toBe(true);
});
it("rejects concurrent jobs while rebuild is running", async () => {
const root = createFakeSourceCheckout("setTimeout(() => {}, 2000);\n");
const { app } = createApp({
options: { systemControl: { supervised: true, requestRestart: vi.fn(() => true), sourceWorkspaceRoot: root } },
});
const rebuild = await postJson(app, "/api/system/rebuild", { scope: "app", restart: false });
expect(rebuild.status).toBe(202);
const second = await postJson(app, "/api/system/fn-binary/use-global");
expect(second.status).toBe(409);
await vi.waitFor(async () => {
const current = await getJson(app, "/api/system/rebuild/current");
expect(current.body.job.status).not.toBe("running");
}, { timeout: 10_000, interval: 100 });
});
});
describe("GET /system/logs", () => {
it("409s without a log provider", async () => {
const { app } = createApp();

View File

@@ -4,6 +4,10 @@ import { join } from "node:path";
import type { Request, Response } from "express";
import { superviseSpawn, AgentStore } from "@fusion/core";
import { ApiError, badRequest, notFound } from "../api-error.js";
import {
runLinkLocalFnBinary,
runUseGlobalFnBinary,
} from "../fn-binary-local-install.js";
import { writeSSEEvent } from "../sse-buffer.js";
import type { ApiRoutesContext } from "./types.js";
import type { SystemLogEntry } from "../server.js";
@@ -24,10 +28,16 @@ in-dashboard debug/maintenance controls:
- POST /system/agents/restart-all pause+resume every active agent (stops active runs)
- POST /system/plugins/reload-all hot-reload every started plugin
FNXC:SystemPanelFnBinary 2026-07-15-09:54:
- POST /system/fn-binary/link-local build standalone `fn` from source + install as default
(~/.local/share/fusion + ~/.local/bin shims). Source
checkout only (same gate as rebuild).
- POST /system/fn-binary/use-global remove local shims and `npm install -g runfusion.ai`.
Restart/rebuild only work when the host CLI injected `systemControl`
(supervised process + source checkout); every route degrades to an explicit
409 with a reason instead of failing silently, so the UI can disable controls.
Rebuild jobs are serialized — one at a time — because concurrent workspace
Rebuild and fn-binary jobs share one active-job slot — concurrent workspace
builds would corrupt each other's dist output.
*/
@@ -86,6 +96,9 @@ function startSseHeartbeat(res: Response): () => void {
}
type RebuildScope = "app" | "full" | "plugins";
type FnBinaryScope = "link-local" | "use-global";
type SystemJobScope = RebuildScope | FnBinaryScope;
type SystemJobKind = "rebuild" | "fn-binary";
interface SystemJobLine {
i: number;
@@ -96,8 +109,8 @@ interface SystemJobLine {
interface SystemJob {
id: string;
kind: "rebuild";
scope: RebuildScope;
kind: SystemJobKind;
scope: SystemJobScope;
restartAfter: boolean;
status: "running" | "succeeded" | "failed";
startedAt: number;
@@ -244,10 +257,20 @@ export function registerSystemRoutes(ctx: ApiRoutesContext, deps: SystemRouteDep
/** GET /api/system/info — capability discovery for the System panel. */
router.get("/system/info", (_req, res) => {
const fromSource = Boolean(systemControl?.sourceWorkspaceRoot);
res.json({
supervised: systemControl?.supervised ?? false,
restartSupported: systemControl?.supervised ?? false,
rebuildSupported: Boolean(systemControl?.sourceWorkspaceRoot),
rebuildSupported: fromSource,
/*
FNXC:SystemPanelFnBinary 2026-07-15-09:54:
Build-and-link-local only makes sense from a Fusion source checkout under
a supervised/dev host (`pnpm dev` / `pnpm local` / `fn dashboard` from
source). Packaged installs hide that control; use-global remains always
available so operators can drop a prior local link without a checkout.
*/
fnBinaryLinkLocalSupported: fromSource,
fnBinaryUseGlobalSupported: true,
sourceWorkspaceRoot: systemControl?.sourceWorkspaceRoot,
logsSupported: Boolean(systemLogs),
// Engine restart needs both the manager and CentralCore (see the
@@ -591,4 +614,113 @@ export function registerSystemRoutes(ctx: ApiRoutesContext, deps: SystemRouteDep
rethrowAsApiError(err, "Failed to reload plugins");
}
});
/**
* Begin a serialized system job. Shares the activeJob slot with rebuild so
* concurrent workspace builds can't clobber each other's dist.
*/
function beginSystemJob(kind: SystemJobKind, scope: SystemJobScope, restartAfter = false): SystemJob {
if (activeJob) {
throw new ApiError(409, `A ${activeJob.kind}/${activeJob.scope} job is already running`);
}
const job: SystemJob = {
id: randomUUID(),
kind,
scope,
restartAfter,
status: "running",
startedAt: Date.now(),
droppedLines: 0,
lines: [],
subscribers: new Set(),
};
activeJob = job;
jobsById.set(job.id, job);
if (jobsById.size > 5) {
const oldest = jobsById.keys().next().value;
if (oldest && oldest !== job.id) jobsById.delete(oldest);
}
return job;
}
/*
FNXC:SystemPanelFnBinary 2026-07-15-09:54:
Build the standalone Bun `fn` binary from this source checkout and install it
as the default PATH binary under ~/.local. Gated on sourceWorkspaceRoot so
packaged npm/desktop installs never offer a button that cannot succeed.
Output streams through the shared /system/jobs/:id/stream SSE used by rebuild.
*/
router.post("/system/fn-binary/link-local", (req, res) => {
if (rejectCrossOrigin(req, res)) return;
const root = systemControl?.sourceWorkspaceRoot;
if (!root) {
throw new ApiError(
409,
"Build & link local fn is only available when running from a Fusion source checkout in dev mode",
);
}
const job = beginSystemJob("fn-binary", "link-local", false);
appendJobLine(job, "system", "Building and linking local fn binary from source…");
log.info("System fn-binary link-local started", { jobId: job.id, root });
void runLinkLocalFnBinary(root, (stream, text) => appendJobLine(job, stream, text))
.then((result) => {
if (!result.success) {
appendJobLine(job, "system", result.error ?? "Link-local failed");
finishJob(job, "failed", { exitCode: 1, error: result.error });
log.warn("System fn-binary link-local failed", { jobId: job.id, error: result.error });
return;
}
appendJobLine(job, "system", "Link-local completed.");
finishJob(job, "succeeded", { exitCode: 0 });
log.info("System fn-binary link-local succeeded", { jobId: job.id });
})
.catch((err) => {
const message = err instanceof Error ? err.message : String(err);
appendJobLine(job, "system", `Link-local failed: ${message}`);
finishJob(job, "failed", { error: message });
log.error("System fn-binary link-local crashed", { jobId: job.id, error: message });
});
res.status(202).json(jobSnapshot(job, false));
});
/*
FNXC:SystemPanelFnBinary 2026-07-15-09:54:
Drop ~/.local/bin shims that point at the local Fusion install and reinstall
the published `runfusion.ai` package globally so PATH returns to npm.
Available outside source checkouts so an operator can undo a prior link-local.
*/
router.post("/system/fn-binary/use-global", (req, res) => {
if (rejectCrossOrigin(req, res)) return;
const job = beginSystemJob("fn-binary", "use-global", false);
appendJobLine(job, "system", "Switching default fn to the global npm install…");
log.info("System fn-binary use-global started", { jobId: job.id });
void runUseGlobalFnBinary((stream, text) => appendJobLine(job, stream, text))
.then((result) => {
if (!result.success) {
if (result.permissionsHint) {
appendJobLine(job, "system", result.permissionsHint);
}
appendJobLine(job, "system", result.error ?? "Use-global failed");
finishJob(job, "failed", { exitCode: 1, error: result.error });
log.warn("System fn-binary use-global failed", { jobId: job.id, error: result.error });
return;
}
appendJobLine(job, "system", "Use-global completed.");
finishJob(job, "succeeded", { exitCode: 0 });
log.info("System fn-binary use-global succeeded", { jobId: job.id });
})
.catch((err) => {
const message = err instanceof Error ? err.message : String(err);
appendJobLine(job, "system", `Use-global failed: ${message}`);
finishJob(job, "failed", { error: message });
log.error("System fn-binary use-global crashed", { jobId: job.id, error: message });
});
res.status(202).json(jobSnapshot(job, false));
});
}