#!/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", "@playwright/test"]; const requiredWebBins = [path.join(".bin", "vite"), path.join(".bin", "vue-tsc"), path.join(".bin", "playwright")]; 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); }