Files
pikasTech-HWLAB/web/hwlab-cloud-web/scripts/dist-contract.ts
T
2026-06-17 16:51:38 +08:00

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