feat(FN-1073): add desktop CLI workflow and packaging support
- Add new `fn desktop` CLI command with argument handling and comprehensive command/bin tests - Implement desktop build and hot-reload dev scripts and wire package scripts/dependencies for Electron workflows - Add electron-builder configuration and desktop main-process/integration test coverage to stabilize packaging behavior - Document desktop development and usage in README files and include a changeset for the published CLI package
This commit is contained in:
98
packages/desktop/scripts/build.ts
Normal file
98
packages/desktop/scripts/build.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { build } from "esbuild";
|
||||
import { cp, mkdir, rm, stat } from "node:fs/promises";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const packageRoot = resolve(__dirname, "..");
|
||||
const workspaceRoot = resolve(packageRoot, "..", "..");
|
||||
const dashboardClientDir = join(workspaceRoot, "packages", "dashboard", "dist", "client");
|
||||
const desktopDistDir = join(packageRoot, "dist");
|
||||
const desktopClientDistDir = join(desktopDistDir, "client");
|
||||
|
||||
function run(command: string, args: string[], cwd: string): Promise<void> {
|
||||
return new Promise((resolvePromise, rejectPromise) => {
|
||||
const child = spawn(command, args, {
|
||||
cwd,
|
||||
stdio: "inherit",
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
child.on("error", (error) => {
|
||||
rejectPromise(error);
|
||||
});
|
||||
|
||||
child.on("exit", (code) => {
|
||||
if (code === 0) {
|
||||
resolvePromise();
|
||||
return;
|
||||
}
|
||||
|
||||
rejectPromise(new Error(`${command} ${args.join(" ")} exited with code ${code ?? "unknown"}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureDashboardBuild(): Promise<void> {
|
||||
console.log("[desktop:build] Building dashboard client...");
|
||||
await run("pnpm", ["--filter", "@fusion/dashboard", "build:client"], workspaceRoot);
|
||||
|
||||
try {
|
||||
await stat(dashboardClientDir);
|
||||
} catch {
|
||||
throw new Error(`Dashboard client assets not found: ${dashboardClientDir}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function buildElectronEntrypoints(): Promise<void> {
|
||||
console.log("[desktop:build] Bundling Electron main/preload with esbuild...");
|
||||
|
||||
await Promise.all([
|
||||
build({
|
||||
entryPoints: [join(packageRoot, "src", "main.ts")],
|
||||
outfile: join(desktopDistDir, "main.js"),
|
||||
bundle: true,
|
||||
format: "esm",
|
||||
platform: "node",
|
||||
target: "node22",
|
||||
sourcemap: true,
|
||||
packages: "external",
|
||||
external: ["electron"],
|
||||
logLevel: "info",
|
||||
}),
|
||||
build({
|
||||
entryPoints: [join(packageRoot, "src", "preload.ts")],
|
||||
outfile: join(desktopDistDir, "preload.js"),
|
||||
bundle: true,
|
||||
format: "esm",
|
||||
platform: "node",
|
||||
target: "node22",
|
||||
sourcemap: true,
|
||||
packages: "external",
|
||||
external: ["electron"],
|
||||
logLevel: "info",
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
async function copyDashboardClient(): Promise<void> {
|
||||
console.log("[desktop:build] Copying dashboard client into desktop dist/client...");
|
||||
await cp(dashboardClientDir, desktopClientDistDir, { recursive: true });
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
await rm(desktopDistDir, { recursive: true, force: true });
|
||||
await mkdir(desktopDistDir, { recursive: true });
|
||||
|
||||
await ensureDashboardBuild();
|
||||
await buildElectronEntrypoints();
|
||||
await copyDashboardClient();
|
||||
|
||||
console.log("[desktop:build] Desktop build complete");
|
||||
}
|
||||
|
||||
void main().catch((error) => {
|
||||
console.error("[desktop:build] Build failed", error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
175
packages/desktop/scripts/dev.ts
Normal file
175
packages/desktop/scripts/dev.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { build } from "esbuild";
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const packageRoot = resolve(__dirname, "..");
|
||||
const workspaceRoot = resolve(packageRoot, "..", "..");
|
||||
const distDir = join(packageRoot, "dist");
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
const sleep = (ms: number): Promise<void> => new Promise((resolvePromise) => {
|
||||
setTimeout(resolvePromise, ms);
|
||||
});
|
||||
|
||||
function run(command: string, args: string[], cwd: string, env: NodeJS.ProcessEnv = process.env): ChildProcess {
|
||||
return spawn(command, args, {
|
||||
cwd,
|
||||
env,
|
||||
stdio: "inherit",
|
||||
});
|
||||
}
|
||||
|
||||
async function buildMainProcess(): Promise<void> {
|
||||
await mkdir(distDir, { recursive: true });
|
||||
|
||||
await Promise.all([
|
||||
build({
|
||||
entryPoints: [join(packageRoot, "src", "main.ts")],
|
||||
outfile: join(distDir, "main.js"),
|
||||
bundle: true,
|
||||
format: "esm",
|
||||
platform: "node",
|
||||
target: "node22",
|
||||
sourcemap: true,
|
||||
packages: "external",
|
||||
external: ["electron"],
|
||||
logLevel: "info",
|
||||
}),
|
||||
build({
|
||||
entryPoints: [join(packageRoot, "src", "preload.ts")],
|
||||
outfile: join(distDir, "preload.js"),
|
||||
bundle: true,
|
||||
format: "esm",
|
||||
platform: "node",
|
||||
target: "node22",
|
||||
sourcemap: true,
|
||||
packages: "external",
|
||||
external: ["electron"],
|
||||
logLevel: "info",
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
function resolveDashboardUrl(): URL {
|
||||
const raw = process.env.FUSION_DASHBOARD_URL ?? "http://localhost:5173";
|
||||
const parsed = new URL(raw);
|
||||
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw new Error(`FUSION_DASHBOARD_URL must be http(s), received: ${raw}`);
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
async function waitForRenderer(url: string, timeoutMs: number = 60_000): Promise<void> {
|
||||
const startedAt = Date.now();
|
||||
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (response.ok) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Keep polling until timeout.
|
||||
}
|
||||
|
||||
await sleep(500);
|
||||
}
|
||||
|
||||
throw new Error(`Renderer dev server did not become ready at ${url} within ${timeoutMs}ms`);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
console.log("[desktop:dev] Building main/preload entrypoints...");
|
||||
await buildMainProcess();
|
||||
|
||||
const dashboardUrl = resolveDashboardUrl();
|
||||
const dashboardHost = dashboardUrl.hostname;
|
||||
const dashboardPort = dashboardUrl.port || (dashboardUrl.protocol === "https:" ? "443" : "80");
|
||||
|
||||
console.log(`[desktop:dev] Starting dashboard Vite dev server on ${dashboardUrl.origin}...`);
|
||||
const viteProcess = run(
|
||||
"pnpm",
|
||||
[
|
||||
"--filter",
|
||||
"@fusion/dashboard",
|
||||
"dev:serve",
|
||||
"--host",
|
||||
dashboardHost,
|
||||
"--port",
|
||||
dashboardPort,
|
||||
"--strictPort",
|
||||
],
|
||||
workspaceRoot,
|
||||
);
|
||||
|
||||
let isShuttingDown = false;
|
||||
let electronProcess: ChildProcess | null = null;
|
||||
|
||||
const shutdown = (code: number): void => {
|
||||
if (isShuttingDown) {
|
||||
return;
|
||||
}
|
||||
|
||||
isShuttingDown = true;
|
||||
|
||||
if (electronProcess && !electronProcess.killed) {
|
||||
electronProcess.kill("SIGTERM");
|
||||
}
|
||||
|
||||
if (!viteProcess.killed) {
|
||||
viteProcess.kill("SIGTERM");
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (electronProcess && !electronProcess.killed) {
|
||||
electronProcess.kill("SIGKILL");
|
||||
}
|
||||
if (!viteProcess.killed) {
|
||||
viteProcess.kill("SIGKILL");
|
||||
}
|
||||
process.exit(code);
|
||||
}, 200);
|
||||
};
|
||||
|
||||
process.on("SIGINT", () => shutdown(0));
|
||||
process.on("SIGTERM", () => shutdown(0));
|
||||
|
||||
viteProcess.on("exit", (code) => {
|
||||
if (!isShuttingDown) {
|
||||
const exitCode = code ?? 1;
|
||||
console.error(`[desktop:dev] Dashboard dev server exited early with code ${exitCode}`);
|
||||
shutdown(exitCode);
|
||||
}
|
||||
});
|
||||
|
||||
await waitForRenderer(dashboardUrl.toString());
|
||||
console.log("[desktop:dev] Renderer dev server ready. Launching Electron...");
|
||||
|
||||
const electronBinary = require("electron") as string;
|
||||
electronProcess = run(
|
||||
electronBinary,
|
||||
["--enable-source-maps", join(distDir, "main.js"), "--dev"],
|
||||
packageRoot,
|
||||
{
|
||||
...process.env,
|
||||
NODE_ENV: "development",
|
||||
FUSION_DASHBOARD_URL: dashboardUrl.toString(),
|
||||
},
|
||||
);
|
||||
|
||||
electronProcess.on("exit", (code) => {
|
||||
shutdown(code ?? 0);
|
||||
});
|
||||
}
|
||||
|
||||
void main().catch((error) => {
|
||||
console.error("[desktop:dev] Failed to start development workflow", error);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user