diff --git a/.changeset/improve-local-startup-ux.md b/.changeset/improve-local-startup-ux.md new file mode 100644 index 000000000..9aeebee8b --- /dev/null +++ b/.changeset/improve-local-startup-ux.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Improve local dashboard startup by replacing the default full workspace prebuild with a dashboard-client prebuild, adding explicit prebuild modes, and making update notices clearer for source checkouts. diff --git a/docs/contributing.md b/docs/contributing.md index 1e7094bfe..fa7c35878 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -41,8 +41,13 @@ pnpm build:all # full recursive build including desktop/mobile ## Development Workflow ```bash -pnpm dev # build + run CLI entrypoint in dev mode +pnpm local # fast local dashboard/API startup on a safe localhost port +pnpm local --engine # fast local startup with the AI engine enabled +pnpm local --prebuild # local dashboard/API startup with an explicit prebuild level +pnpm dev # source-mode CLI; dashboard gets a client-only prebuild, other commands skip it +FUSION_DEV_PREBUILD=full pnpm dev dashboard # production-like full workspace prebuild pnpm dev:ui # dashboard dev server only +pnpm dev:hmr # dashboard API + Vite HMR UI, with no startup prebuild pnpm lint # lint all packages pnpm test # changed-only workspace tests (falls back to full suite in safety contexts) pnpm test:full # full workspace quality gate (clean-worktree compatible) diff --git a/packages/cli/src/__tests__/dev-with-memory-lib.test.ts b/packages/cli/src/__tests__/dev-with-memory-lib.test.ts index ac70c301c..7911b8d38 100644 --- a/packages/cli/src/__tests__/dev-with-memory-lib.test.ts +++ b/packages/cli/src/__tests__/dev-with-memory-lib.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it } from "vitest"; -import { buildDevNodeArgs } from "../../../../scripts/dev-with-memory-lib.mjs"; +import { + buildForwardedDevArgs, + buildDevNodeArgs, + getPrebuildCommand, + normalizePrebuildMode, + parseDevWrapperArgs, + resolvePrebuildMode, +} from "../../../../scripts/dev-with-memory-lib.mjs"; describe("buildDevNodeArgs", () => { it("enables source-condition resolution before loading the tsx runtime", () => { @@ -25,3 +32,63 @@ describe("buildDevNodeArgs", () => { ]); }); }); + +describe("dev-with-memory prebuild options", () => { + it("strips wrapper-only prebuild and inspector flags before forwarding CLI args", () => { + const parsed = parseDevWrapperArgs( + ["--inspect=9230", "--prebuild=none", "dashboard", "--port", "4050"], + {}, + ); + + expect(parsed).toEqual({ + inspectFlags: ["--inspect=9230"], + args: ["dashboard", "--port", "4050"], + requestedPrebuild: "none", + }); + }); + + it("rejects explicit empty prebuild modes", () => { + expect(() => normalizePrebuildMode("")).toThrow(/Invalid prebuild mode/); + expect(() => parseDevWrapperArgs(["--prebuild=", "dashboard"], {})).toThrow(/Invalid prebuild mode/); + }); + + it("does not inject a dev host when --host=value is already present", () => { + expect(buildForwardedDevArgs(["dashboard", "--host=127.0.0.1"])).toEqual([ + "dashboard", + "--host=127.0.0.1", + ]); + }); + + it("injects a LAN-reachable dev host for dashboard startup without a host override", () => { + expect(buildForwardedDevArgs(["dashboard", "--port", "4050"])).toEqual([ + "dashboard", + "--port", + "4050", + "--host", + "0.0.0.0", + ]); + }); + + it("defaults dashboard startup to client-only prebuild instead of full workspace build", () => { + expect(resolvePrebuildMode("auto", ["dashboard", "--port", "4050"])).toBe("client"); + expect(getPrebuildCommand("client")).toEqual({ + command: "pnpm", + args: ["--filter", "@fusion/dashboard", "build:client"], + label: "dashboard client build", + }); + }); + + it("skips prebuild by default for non-dashboard CLI commands", () => { + expect(resolvePrebuildMode("auto", ["task", "list"])).toBe("none"); + expect(getPrebuildCommand("none")).toBeNull(); + }); + + it("keeps full workspace prebuild available when requested", () => { + expect(resolvePrebuildMode("full", ["dashboard"])).toBe("full"); + expect(getPrebuildCommand("full")).toEqual({ + command: "pnpm", + args: ["build"], + label: "workspace build", + }); + }); +}); diff --git a/packages/cli/src/commands/dashboard-tui/__tests__/app.test.tsx b/packages/cli/src/commands/dashboard-tui/__tests__/app.test.tsx index 75d3443c6..b284ad87b 100644 --- a/packages/cli/src/commands/dashboard-tui/__tests__/app.test.tsx +++ b/packages/cli/src/commands/dashboard-tui/__tests__/app.test.tsx @@ -166,8 +166,15 @@ async function flushFrames() { await Promise.resolve(); } +async function waitForFrameUpdateAfterInput() { + // Ink can schedule the frame triggered by stdin on the next timer tick. + await new Promise((resolve) => setTimeout(resolve, 25)); + await flushFrames(); +} + async function focusSettingsDetailPane(stdin: { write: (chunk: string) => void }, lastFrame: () => string | undefined) { stdin.write("\u001b[C"); + await waitForFrameUpdateAfterInput(); await waitForFrameContains(lastFrame, "[C/V/X/P/L/U/K/R] remote actions"); } diff --git a/packages/cli/src/commands/dashboard-tui/app.tsx b/packages/cli/src/commands/dashboard-tui/app.tsx index c543f5627..7731bffea 100644 --- a/packages/cli/src/commands/dashboard-tui/app.tsx +++ b/packages/cli/src/commands/dashboard-tui/app.tsx @@ -236,7 +236,7 @@ function SplashScreen({ loadingStatus, updateStatus }: { loadingStatus: string; {FUSION_URL} {`v${FUSION_VERSION}`} {updateStatus?.updateAvailable && ( - {`Update available: v${updateStatus.currentVersion} → v${updateStatus.latestVersion}. Run \`npm install -g @runfusion/fusion\`.`} + {`Update available: v${updateStatus.currentVersion} → v${updateStatus.latestVersion}. Run \`fn update\` for an installed CLI, or pull this source checkout.`} )} diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts index 03c86ae38..81a8e1d17 100644 --- a/packages/cli/src/commands/dashboard.ts +++ b/packages/cli/src/commands/dashboard.ts @@ -147,7 +147,7 @@ function formatUpdateMessage(updateStatus: StartupUpdateStatus | null): string | return null; } - return `⬆ Update available: v${updateStatus.latestVersion} (current: v${updateStatus.currentVersion})`; + return `⬆ Update available: v${updateStatus.latestVersion} (current: v${updateStatus.currentVersion}). Run \`fn update\` for an installed CLI, or pull the source checkout.`; } export class StreamedLogBuffer { diff --git a/packages/dashboard/app/components/UpdateAvailableBanner.tsx b/packages/dashboard/app/components/UpdateAvailableBanner.tsx index 5c7d36705..e6e4c0503 100644 --- a/packages/dashboard/app/components/UpdateAvailableBanner.tsx +++ b/packages/dashboard/app/components/UpdateAvailableBanner.tsx @@ -11,8 +11,8 @@ export function UpdateAvailableBanner({ latestVersion, currentVersion, onDismiss return (

