import fs from "node:fs"; import { readFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; export const cloudWebDistPath = "web/hwlab-cloud-web/dist"; export const cloudWebDistBuildCommand = "cd web/hwlab-cloud-web && bun run build"; export const cloudWebDistFreshnessCommand = cloudWebDistBuildCommand; export const cloudWebAppSourceFiles = Object.freeze([ "app.ts", "app-device-pod.ts", "app-conversation.ts", "app-trace.ts", "app-helpers.ts" ]); export const cloudWebDistRuntimeFiles = Object.freeze([ "index.html", "styles.css", "app.js", "favicon.svg", "favicon.ico", "help.md", "third_party/marked/marked.esm.js", "third_party/marked/LICENSE" ]); export const cloudWebDistAliasFiles = Object.freeze(["gate/index.html", "diagnostics/gate/index.html", "help/index.html"]); const staticRuntimeFiles = cloudWebDistRuntimeFiles.filter((file) => file !== "app.js"); function absolutePath(rootDir, relativePath) { return path.resolve(rootDir, relativePath); } function exists(filePath) { return fs.existsSync(filePath); } export function readCloudWebAppSource(rootDir) { return cloudWebAppSourceFiles.map((file) => fs.readFileSync(absolutePath(rootDir, file), "utf8")).join("\n"); } async function compareFile({ files, mismatches, kind, relativePath, sourcePath, distPath }) { const entry = { kind, path: relativePath, sourcePath, distPath, status: "match" }; if (!exists(sourcePath)) { entry.status = "missing_source"; entry.reason = "source file is missing"; } else if (!exists(distPath)) { entry.status = "missing_dist"; entry.reason = "dist file is missing"; } else { const [sourceText, distText] = await Promise.all([ readFile(sourcePath, "utf8"), readFile(distPath, "utf8") ]); if (sourceText !== distText) { entry.status = "mismatch"; entry.reason = "dist file differs from source"; } } if (entry.status !== "match") { mismatches.push(relativePath); } files.push(entry); } export async function buildCloudWebDist(rootDir) { const distRoot = absolutePath(rootDir, "dist"); fs.rmSync(distRoot, { recursive: true, force: true }); fs.mkdirSync(distRoot, { recursive: true }); for (const relativePath of staticRuntimeFiles) { const distPath = absolutePath(distRoot, relativePath); fs.mkdirSync(path.dirname(distPath), { recursive: true }); fs.copyFileSync(absolutePath(rootDir, relativePath), distPath); } await buildAppBundle(rootDir, absolutePath(distRoot, "app.js")); for (const aliasPath of cloudWebDistAliasFiles) { const distPath = absolutePath(distRoot, aliasPath); fs.mkdirSync(path.dirname(distPath), { recursive: true }); fs.copyFileSync(absolutePath(rootDir, "index.html"), distPath); } return distRoot; } export async function inspectCloudWebDistFreshness(rootDir) { const distRoot = absolutePath(rootDir, "dist"); const files = []; const mismatches = []; for (const relativePath of staticRuntimeFiles) { await compareFile({ files, mismatches, kind: "runtime", relativePath, sourcePath: absolutePath(rootDir, relativePath), distPath: absolutePath(distRoot, relativePath) }); } await compareGeneratedApp({ files, mismatches, rootDir, distRoot }); for (const aliasPath of cloudWebDistAliasFiles) { await compareFile({ files, mismatches, kind: "alias", relativePath: aliasPath, sourcePath: absolutePath(rootDir, "index.html"), distPath: absolutePath(distRoot, aliasPath) }); } return { status: mismatches.length === 0 ? "pass" : "blocked", distPath: cloudWebDistPath, buildCommand: cloudWebDistBuildCommand, freshnessCommand: cloudWebDistFreshnessCommand, files, mismatches }; } export function cloudWebDistFreshnessMessage(distFreshness) { const mismatches = distFreshness.mismatches.length > 0 ? distFreshness.mismatches.join(", ") : "unknown"; return [ `Cloud Web dist is stale or missing before image build: ${mismatches}.`, `Run ${cloudWebDistBuildCommand} before publishing ${cloudWebDistPath}.` ].join(" "); } export async function assertCloudWebDistFresh(rootDir) { const distFreshness = await inspectCloudWebDistFreshness(rootDir); if (distFreshness.status !== "pass") { throw new Error(cloudWebDistFreshnessMessage(distFreshness)); } return distFreshness; } async function compareGeneratedApp({ files, mismatches, rootDir, distRoot }) { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "hwlab-cloud-web-app-")); const generatedPath = path.join(tmpDir, "app.js"); try { await buildAppBundle(rootDir, generatedPath); await compareFile({ files, mismatches, kind: "runtime", relativePath: "app.js", sourcePath: generatedPath, distPath: absolutePath(distRoot, "app.js") }); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); } } async function buildAppBundle(rootDir, outFile) { const entryPath = absolutePath(rootDir, ".app-entry.generated.ts"); const buildDir = fs.mkdtempSync(path.join(os.tmpdir(), "hwlab-cloud-web-bun-")); fs.writeFileSync(entryPath, `${readCloudWebAppSource(rootDir)}\n`); try { const result = await Bun.build({ entrypoints: [entryPath], outdir: buildDir, target: "browser", format: "esm", sourcemap: "none", minify: false, naming: "app.js" }); if (!result.success) { throw new Error(`Bun build failed:\n${result.logs.map((log) => String(log)).join("\n")}`); } fs.mkdirSync(path.dirname(outFile), { recursive: true }); fs.copyFileSync(path.join(buildDir, "app.js"), outFile); } finally { fs.rmSync(entryPath, { force: true }); fs.rmSync(buildDir, { recursive: true, force: true }); } } if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { const args = process.argv.slice(2); const rootIndex = args.indexOf("--root"); const rootDir = rootIndex >= 0 ? path.resolve(args[rootIndex + 1]) : path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); if (args.includes("--build")) { await buildCloudWebDist(rootDir); } const freshness = await inspectCloudWebDistFreshness(rootDir); process.stdout.write(`${JSON.stringify(freshness, null, args.includes("--pretty") ? 2 : 0)}\n`); }