Files
pikasTech-HWLAB/web/hwlab-cloud-web/scripts/dist-contract.ts
T
2026-06-03 17:28:30 +08:00

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`);
}