6ecfe015cf
Co-authored-by: Codex <codex@local>
230 lines
8.1 KiB
TypeScript
230 lines
8.1 KiB
TypeScript
import fs from "node:fs";
|
|
import { readFile } from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { spawn } from "node:child_process";
|
|
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 cloudWebAppEntry = "web/hwlab-cloud-web/src/main.tsx";
|
|
export const cloudWebStaticSourceFiles = Object.freeze([
|
|
"help.md",
|
|
"favicon.ico",
|
|
"third_party/marked/marked.esm.js",
|
|
"third_party/marked/LICENSE",
|
|
]);
|
|
export const cloudWebDistRuntimeFiles = Object.freeze([
|
|
"index.html",
|
|
"app.js",
|
|
"help.md",
|
|
"favicon.ico",
|
|
"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",
|
|
"skills/index.html",
|
|
]);
|
|
|
|
const VITE_OUTPUTS = Object.freeze(["index.html", "app.js"]);
|
|
const VITE_ASSET_DIR = "app-assets";
|
|
|
|
function absolutePath(rootDir, relativePath) {
|
|
return path.resolve(rootDir, relativePath);
|
|
}
|
|
|
|
function exists(filePath) {
|
|
return fs.existsSync(filePath);
|
|
}
|
|
|
|
export async function buildCloudWebDist(rootDir) {
|
|
const distRoot = absolutePath(rootDir, "dist");
|
|
fs.rmSync(distRoot, { recursive: true, force: true });
|
|
fs.mkdirSync(distRoot, { recursive: true });
|
|
await runViteBuild(rootDir, distRoot);
|
|
|
|
for (const relativePath of cloudWebStaticSourceFiles) {
|
|
const distPath = absolutePath(distRoot, relativePath);
|
|
fs.mkdirSync(path.dirname(distPath), { recursive: true });
|
|
fs.copyFileSync(absolutePath(rootDir, relativePath), distPath);
|
|
}
|
|
|
|
const builtIndex = absolutePath(distRoot, "index.html");
|
|
for (const aliasPath of cloudWebDistAliasFiles) {
|
|
const distPath = absolutePath(distRoot, aliasPath);
|
|
fs.mkdirSync(path.dirname(distPath), { recursive: true });
|
|
fs.copyFileSync(builtIndex, distPath);
|
|
}
|
|
|
|
return distRoot;
|
|
}
|
|
|
|
export async function inspectCloudWebDistFreshness(rootDir) {
|
|
const distRoot = absolutePath(rootDir, "dist");
|
|
const files = [];
|
|
const mismatches = [];
|
|
|
|
for (const relativePath of cloudWebStaticSourceFiles) {
|
|
await compareFile({
|
|
files,
|
|
mismatches,
|
|
kind: "static",
|
|
relativePath,
|
|
sourcePath: absolutePath(rootDir, relativePath),
|
|
distPath: absolutePath(distRoot, relativePath),
|
|
});
|
|
}
|
|
|
|
await compareViteBundle({ files, mismatches, rootDir, distRoot });
|
|
|
|
const builtIndex = absolutePath(distRoot, "index.html");
|
|
for (const aliasPath of cloudWebDistAliasFiles) {
|
|
await compareFile({
|
|
files,
|
|
mismatches,
|
|
kind: "alias",
|
|
relativePath: aliasPath,
|
|
sourcePath: builtIndex,
|
|
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 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);
|
|
}
|
|
|
|
async function compareViteBundle({ files, mismatches, rootDir, distRoot }) {
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "hwlab-cloud-web-vite-"));
|
|
try {
|
|
await runViteBuild(rootDir, tmpDir);
|
|
for (const viteOutput of VITE_OUTPUTS) {
|
|
await compareFile({
|
|
files,
|
|
mismatches,
|
|
kind: "vite",
|
|
relativePath: viteOutput,
|
|
sourcePath: absolutePath(tmpDir, viteOutput),
|
|
distPath: absolutePath(distRoot, viteOutput),
|
|
});
|
|
}
|
|
const tmpAssets = path.join(tmpDir, VITE_ASSET_DIR);
|
|
const distAssets = path.join(distRoot, VITE_ASSET_DIR);
|
|
const tmpAssetFiles = fs.existsSync(tmpAssets) ? listFiles(tmpAssets) : [];
|
|
const distAssetFiles = fs.existsSync(distAssets) ? listFiles(distAssets) : [];
|
|
const seen = new Set([...tmpAssetFiles, ...distAssetFiles]);
|
|
for (const rel of seen) {
|
|
const a = path.join(tmpAssets, rel);
|
|
const b = path.join(distAssets, rel);
|
|
if (!exists(a) || !exists(b)) {
|
|
mismatches.push(`app-assets/${rel}`);
|
|
files.push({ kind: "vite-asset", path: `app-assets/${rel}`, sourcePath: a, distPath: b, status: "mismatch", reason: "vite asset missing" });
|
|
continue;
|
|
}
|
|
const [aText, bText] = await Promise.all([readFile(a, "utf8"), readFile(b, "utf8")]);
|
|
if (aText !== bText) {
|
|
mismatches.push(`app-assets/${rel}`);
|
|
files.push({ kind: "vite-asset", path: `app-assets/${rel}`, sourcePath: a, distPath: b, status: "mismatch", reason: "vite asset drifted" });
|
|
}
|
|
}
|
|
} finally {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
function listFiles(dir) {
|
|
return fs.readdirSync(dir).flatMap((entry) => {
|
|
const full = path.join(dir, entry);
|
|
const stat = fs.statSync(full);
|
|
if (stat.isDirectory()) return listFiles(full).map((sub) => `${entry}/${sub}`);
|
|
return [entry];
|
|
});
|
|
}
|
|
|
|
async function runViteBuild(rootDir, outDir) {
|
|
await ensureWebDeps(rootDir);
|
|
const localVite = path.join(rootDir, "node_modules", ".bin", "vite");
|
|
const command = fs.existsSync(localVite) ? localVite : "npx";
|
|
const args = fs.existsSync(localVite)
|
|
? ["build", "--outDir", outDir, "--emptyOutDir", "false"]
|
|
: ["--yes", "vite@5.4.10", "build", "--outDir", outDir, "--emptyOutDir", "false"];
|
|
await new Promise((resolve, reject) => {
|
|
const child = spawn(command, args, { cwd: rootDir, stdio: ["ignore", "inherit", "inherit"] });
|
|
child.on("error", reject);
|
|
child.on("exit", (code) => {
|
|
if (code === 0) resolve();
|
|
else reject(new Error(`vite build exited with code ${code}`));
|
|
});
|
|
});
|
|
}
|
|
|
|
async function ensureWebDeps(rootDir) {
|
|
const lockPath = path.join(rootDir, "bun.lock");
|
|
const nodeModulesPath = path.join(rootDir, "node_modules");
|
|
if (!fs.existsSync(lockPath)) return;
|
|
if (fs.existsSync(path.join(nodeModulesPath, "vite")) && fs.existsSync(path.join(nodeModulesPath, "react"))) return;
|
|
await new Promise((resolve, reject) => {
|
|
const child = spawn("bun", ["install", "--frozen-lockfile"], { cwd: rootDir, stdio: ["ignore", "inherit", "inherit"] });
|
|
child.on("error", reject);
|
|
child.on("exit", (code) => {
|
|
if (code === 0) resolve();
|
|
else reject(new Error(`bun install exited with code ${code}`));
|
|
});
|
|
});
|
|
}
|
|
|
|
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`);
|
|
}
|