198 lines
8.2 KiB
TypeScript
198 lines
8.2 KiB
TypeScript
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<string> {
|
|
const distRoot = absolutePath(rootDir, "dist");
|
|
runViteBuild(rootDir, distRoot);
|
|
finalizeGeneratedDist(rootDir, distRoot);
|
|
return distRoot;
|
|
}
|
|
|
|
export async function inspectCloudWebDistFreshness(rootDir: string): Promise<DistFreshness> {
|
|
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<DistFreshness> {
|
|
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<void> {
|
|
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`);
|
|
}
|