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:
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
390
packages/dashboard/src/fn-binary-local-install.ts
Normal file
390
packages/dashboard/src/fn-binary-local-install.ts
Normal 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 };
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user