diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index 47c7b2c66b..6589839ec2 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -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 { + return api("/system/fn-binary/link-local", { method: "POST" }); +} + +export function startFnBinaryUseGlobal(): Promise { + return api("/system/fn-binary/use-global", { method: "POST" }); +} + export function fetchCurrentSystemRebuild(): Promise<{ job: SystemRebuildJobSnapshot | null }> { return api<{ job: SystemRebuildJobSnapshot | null }>("/system/rebuild/current"); } diff --git a/packages/dashboard/app/components/command-center/__tests__/SystemControlsArea.test.tsx b/packages/dashboard/app/components/command-center/__tests__/SystemControlsArea.test.tsx index c61b479601..f9e286b3a5 100644 --- a/packages/dashboard/app/components/command-center/__tests__/SystemControlsArea.test.tsx +++ b/packages/dashboard/app/components/command-center/__tests__/SystemControlsArea.test.tsx @@ -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 = {}) { 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(); + 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(); + 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(); + }); }); diff --git a/packages/dashboard/app/components/command-center/areas/SystemControlsArea.tsx b/packages/dashboard/app/components/command-center/areas/SystemControlsArea.tsx index 15ccd57db2..4940f26c54 100644 --- a/packages/dashboard/app/components/command-center/areas/SystemControlsArea.tsx +++ b/packages/dashboard/app/components/command-center/areas/SystemControlsArea.tsx @@ -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(null); const [jobLines, setJobLines] = useState([]); const jobOutputRef = useRef(null); + const jobSectionRef = useRef(null); const [restartPhase, setRestartPhase] = useState(null); const prevPidRef = useRef(null); @@ -120,6 +136,8 @@ export function SystemControlsArea({ projectId, addToast }: SystemControlsAreaPr const [logEntries, setLogEntries] = useState([]); const logOutputRef = useRef(null); + const [updateCheckResult, setUpdateCheckResult] = useState(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
 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 (
     <>
       
@@ -669,6 +824,27 @@ export function SystemControlsArea({ projectId, addToast }: SystemControlsAreaPr
) : null} + {updateCheckResult && !updateCheckResult.error ? ( +
+ + {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, + })} + +
+ ) : null} +
{controls.filter((control) => !("hidden" in control && control.hidden)).map((control) => (
@@ -694,9 +870,9 @@ export function SystemControlsArea({ projectId, addToast }: SystemControlsAreaPr
{job ? ( -
+
-

{t("systemControls.rebuildOutput", "Rebuild output")}

+

{jobTitle}

{jobStatusLabel} diff --git a/packages/dashboard/src/__tests__/fn-binary-local-install.test.ts b/packages/dashboard/src/__tests__/fn-binary-local-install.test.ts new file mode 100644 index 0000000000..ba229f8158 --- /dev/null +++ b/packages/dashboard/src/__tests__/fn-binary-local-install.test.ts @@ -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"), ""); + 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"), ""); + + 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(); + }); +}); diff --git a/packages/dashboard/src/fn-binary-local-install.ts b/packages/dashboard/src/fn-binary-local-install.ts new file mode 100644 index 0000000000..122ef656fe --- /dev/null +++ b/packages/dashboard/src/fn-binary-local-install.ts @@ -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 { + 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 }; +} diff --git a/packages/dashboard/src/routes/__tests__/register-system-routes.test.ts b/packages/dashboard/src/routes/__tests__/register-system-routes.test.ts index eb5383dc65..e32ec18ab5 100644 --- a/packages/dashboard/src/routes/__tests__/register-system-routes.test.ts +++ b/packages/dashboard/src/routes/__tests__/register-system-routes.test.ts @@ -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(); + return { + ...actual, + runLinkLocalFnBinary: (...args: unknown[]) => mockRunLinkLocalFnBinary(...args), + runUseGlobalFnBinary: (...args: unknown[]) => mockRunUseGlobalFnBinary(...args), + }; +}); + type App = Parameters[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(); diff --git a/packages/dashboard/src/routes/register-system-routes.ts b/packages/dashboard/src/routes/register-system-routes.ts index cf13479393..340da44163 100644 --- a/packages/dashboard/src/routes/register-system-routes.ts +++ b/packages/dashboard/src/routes/register-system-routes.ts @@ -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)); + }); }