// Manage a Home Assistant frontend production build with an agent-friendly // interface, matching build-scripts/dev-server.mjs. // // node build-scripts/build-manager.mjs [--modern] [mode] // // (no mode) Run in the foreground. // --background Start detached, print the pid, then exit and leave it // running. // --status Report whether a managed build is running. // --stop Stop a managed build. // --logs [--follow] Print (or follow) the background build log. // // --modern Build only the modern frontend_latest bundle. import path from "node:path"; import { fileURLToPath } from "node:url"; import { LIFECYCLE_MODE_FLAGS, acquireProcessRecord, isProcessRecordAlive, outputLog, offerToStopProcessRecord, processStartTime, readProcessRecord, releaseProcessRecord, runCli, spawnDetachedToLog, spawnForeground, terminateDetachedProcess, terminateProcess, waitFor, writeProcessRecord, } from "./managed-process.mjs"; import { buildCacheDir, describeOutputOwner, workflowLockEnv, workflowLockFile, } from "./output-lock.mjs"; const repoRoot = path.resolve( path.dirname(fileURLToPath(import.meta.url)), ".." ); const gulpBin = path.join(repoRoot, "node_modules", ".bin", "gulp"); const stateDir = path.join(buildCacheDir, "ha-build"); const logFile = path.join(stateDir, "build.log"); const lockFile = workflowLockFile; const usage = () => { process.stderr.write( "Usage: node build-scripts/build-manager.mjs [--modern] " + "[--background | --status | --stop | --logs [--follow]]\n" ); }; const parseArgs = (argv) => { const args = { mode: "foreground", modes: [], follow: false, modern: false, unknown: [], }; for (const arg of argv) { if (LIFECYCLE_MODE_FLAGS.has(arg)) { args.mode = LIFECYCLE_MODE_FLAGS.get(arg); args.modes.push(arg); } else if (arg === "--modern") { args.modern = true; } else if (arg === "--follow") { args.follow = true; } else { args.unknown.push(arg); } } return args; }; const hints = () => " Stop: yarn build --stop\n" + " Status: yarn build --status\n" + " Logs: yarn build --logs\n"; const devCommand = (suite) => { switch (suite) { case "app-serve": return "dev:serve"; case "demo": return "dev:demo"; case "gallery": return "dev:gallery"; case "e2e-app": return "test:e2e:app:dev"; default: return "dev"; } }; const readBuild = () => readProcessRecord(lockFile); const releaseBuild = (token) => releaseProcessRecord(lockFile, token); const stopCommandFor = (owner) => owner?.kind === "build" ? "yarn build --stop" : owner?.kind === "dev" ? `yarn ${devCommand(owner.suite)} --stop` : undefined; const acquireBuild = async (modern, foreground) => { const token = `${process.pid}-${Date.now()}-${Math.random()}`; const record = { pid: process.pid, startTime: processStartTime(process.pid), processGroup: false, foreground, kind: "build", modern, starting: true, token, }; const result = acquireProcessRecord(lockFile, record); if (result.acquired) { return { token }; } reportExisting(result.existing); return (await offerToStopProcessRecord({ file: lockFile, owner: result.existing, ownerDescription: describeOutputOwner(result.existing), stopCommand: stopCommandFor(result.existing), })) ? acquireBuild(modern, foreground) : { existing: result.existing }; }; const updateBuild = (token, child, processGroup) => { const existing = readBuild(); if (existing?.token !== token) { throw Error("Frontend build lock ownership was lost during startup."); } writeProcessRecord(lockFile, { ...existing, pid: child.pid, startTime: processStartTime(child.pid), processGroup, starting: false, }); }; const taskFor = (modern) => (modern ? "build-app-modern" : "build-app"); const reportExisting = (existing) => { if (existing?.kind === "output") { process.stdout.write( `${describeOutputOwner(existing)} already owns the build and development workflow` + `${existing.pid ? ` (pid ${existing.pid})` : ""}.\n` ); return; } if (existing?.kind === "dev") { const command = devCommand(existing.suite); process.stdout.write( `Dev server (${existing.suite}) already running` + `${existing.pid ? ` (pid ${existing.pid})` : ""}.\n` + ` Stop: yarn ${command} --stop\n` + ` Status: yarn ${command} --status\n` + ` Logs: yarn ${command} --logs\n` ); return; } process.stdout.write( `Frontend ${existing?.modern ? "modern " : ""}build already running` + `${existing?.pid ? ` (pid ${existing.pid})` : ""}.\n${hints()}` ); }; const runForeground = async (modern) => { const lock = await acquireBuild(modern, true); if (!lock.token) { return 1; } try { return await spawnForeground({ cmd: gulpBin, args: [taskFor(modern)], cwd: repoRoot, env: workflowLockEnv(lock.token), processGroup: true, onSpawn: (child) => updateBuild(lock.token, child, true), }); } finally { releaseBuild(lock.token); } }; const runBackground = async (modern) => { const lock = await acquireBuild(modern, false); if (!lock.token) { return 1; } let child; try { child = await spawnDetachedToLog({ cmd: gulpBin, args: [taskFor(modern)], cwd: repoRoot, env: workflowLockEnv(lock.token), logFile, }); updateBuild(lock.token, child, true); process.stdout.write( `Started ${modern ? "modern " : ""}frontend build (pid ${child.pid})\n` + hints() ); return 0; } catch (err) { if (child) { await terminateDetachedProcess(child); } releaseBuild(lock.token); throw err; } }; const runStatus = () => { const existing = readBuild(); if (existing?.kind === "build" && isProcessRecordAlive(existing)) { process.stdout.write( `Frontend ${existing.modern ? "modern " : ""}build running (pid ${existing.pid}).\n` ); } else { if (existing?.kind === "build") { releaseBuild(existing.token); } process.stdout.write("Frontend build not running.\n"); } return 0; }; const runStop = async () => { let existing = readBuild(); if (existing?.kind !== "build") { process.stdout.write("Frontend build not running.\n"); return 0; } if (existing?.starting) { const token = existing.token; await waitFor( () => { const current = readBuild(); return !current?.starting || current.token !== token; }, 100, 5000 ); existing = readBuild(); } if ( !existing || existing.kind !== "build" || !isProcessRecordAlive(existing) ) { if (existing) releaseBuild(existing.token); process.stdout.write("Frontend build not running.\n"); return 0; } if ( !(await terminateProcess({ pid: existing.pid, processGroup: existing.processGroup, isStopped: () => !isProcessRecordAlive(existing), })) ) { process.stderr.write( `Failed to stop frontend build (pid ${existing.pid}). Stop it manually.\n` ); return 1; } releaseBuild(existing.token); process.stdout.write(`Stopped frontend build (pid ${existing.pid}).\n`); return 0; }; const runLogs = (follow) => outputLog(logFile, follow, `No frontend build log yet (${logFile}).\n`); const main = async () => { const args = parseArgs(process.argv.slice(2)); if (args.unknown.length) { process.stderr.write(`Unknown arguments: ${args.unknown.join(" ")}\n`); usage(); return 1; } if (args.modes.length > 1 || (args.follow && args.mode !== "logs")) { process.stderr.write("Invalid combination of build arguments.\n"); usage(); return 1; } const handlers = { foreground: () => runForeground(args.modern), background: () => runBackground(args.modern), status: runStatus, stop: runStop, logs: () => runLogs(args.follow), }; return handlers[args.mode](); }; runCli(main);