mirror of
https://github.com/home-assistant/frontend.git
synced 2026-07-08 06:53:54 +01:00
67e1ca3f43
* Dont open demo * Scripts to run script/develop* * Scripts for dev demo and gallery * Background wrapper for agents with `--background` * Background `yarn dev` and `dev:serve` for agents * Dedupe lifecycle functions * Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
679 lines
19 KiB
JavaScript
679 lines
19 KiB
JavaScript
// Manage a Home Assistant frontend dev server with an agent-friendly interface.
|
|
//
|
|
// node build-scripts/dev-server.mjs --suite <suite> [mode] [extra args]
|
|
//
|
|
// (no mode) Run in the foreground.
|
|
// --background Start detached, wait until it is ready, print the URL
|
|
// (when it has one) and pid, then exit and leave it running.
|
|
// --status Report whether the suite's dev server is running.
|
|
// --stop Stop a running background dev server.
|
|
// --logs [--follow] Print (or follow) the background dev server log.
|
|
//
|
|
// Extra args (for example -p or -c on app-serve) are forwarded to the underlying
|
|
// script. Suites use one of two liveness models:
|
|
//
|
|
// health demo, gallery, e2e-app: a fixed port plus the /__ha_dev_status
|
|
// endpoint each dev server exposes (see runDevServer in
|
|
// build-scripts/gulp/rspack.js). The port is the source of truth and
|
|
// the pid is found from it; no state file.
|
|
// process app (yarn dev) and app-serve (yarn dev:serve): the app watcher has
|
|
// no health endpoint, and plain yarn dev has no port at all, so these
|
|
// track a pidfile and treat the first "Build done" log line as ready.
|
|
|
|
import { spawn, execFileSync } from "node:child_process";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const repoRoot = path.resolve(
|
|
path.dirname(fileURLToPath(import.meta.url)),
|
|
".."
|
|
);
|
|
const gulpBin = path.join(repoRoot, "node_modules", ".bin", "gulp");
|
|
const developAndServeScript = path.join(
|
|
repoRoot,
|
|
"script",
|
|
"develop_and_serve"
|
|
);
|
|
const logDir = path.join(repoRoot, "node_modules", ".cache", "ha-dev-server");
|
|
|
|
// Each suite names its yarn alias (for hints), a liveness model, and how to
|
|
// spawn it. health suites carry a fixed port; process suites carry the log line
|
|
// that means "ready" and, for app-serve, forward extra args to the script.
|
|
const SUITES = {
|
|
"e2e-app": {
|
|
alias: "test:e2e:app:dev",
|
|
liveness: "health",
|
|
port: 8095,
|
|
spawn: { cmd: gulpBin, args: ["develop-e2e-test-app"] },
|
|
},
|
|
demo: {
|
|
alias: "dev:demo",
|
|
liveness: "health",
|
|
port: 8090,
|
|
spawn: { cmd: gulpBin, args: ["develop-demo"] },
|
|
},
|
|
gallery: {
|
|
alias: "dev:gallery",
|
|
liveness: "health",
|
|
port: 8100,
|
|
spawn: { cmd: gulpBin, args: ["develop-gallery"] },
|
|
},
|
|
app: {
|
|
alias: "dev",
|
|
liveness: "process",
|
|
readyLog: /Build done @/,
|
|
spawn: { cmd: gulpBin, args: ["develop-app"] },
|
|
},
|
|
"app-serve": {
|
|
alias: "dev:serve",
|
|
liveness: "process",
|
|
acceptsArgs: true,
|
|
readyLog: /Build done @/,
|
|
spawn: { cmd: developAndServeScript, args: [] },
|
|
},
|
|
};
|
|
|
|
// Cover a cold build on a slow machine before the server starts listening.
|
|
// Override with HA_DEV_SERVER_TIMEOUT (seconds).
|
|
const READY_TIMEOUT_MS =
|
|
Number(process.env.HA_DEV_SERVER_TIMEOUT || "180") * 1000;
|
|
|
|
// Detect a coding agent from a small set of environment markers set by common
|
|
// agent CLIs (env-only; no process-ancestry detection).
|
|
const detectAgent = () => {
|
|
const env = process.env;
|
|
const has = (name) => Boolean(env[name]);
|
|
const eq = (name, value) => env[name] === value;
|
|
const signals = {
|
|
opencode: () =>
|
|
[
|
|
"OPENCODE",
|
|
"OPENCODE_BIN_PATH",
|
|
"OPENCODE_SERVER",
|
|
"OPENCODE_APP_INFO",
|
|
].some(has),
|
|
"claude-code": () => has("CLAUDECODE"),
|
|
cursor: () => has("CURSOR_TRACE_ID"),
|
|
"github-copilot": () =>
|
|
eq("TERM_PROGRAM", "vscode") && eq("GIT_PAGER", "cat"),
|
|
// Convention shared by several agents (Crush, Amp, ...).
|
|
generic: () => has("AGENT") || has("AI_AGENT"),
|
|
};
|
|
return Object.keys(signals).find((id) => signals[id]());
|
|
};
|
|
|
|
const usage = () => {
|
|
const suites = Object.keys(SUITES).join("|");
|
|
process.stderr.write(
|
|
`Usage: node build-scripts/dev-server.mjs --suite <${suites}> ` +
|
|
`[--background | --status | --stop | --logs [--follow]]\n`
|
|
);
|
|
};
|
|
|
|
const parseArgs = (argv) => {
|
|
const args = {
|
|
mode: "foreground",
|
|
follow: false,
|
|
suite: undefined,
|
|
passthrough: [],
|
|
};
|
|
for (let i = 0; i < argv.length; i++) {
|
|
const arg = argv[i];
|
|
switch (arg) {
|
|
case "--suite":
|
|
args.suite = argv[++i];
|
|
break;
|
|
case "--background":
|
|
args.mode = "background";
|
|
break;
|
|
case "--status":
|
|
args.mode = "status";
|
|
break;
|
|
case "--stop":
|
|
args.mode = "stop";
|
|
break;
|
|
case "--logs":
|
|
args.mode = "logs";
|
|
break;
|
|
case "--follow":
|
|
args.follow = true;
|
|
break;
|
|
default:
|
|
// Anything unrecognised is forwarded to the underlying script.
|
|
args.passthrough.push(arg);
|
|
}
|
|
}
|
|
return args;
|
|
};
|
|
|
|
const sleep = (ms) =>
|
|
new Promise((resolve) => {
|
|
setTimeout(resolve, ms);
|
|
});
|
|
|
|
const logFileFor = (suite) => path.join(logDir, `${suite}.log`);
|
|
const pidFileFor = (suite) => path.join(logDir, `${suite}.pid`);
|
|
|
|
const hints = (suite) => {
|
|
const alias = `yarn ${SUITES[suite].alias}`;
|
|
return (
|
|
` Stop: ${alias} --stop\n` +
|
|
` Status: ${alias} --status\n` +
|
|
` Logs: ${alias} --logs\n`
|
|
);
|
|
};
|
|
|
|
// --- shared spawning and lifecycle ------------------------------------------
|
|
|
|
// Signal the whole process group (the background server is its group leader),
|
|
// falling back to the bare pid if that is not permitted.
|
|
const killProcessTree = (pid, sig) => {
|
|
try {
|
|
process.kill(-pid, sig);
|
|
} catch {
|
|
try {
|
|
process.kill(pid, sig);
|
|
} catch {
|
|
// Already gone.
|
|
}
|
|
}
|
|
};
|
|
|
|
const urlSuffix = (port) => (port ? ` at http://localhost:${port}` : "");
|
|
|
|
// Run a server in the foreground, inheriting stdio; resolve with its exit code.
|
|
const spawnInherit = (cmd, args) =>
|
|
new Promise((resolve) => {
|
|
const child = spawn(cmd, args, { cwd: repoRoot, stdio: "inherit" });
|
|
child.on("exit", (code) => resolve(code ?? 0));
|
|
});
|
|
|
|
// Spawn a detached server that writes stdout and stderr to the suite's log file.
|
|
const spawnDetachedToLog = (suite, cmd, args) => {
|
|
fs.mkdirSync(logDir, { recursive: true });
|
|
const logFile = logFileFor(suite);
|
|
const fd = fs.openSync(logFile, "w");
|
|
const child = spawn(cmd, args, {
|
|
cwd: repoRoot,
|
|
detached: true,
|
|
stdio: ["ignore", fd, fd],
|
|
});
|
|
fs.closeSync(fd);
|
|
child.unref();
|
|
return { child, logFile };
|
|
};
|
|
|
|
// Poll until the server is ready, the child exits, or we time out. Prints the
|
|
// progress dots and outcome; returns 0 when ready, 1 otherwise. onExit runs if
|
|
// the child dies before it is ready (used to clear a stale pidfile).
|
|
const awaitReady = async ({ suite, child, logFile, port, isReady, onExit }) => {
|
|
let childExited = false;
|
|
child.on("exit", () => {
|
|
childExited = true;
|
|
});
|
|
const deadline = Date.now() + READY_TIMEOUT_MS;
|
|
process.stdout.write(`Starting ${suite} dev server`);
|
|
/* eslint-disable no-await-in-loop -- poll until the server is ready */
|
|
while (Date.now() < deadline) {
|
|
if (childExited) {
|
|
process.stdout.write("\n");
|
|
process.stderr.write(
|
|
`Dev server (${suite}) exited before it was ready. See ${logFile}\n`
|
|
);
|
|
onExit?.();
|
|
return 1;
|
|
}
|
|
if (await isReady()) {
|
|
process.stdout.write("\n");
|
|
process.stdout.write(
|
|
`Dev server (${suite}) running${urlSuffix(port)} ` +
|
|
`(pid ${child.pid})\n${hints(suite)}`
|
|
);
|
|
return 0;
|
|
}
|
|
process.stdout.write(".");
|
|
await sleep(1000);
|
|
}
|
|
/* eslint-enable no-await-in-loop */
|
|
process.stdout.write("\n");
|
|
process.stderr.write(
|
|
`Dev server (${suite}) did not become ready within ${
|
|
READY_TIMEOUT_MS / 1000
|
|
}s. See ${logFile}\n`
|
|
);
|
|
return 1;
|
|
};
|
|
|
|
// Stop a running background server: SIGTERM, wait for it to go, then SIGKILL.
|
|
// isStopped reports when it is gone; onStopped runs on success (pidfile cleanup).
|
|
const terminate = async (suite, pid, isStopped, onStopped) => {
|
|
killProcessTree(pid, "SIGTERM");
|
|
const deadline = Date.now() + 10_000;
|
|
/* eslint-disable no-await-in-loop -- poll until the server is gone */
|
|
while (Date.now() < deadline) {
|
|
await sleep(300);
|
|
if (await isStopped()) {
|
|
onStopped?.();
|
|
process.stdout.write(`Stopped dev server (${suite}) (pid ${pid}).\n`);
|
|
return 0;
|
|
}
|
|
}
|
|
/* eslint-enable no-await-in-loop */
|
|
// Escalate if it is still up.
|
|
killProcessTree(pid, "SIGKILL");
|
|
await sleep(300);
|
|
if (!(await isStopped())) {
|
|
process.stderr.write(
|
|
`Failed to stop dev server (${suite}) (pid ${pid}). Stop it manually.\n`
|
|
);
|
|
return 1;
|
|
}
|
|
onStopped?.();
|
|
process.stdout.write(`Stopped dev server (${suite}) (pid ${pid}).\n`);
|
|
return 0;
|
|
};
|
|
|
|
// --- health liveness (port + /__ha_dev_status) ------------------------------
|
|
|
|
/**
|
|
* Probe the health endpoint. Dev servers bind IPv4 or IPv6 localhost depending
|
|
* on the OS, so try each; the port is "free" only if every address refuses.
|
|
* @returns {Promise<{state: "ours" | "foreign" | "free", suite?: string}>}
|
|
*/
|
|
const PROBE_HOSTS = ["localhost", "127.0.0.1", "[::1]"];
|
|
|
|
const probe = async (port, timeoutMs = 1000) => {
|
|
let sawResponse = false;
|
|
/* eslint-disable no-await-in-loop -- probe localhost addresses in order, stopping at the first that answers */
|
|
for (const host of PROBE_HOSTS) {
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
try {
|
|
const res = await fetch(`http://${host}:${port}/__ha_dev_status`, {
|
|
signal: controller.signal,
|
|
});
|
|
sawResponse = true;
|
|
if (res.ok) {
|
|
const body = await res.json().catch(() => null);
|
|
if (body && body.server === "ha-frontend-dev") {
|
|
return { state: "ours", suite: body.suite };
|
|
}
|
|
}
|
|
} catch {
|
|
// Try the next address.
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
/* eslint-enable no-await-in-loop */
|
|
return sawResponse ? { state: "foreign" } : { state: "free" };
|
|
};
|
|
|
|
// Find the pid listening on a port via the first available tool (no state file).
|
|
const pidFromPort = (port) => {
|
|
const attempts = [
|
|
[
|
|
"lsof",
|
|
["-ti", `tcp:${port}`, "-sTCP:LISTEN"],
|
|
(out) => out.trim().split("\n")[0],
|
|
],
|
|
[
|
|
"ss",
|
|
["-ltnpH", `sport = :${port}`],
|
|
(out) => out.match(/pid=(\d+)/)?.[1],
|
|
],
|
|
["fuser", [`${port}/tcp`], (out) => out.trim().split(/\s+/)[0]],
|
|
];
|
|
for (const [cmd, cmdArgs, extract] of attempts) {
|
|
try {
|
|
const out = execFileSync(cmd, cmdArgs, {
|
|
encoding: "utf8",
|
|
stdio: ["ignore", "pipe", "ignore"],
|
|
});
|
|
const pid = Number(extract(out));
|
|
if (Number.isInteger(pid) && pid > 0) {
|
|
return pid;
|
|
}
|
|
} catch {
|
|
// Try the next tool.
|
|
}
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
const runForegroundHealth = async (suite, cfg) => {
|
|
const { port } = cfg;
|
|
const status = await probe(port);
|
|
if (status.state === "ours" && status.suite === suite) {
|
|
process.stdout.write(
|
|
`Dev server (${suite}) is already running at http://localhost:${port}\n`
|
|
);
|
|
return 0;
|
|
}
|
|
if (status.state === "foreign") {
|
|
process.stderr.write(
|
|
`Port ${port} is in use by another process; not the ${suite} dev server.\n`
|
|
);
|
|
return 1;
|
|
}
|
|
return spawnInherit(cfg.spawn.cmd, cfg.spawn.args);
|
|
};
|
|
|
|
const runBackgroundHealth = async (suite, cfg) => {
|
|
const { port } = cfg;
|
|
const preflight = await probe(port);
|
|
if (preflight.state === "ours" && preflight.suite === suite) {
|
|
const pid = pidFromPort(port);
|
|
process.stdout.write(
|
|
`Dev server (${suite}) already running at http://localhost:${port}` +
|
|
`${pid ? ` (pid ${pid})` : ""}\n${hints(suite)}`
|
|
);
|
|
return 0;
|
|
}
|
|
if (preflight.state === "foreign") {
|
|
process.stderr.write(
|
|
`Port ${port} is in use by another process; not the ${suite} dev server.\n`
|
|
);
|
|
return 1;
|
|
}
|
|
|
|
const { child, logFile } = spawnDetachedToLog(
|
|
suite,
|
|
cfg.spawn.cmd,
|
|
cfg.spawn.args
|
|
);
|
|
return awaitReady({
|
|
suite,
|
|
child,
|
|
logFile,
|
|
port,
|
|
isReady: async () => {
|
|
const status = await probe(port, 1000);
|
|
return status.state === "ours" && status.suite === suite;
|
|
},
|
|
});
|
|
};
|
|
|
|
const runStatusHealth = async (suite, cfg) => {
|
|
const { port } = cfg;
|
|
const status = await probe(port);
|
|
if (status.state === "ours" && status.suite === suite) {
|
|
const pid = pidFromPort(port);
|
|
process.stdout.write(
|
|
`Dev server (${suite}) running at http://localhost:${port}` +
|
|
`${pid ? ` (pid ${pid})` : ""}\n`
|
|
);
|
|
} else if (status.state === "ours") {
|
|
process.stdout.write(
|
|
`Port ${port} is serving a different Home Assistant frontend dev server (suite ${status.suite ?? "unknown"}); not ${suite}.\n`
|
|
);
|
|
} else if (status.state === "foreign") {
|
|
process.stdout.write(
|
|
`Port ${port} is in use by another process; not the ${suite} dev server.\n`
|
|
);
|
|
} else {
|
|
process.stdout.write(`Dev server (${suite}) not running.\n`);
|
|
}
|
|
return 0;
|
|
};
|
|
|
|
const runStopHealth = async (suite, cfg) => {
|
|
const { port } = cfg;
|
|
const status = await probe(port);
|
|
if (!(status.state === "ours" && status.suite === suite)) {
|
|
// Idempotent: stopping something that is not running is a success.
|
|
process.stdout.write(`Dev server (${suite}) not running.\n`);
|
|
return 0;
|
|
}
|
|
const pid = pidFromPort(port);
|
|
if (!pid) {
|
|
process.stderr.write(
|
|
`Dev server (${suite}) is running but its pid could not be found ` +
|
|
`(no lsof/ss/fuser?). Stop it manually.\n`
|
|
);
|
|
return 1;
|
|
}
|
|
return terminate(
|
|
suite,
|
|
pid,
|
|
async () => (await probe(port, 800)).state === "free"
|
|
);
|
|
};
|
|
|
|
// --- process liveness (pidfile + log-readiness) -----------------------------
|
|
|
|
const isAlive = (pid) => {
|
|
if (!Number.isInteger(pid) || pid <= 0) {
|
|
return false;
|
|
}
|
|
try {
|
|
process.kill(pid, 0);
|
|
return true;
|
|
} catch (err) {
|
|
// EPERM means the process exists but is owned by someone else.
|
|
return err.code === "EPERM";
|
|
}
|
|
};
|
|
|
|
const readPidFile = (suite) => {
|
|
try {
|
|
const data = JSON.parse(fs.readFileSync(pidFileFor(suite), "utf8"));
|
|
if (data && Number.isInteger(data.pid)) {
|
|
return data;
|
|
}
|
|
} catch {
|
|
// Missing or corrupt.
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
const writePidFile = (suite, data) => {
|
|
fs.mkdirSync(logDir, { recursive: true });
|
|
fs.writeFileSync(pidFileFor(suite), JSON.stringify(data));
|
|
};
|
|
|
|
const removePidFile = (suite) => {
|
|
try {
|
|
fs.rmSync(pidFileFor(suite));
|
|
} catch {
|
|
// Already gone.
|
|
}
|
|
};
|
|
|
|
const logIsReady = (logFile, readyLog) => {
|
|
try {
|
|
return readyLog.test(fs.readFileSync(logFile, "utf8"));
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
// app-serve serves on 8124 by default (8123 in a devcontainer), or whatever -p
|
|
// the caller passed. Used only to show a URL; liveness comes from the pidfile.
|
|
const resolveServePort = (passthrough) => {
|
|
const i = passthrough.indexOf("-p");
|
|
if (i !== -1) {
|
|
const port = Number(passthrough[i + 1]);
|
|
if (Number.isInteger(port) && port > 0) {
|
|
return port;
|
|
}
|
|
}
|
|
return process.env.DEVCONTAINER ? 8123 : 8124;
|
|
};
|
|
|
|
const spawnArgs = (cfg, passthrough) => [
|
|
...cfg.spawn.args,
|
|
...(cfg.acceptsArgs ? passthrough : []),
|
|
];
|
|
|
|
const runForegroundProcess = async (suite, cfg, passthrough) => {
|
|
const existing = readPidFile(suite);
|
|
if (existing && isAlive(existing.pid)) {
|
|
process.stdout.write(
|
|
`Dev server (${suite}) already running in the background ` +
|
|
`(pid ${existing.pid}). Stop it with yarn ${cfg.alias} --stop.\n`
|
|
);
|
|
return 0;
|
|
}
|
|
if (existing) {
|
|
removePidFile(suite);
|
|
}
|
|
return spawnInherit(cfg.spawn.cmd, spawnArgs(cfg, passthrough));
|
|
};
|
|
|
|
const runBackgroundProcess = async (suite, cfg, passthrough) => {
|
|
const existing = readPidFile(suite);
|
|
if (existing && isAlive(existing.pid)) {
|
|
process.stdout.write(
|
|
`Dev server (${suite}) already running${urlSuffix(existing.port)} ` +
|
|
`(pid ${existing.pid})\n${hints(suite)}`
|
|
);
|
|
return 0;
|
|
}
|
|
if (existing) {
|
|
removePidFile(suite);
|
|
}
|
|
|
|
const { child, logFile } = spawnDetachedToLog(
|
|
suite,
|
|
cfg.spawn.cmd,
|
|
spawnArgs(cfg, passthrough)
|
|
);
|
|
|
|
const port = cfg.acceptsArgs ? resolveServePort(passthrough) : cfg.port;
|
|
writePidFile(suite, { pid: child.pid, port });
|
|
|
|
return awaitReady({
|
|
suite,
|
|
child,
|
|
logFile,
|
|
port,
|
|
isReady: () => logIsReady(logFile, cfg.readyLog),
|
|
onExit: () => removePidFile(suite),
|
|
});
|
|
};
|
|
|
|
const runStatusProcess = async (suite) => {
|
|
const existing = readPidFile(suite);
|
|
if (existing && isAlive(existing.pid)) {
|
|
process.stdout.write(
|
|
`Dev server (${suite}) running${urlSuffix(existing.port)} ` +
|
|
`(pid ${existing.pid})\n`
|
|
);
|
|
} else {
|
|
if (existing) {
|
|
removePidFile(suite);
|
|
}
|
|
process.stdout.write(`Dev server (${suite}) not running.\n`);
|
|
}
|
|
return 0;
|
|
};
|
|
|
|
const runStopProcess = async (suite) => {
|
|
const existing = readPidFile(suite);
|
|
if (!existing || !isAlive(existing.pid)) {
|
|
// Idempotent: stopping something that is not running is a success.
|
|
if (existing) {
|
|
removePidFile(suite);
|
|
}
|
|
process.stdout.write(`Dev server (${suite}) not running.\n`);
|
|
return 0;
|
|
}
|
|
const { pid } = existing;
|
|
return terminate(
|
|
suite,
|
|
pid,
|
|
() => !isAlive(pid),
|
|
() => removePidFile(suite)
|
|
);
|
|
};
|
|
|
|
// --- shared -----------------------------------------------------------------
|
|
|
|
const runLogs = (suite, follow) => {
|
|
const logFile = logFileFor(suite);
|
|
if (!fs.existsSync(logFile)) {
|
|
process.stdout.write(
|
|
`No log for the ${suite} dev server yet (${logFile}).\n`
|
|
);
|
|
return Promise.resolve(0);
|
|
}
|
|
if (!follow) {
|
|
process.stdout.write(fs.readFileSync(logFile, "utf8"));
|
|
return Promise.resolve(0);
|
|
}
|
|
return new Promise((resolve) => {
|
|
const tail = spawn("tail", ["-f", logFile], { stdio: "inherit" });
|
|
tail.on("error", () => {
|
|
// No tail available; fall back to a one-shot dump.
|
|
process.stdout.write(fs.readFileSync(logFile, "utf8"));
|
|
resolve(0);
|
|
});
|
|
tail.on("exit", (code) => resolve(code ?? 0));
|
|
});
|
|
};
|
|
|
|
const main = async () => {
|
|
const args = parseArgs(process.argv.slice(2));
|
|
const cfg = SUITES[args.suite];
|
|
if (!cfg) {
|
|
usage();
|
|
return 1;
|
|
}
|
|
if (args.passthrough.length && !cfg.acceptsArgs) {
|
|
process.stderr.write(
|
|
`Ignoring unexpected arguments: ${args.passthrough.join(" ")}\n`
|
|
);
|
|
}
|
|
|
|
// A plain dev:<suite> under a coding agent backgrounds itself; explicit modes
|
|
// are untouched.
|
|
let { mode } = args;
|
|
if (
|
|
mode === "foreground" &&
|
|
!["0", "false"].includes(process.env.HA_DEV_BACKGROUND)
|
|
) {
|
|
const agent = detectAgent();
|
|
if (agent) {
|
|
process.stdout.write(
|
|
`Detected coding agent (${agent}); starting in the background. ` +
|
|
`Set HA_DEV_BACKGROUND=0 to force foreground.\n`
|
|
);
|
|
mode = "background";
|
|
}
|
|
}
|
|
|
|
const health = cfg.liveness === "health";
|
|
switch (mode) {
|
|
case "background":
|
|
return health
|
|
? runBackgroundHealth(args.suite, cfg)
|
|
: runBackgroundProcess(args.suite, cfg, args.passthrough);
|
|
case "status":
|
|
return health
|
|
? runStatusHealth(args.suite, cfg)
|
|
: runStatusProcess(args.suite);
|
|
case "stop":
|
|
return health
|
|
? runStopHealth(args.suite, cfg)
|
|
: runStopProcess(args.suite);
|
|
case "logs":
|
|
return runLogs(args.suite, args.follow);
|
|
default:
|
|
return health
|
|
? runForegroundHealth(args.suite, cfg)
|
|
: runForegroundProcess(args.suite, cfg, args.passthrough);
|
|
}
|
|
};
|
|
|
|
main().then(
|
|
(code) => {
|
|
process.exitCode = code;
|
|
},
|
|
(err) => {
|
|
process.stderr.write(`${err?.stack || err}\n`);
|
|
process.exitCode = 1;
|
|
}
|
|
);
|