diff --git a/.gitignore b/.gitignore index 99bf1bbe..be4a82c5 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ vendor/ # Node package managers, for future web work node_modules/ +node_modules .pnpm-store/ npm-debug.log* yarn-debug.log* diff --git a/package.json b/package.json index 886b6e71..7e364b60 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/worktree-deps.mjs b/scripts/worktree-deps.mjs new file mode 100644 index 00000000..f8ba072c --- /dev/null +++ b/scripts/worktree-deps.mjs @@ -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); +} diff --git a/web/hwlab-cloud-web/scripts/dist-contract.ts b/web/hwlab-cloud-web/scripts/dist-contract.ts index 234908c5..846e430a 100644 --- a/web/hwlab-cloud-web/scripts/dist-contract.ts +++ b/web/hwlab-cloud-web/scripts/dist-contract.ts @@ -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");