fix: reuse cloud web deps across worktrees

This commit is contained in:
lyon
2026-06-17 16:51:38 +08:00
parent 08e17aa063
commit 7434d891c0
4 changed files with 192 additions and 3 deletions
+1
View File
@@ -28,6 +28,7 @@ vendor/
# Node package managers, for future web work
node_modules/
node_modules
.pnpm-store/
npm-debug.log*
yarn-debug.log*
+3 -2
View File
@@ -23,8 +23,9 @@
"deploy:desired-state:plan": "node scripts/run-bun.mjs scripts/deploy-desired-state-plan.mjs --pretty",
"deploy:desired-state:check": "node scripts/run-bun.mjs scripts/deploy-desired-state-plan.mjs --check",
"cli:client": "node scripts/run-bun.mjs tools/hwlab-cli/bin/hwlab-cli.ts client",
"web:check": "cd web/hwlab-cloud-web && bun run check",
"web:build": "(cd web/hwlab-cloud-web && bun run build)",
"worktree:deps": "node scripts/worktree-deps.mjs --web",
"web:check": "node scripts/worktree-deps.mjs --web && cd web/hwlab-cloud-web && bun run check",
"web:build": "node scripts/worktree-deps.mjs --web && cd web/hwlab-cloud-web && bun run build",
"artifact-catalog:preview-blocked": "node scripts/refresh-artifact-catalog.mjs --target-ref HEAD --blocked --no-write",
"lane:expand": "node scripts/hwlab-lane-expand.mjs",
"gitops:render": "node scripts/run-bun.mjs scripts/gitops-render.mjs",
+172
View File
@@ -0,0 +1,172 @@
#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
const webRelativePath = path.join("web", "hwlab-cloud-web");
const sharedWebDepsName = "hwlab-cloud-web-deps";
const requiredWebPackages = ["vite", "vue", "@vitejs/plugin-vue", "vue-tsc", "vitest"];
const requiredWebBins = [path.join(".bin", "vite"), path.join(".bin", "vue-tsc")];
const allowedArgs = new Set(["--web", "--json", "--quiet", "--help"]);
const args = process.argv.slice(2);
const argSet = new Set(args);
try {
for (const arg of args) {
if (!allowedArgs.has(arg)) fail(`Unknown argument: ${arg}`);
}
if (argSet.has("--help")) {
process.stdout.write(`Usage: node scripts/worktree-deps.mjs [--web] [--json] [--quiet]\n\nPrepares shared, ignored dependencies for HWLAB git worktrees.\n`);
process.exit(0);
}
const repoRoot = findRepoRoot(process.cwd());
const result = ensureWebDeps(repoRoot);
writeResult(result);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (argSet.has("--json")) process.stdout.write(`${JSON.stringify({ ok: false, error: message })}\n`);
else process.stderr.write(`hwlab worktree deps: ${message}\n`);
process.exit(1);
}
function ensureWebDeps(repoRoot) {
const webRoot = path.join(repoRoot, webRelativePath);
const packagePath = path.join(webRoot, "package.json");
const lockPath = path.join(webRoot, "bun.lock");
if (!fs.existsSync(packagePath)) fail(`Missing ${path.relative(repoRoot, packagePath)}`);
if (!fs.existsSync(lockPath)) fail(`Missing ${path.relative(repoRoot, lockPath)}`);
const fixedRoot = fixedRepoRoot(repoRoot);
const sharedRoot = path.join(fixedRoot, ".worktree", sharedWebDepsName);
const sharedNodeModules = path.join(sharedRoot, "node_modules");
const nodeModulesPath = path.join(webRoot, "node_modules");
const existing = tryLstat(nodeModulesPath);
if (hasRequiredWebDeps(nodeModulesPath)) {
return {
ok: true,
mode: existing?.isSymbolicLink() ? "shared-symlink" : "local-existing",
repoRoot,
fixedRoot,
webRoot,
sharedRoot,
nodeModulesPath,
installRan: false,
linked: existing?.isSymbolicLink() ?? false
};
}
if (existing && !existing.isSymbolicLink()) {
if (!existing.isDirectory()) fail(`${path.relative(repoRoot, nodeModulesPath)} exists but is not a directory or symlink`);
runBunInstall(webRoot);
if (!hasRequiredWebDeps(nodeModulesPath)) fail("Local web dependency install completed but required packages are still missing");
return {
ok: true,
mode: "local-install",
repoRoot,
fixedRoot,
webRoot,
sharedRoot,
nodeModulesPath,
installRan: true,
linked: false
};
}
const shared = ensureSharedWebDeps(webRoot, sharedRoot, sharedNodeModules);
linkNodeModules(nodeModulesPath, sharedNodeModules);
if (!hasRequiredWebDeps(nodeModulesPath)) fail("Shared web dependency link completed but required packages are still missing");
return {
ok: true,
mode: "shared-symlink",
repoRoot,
fixedRoot,
webRoot,
sharedRoot,
nodeModulesPath,
installRan: shared.installRan,
linked: true
};
}
function ensureSharedWebDeps(webRoot, sharedRoot, sharedNodeModules) {
fs.mkdirSync(sharedRoot, { recursive: true });
const packageChanged = copyIfDifferent(path.join(webRoot, "package.json"), path.join(sharedRoot, "package.json"));
const lockChanged = copyIfDifferent(path.join(webRoot, "bun.lock"), path.join(sharedRoot, "bun.lock"));
const needsInstall = packageChanged || lockChanged || !hasRequiredWebDeps(sharedNodeModules);
if (needsInstall) runBunInstall(sharedRoot);
if (!hasRequiredWebDeps(sharedNodeModules)) fail(`Shared dependency install is incomplete at ${sharedNodeModules}`);
return { installRan: needsInstall };
}
function linkNodeModules(nodeModulesPath, sharedNodeModules) {
const existing = tryLstat(nodeModulesPath);
if (existing?.isSymbolicLink()) fs.unlinkSync(nodeModulesPath);
else if (existing) fail(`${nodeModulesPath} already exists and is not a symlink`);
const relativeTarget = path.relative(path.dirname(nodeModulesPath), sharedNodeModules) || sharedNodeModules;
fs.symlinkSync(relativeTarget, nodeModulesPath, "dir");
}
function runBunInstall(cwd) {
const result = spawnSync("bun", ["install", "--frozen-lockfile"], { cwd, encoding: "utf8", stdio: ["ignore", "inherit", "inherit"] });
if (result.status !== 0) fail(`bun install --frozen-lockfile failed in ${cwd} with exit ${result.status ?? "unknown"}`);
}
function hasRequiredWebDeps(nodeModulesPath) {
return requiredWebPackages.every((name) => fs.existsSync(path.join(nodeModulesPath, name))) && requiredWebBins.every((name) => fs.existsSync(path.join(nodeModulesPath, name)));
}
function copyIfDifferent(source, target) {
if (fs.existsSync(target) && fs.readFileSync(source).equals(fs.readFileSync(target))) return false;
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.copyFileSync(source, target);
return true;
}
function findRepoRoot(startDir) {
const gitResult = spawnSync("git", ["rev-parse", "--show-toplevel"], { cwd: startDir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
if (gitResult.status === 0 && gitResult.stdout.trim()) return path.resolve(gitResult.stdout.trim());
let current = path.resolve(startDir);
while (true) {
if (fs.existsSync(path.join(current, "package.json")) && fs.existsSync(path.join(current, webRelativePath, "package.json"))) return current;
const parent = path.dirname(current);
if (parent === current) break;
current = parent;
}
fail(`Unable to find HWLAB repo root from ${startDir}`);
}
function fixedRepoRoot(repoRoot) {
const marker = `${path.sep}.worktree${path.sep}`;
const index = repoRoot.indexOf(marker);
return index >= 0 ? repoRoot.slice(0, index) : repoRoot;
}
function tryLstat(filePath) {
try {
return fs.lstatSync(filePath);
} catch (error) {
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") return null;
throw error;
}
}
function writeResult(result) {
if (argSet.has("--json")) {
process.stdout.write(`${JSON.stringify(result)}\n`);
return;
}
if (argSet.has("--quiet")) return;
const install = result.installRan ? "install=ran" : "install=skipped";
const link = result.linked ? `node_modules -> ${path.relative(result.webRoot, path.join(result.sharedRoot, "node_modules"))}` : "node_modules=local";
process.stdout.write(`hwlab worktree deps: web ready (${result.mode}, ${install}, ${link})\n`);
}
function fail(message) {
throw new Error(message);
}
+16 -1
View File
@@ -167,11 +167,26 @@ 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 (fs.existsSync(path.join(nodeModulesPath, "vite")) && fs.existsSync(path.join(nodeModulesPath, "vue")) && fs.existsSync(path.join(nodeModulesPath, "@vitejs/plugin-vue"))) 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");