- Update available: v{latestVersion} (current: v{currentVersion}). Run npm i -g @runfusion/fusion to - update. {" "} + Update available: v{latestVersion} (current: v{currentVersion}). Run fn update for an installed CLI, + or pull this source checkout.{" "} { ); expect(screen.getByText(/Update available: v0.7.0 \(current: v0.6.0\)/)).toBeInTheDocument(); - expect(screen.getByText("npm i -g @runfusion/fusion")).toBeInTheDocument(); + expect(screen.getByText("fn update")).toBeInTheDocument(); + expect(screen.getByText(/or pull this source checkout/)).toBeInTheDocument(); expect(screen.getByRole("link", { name: "Release notes" })).toHaveAttribute( "href", "https://github.com/Runfusion/Fusion/blob/main/CHANGELOG.md", diff --git a/scripts/dev-hmr.mjs b/scripts/dev-hmr.mjs index ee3c5189b..7db9d2b2d 100644 --- a/scripts/dev-hmr.mjs +++ b/scripts/dev-hmr.mjs @@ -5,8 +5,8 @@ * live API/WebSocket backend. * * Two processes: - * 1. API: `pnpm dev dashboard --no-auth --port ` - * (handles the full build, typecheck, engine, etc.) + * 1. API: `pnpm dev --prebuild=none dashboard --no-auth --port ` + * (source-mode API/engine; Vite serves the browser UI) * 2. Vite: `vite dev` in packages/dashboard * (serves app/ with HMR; proxies /api and WS to the API) * @@ -15,7 +15,7 @@ * code) still require restarting this script. * * Env: - * FUSION_API_PORT API port (default 4040). Vite's proxy reads the same + * FUSION_API_PORT API port (default 4050). Vite's proxy reads the same * var so both sides stay in sync. * FUSION_VITE_PORT Vite dev port (default 5173). */ @@ -28,7 +28,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(__dirname, ".."); const dashboardDir = resolve(repoRoot, "packages/dashboard"); -const API_PORT = globalThis.process.env.FUSION_API_PORT ?? "4040"; +const API_PORT = globalThis.process.env.FUSION_API_PORT ?? "4050"; const VITE_PORT = globalThis.process.env.FUSION_VITE_PORT ?? "5173"; const children = []; @@ -88,13 +88,13 @@ globalThis.process.on("SIGTERM", () => shutdown(0)); console.log(`[dev-hmr] starting API on :${API_PORT} + vite on :${VITE_PORT}`); console.log(`[dev-hmr] open http://localhost:${VITE_PORT} for HMR (not the API URL)`); -// API: green. Runs the existing memory-aware dev entry so the engine, build, -// and typecheck all happen exactly as they do for `pnpm dev dashboard`. +// API: green. Vite owns the browser UI in this mode, so skip the dashboard +// client prebuild and run the API/engine directly from source. launch( "api", "32", "pnpm", - ["dev", "dashboard", "--no-auth", "--port", API_PORT, "--host", "127.0.0.1"], + ["dev", "--prebuild=none", "dashboard", "--no-auth", "--port", API_PORT, "--host", "127.0.0.1"], { cwd: repoRoot, env: { ...globalThis.process.env, FUSION_API_PORT: API_PORT } }, ); diff --git a/scripts/dev-with-memory-lib.mjs b/scripts/dev-with-memory-lib.mjs index 24fe1f95b..2e4679c33 100644 --- a/scripts/dev-with-memory-lib.mjs +++ b/scripts/dev-with-memory-lib.mjs @@ -16,3 +16,90 @@ export function buildDevNodeArgs({ ...args, ]; } + +const VALID_PREBUILD_MODES = new Set(["auto", "none", "client", "full"]); + +export function normalizePrebuildMode(value) { + const mode = value === undefined || value === null ? "auto" : String(value).toLowerCase(); + if (mode === "" || !VALID_PREBUILD_MODES.has(mode)) { + throw new Error(`Invalid prebuild mode "${value}". Expected one of: auto, none, client, full.`); + } + return mode; +} + +export function hasHostOverride(args) { + return args.includes("--host") || args.some((arg) => arg.startsWith("--host=")); +} + +export function buildForwardedDevArgs(args) { + const needsDevHostInjection = args[0] === "dashboard" && !hasHostOverride(args); + return needsDevHostInjection ? [...args, "--host", "0.0.0.0"] : args; +} + +export function parseDevWrapperArgs(rawArgs, env = process.env) { + const inspectFlags = []; + const args = []; + let requestedPrebuild = env.FUSION_DEV_PREBUILD ?? "auto"; + + for (let i = 0; i < rawArgs.length; i += 1) { + const arg = rawArgs[i]; + if (arg === "--inspect" || arg === "--inspect-brk" || arg.startsWith("--inspect=") || arg.startsWith("--inspect-brk=")) { + inspectFlags.push(arg); + continue; + } + + if (arg === "--prebuild") { + const value = rawArgs[i + 1]; + if (!value) { + throw new Error("Missing value for --prebuild. Expected one of: auto, none, client, full."); + } + requestedPrebuild = value; + i += 1; + continue; + } + + if (arg.startsWith("--prebuild=")) { + requestedPrebuild = arg.slice("--prebuild=".length); + continue; + } + + if (arg === "--skip-build") { + requestedPrebuild = "none"; + continue; + } + + args.push(arg); + } + + return { + inspectFlags, + args, + requestedPrebuild: normalizePrebuildMode(requestedPrebuild), + }; +} + +export function resolvePrebuildMode(requestedPrebuild, forwardedArgs) { + const mode = normalizePrebuildMode(requestedPrebuild); + if (mode !== "auto") { + return mode; + } + + const command = forwardedArgs[0] ?? "dashboard"; + return command === "dashboard" ? "client" : "none"; +} + +export function getPrebuildCommand(mode) { + switch (normalizePrebuildMode(mode)) { + case "full": + return { command: "pnpm", args: ["build"], label: "workspace build" }; + case "client": + return { + command: "pnpm", + args: ["--filter", "@fusion/dashboard", "build:client"], + label: "dashboard client build", + }; + case "none": + case "auto": + return null; + } +} diff --git a/scripts/dev-with-memory.mjs b/scripts/dev-with-memory.mjs index d0d4c578c..f5ec1c371 100644 --- a/scripts/dev-with-memory.mjs +++ b/scripts/dev-with-memory.mjs @@ -3,12 +3,18 @@ * Memory-aware development entrypoint for Fusion. * * This script increases the Node.js heap size to prevent memory pressure - * during the initial build/start sequence, while preserving argument + * during the optional prebuild/start sequence, while preserving argument * pass-through for documented invocations like `pnpm dev dashboard`. * * Cross-platform: Works on Windows, macOS, and Linux. */ -import { buildDevNodeArgs } from "./dev-with-memory-lib.mjs"; +import { + buildForwardedDevArgs, + buildDevNodeArgs, + getPrebuildCommand, + parseDevWrapperArgs, + resolvePrebuildMode, +} from "./dev-with-memory-lib.mjs"; // Set increased heap size (8GB) to prevent OOM during initial build/start const MEMORY_MB = process.env.FUSION_DEV_MEMORY_MB || "8192"; @@ -16,21 +22,14 @@ const MEMORY_MB = process.env.FUSION_DEV_MEMORY_MB || "8192"; // Spawn the actual dev command with all arguments passed through const { spawn } = await import("child_process"); const rawArgs = process.argv.slice(2); - -// --inspect / --inspect-brk / --inspect=PORT enables the Node inspector. -// Strip these from forwarded args so they don't reach the dashboard CLI -// parser. We pass them as CLI flags directly to node (NOT via NODE_OPTIONS), -// because NODE_OPTIONS is inherited by every grandchild process — every -// vitest / agent / claude subprocess would then try to bind 9229 and fail. -const inspectFlags = []; -const args = []; -for (const a of rawArgs) { - if (a === "--inspect" || a === "--inspect-brk" || a.startsWith("--inspect=") || a.startsWith("--inspect-brk=")) { - inspectFlags.push(a); - } else { - args.push(a); - } +let parsedArgs; +try { + parsedArgs = parseDevWrapperArgs(rawArgs); +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); } +const { inspectFlags, args, requestedPrebuild } = parsedArgs; // NODE_OPTIONS is shared with every spawned node process (build + run + // agents). Heap size belongs here. Inspector flags do NOT — see comment above. @@ -41,11 +40,9 @@ process.env.NODE_OPTIONS = nodeOptions; // mobile devices and other machines on the LAN for testing. Production // builds default to 127.0.0.1; this override only applies when starting // the dashboard via `pnpm dev dashboard` and only if no --host was passed. -const needsDevHostInjection = - args[0] === "dashboard" && !args.includes("--host"); -const forwardedArgs = needsDevHostInjection - ? [...args, "--host", "0.0.0.0"] - : args; +const forwardedArgs = buildForwardedDevArgs(args); +const prebuildMode = resolvePrebuildMode(requestedPrebuild, forwardedArgs); +const prebuildCommand = getPrebuildCommand(prebuildMode); // Resolve absolute paths to tsx loader so they survive shell quoting. // Use Node's resolver instead of hardcoding the pnpm version-specific path. @@ -73,15 +70,70 @@ function runApp(extraArgs) { tsx.on("close", (c) => process.exit(c ?? 1)); } -// If no args, run default: build + CLI -if (forwardedArgs.length === 0) { - const pnpm = spawn("pnpm", ["build"], { stdio: "inherit", shell: true }); - pnpm.on("close", (code) => { - if (code !== 0) process.exit(code ?? 1); - runApp([]); - }); +async function warnIfSourceVersionBehind() { + if (process.env.FUSION_SKIP_STARTUP_UPDATE_PREFLIGHT === "1") { + return; + } + + let currentVersion; + try { + const { readFile } = await import("node:fs/promises"); + const pkg = JSON.parse(await readFile(path.resolve(process.cwd(), "packages/cli/package.json"), "utf8")); + currentVersion = typeof pkg.version === "string" ? pkg.version : undefined; + } catch { + return; + } + + if (!currentVersion) return; + + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 1_500); + let payload; + try { + const response = await fetch("https://registry.npmjs.org/@runfusion%2Ffusion", { + signal: controller.signal, + }); + payload = await response.json(); + } finally { + clearTimeout(timeout); + } + const latestVersion = payload?.["dist-tags"]?.latest; + if (typeof latestVersion !== "string") return; + + const currentParts = currentVersion.split(".").map((part) => Number.parseInt(part, 10) || 0); + const latestParts = latestVersion.split(".").map((part) => Number.parseInt(part, 10) || 0); + let latestIsNewer = false; + for (let i = 0; i < Math.max(currentParts.length, latestParts.length, 3); i += 1) { + const latest = latestParts[i] ?? 0; + const current = currentParts[i] ?? 0; + if (latest > current) { + latestIsNewer = true; + break; + } + if (latest < current) { + break; + } + } + + if (latestIsNewer) { + console.warn( + `\n[fusion] This source checkout is v${currentVersion}, but npm latest is v${latestVersion}. ` + + "If you meant to run the latest Fusion, pull/switch branches before startup.\n", + ); + } + } catch { + // Best-effort only. Startup must not depend on the registry. + } +} + +await warnIfSourceVersionBehind(); + +if (!prebuildCommand) { + runApp(forwardedArgs); } else { - const build = spawn("pnpm", ["build"], { stdio: "inherit", shell: true }); + console.log(`[fusion] Running ${prebuildCommand.label} (${prebuildMode}) before source startup...`); + const build = spawn(prebuildCommand.command, prebuildCommand.args, { stdio: "inherit", shell: true }); build.on("close", (code) => { if (code !== 0) process.exit(code ?? 1); runApp(forwardedArgs); diff --git a/scripts/start-local.mjs b/scripts/start-local.mjs index 174bb76d0..0dcb6425e 100644 --- a/scripts/start-local.mjs +++ b/scripts/start-local.mjs @@ -31,6 +31,7 @@ Options: --host Host to bind. Default: 127.0.0.1. --auth Keep bearer-token auth enabled on localhost. --no-auth Disable auth even for a non-localhost host. + --prebuild Startup prebuild: client, none, or full. Default: client. --skip-install Do not auto-run pnpm install when node_modules is missing. --skip-register Do not auto-register this repo in Fusion's project registry. --allow-4040 Allow using reserved dashboard port 4040. @@ -63,6 +64,7 @@ function parseArgs(argv) { host: "127.0.0.1", auth: false, noAuth: false, + prebuild: "client", skipInstall: false, skipRegister: false, allow4040: false, @@ -107,6 +109,14 @@ function parseArgs(argv) { case "--no-auth": opts.noAuth = true; break; + case "--prebuild": { + const value = argv[++i]; + if (!["none", "client", "full"].includes(value)) { + fail("Invalid --prebuild value. Expected one of: none, client, full."); + } + opts.prebuild = value; + break; + } case "--skip-install": opts.skipInstall = true; break; @@ -301,12 +311,18 @@ function shouldDisableAuth(opts) { function printSummary(opts, port, dashboardArgs) { const mode = opts.engine ? "dashboard + AI engine" : "dashboard/API only"; const auth = dashboardArgs.includes("--no-auth") ? "disabled" : "enabled"; + const prebuild = opts.prebuild === "client" + ? "dashboard client only" + : opts.prebuild === "full" + ? "full workspace" + : "skipped"; console.log(); ok(`Starting ${mode}`); console.log(` URL: http://${opts.host === "127.0.0.1" ? "localhost" : opts.host}:${port}`); console.log(` Host: ${opts.host}`); console.log(` Auth: ${auth}`); - console.log(` Cmd: node scripts/dev-with-memory.mjs ${dashboardArgs.join(" ")}`); + console.log(` Prebuild: ${prebuild}`); + console.log(` Cmd: node scripts/dev-with-memory.mjs --prebuild=${opts.prebuild} ${dashboardArgs.join(" ")}`); console.log(); } @@ -346,7 +362,7 @@ async function main() { const child = spawn(process.execPath, ["scripts/dev-with-memory.mjs", ...dashboardArgs], { cwd: repoRoot, stdio: "inherit", - env: process.env, + env: { ...process.env, FUSION_DEV_PREBUILD: opts.prebuild }, }); child.on("exit", (code, signal) => {