mirror of
https://github.com/home-assistant/frontend.git
synced 2026-08-14 00:52:39 +01:00
* Offer to stop conflicting managed workflows * Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Fix syntax error: misplaced brace before finally in confirmStopConflict Co-authored-by: timmo001 <28114703+timmo001@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Format --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
762 lines
21 KiB
JavaScript
762 lines
21 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.
|
|
// --fetch-translations
|
|
// Fetch nightly translations before starting (app,
|
|
// app-serve, demo, and gallery only).
|
|
//
|
|
// 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).
|
|
// process app (yarn dev) and app-serve (yarn dev:serve): plain yarn dev has
|
|
// no port, so these treat the first "Build done" log line as ready.
|
|
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import {
|
|
LIFECYCLE_MODE_FLAGS,
|
|
acquireProcessRecord,
|
|
detectCodingAgent,
|
|
isProcessRecordAlive,
|
|
outputLog,
|
|
offerToStopProcessRecord,
|
|
processStartTime,
|
|
readProcessRecord,
|
|
releaseProcessRecord,
|
|
runCli,
|
|
sleep,
|
|
spawnDetachedToLog,
|
|
spawnForeground,
|
|
terminateDetachedProcess,
|
|
terminateProcess,
|
|
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 developAndServeScript = path.join(
|
|
repoRoot,
|
|
"script",
|
|
"develop_and_serve"
|
|
);
|
|
const logDir = path.join(buildCacheDir, "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 = new Map([
|
|
[
|
|
"e2e-app",
|
|
{
|
|
alias: "test:e2e:app:dev",
|
|
liveness: "health",
|
|
port: 8095,
|
|
spawn: { cmd: gulpBin, args: ["develop-e2e-test-app"] },
|
|
},
|
|
],
|
|
[
|
|
"demo",
|
|
{
|
|
alias: "dev:demo",
|
|
fetchTranslations: true,
|
|
liveness: "health",
|
|
port: 8090,
|
|
spawn: { cmd: gulpBin, args: ["develop-demo"] },
|
|
},
|
|
],
|
|
[
|
|
"gallery",
|
|
{
|
|
alias: "dev:gallery",
|
|
fetchTranslations: true,
|
|
liveness: "health",
|
|
port: 8100,
|
|
spawn: { cmd: gulpBin, args: ["develop-gallery"] },
|
|
},
|
|
],
|
|
[
|
|
"app",
|
|
{
|
|
alias: "dev",
|
|
fetchTranslations: true,
|
|
liveness: "process",
|
|
readyLog: /Build done @/,
|
|
spawn: { cmd: gulpBin, args: ["develop-app"] },
|
|
},
|
|
],
|
|
[
|
|
"app-serve",
|
|
{
|
|
alias: "dev:serve",
|
|
liveness: "process",
|
|
acceptsArgs: true,
|
|
fetchTranslations: 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 readyTimeoutSeconds = Number(process.env.HA_DEV_SERVER_TIMEOUT || "180");
|
|
const READY_TIMEOUT_MS =
|
|
Number.isFinite(readyTimeoutSeconds) && readyTimeoutSeconds > 0
|
|
? readyTimeoutSeconds * 1000
|
|
: 180_000;
|
|
|
|
const usage = () => {
|
|
const suites = [...SUITES.keys()].join("|");
|
|
process.stderr.write(
|
|
`Usage: node build-scripts/dev-server.mjs --suite <${suites}> ` +
|
|
`[--background | --status | --stop | --logs [--follow]] ` +
|
|
`[--fetch-translations]\n`
|
|
);
|
|
};
|
|
|
|
const parseArgs = (argv) => {
|
|
const args = {
|
|
fetchTranslations: false,
|
|
mode: "foreground",
|
|
follow: false,
|
|
modes: [],
|
|
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 "--follow":
|
|
args.follow = true;
|
|
break;
|
|
case "--fetch-translations":
|
|
args.fetchTranslations = true;
|
|
break;
|
|
default:
|
|
if (LIFECYCLE_MODE_FLAGS.has(arg)) {
|
|
args.mode = LIFECYCLE_MODE_FLAGS.get(arg);
|
|
args.modes.push(arg);
|
|
} else {
|
|
// Anything unrecognised is forwarded to the underlying script.
|
|
args.passthrough.push(arg);
|
|
}
|
|
}
|
|
}
|
|
return args;
|
|
};
|
|
|
|
const translationPrebuildEnv = (token) => {
|
|
const env = workflowLockEnv(token);
|
|
delete env.SKIP_FETCH_NIGHTLY_TRANSLATIONS;
|
|
return env;
|
|
};
|
|
|
|
const suiteEnv = (token, fetchTranslations = false) => ({
|
|
...workflowLockEnv(token),
|
|
...(fetchTranslations && { SKIP_FETCH_NIGHTLY_TRANSLATIONS: "1" }),
|
|
});
|
|
|
|
const runPrebuild = (token, fetchTranslations = false) =>
|
|
fetchTranslations
|
|
? spawnForeground({
|
|
cmd: gulpBin,
|
|
args: ["setup-and-fetch-nightly-translations"],
|
|
cwd: repoRoot,
|
|
env: translationPrebuildEnv(token),
|
|
processGroup: true,
|
|
})
|
|
: Promise.resolve(0);
|
|
|
|
const logFileFor = (suite) => path.join(logDir, `${suite}.log`);
|
|
const acquireSuite = (suite) => {
|
|
const token = `${process.pid}-${Date.now()}-${Math.random()}`;
|
|
const record = {
|
|
pid: process.pid,
|
|
startTime: processStartTime(process.pid),
|
|
processGroup: false,
|
|
kind: "dev",
|
|
suite,
|
|
starting: true,
|
|
token,
|
|
};
|
|
const result = acquireProcessRecord(workflowLockFile, record);
|
|
return result.acquired ? { token } : { existing: result.existing };
|
|
};
|
|
|
|
const updateSuite = (suite, token, child, port) => {
|
|
const existing = readProcessRecord(workflowLockFile);
|
|
if (existing?.token !== token) {
|
|
throw Error(`Dev server (${suite}) ownership was lost during startup.`);
|
|
}
|
|
writeProcessRecord(workflowLockFile, {
|
|
...existing,
|
|
pid: child.pid,
|
|
startTime: processStartTime(child.pid),
|
|
processGroup: true,
|
|
starting: false,
|
|
port,
|
|
});
|
|
};
|
|
|
|
const releaseSuite = (token) => releaseProcessRecord(workflowLockFile, token);
|
|
|
|
const readSuite = (suite) => {
|
|
const existing = readProcessRecord(workflowLockFile);
|
|
if (existing?.kind !== "dev" || existing.suite !== suite) {
|
|
return undefined;
|
|
}
|
|
if (isProcessRecordAlive(existing)) {
|
|
return existing;
|
|
}
|
|
releaseSuite(existing.token);
|
|
return undefined;
|
|
};
|
|
|
|
const hints = (suite) => {
|
|
const alias = `yarn ${SUITES.get(suite).alias}`;
|
|
return (
|
|
` Stop: ${alias} --stop\n` +
|
|
` Status: ${alias} --status\n` +
|
|
` Logs: ${alias} --logs\n`
|
|
);
|
|
};
|
|
|
|
const reportProcessConflict = (suite, existing) => {
|
|
if (existing?.kind === "output") {
|
|
process.stdout.write(
|
|
`${describeOutputOwner(existing)} already owns the app output` +
|
|
`${existing.pid ? ` (pid ${existing.pid})` : ""}.\n`
|
|
);
|
|
return;
|
|
}
|
|
if (existing?.kind === "build") {
|
|
process.stdout.write(
|
|
`Frontend build already running${existing.pid ? ` (pid ${existing.pid})` : ""}. ` +
|
|
"Stop it with yarn build --stop.\n"
|
|
);
|
|
return;
|
|
}
|
|
process.stdout.write(
|
|
`Dev server (${existing?.suite ?? suite}) already running` +
|
|
`${urlSuffix(existing?.port)} ` +
|
|
`${existing?.pid ? `(pid ${existing.pid})` : ""}\n` +
|
|
hints(existing?.suite ?? suite)
|
|
);
|
|
};
|
|
|
|
const stopCommandFor = (owner) =>
|
|
owner?.kind === "build"
|
|
? "yarn build --stop"
|
|
: owner?.kind === "dev"
|
|
? `yarn ${SUITES.get(owner.suite)?.alias ?? "dev"} --stop`
|
|
: undefined;
|
|
|
|
const acquireSuiteForStart = async (suite) => {
|
|
const lock = acquireSuite(suite);
|
|
if (lock.token) {
|
|
return lock;
|
|
}
|
|
reportProcessConflict(suite, lock.existing);
|
|
if (
|
|
await offerToStopProcessRecord({
|
|
file: workflowLockFile,
|
|
owner: lock.existing,
|
|
ownerDescription: describeOutputOwner(lock.existing),
|
|
stopCommand: stopCommandFor(lock.existing),
|
|
})
|
|
) {
|
|
return acquireSuiteForStart(suite);
|
|
}
|
|
return {
|
|
code:
|
|
lock.existing?.kind === "dev" &&
|
|
lock.existing.suite === suite &&
|
|
!lock.existing.starting
|
|
? 0
|
|
: 1,
|
|
};
|
|
};
|
|
|
|
// --- shared spawning and lifecycle ------------------------------------------
|
|
|
|
const urlSuffix = (port) => (port ? ` at http://localhost:${port}` : "");
|
|
|
|
// 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`);
|
|
const poll = async () => {
|
|
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;
|
|
}
|
|
if (Date.now() >= deadline) {
|
|
return undefined;
|
|
}
|
|
process.stdout.write(".");
|
|
await sleep(1000);
|
|
return poll();
|
|
};
|
|
const result = await poll();
|
|
if (result !== undefined) {
|
|
return result;
|
|
}
|
|
process.stdout.write("\n");
|
|
process.stderr.write(
|
|
`Dev server (${suite}) did not become ready within ${
|
|
READY_TIMEOUT_MS / 1000
|
|
}s. See ${logFile}\n`
|
|
);
|
|
const stopped = await terminateProcess({
|
|
pid: child.pid,
|
|
isStopped: () => childExited,
|
|
});
|
|
if (stopped) {
|
|
onExit?.();
|
|
}
|
|
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) => {
|
|
if (!(await terminateProcess({ pid, 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) => {
|
|
const probeHost = async (index, sawResponse) => {
|
|
const host = PROBE_HOSTS[index];
|
|
if (!host) {
|
|
return sawResponse ? { state: "foreign" } : { state: "free" };
|
|
}
|
|
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);
|
|
}
|
|
return probeHost(index + 1, sawResponse);
|
|
};
|
|
return probeHost(0, false);
|
|
};
|
|
|
|
const isHttpServing = async (port, timeoutMs = 1000) => {
|
|
const probeHost = async (index) => {
|
|
const host = PROBE_HOSTS[index];
|
|
if (!host) {
|
|
return false;
|
|
}
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
try {
|
|
const response = await fetch(`http://${host}:${port}`, {
|
|
signal: controller.signal,
|
|
});
|
|
if (response.ok) {
|
|
return true;
|
|
}
|
|
} catch {
|
|
// Try the next address.
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
return probeHost(index + 1);
|
|
};
|
|
return probeHost(0);
|
|
};
|
|
|
|
const runForegroundHealth = async (suite, cfg, fetchTranslations = false) => {
|
|
const { port } = cfg;
|
|
const lock = await acquireSuiteForStart(suite);
|
|
if (!lock.token) {
|
|
return lock.code;
|
|
}
|
|
const status = await probe(port);
|
|
if (status.state === "ours") {
|
|
releaseSuite(lock.token);
|
|
process.stderr.write(
|
|
`Port ${port} is already serving the ${status.suite ?? "unknown"} dev server.\n`
|
|
);
|
|
return 1;
|
|
}
|
|
if (status.state === "foreign") {
|
|
releaseSuite(lock.token);
|
|
process.stderr.write(
|
|
`Port ${port} is in use by another process; not the ${suite} dev server.\n`
|
|
);
|
|
return 1;
|
|
}
|
|
try {
|
|
const prebuildCode = await runPrebuild(lock.token, fetchTranslations);
|
|
if (prebuildCode !== 0) {
|
|
return prebuildCode;
|
|
}
|
|
return await spawnForeground({
|
|
cmd: cfg.spawn.cmd,
|
|
args: cfg.spawn.args,
|
|
cwd: repoRoot,
|
|
env: suiteEnv(lock.token, fetchTranslations),
|
|
processGroup: true,
|
|
onSpawn: (child) => updateSuite(suite, lock.token, child, port),
|
|
});
|
|
} finally {
|
|
releaseSuite(lock.token);
|
|
}
|
|
};
|
|
|
|
const runBackgroundHealth = async (suite, cfg, fetchTranslations = false) => {
|
|
const { port } = cfg;
|
|
const lock = await acquireSuiteForStart(suite);
|
|
if (!lock.token) {
|
|
return lock.code;
|
|
}
|
|
const preflight = await probe(port);
|
|
if (preflight.state === "ours") {
|
|
releaseSuite(lock.token);
|
|
process.stderr.write(
|
|
`Port ${port} is already serving the ${preflight.suite ?? "unknown"} dev server.\n`
|
|
);
|
|
return 1;
|
|
}
|
|
if (preflight.state === "foreign") {
|
|
releaseSuite(lock.token);
|
|
process.stderr.write(
|
|
`Port ${port} is in use by another process; not the ${suite} dev server.\n`
|
|
);
|
|
return 1;
|
|
}
|
|
let child;
|
|
try {
|
|
const prebuildCode = await runPrebuild(lock.token, fetchTranslations);
|
|
if (prebuildCode !== 0) {
|
|
releaseSuite(lock.token);
|
|
return prebuildCode;
|
|
}
|
|
const logFile = logFileFor(suite);
|
|
child = await spawnDetachedToLog({
|
|
cmd: cfg.spawn.cmd,
|
|
args: cfg.spawn.args,
|
|
cwd: repoRoot,
|
|
env: suiteEnv(lock.token, fetchTranslations),
|
|
logFile,
|
|
});
|
|
updateSuite(suite, lock.token, child, port);
|
|
return awaitReady({
|
|
suite,
|
|
child,
|
|
logFile,
|
|
port,
|
|
isReady: async () => {
|
|
const status = await probe(port, 1000);
|
|
return status.state === "ours" && status.suite === suite;
|
|
},
|
|
onExit: () => releaseSuite(lock.token),
|
|
});
|
|
} catch (err) {
|
|
if (child) {
|
|
await terminateDetachedProcess(child);
|
|
}
|
|
releaseSuite(lock.token);
|
|
throw err;
|
|
}
|
|
};
|
|
|
|
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) => {
|
|
for (let i = passthrough.length - 1; i >= 0; i--) {
|
|
const arg = passthrough[i];
|
|
if (arg === "-p" || arg.startsWith("-p")) {
|
|
const port = Number(arg === "-p" ? passthrough[i + 1] : arg.slice(2));
|
|
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,
|
|
fetchTranslations = false
|
|
) => {
|
|
const lock = await acquireSuiteForStart(suite);
|
|
if (!lock.token) {
|
|
return lock.code;
|
|
}
|
|
try {
|
|
const prebuildCode = await runPrebuild(lock.token, fetchTranslations);
|
|
if (prebuildCode !== 0) {
|
|
return prebuildCode;
|
|
}
|
|
return await spawnForeground({
|
|
cmd: cfg.spawn.cmd,
|
|
args: spawnArgs(cfg, passthrough),
|
|
cwd: repoRoot,
|
|
env: suiteEnv(lock.token, fetchTranslations),
|
|
processGroup: true,
|
|
onSpawn: (child) => updateSuite(suite, lock.token, child),
|
|
});
|
|
} finally {
|
|
releaseSuite(lock.token);
|
|
}
|
|
};
|
|
|
|
const runBackgroundProcess = async (
|
|
suite,
|
|
cfg,
|
|
passthrough,
|
|
fetchTranslations = false
|
|
) => {
|
|
const lock = await acquireSuiteForStart(suite);
|
|
if (!lock.token) {
|
|
return lock.code;
|
|
}
|
|
|
|
let child;
|
|
try {
|
|
const prebuildCode = await runPrebuild(lock.token, fetchTranslations);
|
|
if (prebuildCode !== 0) {
|
|
releaseSuite(lock.token);
|
|
return prebuildCode;
|
|
}
|
|
const logFile = logFileFor(suite);
|
|
child = await spawnDetachedToLog({
|
|
cmd: cfg.spawn.cmd,
|
|
args: spawnArgs(cfg, passthrough),
|
|
cwd: repoRoot,
|
|
env: suiteEnv(lock.token, fetchTranslations),
|
|
logFile,
|
|
});
|
|
|
|
const port = cfg.acceptsArgs ? resolveServePort(passthrough) : cfg.port;
|
|
updateSuite(suite, lock.token, child, port);
|
|
|
|
return awaitReady({
|
|
suite,
|
|
child,
|
|
logFile,
|
|
port,
|
|
isReady: async () =>
|
|
logIsReady(logFile, cfg.readyLog) &&
|
|
(!cfg.acceptsArgs || (await isHttpServing(port))),
|
|
onExit: () => releaseSuite(lock.token),
|
|
});
|
|
} catch (err) {
|
|
if (child) {
|
|
await terminateDetachedProcess(child);
|
|
}
|
|
releaseSuite(lock.token);
|
|
throw err;
|
|
}
|
|
};
|
|
|
|
const runStatusSuite = async (suite, cfg) => {
|
|
const existing = readSuite(suite);
|
|
if (existing) {
|
|
process.stdout.write(
|
|
`Dev server (${existing.suite ?? suite}) running${urlSuffix(existing.port ?? cfg.port)} ` +
|
|
`(pid ${existing.pid})\n`
|
|
);
|
|
} else {
|
|
process.stdout.write(`Dev server (${suite}) not running.\n`);
|
|
}
|
|
return 0;
|
|
};
|
|
|
|
const runStopSuite = async (suite) => {
|
|
const existing = readSuite(suite);
|
|
if (!existing) {
|
|
process.stdout.write(`Dev server (${suite}) not running.\n`);
|
|
return 0;
|
|
}
|
|
const { pid } = existing;
|
|
const activeSuite = existing.suite ?? suite;
|
|
return terminate(
|
|
activeSuite,
|
|
pid,
|
|
() => !isProcessRecordAlive(existing),
|
|
() => releaseSuite(existing.token)
|
|
);
|
|
};
|
|
|
|
// --- shared -----------------------------------------------------------------
|
|
|
|
const runLogs = (suite, follow) => {
|
|
const activeSuite = readSuite(suite)?.suite ?? suite;
|
|
return outputLog(
|
|
logFileFor(activeSuite),
|
|
follow,
|
|
`No log for the ${activeSuite} dev server yet (${logFileFor(activeSuite)}).\n`
|
|
);
|
|
};
|
|
|
|
const main = async () => {
|
|
const args = parseArgs(process.argv.slice(2));
|
|
const cfg = SUITES.get(args.suite);
|
|
if (!cfg) {
|
|
usage();
|
|
return 1;
|
|
}
|
|
if (args.modes.length > 1 || (args.follow && args.mode !== "logs")) {
|
|
process.stderr.write("Invalid combination of lifecycle arguments.\n");
|
|
usage();
|
|
return 1;
|
|
}
|
|
if (
|
|
args.fetchTranslations &&
|
|
(!["foreground", "background"].includes(args.mode) ||
|
|
!cfg.fetchTranslations)
|
|
) {
|
|
process.stderr.write(
|
|
"--fetch-translations is only supported when starting app, app-serve, demo, or gallery.\n"
|
|
);
|
|
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 = detectCodingAgent();
|
|
if (agent) {
|
|
process.stdout.write(
|
|
`Detected coding agent (${agent}); starting in the background. ` +
|
|
`Set HA_DEV_BACKGROUND=0 to force foreground.\n`
|
|
);
|
|
mode = "background";
|
|
}
|
|
}
|
|
|
|
if (mode === "logs") {
|
|
return runLogs(args.suite, args.follow);
|
|
}
|
|
if (mode === "status") {
|
|
return runStatusSuite(args.suite, cfg);
|
|
}
|
|
if (mode === "stop") {
|
|
return runStopSuite(args.suite);
|
|
}
|
|
const handlers =
|
|
cfg.liveness === "health"
|
|
? {
|
|
foreground: () =>
|
|
runForegroundHealth(args.suite, cfg, args.fetchTranslations),
|
|
background: () =>
|
|
runBackgroundHealth(args.suite, cfg, args.fetchTranslations),
|
|
}
|
|
: {
|
|
foreground: () =>
|
|
runForegroundProcess(
|
|
args.suite,
|
|
cfg,
|
|
args.passthrough,
|
|
args.fetchTranslations
|
|
),
|
|
background: () =>
|
|
runBackgroundProcess(
|
|
args.suite,
|
|
cfg,
|
|
args.passthrough,
|
|
args.fetchTranslations
|
|
),
|
|
};
|
|
return handlers[mode]();
|
|
};
|
|
|
|
runCli(main);
|