import { spawnSync } from "node:child_process"; 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(["src/main.ts", "src/App.vue"]); export const cloudWebDistRuntimeFiles = Object.freeze(["index.html", "app.js", "assets/index.css", "assets/favicon.svg", "help.md"]); export const cloudWebDistAliasFiles = Object.freeze(["gate/index.html", "diagnostics/gate/index.html", "help/index.html", "skills/index.html", "access/index.html"]); type FreshnessStatus = "pass" | "blocked"; interface DistFileStatus { kind: "runtime" | "alias"; path: string; expectedPath: string; distPath: string; status: "match" | "missing_expected" | "missing_dist" | "mismatch"; } interface DistFreshness { status: FreshnessStatus; distPath: string; buildCommand: string; freshnessCommand: string; files: DistFileStatus[]; mismatches: string[]; } function absolutePath(rootDir: string, relativePath: string): string { return path.resolve(rootDir, relativePath); } function exists(filePath: string): boolean { return fs.existsSync(filePath); } export function readCloudWebAppSource(rootDir: string): string { return collectSourceFiles(path.join(rootDir, "src")).map((file) => fs.readFileSync(file, "utf8")).join("\n"); } export async function buildCloudWebDist(rootDir: string): Promise { const distRoot = absolutePath(rootDir, "dist"); runViteBuild(rootDir, distRoot); finalizeGeneratedDist(rootDir, distRoot); return distRoot; } export async function inspectCloudWebDistFreshness(rootDir: string): Promise { const distRoot = absolutePath(rootDir, "dist"); const expectedRoot = fs.mkdtempSync(path.join(os.tmpdir(), "hwlab-cloud-web-expected-")); const expectedDist = path.join(expectedRoot, "dist"); const files: DistFileStatus[] = []; const mismatches: string[] = []; try { runViteBuild(rootDir, expectedDist); finalizeGeneratedDist(rootDir, expectedDist); for (const relativePath of cloudWebDistRuntimeFiles) { await compareFile({ files, mismatches, kind: "runtime", relativePath, expectedPath: path.join(expectedDist, relativePath), distPath: path.join(distRoot, relativePath) }); } for (const aliasPath of cloudWebDistAliasFiles) { await compareFile({ files, mismatches, kind: "alias", relativePath: aliasPath, expectedPath: path.join(expectedDist, aliasPath), distPath: path.join(distRoot, aliasPath) }); } } finally { fs.rmSync(expectedRoot, { recursive: true, force: true }); } return { status: mismatches.length === 0 ? "pass" : "blocked", distPath: cloudWebDistPath, buildCommand: cloudWebDistBuildCommand, freshnessCommand: cloudWebDistFreshnessCommand, files, mismatches }; } export function cloudWebDistFreshnessMessage(distFreshness: { mismatches: string[] }): string { 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: string): Promise { const distFreshness = await inspectCloudWebDistFreshness(rootDir); if (distFreshness.status !== "pass") throw new Error(cloudWebDistFreshnessMessage(distFreshness)); return distFreshness; } function runViteBuild(rootDir: string, outDir: string): void { ensureWebDeps(rootDir); const rootLocalVite = path.join(rootDir, "node_modules", ".bin", "vite"); const packageLocalVite = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "node_modules", ".bin", "vite"); const localVite = exists(rootLocalVite) ? rootLocalVite : exists(packageLocalVite) ? packageLocalVite : null; const command = localVite ?? "npx"; const args = localVite ? ["build", "--outDir", outDir, "--emptyOutDir"] : ["--yes", "vite@5.4.10", "build", "--outDir", outDir, "--emptyOutDir"]; const result = spawnSync(command, args, { cwd: rootDir, encoding: "utf8" }); if (result.stdout) process.stdout.write(result.stdout); if (result.stderr) process.stderr.write(result.stderr); if (result.status !== 0) throw new Error(`Vite build failed with exit ${result.status ?? "unknown"}`); } function finalizeGeneratedDist(rootDir: string, distRoot: string): void { const helpSource = absolutePath(rootDir, "help.md"); if (exists(helpSource)) fs.copyFileSync(helpSource, path.join(distRoot, "help.md")); for (const aliasPath of cloudWebDistAliasFiles) { const distPath = path.join(distRoot, aliasPath); fs.mkdirSync(path.dirname(distPath), { recursive: true }); fs.copyFileSync(path.join(distRoot, "index.html"), distPath); } } async function compareFile(input: { files: DistFileStatus[]; mismatches: string[]; kind: "runtime" | "alias"; relativePath: string; expectedPath: string; distPath: string }): Promise { const entry: DistFileStatus = { kind: input.kind, path: input.relativePath, expectedPath: input.expectedPath, distPath: input.distPath, status: "match" }; if (!exists(input.expectedPath)) entry.status = "missing_expected"; else if (!exists(input.distPath)) entry.status = "missing_dist"; else { const [expectedText, distText] = await Promise.all([readFile(input.expectedPath, "utf8"), readFile(input.distPath, "utf8")]); if (expectedText !== distText) entry.status = "mismatch"; } if (entry.status !== "match") input.mismatches.push(input.relativePath); input.files.push(entry); } function collectSourceFiles(dir: string): string[] { const out: string[] = []; for (const entry of fs.readdirSync(dir)) { const full = path.join(dir, entry); const stat = fs.statSync(full); if (stat.isDirectory()) out.push(...collectSourceFiles(full)); else if (/\.(ts|vue|css)$/u.test(full)) out.push(full); } return out.sort(); } function ensureWebDeps(rootDir: string): void { const lockPath = path.join(rootDir, "bun.lock"); const nodeModulesPath = path.join(rootDir, "node_modules"); if (!fs.existsSync(lockPath)) return; if (hasRequiredWebDeps(nodeModulesPath)) return; const repoRoot = path.resolve(rootDir, "..", ".."); const worktreeDepsHelper = path.join(repoRoot, "scripts", "worktree-deps.mjs"); if (fs.existsSync(worktreeDepsHelper)) { const helperResult = spawnSync("node", [worktreeDepsHelper, "--web", "--quiet"], { cwd: repoRoot, encoding: "utf8", stdio: ["ignore", "inherit", "inherit"] }); if (helperResult.status !== 0) throw new Error(`worktree web dependency setup exited with code ${helperResult.status ?? "unknown"}`); if (hasRequiredWebDeps(nodeModulesPath)) return; throw new Error("worktree web dependency setup completed but required packages are still missing"); } const result = spawnSync("bun", ["install", "--frozen-lockfile"], { cwd: rootDir, encoding: "utf8", stdio: ["ignore", "inherit", "inherit"] }); if (result.status !== 0) throw new Error(`bun install exited with code ${result.status ?? "unknown"}`); } function hasRequiredWebDeps(nodeModulesPath: string): boolean { return ["vite", "vue", "@vitejs/plugin-vue", "vue-tsc", "vitest"].every((name) => fs.existsSync(path.join(nodeModulesPath, name))) && [path.join(".bin", "vite"), path.join(".bin", "vue-tsc")].every((name) => fs.existsSync(path.join(nodeModulesPath, name))); } 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`); }