FN-7395: fix packaged desktop launch assets
Fix installed desktop launches so they use packaged runtime assets without parsing caller workspaces. - Resolve normal desktop launches from packaged CLI desktop assets, with explicit override and dev paths. - Stage desktop runtime assets into the published CLI package and include them in npm files. - Route desktop --no-auth into the embedded dashboard server and document installed/dev launch behavior. - Cover invalid JSON caller directories, missing packaged assets, package contents, and CLI routing in tests. Files changed: .changeset/fn-7395-desktop-launcher.md | 7 ++ docs/cli-reference.md | 12 +- packages/cli/package.json | 1 + packages/cli/src/__tests__/bin.test.ts | 19 +++ packages/cli/src/__tests__/package-config.test.ts | 11 ++ packages/cli/src/bin.ts | 10 +- .../cli/src/commands/__tests__/desktop.test.ts | 132 ++++++++++++++++++--- packages/cli/src/commands/desktop.ts | 85 ++++++++----- packages/cli/tsup.config.ts | 60 ++++++++++ 9 files changed, 285 insertions(+), 52 deletions(-) Fusion-Task-Id: FN-7395 Fusion-Task-Lineage: e7a8d065-0d41-4fa3-9cea-1cfb5b466d16 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7395-desktop-launcher.md
Normal file
7
.changeset/fn-7395-desktop-launcher.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Fix desktop launch from npm installs in directories with invalid JSON.
|
||||
category: fix
|
||||
dev: Keeps installed desktop launch independent of source workspace builds and host JSON files.
|
||||
@@ -480,12 +480,18 @@ fn daemon [--port <port>] [--host <host>] [--token <token>] [--paused] [--intera
|
||||
FNXC:DesktopCLI 2026-06-21-12:00:
|
||||
`fn desktop` now starts the same local AI engine lifecycle as CLI dashboard mode by default.
|
||||
Document `--paused` as automation-paused startup and explicitly note that desktop does not expose a `--no-engine` mode so users do not infer dashboard-only flags exist here.
|
||||
|
||||
FNXC:DesktopCLI 2026-07-01-21:12:
|
||||
Installed `fn desktop` / `fusion desktop` must launch packaged desktop runtime assets instead of treating the shell cwd as a Fusion source checkout. This keeps unrelated invalid JSON in user directories from blocking desktop startup; `--dev` is the explicit source-checkout path.
|
||||
-->
|
||||
|
||||
Launch the Fusion desktop app (Electron) with a local AI engine running by default, mirroring `fn dashboard` engine-on startup.
|
||||
Launch the Fusion desktop app (Electron) with a local AI engine running by default, mirroring `fn dashboard` engine-on startup. In normal installed usage, both `fn desktop` and `fusion desktop` resolve the packaged desktop runtime from the installed `@runfusion/fusion` package; they do not build `@fusion/desktop`, inspect a source workspace, or validate `package.json` files from the current directory or its ancestors.
|
||||
|
||||
Use `--dev` only from a Fusion source checkout after building the desktop entrypoint. It points Electron at the checkout's `packages/desktop/dist/main.js` and Vite dev server.
|
||||
|
||||
```bash
|
||||
fn desktop
|
||||
fusion desktop --no-auth
|
||||
fn desktop --dev
|
||||
fn desktop --paused
|
||||
fn desktop --interactive
|
||||
@@ -493,9 +499,10 @@ fn desktop --interactive
|
||||
|
||||
| Option | Description |
|
||||
|---|---|
|
||||
| `--dev` | Launch with hot-reload (connects to Vite dev server). |
|
||||
| `--dev` | Launch from a source checkout with hot-reload (connects to Vite dev server). |
|
||||
| `--paused` | Launch with the AI engine paused (automation disabled). |
|
||||
| `--interactive` | Interactive port selection. |
|
||||
| `--no-auth` | Disable bearer-token auth for the embedded local dashboard server. This matches `fn dashboard --no-auth` for local desktop compatibility and does not change runtime asset resolution. |
|
||||
|
||||
`fn desktop` does not support `--no-engine`. Unlike `fn dashboard`, which can run in dashboard/API-only mode, desktop always starts the local AI engine; use `--paused` when you want the engine process running without automation doing work.
|
||||
|
||||
@@ -1152,6 +1159,7 @@ Subcommands: `search`, `install`.
|
||||
| `--interactive` | `fn dashboard`, `fn serve`, `fn daemon`, `fn desktop`, `fn task import`, `fn project add` |
|
||||
| `--paused` | `fn dashboard`, `fn serve`, `fn daemon`, `fn desktop` |
|
||||
| `--dev` | `fn dashboard`, `fn desktop` |
|
||||
| `--no-auth` | `fn dashboard`, `fn desktop`, `fn chat` |
|
||||
| `--no-engine` | `fn dashboard` |
|
||||
| `--attach` | `fn task create` |
|
||||
| `--depends` | `fn task create` |
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"dist/**/*.d.ts.map",
|
||||
"dist/**/*.js.map",
|
||||
"dist/client/**",
|
||||
"dist/desktop/**",
|
||||
"dist/pi-claude-cli/**",
|
||||
"dist/droid-cli/**",
|
||||
"dist/plugins/**",
|
||||
|
||||
@@ -949,6 +949,25 @@ describe("bin command routing and fallbacks", () => {
|
||||
paused: true,
|
||||
dev: true,
|
||||
interactive: true,
|
||||
noAuth: false,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
args: ["desktop", "--no-auth"],
|
||||
expected: { paused: false, dev: false, interactive: false, noAuth: true },
|
||||
},
|
||||
{
|
||||
args: ["desktop", "--no-auth", "--paused"],
|
||||
expected: { paused: true, dev: false, interactive: false, noAuth: true },
|
||||
},
|
||||
{
|
||||
args: ["desktop", "--dev", "--no-auth"],
|
||||
expected: { paused: false, dev: true, interactive: false, noAuth: true },
|
||||
},
|
||||
])("routes desktop --no-auth variants %#", async ({ args, expected }) => {
|
||||
await runBin(args);
|
||||
expect(commandMocks.runDesktop).toHaveBeenCalledWith(expected);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -89,6 +89,7 @@ describe("CLI package.json publishing config", () => {
|
||||
expect(pkg.files).toContain("dist/**/*.d.ts.map");
|
||||
expect(pkg.files).toContain("dist/**/*.js.map");
|
||||
expect(pkg.files).toContain("dist/client/**");
|
||||
expect(pkg.files).toContain("dist/desktop/**");
|
||||
expect(pkg.files).toContain("README.md");
|
||||
});
|
||||
|
||||
@@ -113,6 +114,16 @@ describe("CLI package.json publishing config", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("stages packaged desktop runtime assets without publishing raw workspace manifests", () => {
|
||||
const tsupRaw = readFileSync(join(workspaceRoot, "packages", "cli", "tsup.config.ts"), "utf-8");
|
||||
|
||||
expect(pkg.files).toContain("dist/desktop/**");
|
||||
expect(tsupRaw).toContain("desktopRuntimeSrc");
|
||||
expect(tsupRaw).toContain("ensureDesktopRuntimeAssetsBuilt");
|
||||
expect(tsupRaw).toContain("Copied desktop runtime assets to dist/desktop/");
|
||||
expect(tsupRaw).not.toContain("join(desktopRuntimeSrc, \"package.json\")");
|
||||
});
|
||||
|
||||
it("excludes runtime directory from npm package (GitHub Releases only)", () => {
|
||||
// Runtime assets are for standalone binaries distributed via GitHub Releases
|
||||
// npm package should not include them (users install via npm get node-pty naturally)
|
||||
|
||||
@@ -301,9 +301,10 @@ Usage:
|
||||
Auto-registers cwd project on first run (use --no-auto-register to disable)
|
||||
fn daemon [--port <port>] [--host <host>] [--token <token>] [--paused] [--token-only] [--project <id|name>] [--no-auto-register]
|
||||
Start Fusion daemon (API + engine, auth required)
|
||||
fn desktop Launch the Fusion desktop app (Electron)
|
||||
fn desktop --dev Launch with hot-reload (connects to Vite dev server)
|
||||
fn desktop Launch the installed Fusion desktop app (Electron)
|
||||
fn desktop --dev Launch source-checkout desktop with hot-reload (connects to Vite dev server)
|
||||
fn desktop --paused Launch with automation paused
|
||||
fn desktop --no-auth Disable bearer-token auth for the embedded local dashboard
|
||||
fn update [--check] [--global] [--json] Update Fusion to the latest version
|
||||
fn upgrade Alias for fn update
|
||||
fn task create [desc] [opts] Create a new task (goes to triage; supports --node <name>, --no-dedup)
|
||||
@@ -470,7 +471,7 @@ Options:
|
||||
--port, -p <port> Dashboard/serve port (default: 4040)
|
||||
--host <host> Serve host (default: 127.0.0.1 — localhost only; pass 0.0.0.0 to expose)
|
||||
--token <token> Dashboard/daemon bearer token. Default: $FUSION_DASHBOARD_TOKEN, $FUSION_DAEMON_TOKEN, or auto-generated.
|
||||
--no-auth Disable dashboard bearer-token auth (local-only; not recommended on 0.0.0.0)
|
||||
--no-auth Disable dashboard bearer-token auth for dashboard/desktop (local-only; not recommended on 0.0.0.0)
|
||||
--interactive Interactive mode (port selection for dashboard, issue selection for import)
|
||||
--paused Start with engine paused (automation disabled)
|
||||
--dev Start dashboard in development mode
|
||||
@@ -888,7 +889,8 @@ async function main() {
|
||||
const paused = args.includes("--paused");
|
||||
const dev = args.includes("--dev");
|
||||
const interactive = args.includes("--interactive");
|
||||
await runDesktop({ paused, dev, interactive });
|
||||
const noAuth = args.includes("--no-auth");
|
||||
await runDesktop({ paused, dev, interactive, noAuth });
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
type Listener = (...args: any[]) => void;
|
||||
|
||||
@@ -119,16 +123,10 @@ const mocks = vi.hoisted(() => {
|
||||
}),
|
||||
};
|
||||
|
||||
const spawn = vi.fn((command: string) => {
|
||||
if (command === "pnpm") {
|
||||
queueMicrotask(() => {
|
||||
state.buildChild.emit("exit", 0);
|
||||
});
|
||||
return state.buildChild;
|
||||
}
|
||||
const existingPaths = new Set<string>();
|
||||
const existsSync = vi.fn((path: string) => existingPaths.has(path));
|
||||
|
||||
return state.electronChild;
|
||||
});
|
||||
const spawn = vi.fn(() => state.electronChild);
|
||||
|
||||
return {
|
||||
state,
|
||||
@@ -137,6 +135,8 @@ const mocks = vi.hoisted(() => {
|
||||
server,
|
||||
app,
|
||||
spawn,
|
||||
existingPaths,
|
||||
existsSync,
|
||||
taskStoreCtor: vi.fn(function () {
|
||||
return store;
|
||||
}),
|
||||
@@ -159,6 +159,10 @@ vi.mock("node:child_process", () => ({
|
||||
spawn: mocks.spawn,
|
||||
}));
|
||||
|
||||
vi.mock("node:fs", () => ({
|
||||
existsSync: mocks.existsSync,
|
||||
}));
|
||||
|
||||
vi.mock("@fusion/core", () => ({
|
||||
TaskStore: mocks.taskStoreCtor,
|
||||
CentralCore: mocks.centralCoreCtor,
|
||||
@@ -184,13 +188,18 @@ describe("runDesktop", () => {
|
||||
const originalExit = process.exit;
|
||||
const originalElectronBinary = process.env.FUSION_ELECTRON_BINARY;
|
||||
const originalDashboardUrl = process.env.FUSION_DASHBOARD_URL;
|
||||
const originalDesktopEntry = process.env.FUSION_DESKTOP_ENTRY;
|
||||
const packagedDesktopEntry = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "dist", "desktop", "main.js");
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
process.env.FUSION_ELECTRON_BINARY = "electron-bin";
|
||||
delete process.env.FUSION_DESKTOP_ENTRY;
|
||||
delete process.env.FUSION_DASHBOARD_URL;
|
||||
|
||||
mocks.existingPaths.clear();
|
||||
mocks.existingPaths.add(packagedDesktopEntry);
|
||||
mocks.state.buildChild = mocks.createMockChild();
|
||||
mocks.state.electronChild = mocks.createMockChild();
|
||||
|
||||
@@ -223,15 +232,20 @@ describe("runDesktop", () => {
|
||||
} else {
|
||||
process.env.FUSION_DASHBOARD_URL = originalDashboardUrl;
|
||||
}
|
||||
if (originalDesktopEntry === undefined) {
|
||||
delete process.env.FUSION_DESKTOP_ENTRY;
|
||||
} else {
|
||||
process.env.FUSION_DESKTOP_ENTRY = originalDesktopEntry;
|
||||
}
|
||||
});
|
||||
|
||||
it("builds desktop app, starts dashboard on random port, and launches Electron", async () => {
|
||||
it("starts dashboard on a random port and launches packaged Electron runtime without building cwd", async () => {
|
||||
await runDesktop({ paused: true });
|
||||
|
||||
expect(mocks.spawn).toHaveBeenCalledWith(
|
||||
expect(mocks.spawn).not.toHaveBeenCalledWith(
|
||||
"pnpm",
|
||||
["--filter", "@fusion/desktop", "build"],
|
||||
expect.objectContaining({ cwd: "/repo" }),
|
||||
expect.arrayContaining(["--filter", "@fusion/desktop", "build"]),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(mocks.taskStoreCtor).toHaveBeenCalledWith("/repo");
|
||||
expect(mocks.store.updateSettings).toHaveBeenCalledWith({ enginePaused: true });
|
||||
@@ -254,7 +268,7 @@ describe("runDesktop", () => {
|
||||
// In production mode (not dev), renderer uses embedded assets, so no FUSION_DASHBOARD_URL
|
||||
expect(mocks.spawn).toHaveBeenCalledWith(
|
||||
"electron-bin",
|
||||
["--enable-source-maps", "/repo/packages/desktop/dist/main.js"],
|
||||
["--enable-source-maps", packagedDesktopEntry],
|
||||
expect.objectContaining({
|
||||
cwd: "/repo",
|
||||
env: expect.objectContaining({
|
||||
@@ -270,6 +284,7 @@ describe("runDesktop", () => {
|
||||
|
||||
it("supports --dev mode by skipping build and pointing at Vite URL", async () => {
|
||||
process.env.FUSION_DASHBOARD_URL = "http://localhost:5173";
|
||||
mocks.existingPaths.add("/repo/packages/desktop/dist/main.js");
|
||||
|
||||
await runDesktop({ dev: true });
|
||||
|
||||
@@ -291,6 +306,95 @@ describe("runDesktop", () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "empty directory", files: [] },
|
||||
{ name: "invalid package.json", files: [["package.json", "INVALID\r"]] },
|
||||
{ name: "valid non-Fusion package", files: [["package.json", JSON.stringify({ name: "not-fusion" })]] },
|
||||
{ name: "templated fixture JSON", files: [[join("fixtures", "cookiecutter.json"), "{{ cookiecutter.app_name }}"]] },
|
||||
] as const)("launches from a non-Fusion $name without reading workspace JSON", async ({ files }) => {
|
||||
const workspace = await mkdtemp(join(tmpdir(), "fn-7395-C-Users-alice-"));
|
||||
const cwd = join(workspace, "C", "Users", "alice", "project");
|
||||
await mkdir(cwd, { recursive: true });
|
||||
for (const [relativePath, contents] of files) {
|
||||
const filePath = join(cwd, relativePath);
|
||||
await mkdir(join(filePath, ".."), { recursive: true });
|
||||
await writeFile(filePath, contents, "utf-8");
|
||||
}
|
||||
vi.spyOn(process, "cwd").mockReturnValue(cwd);
|
||||
|
||||
await runDesktop({ noAuth: true });
|
||||
|
||||
const spawnedCommands = mocks.spawn.mock.calls.map(([command, args]) => `${command} ${(args as string[]).join(" ")}`);
|
||||
expect(spawnedCommands).not.toContain("pnpm --filter @fusion/desktop build");
|
||||
expect(mocks.spawn).toHaveBeenCalledWith(
|
||||
"electron-bin",
|
||||
["--enable-source-maps", packagedDesktopEntry],
|
||||
expect.objectContaining({ cwd }),
|
||||
);
|
||||
expect(mocks.createServer).toHaveBeenCalledWith(
|
||||
mocks.store,
|
||||
expect.objectContaining({ noAuth: true }),
|
||||
);
|
||||
|
||||
mocks.state.electronChild.emit("exit", 0);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
it("reports missing packaged assets from invalid-JSON directories without pnpm parse symptoms", async () => {
|
||||
const workspace = await mkdtemp(join(tmpdir(), "fn-7395-C-Users-bob-"));
|
||||
const cwd = join(workspace, "C", "Users", "bob", "broken-json");
|
||||
await mkdir(cwd, { recursive: true });
|
||||
await writeFile(join(cwd, "package.json"), "INVALID\r", "utf-8");
|
||||
await mkdir(join(cwd, "template"), { recursive: true });
|
||||
await writeFile(join(cwd, "template", "cookiecutter.json"), "{{ cookiecutter.app_name }}", "utf-8");
|
||||
vi.spyOn(process, "cwd").mockReturnValue(cwd);
|
||||
delete process.env.FUSION_DESKTOP_ENTRY;
|
||||
mocks.existingPaths.clear();
|
||||
|
||||
await expect(runDesktop({ noAuth: true })).rejects.toThrow(/Fusion desktop runtime asset is missing/);
|
||||
await expect(runDesktop({ noAuth: true })).rejects.not.toThrow(
|
||||
/ERR_PNPM_JSON_PARSE|Unexpected token|cookiecutter|broken-json|pnpm --filter @fusion\/desktop build/,
|
||||
);
|
||||
expect(mocks.spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses packaged assets even when cwd is an actual Fusion source checkout unless --dev is explicit", async () => {
|
||||
mocks.existingPaths.add("/repo/packages/desktop/dist/main.js");
|
||||
|
||||
await runDesktop();
|
||||
|
||||
expect(mocks.spawn).toHaveBeenCalledWith(
|
||||
"electron-bin",
|
||||
["--enable-source-maps", packagedDesktopEntry],
|
||||
expect.objectContaining({ cwd: "/repo" }),
|
||||
);
|
||||
|
||||
mocks.state.electronChild.emit("exit", 0);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
it("passes --no-auth through to the embedded dashboard server", async () => {
|
||||
await runDesktop({ noAuth: true });
|
||||
|
||||
expect(mocks.createServer).toHaveBeenCalledWith(
|
||||
mocks.store,
|
||||
expect.objectContaining({ noAuth: true }),
|
||||
);
|
||||
|
||||
mocks.state.electronChild.emit("exit", 0);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
it("reports a Fusion-owned missing runtime error without falling back to cwd source builds", async () => {
|
||||
delete process.env.FUSION_DESKTOP_ENTRY;
|
||||
mocks.existingPaths.clear();
|
||||
|
||||
await expect(runDesktop()).rejects.toThrow(/Fusion desktop runtime asset is missing/);
|
||||
|
||||
expect(mocks.spawn).not.toHaveBeenCalled();
|
||||
expect(mocks.taskStoreCtor).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cleans up dashboard runtime when Electron exits", async () => {
|
||||
await runDesktop();
|
||||
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import { join } from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname, isAbsolute, join, resolve } from "node:path";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { createRequire } from "node:module";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { CentralCore, TaskStore } from "@fusion/core";
|
||||
import { createServer } from "@fusion/dashboard";
|
||||
import { ProjectEngineManager } from "@fusion/engine";
|
||||
import { ensureCwdProjectRegistered } from "./ensure-project-registered.js";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const cliModuleDir = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export interface RunDesktopOptions {
|
||||
dev?: boolean;
|
||||
paused?: boolean;
|
||||
interactive?: boolean;
|
||||
noAuth?: boolean;
|
||||
}
|
||||
|
||||
interface DashboardRuntime {
|
||||
@@ -24,31 +28,7 @@ interface DashboardRuntime {
|
||||
centralCore?: CentralCore;
|
||||
}
|
||||
|
||||
function runCommand(command: string, args: string[], cwd: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
cwd,
|
||||
stdio: "inherit",
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
child.on("error", (error) => reject(error));
|
||||
child.on("exit", (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
reject(new Error(`${command} ${args.join(" ")} exited with code ${code ?? "unknown"}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function buildDesktopArtifacts(rootDir: string): Promise<void> {
|
||||
await runCommand("pnpm", ["--filter", "@fusion/desktop", "build"], rootDir);
|
||||
}
|
||||
|
||||
async function startDashboardRuntime(rootDir: string, paused: boolean): Promise<DashboardRuntime> {
|
||||
async function startDashboardRuntime(rootDir: string, paused: boolean, noAuth: boolean): Promise<DashboardRuntime> {
|
||||
const store = new TaskStore(rootDir);
|
||||
let server: import("node:http").Server | null = null;
|
||||
let engineManager: ProjectEngineManager | undefined;
|
||||
@@ -87,6 +67,11 @@ async function startDashboardRuntime(rootDir: string, paused: boolean): Promise<
|
||||
engine: cwdEngine,
|
||||
engineManager,
|
||||
centralCore,
|
||||
/*
|
||||
* FNXC:DesktopLauncher 2026-07-01-20:19:
|
||||
* `fusion desktop --no-auth` is a compatibility flag for users who learned the dashboard launcher semantics. Propagate it to the embedded dashboard server explicitly so desktop routing never treats it as an unknown flag or falls back to source-workspace discovery.
|
||||
*/
|
||||
noAuth,
|
||||
onProjectFirstAccessed: (projectId: string) => engineManager?.onProjectAccessed(projectId),
|
||||
});
|
||||
server = app.listen(0);
|
||||
@@ -145,17 +130,53 @@ function terminateProcess(child: ChildProcess | null, signal: NodeJS.Signals = "
|
||||
child.kill(signal);
|
||||
}
|
||||
|
||||
export async function runDesktop(options: RunDesktopOptions = {}): Promise<void> {
|
||||
const rootDir = process.cwd();
|
||||
|
||||
if (!options.dev) {
|
||||
await buildDesktopArtifacts(rootDir);
|
||||
function resolveDevDesktopEntry(rootDir: string): string {
|
||||
const desktopEntry = join(rootDir, "packages", "desktop", "dist", "main.js");
|
||||
if (existsSync(desktopEntry)) {
|
||||
return desktopEntry;
|
||||
}
|
||||
|
||||
const runtime = await startDashboardRuntime(rootDir, Boolean(options.paused));
|
||||
throw new Error(
|
||||
`Fusion desktop dev entry is missing at ${desktopEntry}. Run \`pnpm --filter @fusion/desktop build\` from a Fusion source checkout before using \`fusion desktop --dev\`.`,
|
||||
);
|
||||
}
|
||||
|
||||
function resolvePackagedDesktopEntry(): string {
|
||||
const override = process.env.FUSION_DESKTOP_ENTRY;
|
||||
if (override) {
|
||||
const resolvedOverride = isAbsolute(override) ? override : resolve(process.cwd(), override);
|
||||
if (existsSync(resolvedOverride)) {
|
||||
return resolvedOverride;
|
||||
}
|
||||
throw new Error(`Fusion desktop runtime override is missing: ${resolvedOverride}`);
|
||||
}
|
||||
|
||||
const packagedEntry = resolve(cliModuleDir, "desktop", "main.js");
|
||||
if (existsSync(packagedEntry)) {
|
||||
return packagedEntry;
|
||||
}
|
||||
|
||||
const sourceCheckoutEntry = resolve(cliModuleDir, "..", "..", "dist", "desktop", "main.js");
|
||||
if (existsSync(sourceCheckoutEntry)) {
|
||||
return sourceCheckoutEntry;
|
||||
}
|
||||
|
||||
/*
|
||||
* FNXC:DesktopLauncher 2026-07-01-20:04:
|
||||
* Installed `fusion desktop` must never infer a source workspace from the caller's cwd or run pnpm there. When package assets are absent, fail with a Fusion-owned runtime diagnostic instead of letting unrelated package.json/template JSON parse errors gate startup. The bundled CLI entry lives in dist, so the packaged runtime is dist/desktop/main.js; the source-checkout fallback only supports tests and direct TS execution without inspecting the caller's workspace.
|
||||
*/
|
||||
throw new Error(
|
||||
`Fusion desktop runtime asset is missing: ${packagedEntry}. Reinstall @runfusion/fusion or set FUSION_DESKTOP_ENTRY to a built Fusion desktop main.js.`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function runDesktop(options: RunDesktopOptions = {}): Promise<void> {
|
||||
const rootDir = process.cwd();
|
||||
const desktopEntry = options.dev ? resolveDevDesktopEntry(rootDir) : resolvePackagedDesktopEntry();
|
||||
|
||||
const runtime = await startDashboardRuntime(rootDir, Boolean(options.paused), Boolean(options.noAuth));
|
||||
|
||||
const electronBinary = resolveElectronBinary();
|
||||
const desktopEntry = join(rootDir, "packages", "desktop", "dist", "main.js");
|
||||
const electronArgs = ["--enable-source-maps", desktopEntry, ...(options.dev ? ["--dev"] : [])];
|
||||
|
||||
// Build environment for Electron process
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { defineConfig } from "tsup";
|
||||
import { spawn } from "node:child_process";
|
||||
import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -13,12 +14,15 @@ const RUNTIME_PLUGINS_WITH_MCP_SCHEMA_SERVER = new Set([
|
||||
]);
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const workspaceRoot = join(__dirname, "..", "..");
|
||||
const dashboardClientSrc = join(__dirname, "..", "dashboard", "dist", "client");
|
||||
const dashboardClientDest = join(__dirname, "dist", "client");
|
||||
const piClaudeCliSrc = join(__dirname, "..", "pi-claude-cli");
|
||||
const piClaudeCliDest = join(__dirname, "dist", "pi-claude-cli");
|
||||
const droidCliSrc = join(__dirname, "..", "droid-cli");
|
||||
const droidCliDest = join(__dirname, "dist", "droid-cli");
|
||||
const desktopRuntimeSrc = join(__dirname, "..", "desktop", "dist");
|
||||
const desktopRuntimeDest = join(__dirname, "dist", "desktop");
|
||||
const llamaCppSrc = join(__dirname, "..", "pi-llama-cpp");
|
||||
const llamaCppDest = join(__dirname, "dist", "pi-llama-cpp");
|
||||
const dependencyGraphPluginSrc = join(__dirname, "..", "..", "plugins", "fusion-plugin-dependency-graph");
|
||||
@@ -190,6 +194,50 @@ async function bundlePluginEntry({ pluginId, srcDir, destDir, withMcpAsset = fal
|
||||
console.log(`Bundled plugin ${pluginId} to dist/plugins/${pluginId}/bundled.js`);
|
||||
}
|
||||
|
||||
function runWorkspaceCommand(command: string, args: string[], cwd: string, timeoutMs = 600_000): Promise<void> {
|
||||
return new Promise((resolvePromise, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
cwd,
|
||||
stdio: "inherit",
|
||||
env: process.env,
|
||||
});
|
||||
const timer = setTimeout(() => {
|
||||
child.kill("SIGTERM");
|
||||
reject(new Error(`${command} ${args.join(" ")} timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
|
||||
child.on("error", (error) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
});
|
||||
child.on("exit", (code) => {
|
||||
clearTimeout(timer);
|
||||
if (code === 0) {
|
||||
resolvePromise();
|
||||
return;
|
||||
}
|
||||
reject(new Error(`${command} ${args.join(" ")} exited with code ${code ?? "unknown"}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureDesktopRuntimeAssetsBuilt() {
|
||||
if (existsSync(desktopRuntimeSrc)) {
|
||||
return;
|
||||
}
|
||||
|
||||
/*
|
||||
* FNXC:DesktopPackaging 2026-07-01-20:53:
|
||||
* The published CLI package must contain the desktop runtime it launches. Build the private desktop package during CLI packaging when dist is absent, but keep this strictly in the repository build path — the installed `fusion desktop` command itself must never run pnpm from an operator's cwd.
|
||||
*/
|
||||
console.log("Desktop runtime assets missing; building @fusion/desktop before staging CLI package assets...");
|
||||
await runWorkspaceCommand("pnpm", ["--filter", "@fusion/desktop", "build"], workspaceRoot);
|
||||
|
||||
if (!existsSync(desktopRuntimeSrc)) {
|
||||
throw new Error(`[tsup] Desktop runtime build did not create expected assets at ${desktopRuntimeSrc}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertAllStagedBundledPluginsLoadable() {
|
||||
const missingEntries: string[] = [];
|
||||
|
||||
@@ -242,6 +290,18 @@ const cliBuildConfig = {
|
||||
js: 'import { createRequire as __createRequire } from "node:module"; const require = __createRequire(import.meta.url);',
|
||||
},
|
||||
onSuccess: async () => {
|
||||
if (existsSync(desktopRuntimeDest)) {
|
||||
rmSync(desktopRuntimeDest, { recursive: true, force: true });
|
||||
}
|
||||
await ensureDesktopRuntimeAssetsBuilt();
|
||||
/*
|
||||
* FNXC:DesktopPackaging 2026-07-01-20:31:
|
||||
* Published `@runfusion/fusion` desktop launches must resolve Electron runtime assets from the installed package, not from the operator's current directory. Stage the private @fusion/desktop dist output under CLI dist/desktop so npm installs can launch without pnpm workspace discovery or host JSON parsing.
|
||||
*/
|
||||
mkdirSync(desktopRuntimeDest, { recursive: true });
|
||||
cpSync(desktopRuntimeSrc, desktopRuntimeDest, { recursive: true });
|
||||
console.log("Copied desktop runtime assets to dist/desktop/");
|
||||
|
||||
// Stage the vendored pi-claude-cli pi extension into dist/. It can't
|
||||
// be bundled by esbuild because pi loads extensions as separate files
|
||||
// at runtime via jiti, so we ship the raw .ts source. This also lets
|
||||
|
||||
Reference in New Issue
Block a user