fix(FN-1243): add build-time dashboard stub fallback for CLI bundles

- Add resilient client asset staging in packages/cli/build.ts with automatic dist/client/index.html stub generation when dashboard assets are missing
- Update packages/cli/tsup.config.ts onSuccess hook to clear stale client output and emit the same minimal stub fallback in clean checkout builds
- Make bundle-output test build-aware by running pnpm build in beforeAll when bundle artifacts are absent, then assert client assets are present
- Preserve build output visibility by reporting whether real or stub client assets were staged
This commit is contained in:
gsxdsm
2026-04-08 09:44:42 -07:00
parent 108c3958ac
commit 8db7689f77
3 changed files with 94 additions and 35 deletions

View File

@@ -11,12 +11,15 @@
* bun run build.ts --all # Build for all supported platforms
*
* Prerequisites:
* - `pnpm build` must have been run first (dashboard client + tsc)
* - Bun >= 1.1 (cross-compilation support)
*
* Notes:
* - If dashboard client assets are missing, this script generates a
* minimal dist/client/index.html stub so clean-checkout tests can run.
*/
import { join, dirname, basename } from "node:path";
import { cpSync, mkdirSync, existsSync, rmSync, readdirSync, statSync } from "node:fs";
import { cpSync, mkdirSync, existsSync, rmSync, readdirSync, statSync, writeFileSync } from "node:fs";
const cliRoot = dirname(new URL(import.meta.url).pathname);
const workspaceRoot = join(cliRoot, "..", "..");
@@ -105,13 +108,49 @@ function parseArgs(): { targets: BunTarget[] | null } {
return { targets: null };
}
// ── Validate prerequisites ────────────────────────────────────────────
if (!existsSync(dashboardClientSrc)) {
console.error(
`ERROR: Dashboard client not built. Expected: ${dashboardClientSrc}\n` +
`Run 'pnpm build' first to build all packages.`,
// ── Client asset staging ──────────────────────────────────────────────
type ClientAssetMode = "real" | "stub";
const CLIENT_STUB_HTML = `<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Fusion Dashboard</title>
</head>
<body>
<main>
<h1>Fusion Dashboard</h1>
<p>Dashboard assets not built — run \`pnpm build\` to generate full client assets.</p>
</main>
</body>
</html>
`;
function ensureClientAssets(): ClientAssetMode {
try {
if (existsSync(dashboardClientDest)) {
rmSync(dashboardClientDest, { recursive: true, force: true });
}
} catch {
// Ignore cleanup errors - directory might not exist or be accessible
}
mkdirSync(outDir, { recursive: true });
if (existsSync(dashboardClientSrc)) {
console.log("Copying dashboard client assets...");
cpSync(dashboardClientSrc, dashboardClientDest, { recursive: true });
console.log(`${dashboardClientDest}`);
return "real";
}
mkdirSync(dashboardClientDest, { recursive: true });
writeFileSync(join(dashboardClientDest, "index.html"), CLIENT_STUB_HTML, "utf-8");
console.warn(
`WARNING: Dashboard client assets not found at ${dashboardClientSrc}. Generated minimal stub at ${join(dashboardClientDest, "index.html")}.`,
);
process.exit(1);
return "stub";
}
// ── Copy native terminal assets for a specific target ─────────────────
@@ -170,23 +209,6 @@ function copyNativeAssets(target?: BunTarget) {
}
}
// ── Copy dashboard client assets alongside output ─────────────────────
// Express.static requires a real filesystem directory, so we co-locate
// the pre-built SPA next to the binary rather than embedding blobs.
function copyClientAssets() {
try {
if (existsSync(dashboardClientDest)) {
rmSync(dashboardClientDest, { recursive: true, force: true });
}
} catch {
// Ignore cleanup errors - directory might not exist or be accessible
}
console.log("Copying dashboard client assets...");
mkdirSync(outDir, { recursive: true });
cpSync(dashboardClientSrc, dashboardClientDest, { recursive: true });
console.log(`${dashboardClientDest}`);
}
// ── Compile a single binary ───────────────────────────────────────────
function compileBinary(outFile: string, target: string, isCrossCompile: boolean): boolean {
console.log(`Compiling ${outFile} (target: ${target})...`);
@@ -246,8 +268,8 @@ function compileBinary(outFile: string, target: string, isCrossCompile: boolean)
// ── Main ──────────────────────────────────────────────────────────────
const { targets } = parseArgs();
// Copy assets once (shared across all binaries)
copyClientAssets();
// Stage assets once (shared across all binaries)
const clientAssetMode = ensureClientAssets();
if (targets === null) {
// Default: build for current platform → dist/kb
@@ -255,7 +277,7 @@ if (targets === null) {
const ok = compileBinary(outBinary, "bun", false);
if (!ok) process.exit(1);
console.log(`\n✓ Built: ${outBinary}`);
console.log(` Assets: ${dashboardClientDest}`);
console.log(` Assets: ${dashboardClientDest} (${clientAssetMode})`);
console.log(` Runtime: ${runtimeDir}`);
console.log(`\nRun with: ${outBinary} --help`);
} else {
@@ -279,7 +301,7 @@ if (targets === null) {
console.log(` Built ${built.length} binaries:`);
built.forEach((b) => console.log(` dist/${b}`));
}
console.log(` Assets: ${dashboardClientDest}`);
console.log(` Assets: ${dashboardClientDest} (${clientAssetMode})`);
console.log(` Runtime: ${runtimeDir}`);
if (failed) process.exit(1);

View File

@@ -1,11 +1,25 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, beforeAll } from "vitest";
import { execSync } from "node:child_process";
import { readFileSync, existsSync, readdirSync } from "node:fs";
import { join } from "node:path";
const cliRoot = join(__dirname, "..", "..");
const bundlePath = join(cliRoot, "dist", "bin.js");
const clientIndexPath = join(cliRoot, "dist", "client", "index.html");
describe("CLI bundle output", () => {
beforeAll(() => {
if (existsSync(bundlePath) && existsSync(clientIndexPath)) {
return;
}
execSync("pnpm build", {
cwd: cliRoot,
stdio: "pipe",
timeout: 120_000,
});
}, 180_000);
it("dist/bin.js exists", () => {
expect(existsSync(bundlePath)).toBe(true);
});
@@ -31,8 +45,7 @@ describe("CLI bundle output", () => {
});
it("dashboard client assets are included", () => {
const clientIndex = join(cliRoot, "dist", "client", "index.html");
expect(existsSync(clientIndex)).toBe(true);
expect(existsSync(clientIndexPath)).toBe(true);
});
it("runtime native assets are staged after build:exe", () => {

View File

@@ -1,11 +1,26 @@
import { defineConfig } from "tsup";
import { cpSync, existsSync } from "node:fs";
import { cpSync, existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const dashboardClientSrc = join(__dirname, "..", "dashboard", "dist", "client");
const dashboardClientDest = join(__dirname, "dist", "client");
const dashboardClientStub = `<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Fusion Dashboard</title>
</head>
<body>
<main>
<h1>Fusion Dashboard</h1>
<p>Dashboard assets not built — run \`pnpm build\` to generate full client assets.</p>
</main>
</body>
</html>
`;
export default defineConfig({
entry: ["src/bin.ts", "src/extension.ts"],
@@ -16,11 +31,20 @@ export default defineConfig({
splitting: false,
clean: true,
onSuccess: async () => {
if (existsSync(dashboardClientDest)) {
rmSync(dashboardClientDest, { recursive: true, force: true });
}
if (existsSync(dashboardClientSrc)) {
cpSync(dashboardClientSrc, dashboardClientDest, { recursive: true });
console.log("Copied dashboard client assets to dist/client/");
} else {
console.warn("WARNING: Dashboard client assets not found at", dashboardClientSrc);
return;
}
mkdirSync(dashboardClientDest, { recursive: true });
writeFileSync(join(dashboardClientDest, "index.html"), dashboardClientStub, "utf-8");
console.warn(
`WARNING: Dashboard client assets not found at ${dashboardClientSrc}. Generated minimal stub at ${join(dashboardClientDest, "index.html")}.`,
);
},
});