@neat.is/core 0.4.18 → 0.4.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/neatd.js CHANGED
@@ -1,11 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  startDaemon
4
- } from "./chunk-GXWBMJDJ.js";
4
+ } from "./chunk-ZV7GHZ2D.js";
5
5
  import {
6
6
  listProjects,
7
7
  registryPath
8
- } from "./chunk-T3X6XJPU.js";
8
+ } from "./chunk-Q5EDVWKZ.js";
9
9
  import {
10
10
  BindAuthorityError,
11
11
  __require
@@ -147,7 +147,10 @@ async function spawnWebUI(restPort, opts = {}) {
147
147
  })();
148
148
  return starting;
149
149
  }
150
+ const liveSockets = /* @__PURE__ */ new Set();
150
151
  const front = net.createServer((socket) => {
152
+ liveSockets.add(socket);
153
+ socket.on("close", () => liveSockets.delete(socket));
151
154
  socket.on("error", () => socket.destroy());
152
155
  ensureStarted().then(() => {
153
156
  if (stopped) {
@@ -173,7 +176,11 @@ async function spawnWebUI(restPort, opts = {}) {
173
176
  async function stop() {
174
177
  if (stopped) return;
175
178
  stopped = true;
176
- await new Promise((resolve) => front.close(() => resolve()));
179
+ await new Promise((resolve) => {
180
+ front.close(() => resolve());
181
+ for (const socket of liveSockets) socket.destroy();
182
+ liveSockets.clear();
183
+ });
177
184
  if (!child || !child.pid) return;
178
185
  try {
179
186
  child.kill("SIGTERM");
package/dist/neatd.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/neatd.ts","../src/web-spawn.ts","../src/version-skew.ts"],"sourcesContent":["#!/usr/bin/env node\n/**\n * `neatd` — distribution-layer daemon CLI (ADR-049).\n *\n * Subcommands:\n * neatd start [--foreground] boot the daemon and watch the registry\n * neatd stop signal the running daemon to shut down\n * neatd reload signal the running daemon to re-read the registry\n * neatd status print PID + per-project last-seen timestamps\n *\n * MVP runs in foreground only. Backgrounding is the supervisor's job\n * (launchd / systemd / nohup) — `neatd start` blocks until SIGINT/SIGTERM.\n *\n * v0.2.10: also brings up the web UI on port 6328 by default (ADR-059).\n */\n\nimport { promises as fs } from 'node:fs'\nimport path from 'node:path'\nimport { createRequire } from 'node:module'\nimport { startDaemon } from './daemon.js'\nimport { BindAuthorityError } from './auth.js'\nimport { listProjects, registryPath } from './registry.js'\nimport { spawnWebUI, DEFAULT_WEB_PORT, type WebHandle } from './web-spawn.js'\nimport { checkVersionSkew } from './version-skew.js'\n\n// Resolve the running @neat.is/core version from the bundled package.json.\n// Lockstep publishing (ADR-052) keeps this in step with the `neat.is` meta\n// package, which is what the operator installs globally. Tests stub by\n// setting NEAT_LOCAL_VERSION.\nfunction localVersion(): string {\n if (process.env.NEAT_LOCAL_VERSION && process.env.NEAT_LOCAL_VERSION.length > 0) {\n return process.env.NEAT_LOCAL_VERSION\n }\n try {\n const req = createRequire(import.meta.url)\n const pkg = req('../package.json') as { version?: string }\n return typeof pkg.version === 'string' ? pkg.version : '0.0.0'\n } catch {\n return '0.0.0'\n }\n}\n\nfunction neatHome(): string {\n if (process.env.NEAT_HOME && process.env.NEAT_HOME.length > 0) {\n return path.resolve(process.env.NEAT_HOME)\n }\n const home = process.env.HOME ?? process.env.USERPROFILE ?? ''\n return path.join(home, '.neat')\n}\n\nasync function readPid(): Promise<number | null> {\n try {\n const raw = await fs.readFile(path.join(neatHome(), 'neatd.pid'), 'utf8')\n const n = Number.parseInt(raw.trim(), 10)\n return Number.isFinite(n) ? n : null\n } catch {\n return null\n }\n}\n\nfunction usage(): void {\n console.log('usage: neatd <start|stop|reload|status> [--foreground]')\n}\n\nfunction restPortFromEnv(): number {\n const raw = process.env.PORT\n return raw && raw.length > 0 ? Number.parseInt(raw, 10) : 8080\n}\n\nfunction webPortFromEnv(): number | undefined {\n const raw = process.env.NEAT_WEB_PORT\n if (!raw || raw.length === 0) return undefined\n const n = Number.parseInt(raw, 10)\n return Number.isFinite(n) ? n : undefined\n}\n\nasync function cmdStart(): Promise<void> {\n // ADR-096 — when the orchestrator spawns a per-project daemon it passes the\n // project name + root + allocated ports through the environment. Forward them\n // into startDaemon so the daemon scopes itself to that one project and writes\n // its daemon.json self-description. A bare `neatd start` with none of these\n // set falls through to the legacy multi-project daemon. (startDaemon also\n // reads PORT / OTEL_PORT / NEAT_WEB_PORT itself; we thread the project bits\n // explicitly so the wiring is visible at the entrypoint.)\n const project = process.env.NEAT_PROJECT\n const projectPath = process.env.NEAT_PROJECT_PATH\n const startOpts =\n project && project.length > 0\n ? { project, projectPath, webPort: webPortFromEnv() }\n : {}\n\n let handle: Awaited<ReturnType<typeof startDaemon>>\n try {\n handle = await startDaemon(startOpts)\n } catch (err) {\n if (err instanceof BindAuthorityError) {\n // ADR-073 §3 — refuse to bind on a public interface without a token.\n // Clean stderr line, exit 1 — no stack trace; the operator wants the\n // single-line directive, not a Node.js Error dump.\n console.error(`neatd: ${err.message}`)\n process.exit(1)\n }\n throw err\n }\n console.log(`neatd: started, PID ${process.pid}, ${handle.slots.size} project(s)`)\n console.log(`neatd: registry at ${registryPath()}`)\n // ADR-063 — surface the bound addresses so the operator can sanity-check\n // the documented happy path without grepping startup logs.\n if (handle.restAddress) console.log(`neatd: REST → ${handle.restAddress}`)\n if (handle.otlpAddress) console.log(`neatd: OTLP → ${handle.otlpAddress}`)\n\n // Version-skew advisory — non-fatal, fail-open. Surfaces the gap when the\n // operator's globally installed `neat.is` binary lags the published\n // version. Set NEAT_DISABLE_VERSION_CHECK=1 to silence the check (e.g. in\n // air-gapped environments).\n if (process.env.NEAT_DISABLE_VERSION_CHECK !== '1') {\n void checkVersionSkew({ localVersion: localVersion() }).catch(() => {\n // Fail-open: any thrown error from the helper resolves silently. The\n // helper already catches its own internals; this catches anything\n // exotic the Promise chain bubbles up.\n })\n }\n\n // ADR-059 — bring up the web UI alongside the daemon. Failure here aborts\n // start with the clear-error pattern from ADR-049 instead of silently\n // running headless.\n const skipWeb = process.env.NEAT_WEB_DISABLED === '1'\n let web: WebHandle | null = null\n if (!skipWeb) {\n try {\n web = await spawnWebUI(restPortFromEnv())\n } catch (err) {\n console.error((err as Error).message)\n await handle.stop().catch(() => {})\n process.exit(3)\n }\n } else {\n console.log('neatd: web UI disabled (NEAT_WEB_DISABLED=1)')\n }\n\n console.log('neatd: SIGHUP reloads, SIGTERM/SIGINT stops')\n\n let stopping = false\n const shutdown = (signal: NodeJS.Signals): void => {\n if (stopping) return\n stopping = true\n console.log(`neatd: ${signal} received, stopping…`)\n void Promise.allSettled([handle.stop(), web ? web.stop() : Promise.resolve()])\n .catch((err) => console.error(`neatd: shutdown error — ${(err as Error).message}`))\n .finally(() => process.exit(0))\n }\n process.on('SIGTERM', shutdown)\n process.on('SIGINT', shutdown)\n\n // Block forever — supervisors keep us in the foreground.\n await new Promise<void>(() => {})\n}\n\nasync function cmdStop(): Promise<void> {\n const pid = await readPid()\n if (pid === null) {\n console.error('neatd: no running daemon found (no PID file)')\n process.exit(1)\n }\n try {\n process.kill(pid, 'SIGTERM')\n console.log(`neatd: SIGTERM sent to PID ${pid}`)\n } catch (err) {\n console.error(`neatd: failed to signal PID ${pid} — ${(err as Error).message}`)\n process.exit(1)\n }\n}\n\nasync function cmdReload(): Promise<void> {\n const pid = await readPid()\n if (pid === null) {\n console.error('neatd: no running daemon found (no PID file)')\n process.exit(1)\n }\n try {\n process.kill(pid, 'SIGHUP')\n console.log(`neatd: SIGHUP sent to PID ${pid}`)\n } catch (err) {\n console.error(`neatd: failed to signal PID ${pid} — ${(err as Error).message}`)\n process.exit(1)\n }\n}\n\nasync function cmdStatus(): Promise<void> {\n const pid = await readPid()\n console.log(`pid: ${pid ?? '(not running)'}`)\n console.log(`registry: ${registryPath()}`)\n const webPort = process.env.NEAT_WEB_PORT\n ? Number.parseInt(process.env.NEAT_WEB_PORT, 10)\n : DEFAULT_WEB_PORT\n console.log(`web ui: http://localhost:${webPort}`)\n const projects = await listProjects().catch(() => [])\n if (projects.length === 0) {\n console.log('projects: (none)')\n return\n }\n console.log('projects:')\n for (const p of projects) {\n const seen = p.lastSeenAt ?? 'never'\n console.log(` ${p.name}\\t${p.status}\\t${p.path}\\tlast-seen=${seen}`)\n }\n}\n\nasync function main(): Promise<void> {\n const cmd = process.argv[2]\n if (!cmd || cmd === '-h' || cmd === '--help') {\n usage()\n process.exit(cmd ? 0 : 2)\n }\n\n if (cmd === 'start') return cmdStart()\n if (cmd === 'stop') return cmdStop()\n if (cmd === 'reload') return cmdReload()\n if (cmd === 'status') return cmdStatus()\n\n console.error(`neatd: unknown command \"${cmd}\"`)\n usage()\n process.exit(1)\n}\n\nconst entry = process.argv[1] ?? ''\nif (/[\\\\/]neatd\\.(?:cjs|js)$/.test(entry) || entry.endsWith('/neatd')) {\n main().catch((err) => {\n console.error(err)\n process.exit(1)\n })\n}\n","/**\n * Web UI spawn helper (ADR-059, ADR-096).\n *\n * A project's daemon brings up the REST API + OTel receivers, then calls\n * `spawnWebUI(restPort)` to make its dashboard reachable. Lifecycle is\n * parent-tied: SIGTERM/SIGINT on the daemon cascades to the web child via\n * `stop()`.\n *\n * Per-project ports (ADR-096 §5). The dashboard binds its own port and points\n * at its own REST API, both read from the project's\n * `<projectRoot>/neat-out/daemon.json` — the single source of truth for \"where\n * is this project's daemon\" (project-daemon contract §2). The `restPort`\n * argument is tolerated as a fallback so callers that still pass it keep\n * working, but `daemon.json` wins when present. A missing or malformed\n * `daemon.json` falls back to the canonical `6328`/`8080`; the read never\n * throws.\n *\n * Lazy spawn (ADR-096 §7). The web port is bound up front by a thin listener,\n * but the heavyweight Next.js server is not started until someone actually\n * opens the dashboard. A daemon that nobody is looking at keeps no web process\n * resident — this matters once there's one daemon per project and the cost\n * would otherwise multiply by N.\n */\n\nimport { spawn, type ChildProcess } from 'node:child_process'\nimport { promises as fsp } from 'node:fs'\nimport net from 'node:net'\nimport path from 'node:path'\n\nexport const DEFAULT_WEB_PORT = 6328\nexport const DEFAULT_REST_PORT = 8080\n\nexport interface WebHandle {\n /** The Next.js child once it's been lazily spawned; null until first open. */\n readonly child: ChildProcess | null\n port: number\n /** True once the underlying Next.js server has actually been spawned. */\n started: () => boolean\n stop: () => Promise<void>\n}\n\n// The slice of daemon.json we read. Other fields exist (pid, status, …) but the\n// web UI only needs the ports, so we narrow to those and tolerate the rest.\ninterface DaemonPortsShape {\n rest?: unknown\n web?: unknown\n}\ninterface DaemonRecordShape {\n ports?: DaemonPortsShape\n}\n\nfunction asValidPort(value: unknown): number | null {\n const n = typeof value === 'number' ? value : Number.parseInt(String(value ?? ''), 10)\n return Number.isInteger(n) && n > 0 && n <= 65535 ? n : null\n}\n\n/**\n * Resolve the project root whose `neat-out/daemon.json` describes this\n * daemon. `NEAT_SCAN_PATH` is the canonical project-root env (the REST server\n * reads the same name); `process.cwd()` is the fallback for a daemon launched\n * from inside the project.\n */\nfunction projectRoot(): string {\n const fromEnv = process.env.NEAT_SCAN_PATH\n return path.resolve(fromEnv && fromEnv.length > 0 ? fromEnv : process.cwd())\n}\n\n/**\n * Read `<projectRoot>/neat-out/daemon.json` and pull out the web + REST ports.\n * Bulletproof: a missing file, unreadable file, bad JSON, or a malformed port\n * all resolve to `null` for that field — never a throw. Authoritative when\n * present (project-daemon contract §2), so its ports take precedence over the\n * `restPort` argument and over `NEAT_WEB_PORT`.\n */\nexport async function readDaemonPorts(\n root: string,\n): Promise<{ web: number | null; rest: number | null }> {\n try {\n const raw = await fsp.readFile(path.join(root, 'neat-out', 'daemon.json'), 'utf8')\n const parsed = JSON.parse(raw) as DaemonRecordShape\n const ports = parsed?.ports ?? {}\n return { web: asValidPort(ports.web), rest: asValidPort(ports.rest) }\n } catch {\n return { web: null, rest: null }\n }\n}\n\n/**\n * Pure port resolution, factored out so it's testable without binding a\n * socket. `daemon.json` is the source of truth (ADR-096 §2); `NEAT_WEB_PORT`\n * and the `restPort` argument are fallbacks for installs predating the file.\n */\nexport function resolveWebPorts(args: {\n daemonWeb: number | null\n daemonRest: number | null\n webPortEnv: string | undefined\n apiUrlEnv: string | undefined\n restPortArg: number\n}): { webPort: number; apiUrl: string } {\n const { daemonWeb, daemonRest, webPortEnv, apiUrlEnv, restPortArg } = args\n\n const portFromEnv = webPortEnv && webPortEnv.length > 0 ? Number.parseInt(webPortEnv, 10) : null\n if (portFromEnv !== null && (!Number.isFinite(portFromEnv) || portFromEnv <= 0 || portFromEnv > 65535)) {\n throw new Error(`neatd: invalid NEAT_WEB_PORT=\"${webPortEnv}\"`)\n }\n const webPort = daemonWeb ?? portFromEnv ?? DEFAULT_WEB_PORT\n\n const restPort = daemonRest ?? asValidPort(restPortArg) ?? DEFAULT_REST_PORT\n const apiUrl = apiUrlEnv ?? `http://localhost:${restPort}`\n\n return { webPort, apiUrl }\n}\n\n/**\n * Best-effort port collision check before binding. Binds, closes, returns. A\n * race between the check and the real bind is acceptable — the placeholder's\n * own `listen` will then fail loudly and the parent exits.\n */\nasync function assertPortFree(port: number): Promise<void> {\n await new Promise<void>((resolve, reject) => {\n const tester = net.createServer()\n tester.once('error', (err: NodeJS.ErrnoException) => {\n if (err.code === 'EADDRINUSE') {\n reject(\n new Error(\n `neatd: web UI port ${port} in use; set NEAT_WEB_PORT to override or stop the conflicting process`,\n ),\n )\n } else {\n reject(err)\n }\n })\n tester.once('listening', () => {\n tester.close(() => resolve())\n })\n tester.listen(port, '127.0.0.1')\n })\n}\n\n/**\n * Resolve the web package directory. In a global install\n * (`npm install -g neat.is`) the package lives at\n * `node_modules/@neat.is/web/`; in the monorepo it's `packages/web/`.\n * `require.resolve` finds whichever one Node has on its search path.\n */\nfunction resolveWebPackageDir(): string {\n const req = (typeof require !== 'undefined'\n ? require\n : // ESM fallback — daemon CJS bundle has `require`, but typecheck wants this\n (eval('require') as NodeRequire))\n const pkgJsonPath = req.resolve('@neat.is/web/package.json')\n return path.dirname(pkgJsonPath)\n}\n\n/**\n * Locate the standalone server.js inside the web package. ADR-064 #2 — the\n * tarball ships `.next/standalone/packages/web/server.js` (path preserved\n * relative to the monorepo tracing root). Missing means the package was\n * published without `next build` having run, which the smoke-test gate\n * catches at publish time.\n */\nfunction resolveStandaloneServerEntry(webDir: string): string {\n return path.join(webDir, '.next/standalone/packages/web/server.js')\n}\n\n// Find a free loopback port for the lazily-spawned Next server to listen on,\n// behind the public web port. `listen(0)` lets the OS pick.\nasync function pickInternalPort(): Promise<number> {\n return new Promise((resolve, reject) => {\n const srv = net.createServer()\n srv.once('error', reject)\n srv.listen(0, '127.0.0.1', () => {\n const addr = srv.address()\n if (addr && typeof addr === 'object') {\n const port = addr.port\n srv.close(() => resolve(port))\n } else {\n srv.close(() => reject(new Error('neatd: could not pick an internal web port')))\n }\n })\n })\n}\n\n/**\n * Test-only seams. Production callers pass `spawnWebUI(restPort)` untouched —\n * the orchestrator's call signature is unchanged (ADR-096 scope). Tests inject\n * an alternate server entry (a throwaway script) so they can exercise the lazy\n * spawn and the proxy handoff without a real Next build on disk.\n */\nexport interface SpawnWebUIOptions {\n serverEntry?: string\n skipBuildCheck?: boolean\n}\n\nexport async function spawnWebUI(\n restPort: number,\n opts: SpawnWebUIOptions = {},\n): Promise<WebHandle> {\n const root = projectRoot()\n const fromDaemon = await readDaemonPorts(root)\n\n // daemon.json is authoritative; NEAT_WEB_PORT / NEAT_API_URL / the restPort\n // argument are fallbacks for installs predating per-project daemon.json.\n const { webPort: port, apiUrl } = resolveWebPorts({\n daemonWeb: fromDaemon.web,\n daemonRest: fromDaemon.rest,\n webPortEnv: process.env.NEAT_WEB_PORT,\n apiUrlEnv: process.env.NEAT_API_URL,\n restPortArg: restPort,\n })\n\n await assertPortFree(port)\n\n const serverEntry =\n opts.serverEntry ?? resolveStandaloneServerEntry(resolveWebPackageDir())\n // ADR-064 — fail loudly if the standalone build is missing. v0.3.0 shipped\n // without it; the symptom on the user side was `next start` aborting with\n // `Could not find a production build in the '.next' directory`. We check at\n // bind time, before anyone opens the dashboard, so the operator learns about\n // a broken install at start rather than on first visit.\n if (!opts.skipBuildCheck) {\n try {\n require.resolve(serverEntry)\n } catch {\n throw new Error(\n `neatd: web UI standalone build missing at ${serverEntry}. ` +\n `The published @neat.is/web tarball should include it; if you're running from a ` +\n `monorepo checkout, run \\`npm run build --workspace @neat.is/web\\` first, or set ` +\n `NEAT_WEB_DISABLED=1 to skip the web UI.`,\n )\n }\n }\n\n let child: ChildProcess | null = null\n let internalPort = 0\n let starting: Promise<void> | null = null\n let stopped = false\n\n // ADR-096 §7 — bring up the real Next server the first time the dashboard is\n // opened. Idempotent: concurrent first connections share one spawn.\n function ensureStarted(): Promise<void> {\n if (starting) return starting\n starting = (async () => {\n internalPort = await pickInternalPort()\n // ADR-059 #6 — the child inherits NEAT_API_URL pointing at this project's\n // REST server, unless the operator pre-configured it.\n const env: NodeJS.ProcessEnv = {\n ...process.env,\n PORT: String(internalPort),\n HOSTNAME: '127.0.0.1',\n NEAT_API_URL: apiUrl,\n }\n // The standalone bundle is self-contained — its own `node_modules` and\n // `package.json` sit alongside `server.js`. Spawn `node` against it\n // directly; no `next start` (which needs the source tree + build cache)\n // and no `npm exec` (which is monorepo-only in practice).\n // `detached: false` keeps the child in our process group so signals reach it.\n child = spawn(process.execPath, [serverEntry], {\n cwd: path.dirname(serverEntry),\n env,\n stdio: ['ignore', 'inherit', 'inherit'],\n detached: false,\n })\n child.on('error', (err) => {\n console.error(`neatd: web UI spawn error — ${err.message}`)\n })\n await waitForListening(internalPort)\n console.log(`neatd: web UI ready on http://localhost:${port}`)\n })()\n return starting\n }\n\n // The public listener on the web port. It costs nothing until visited; the\n // first connection triggers ensureStarted(), then every socket is piped\n // through to the internal Next server. A raw TCP proxy forwards SSE and any\n // other streaming response transparently.\n const front = net.createServer((socket) => {\n socket.on('error', () => socket.destroy())\n ensureStarted()\n .then(() => {\n if (stopped) {\n socket.destroy()\n return\n }\n const upstream = net.connect(internalPort, '127.0.0.1')\n upstream.on('error', () => socket.destroy())\n socket.pipe(upstream)\n upstream.pipe(socket)\n socket.on('close', () => upstream.destroy())\n upstream.on('close', () => socket.destroy())\n })\n .catch((err) => {\n console.error(`neatd: web UI failed to start — ${(err as Error).message}`)\n socket.destroy()\n })\n })\n\n await new Promise<void>((resolve, reject) => {\n front.once('error', reject)\n front.listen(port, '127.0.0.1', () => resolve())\n })\n\n console.log(`neatd: web UI listening on http://localhost:${port} (starts on first open)`)\n\n async function stop(): Promise<void> {\n if (stopped) return\n stopped = true\n await new Promise<void>((resolve) => front.close(() => resolve()))\n if (!child || !child.pid) return\n try {\n child.kill('SIGTERM')\n } catch {\n /* already gone */\n }\n // Give the child up to 3s to exit gracefully, then SIGKILL.\n await new Promise<void>((resolve) => {\n const t = setTimeout(() => {\n try {\n child?.kill('SIGKILL')\n } catch {\n /* gone */\n }\n resolve()\n }, 3000)\n child?.once('exit', () => {\n clearTimeout(t)\n resolve()\n })\n })\n }\n\n return {\n get child() {\n return child\n },\n port,\n started: () => child !== null,\n stop,\n }\n}\n\n// Poll the internal port until the Next server accepts a connection, so the\n// first proxied socket doesn't race the child's bind. Bounded so a child that\n// never comes up doesn't hang the request forever.\nasync function waitForListening(port: number, timeoutMs = 15000): Promise<void> {\n const deadline = Date.now() + timeoutMs\n for (;;) {\n const ok = await new Promise<boolean>((resolve) => {\n const s = net.connect(port, '127.0.0.1')\n s.once('connect', () => {\n s.destroy()\n resolve(true)\n })\n s.once('error', () => {\n s.destroy()\n resolve(false)\n })\n })\n if (ok) return\n if (Date.now() > deadline) {\n throw new Error(`neatd: web UI did not start listening on :${port} within ${timeoutMs}ms`)\n }\n await new Promise((r) => setTimeout(r, 150))\n }\n}\n","/**\n * Version-skew check for `neatd start` (ADR-049 §Lifecycle — non-fatal\n * advisory).\n *\n * A globally installed `neat.is` binary may run an older version than the\n * `neat.is` available on npm — happens when the operator has not run\n * `npm install -g neat.is@latest` since the last publish. The six packages\n * move in lockstep (publish-system contract / ADR-052), so the registry\n * version of `neat.is` mirrors the local `@neat.is/core` version.\n *\n * On daemon start we compare the two and log a single advisory line when\n * the registry is ahead. The check is fail-open by design — registry\n * unreachable, slow, or returning an unexpected payload all resolve to\n * \"no warning, daemon proceeds normally.\"\n */\n\nimport { setTimeout as delay } from 'node:timers/promises'\n\nconst NPM_REGISTRY_URL = 'https://registry.npmjs.org/neat.is/latest'\nconst DEFAULT_TIMEOUT_MS = 2000\n\nexport interface VersionSkewCheckOptions {\n // The local version the running daemon reports. Production: read from the\n // bundled @neat.is/core package.json. Tests pass a literal.\n localVersion: string\n // Override the registry URL (tests point at a mock server).\n registryUrl?: string\n // Timeout for the registry fetch. Defaults to 2s.\n timeoutMs?: number\n // Injected so tests can simulate slow responses without real timers.\n fetchImpl?: typeof fetch\n // Sink for the advisory line. Defaults to console.warn.\n warn?: (message: string) => void\n}\n\nexport interface VersionSkewCheckResult {\n // The version the registry reported, or null on any fetch / parse failure.\n remoteVersion: string | null\n // Whether the local version is behind the registry's.\n skewed: boolean\n // Whether the advisory was emitted.\n warned: boolean\n}\n\n/**\n * Fail-open registry lookup. Returns the version string or null on any\n * non-success path (network, timeout, parse, missing field).\n */\nasync function fetchRegistryVersion(\n url: string,\n timeoutMs: number,\n fetchImpl: typeof fetch,\n): Promise<string | null> {\n const controller = new AbortController()\n let timedOut = false\n const timer = delay(timeoutMs).then(() => {\n timedOut = true\n controller.abort()\n })\n try {\n const res = await Promise.race([\n fetchImpl(url, { signal: controller.signal, headers: { accept: 'application/json' } }),\n timer.then(() => null),\n ])\n if (timedOut || !res) return null\n if (!res.ok) return null\n const body = (await res.json()) as { version?: unknown }\n if (typeof body.version !== 'string' || body.version.length === 0) return null\n return body.version\n } catch {\n return null\n } finally {\n // Make sure the abort fires so the delay promise resolves even on\n // success — keeps the process from hanging on test teardown.\n if (!timedOut) controller.abort()\n }\n}\n\n/**\n * Compare two semver-shaped strings. Returns true when `local` is strictly\n * older than `remote`. We don't use a real semver library because the\n * advisory is intentionally coarse — any non-equal pair where the registry\n * looks newer is enough to suggest a re-install. The implementation does a\n * plain segment-by-segment integer compare and tolerates pre-release\n * suffixes by stripping them off.\n */\nexport function isLocalBehind(local: string, remote: string): boolean {\n if (local === remote) return false\n const parse = (v: string): number[] => {\n const core = v.split(/[-+]/)[0] ?? v\n return core.split('.').map((s) => {\n const n = Number.parseInt(s, 10)\n return Number.isFinite(n) ? n : 0\n })\n }\n const a = parse(local)\n const b = parse(remote)\n const len = Math.max(a.length, b.length)\n for (let i = 0; i < len; i++) {\n const av = a[i] ?? 0\n const bv = b[i] ?? 0\n if (av < bv) return true\n if (av > bv) return false\n }\n return false\n}\n\nexport async function checkVersionSkew(\n opts: VersionSkewCheckOptions,\n): Promise<VersionSkewCheckResult> {\n const url = opts.registryUrl ?? NPM_REGISTRY_URL\n const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS\n const fetchImpl = opts.fetchImpl ?? fetch\n const warn = opts.warn ?? ((m) => console.warn(m))\n\n const remote = await fetchRegistryVersion(url, timeoutMs, fetchImpl)\n if (!remote) return { remoteVersion: null, skewed: false, warned: false }\n\n if (!isLocalBehind(opts.localVersion, remote)) {\n return { remoteVersion: remote, skewed: false, warned: false }\n }\n\n warn(\n `[neatd] running neat.is@${opts.localVersion} but neat.is@${remote} is on npm — run \\`npm install -g neat.is@latest\\` to update`,\n )\n return { remoteVersion: remote, skewed: true, warned: true }\n}\n"],"mappings":"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","names":["path","req","path"]}
1
+ {"version":3,"sources":["../src/neatd.ts","../src/web-spawn.ts","../src/version-skew.ts"],"sourcesContent":["#!/usr/bin/env node\n/**\n * `neatd` — distribution-layer daemon CLI (ADR-049).\n *\n * Subcommands:\n * neatd start [--foreground] boot the daemon and watch the registry\n * neatd stop signal the running daemon to shut down\n * neatd reload signal the running daemon to re-read the registry\n * neatd status print PID + per-project last-seen timestamps\n *\n * MVP runs in foreground only. Backgrounding is the supervisor's job\n * (launchd / systemd / nohup) — `neatd start` blocks until SIGINT/SIGTERM.\n *\n * v0.2.10: also brings up the web UI on port 6328 by default (ADR-059).\n */\n\nimport { promises as fs } from 'node:fs'\nimport path from 'node:path'\nimport { createRequire } from 'node:module'\nimport { startDaemon } from './daemon.js'\nimport { BindAuthorityError } from './auth.js'\nimport { listProjects, registryPath } from './registry.js'\nimport { spawnWebUI, DEFAULT_WEB_PORT, type WebHandle } from './web-spawn.js'\nimport { checkVersionSkew } from './version-skew.js'\n\n// Resolve the running @neat.is/core version from the bundled package.json.\n// Lockstep publishing (ADR-052) keeps this in step with the `neat.is` meta\n// package, which is what the operator installs globally. Tests stub by\n// setting NEAT_LOCAL_VERSION.\nfunction localVersion(): string {\n if (process.env.NEAT_LOCAL_VERSION && process.env.NEAT_LOCAL_VERSION.length > 0) {\n return process.env.NEAT_LOCAL_VERSION\n }\n try {\n const req = createRequire(import.meta.url)\n const pkg = req('../package.json') as { version?: string }\n return typeof pkg.version === 'string' ? pkg.version : '0.0.0'\n } catch {\n return '0.0.0'\n }\n}\n\nfunction neatHome(): string {\n if (process.env.NEAT_HOME && process.env.NEAT_HOME.length > 0) {\n return path.resolve(process.env.NEAT_HOME)\n }\n const home = process.env.HOME ?? process.env.USERPROFILE ?? ''\n return path.join(home, '.neat')\n}\n\nasync function readPid(): Promise<number | null> {\n try {\n const raw = await fs.readFile(path.join(neatHome(), 'neatd.pid'), 'utf8')\n const n = Number.parseInt(raw.trim(), 10)\n return Number.isFinite(n) ? n : null\n } catch {\n return null\n }\n}\n\nfunction usage(): void {\n console.log('usage: neatd <start|stop|reload|status> [--foreground]')\n}\n\nfunction restPortFromEnv(): number {\n const raw = process.env.PORT\n return raw && raw.length > 0 ? Number.parseInt(raw, 10) : 8080\n}\n\nfunction webPortFromEnv(): number | undefined {\n const raw = process.env.NEAT_WEB_PORT\n if (!raw || raw.length === 0) return undefined\n const n = Number.parseInt(raw, 10)\n return Number.isFinite(n) ? n : undefined\n}\n\nasync function cmdStart(): Promise<void> {\n // ADR-096 — when the orchestrator spawns a per-project daemon it passes the\n // project name + root + allocated ports through the environment. Forward them\n // into startDaemon so the daemon scopes itself to that one project and writes\n // its daemon.json self-description. A bare `neatd start` with none of these\n // set falls through to the legacy multi-project daemon. (startDaemon also\n // reads PORT / OTEL_PORT / NEAT_WEB_PORT itself; we thread the project bits\n // explicitly so the wiring is visible at the entrypoint.)\n const project = process.env.NEAT_PROJECT\n const projectPath = process.env.NEAT_PROJECT_PATH\n const startOpts =\n project && project.length > 0\n ? { project, projectPath, webPort: webPortFromEnv() }\n : {}\n\n let handle: Awaited<ReturnType<typeof startDaemon>>\n try {\n handle = await startDaemon(startOpts)\n } catch (err) {\n if (err instanceof BindAuthorityError) {\n // ADR-073 §3 — refuse to bind on a public interface without a token.\n // Clean stderr line, exit 1 — no stack trace; the operator wants the\n // single-line directive, not a Node.js Error dump.\n console.error(`neatd: ${err.message}`)\n process.exit(1)\n }\n throw err\n }\n console.log(`neatd: started, PID ${process.pid}, ${handle.slots.size} project(s)`)\n console.log(`neatd: registry at ${registryPath()}`)\n // ADR-063 — surface the bound addresses so the operator can sanity-check\n // the documented happy path without grepping startup logs.\n if (handle.restAddress) console.log(`neatd: REST → ${handle.restAddress}`)\n if (handle.otlpAddress) console.log(`neatd: OTLP → ${handle.otlpAddress}`)\n\n // Version-skew advisory — non-fatal, fail-open. Surfaces the gap when the\n // operator's globally installed `neat.is` binary lags the published\n // version. Set NEAT_DISABLE_VERSION_CHECK=1 to silence the check (e.g. in\n // air-gapped environments).\n if (process.env.NEAT_DISABLE_VERSION_CHECK !== '1') {\n void checkVersionSkew({ localVersion: localVersion() }).catch(() => {\n // Fail-open: any thrown error from the helper resolves silently. The\n // helper already catches its own internals; this catches anything\n // exotic the Promise chain bubbles up.\n })\n }\n\n // ADR-059 — bring up the web UI alongside the daemon. Failure here aborts\n // start with the clear-error pattern from ADR-049 instead of silently\n // running headless.\n const skipWeb = process.env.NEAT_WEB_DISABLED === '1'\n let web: WebHandle | null = null\n if (!skipWeb) {\n try {\n web = await spawnWebUI(restPortFromEnv())\n } catch (err) {\n console.error((err as Error).message)\n await handle.stop().catch(() => {})\n process.exit(3)\n }\n } else {\n console.log('neatd: web UI disabled (NEAT_WEB_DISABLED=1)')\n }\n\n console.log('neatd: SIGHUP reloads, SIGTERM/SIGINT stops')\n\n let stopping = false\n const shutdown = (signal: NodeJS.Signals): void => {\n if (stopping) return\n stopping = true\n console.log(`neatd: ${signal} received, stopping…`)\n void Promise.allSettled([handle.stop(), web ? web.stop() : Promise.resolve()])\n .catch((err) => console.error(`neatd: shutdown error — ${(err as Error).message}`))\n .finally(() => process.exit(0))\n }\n process.on('SIGTERM', shutdown)\n process.on('SIGINT', shutdown)\n\n // Block forever — supervisors keep us in the foreground.\n await new Promise<void>(() => {})\n}\n\nasync function cmdStop(): Promise<void> {\n const pid = await readPid()\n if (pid === null) {\n console.error('neatd: no running daemon found (no PID file)')\n process.exit(1)\n }\n try {\n process.kill(pid, 'SIGTERM')\n console.log(`neatd: SIGTERM sent to PID ${pid}`)\n } catch (err) {\n console.error(`neatd: failed to signal PID ${pid} — ${(err as Error).message}`)\n process.exit(1)\n }\n}\n\nasync function cmdReload(): Promise<void> {\n const pid = await readPid()\n if (pid === null) {\n console.error('neatd: no running daemon found (no PID file)')\n process.exit(1)\n }\n try {\n process.kill(pid, 'SIGHUP')\n console.log(`neatd: SIGHUP sent to PID ${pid}`)\n } catch (err) {\n console.error(`neatd: failed to signal PID ${pid} — ${(err as Error).message}`)\n process.exit(1)\n }\n}\n\nasync function cmdStatus(): Promise<void> {\n const pid = await readPid()\n console.log(`pid: ${pid ?? '(not running)'}`)\n console.log(`registry: ${registryPath()}`)\n const webPort = process.env.NEAT_WEB_PORT\n ? Number.parseInt(process.env.NEAT_WEB_PORT, 10)\n : DEFAULT_WEB_PORT\n console.log(`web ui: http://localhost:${webPort}`)\n const projects = await listProjects().catch(() => [])\n if (projects.length === 0) {\n console.log('projects: (none)')\n return\n }\n console.log('projects:')\n for (const p of projects) {\n const seen = p.lastSeenAt ?? 'never'\n console.log(` ${p.name}\\t${p.status}\\t${p.path}\\tlast-seen=${seen}`)\n }\n}\n\nasync function main(): Promise<void> {\n const cmd = process.argv[2]\n if (!cmd || cmd === '-h' || cmd === '--help') {\n usage()\n process.exit(cmd ? 0 : 2)\n }\n\n if (cmd === 'start') return cmdStart()\n if (cmd === 'stop') return cmdStop()\n if (cmd === 'reload') return cmdReload()\n if (cmd === 'status') return cmdStatus()\n\n console.error(`neatd: unknown command \"${cmd}\"`)\n usage()\n process.exit(1)\n}\n\nconst entry = process.argv[1] ?? ''\nif (/[\\\\/]neatd\\.(?:cjs|js)$/.test(entry) || entry.endsWith('/neatd')) {\n main().catch((err) => {\n console.error(err)\n process.exit(1)\n })\n}\n","/**\n * Web UI spawn helper (ADR-059, ADR-096).\n *\n * A project's daemon brings up the REST API + OTel receivers, then calls\n * `spawnWebUI(restPort)` to make its dashboard reachable. Lifecycle is\n * parent-tied: SIGTERM/SIGINT on the daemon cascades to the web child via\n * `stop()`.\n *\n * Per-project ports (ADR-096 §5). The dashboard binds its own port and points\n * at its own REST API, both read from the project's\n * `<projectRoot>/neat-out/daemon.json` — the single source of truth for \"where\n * is this project's daemon\" (project-daemon contract §2). The `restPort`\n * argument is tolerated as a fallback so callers that still pass it keep\n * working, but `daemon.json` wins when present. A missing or malformed\n * `daemon.json` falls back to the canonical `6328`/`8080`; the read never\n * throws.\n *\n * Lazy spawn (ADR-096 §7). The web port is bound up front by a thin listener,\n * but the heavyweight Next.js server is not started until someone actually\n * opens the dashboard. A daemon that nobody is looking at keeps no web process\n * resident — this matters once there's one daemon per project and the cost\n * would otherwise multiply by N.\n */\n\nimport { spawn, type ChildProcess } from 'node:child_process'\nimport { promises as fsp } from 'node:fs'\nimport net from 'node:net'\nimport path from 'node:path'\n\nexport const DEFAULT_WEB_PORT = 6328\nexport const DEFAULT_REST_PORT = 8080\n\nexport interface WebHandle {\n /** The Next.js child once it's been lazily spawned; null until first open. */\n readonly child: ChildProcess | null\n port: number\n /** True once the underlying Next.js server has actually been spawned. */\n started: () => boolean\n stop: () => Promise<void>\n}\n\n// The slice of daemon.json we read. Other fields exist (pid, status, …) but the\n// web UI only needs the ports, so we narrow to those and tolerate the rest.\ninterface DaemonPortsShape {\n rest?: unknown\n web?: unknown\n}\ninterface DaemonRecordShape {\n ports?: DaemonPortsShape\n}\n\nfunction asValidPort(value: unknown): number | null {\n const n = typeof value === 'number' ? value : Number.parseInt(String(value ?? ''), 10)\n return Number.isInteger(n) && n > 0 && n <= 65535 ? n : null\n}\n\n/**\n * Resolve the project root whose `neat-out/daemon.json` describes this\n * daemon. `NEAT_SCAN_PATH` is the canonical project-root env (the REST server\n * reads the same name); `process.cwd()` is the fallback for a daemon launched\n * from inside the project.\n */\nfunction projectRoot(): string {\n const fromEnv = process.env.NEAT_SCAN_PATH\n return path.resolve(fromEnv && fromEnv.length > 0 ? fromEnv : process.cwd())\n}\n\n/**\n * Read `<projectRoot>/neat-out/daemon.json` and pull out the web + REST ports.\n * Bulletproof: a missing file, unreadable file, bad JSON, or a malformed port\n * all resolve to `null` for that field — never a throw. Authoritative when\n * present (project-daemon contract §2), so its ports take precedence over the\n * `restPort` argument and over `NEAT_WEB_PORT`.\n */\nexport async function readDaemonPorts(\n root: string,\n): Promise<{ web: number | null; rest: number | null }> {\n try {\n const raw = await fsp.readFile(path.join(root, 'neat-out', 'daemon.json'), 'utf8')\n const parsed = JSON.parse(raw) as DaemonRecordShape\n const ports = parsed?.ports ?? {}\n return { web: asValidPort(ports.web), rest: asValidPort(ports.rest) }\n } catch {\n return { web: null, rest: null }\n }\n}\n\n/**\n * Pure port resolution, factored out so it's testable without binding a\n * socket. `daemon.json` is the source of truth (ADR-096 §2); `NEAT_WEB_PORT`\n * and the `restPort` argument are fallbacks for installs predating the file.\n */\nexport function resolveWebPorts(args: {\n daemonWeb: number | null\n daemonRest: number | null\n webPortEnv: string | undefined\n apiUrlEnv: string | undefined\n restPortArg: number\n}): { webPort: number; apiUrl: string } {\n const { daemonWeb, daemonRest, webPortEnv, apiUrlEnv, restPortArg } = args\n\n const portFromEnv = webPortEnv && webPortEnv.length > 0 ? Number.parseInt(webPortEnv, 10) : null\n if (portFromEnv !== null && (!Number.isFinite(portFromEnv) || portFromEnv <= 0 || portFromEnv > 65535)) {\n throw new Error(`neatd: invalid NEAT_WEB_PORT=\"${webPortEnv}\"`)\n }\n const webPort = daemonWeb ?? portFromEnv ?? DEFAULT_WEB_PORT\n\n const restPort = daemonRest ?? asValidPort(restPortArg) ?? DEFAULT_REST_PORT\n const apiUrl = apiUrlEnv ?? `http://localhost:${restPort}`\n\n return { webPort, apiUrl }\n}\n\n/**\n * Best-effort port collision check before binding. Binds, closes, returns. A\n * race between the check and the real bind is acceptable — the placeholder's\n * own `listen` will then fail loudly and the parent exits.\n */\nasync function assertPortFree(port: number): Promise<void> {\n await new Promise<void>((resolve, reject) => {\n const tester = net.createServer()\n tester.once('error', (err: NodeJS.ErrnoException) => {\n if (err.code === 'EADDRINUSE') {\n reject(\n new Error(\n `neatd: web UI port ${port} in use; set NEAT_WEB_PORT to override or stop the conflicting process`,\n ),\n )\n } else {\n reject(err)\n }\n })\n tester.once('listening', () => {\n tester.close(() => resolve())\n })\n tester.listen(port, '127.0.0.1')\n })\n}\n\n/**\n * Resolve the web package directory. In a global install\n * (`npm install -g neat.is`) the package lives at\n * `node_modules/@neat.is/web/`; in the monorepo it's `packages/web/`.\n * `require.resolve` finds whichever one Node has on its search path.\n */\nfunction resolveWebPackageDir(): string {\n const req = (typeof require !== 'undefined'\n ? require\n : // ESM fallback — daemon CJS bundle has `require`, but typecheck wants this\n (eval('require') as NodeRequire))\n const pkgJsonPath = req.resolve('@neat.is/web/package.json')\n return path.dirname(pkgJsonPath)\n}\n\n/**\n * Locate the standalone server.js inside the web package. ADR-064 #2 — the\n * tarball ships `.next/standalone/packages/web/server.js` (path preserved\n * relative to the monorepo tracing root). Missing means the package was\n * published without `next build` having run, which the smoke-test gate\n * catches at publish time.\n */\nfunction resolveStandaloneServerEntry(webDir: string): string {\n return path.join(webDir, '.next/standalone/packages/web/server.js')\n}\n\n// Find a free loopback port for the lazily-spawned Next server to listen on,\n// behind the public web port. `listen(0)` lets the OS pick.\nasync function pickInternalPort(): Promise<number> {\n return new Promise((resolve, reject) => {\n const srv = net.createServer()\n srv.once('error', reject)\n srv.listen(0, '127.0.0.1', () => {\n const addr = srv.address()\n if (addr && typeof addr === 'object') {\n const port = addr.port\n srv.close(() => resolve(port))\n } else {\n srv.close(() => reject(new Error('neatd: could not pick an internal web port')))\n }\n })\n })\n}\n\n/**\n * Test-only seams. Production callers pass `spawnWebUI(restPort)` untouched —\n * the orchestrator's call signature is unchanged (ADR-096 scope). Tests inject\n * an alternate server entry (a throwaway script) so they can exercise the lazy\n * spawn and the proxy handoff without a real Next build on disk.\n */\nexport interface SpawnWebUIOptions {\n serverEntry?: string\n skipBuildCheck?: boolean\n}\n\nexport async function spawnWebUI(\n restPort: number,\n opts: SpawnWebUIOptions = {},\n): Promise<WebHandle> {\n const root = projectRoot()\n const fromDaemon = await readDaemonPorts(root)\n\n // daemon.json is authoritative; NEAT_WEB_PORT / NEAT_API_URL / the restPort\n // argument are fallbacks for installs predating per-project daemon.json.\n const { webPort: port, apiUrl } = resolveWebPorts({\n daemonWeb: fromDaemon.web,\n daemonRest: fromDaemon.rest,\n webPortEnv: process.env.NEAT_WEB_PORT,\n apiUrlEnv: process.env.NEAT_API_URL,\n restPortArg: restPort,\n })\n\n await assertPortFree(port)\n\n const serverEntry =\n opts.serverEntry ?? resolveStandaloneServerEntry(resolveWebPackageDir())\n // ADR-064 — fail loudly if the standalone build is missing. v0.3.0 shipped\n // without it; the symptom on the user side was `next start` aborting with\n // `Could not find a production build in the '.next' directory`. We check at\n // bind time, before anyone opens the dashboard, so the operator learns about\n // a broken install at start rather than on first visit.\n if (!opts.skipBuildCheck) {\n try {\n require.resolve(serverEntry)\n } catch {\n throw new Error(\n `neatd: web UI standalone build missing at ${serverEntry}. ` +\n `The published @neat.is/web tarball should include it; if you're running from a ` +\n `monorepo checkout, run \\`npm run build --workspace @neat.is/web\\` first, or set ` +\n `NEAT_WEB_DISABLED=1 to skip the web UI.`,\n )\n }\n }\n\n let child: ChildProcess | null = null\n let internalPort = 0\n let starting: Promise<void> | null = null\n let stopped = false\n\n // ADR-096 §7 — bring up the real Next server the first time the dashboard is\n // opened. Idempotent: concurrent first connections share one spawn.\n function ensureStarted(): Promise<void> {\n if (starting) return starting\n starting = (async () => {\n internalPort = await pickInternalPort()\n // ADR-059 #6 — the child inherits NEAT_API_URL pointing at this project's\n // REST server, unless the operator pre-configured it.\n const env: NodeJS.ProcessEnv = {\n ...process.env,\n PORT: String(internalPort),\n HOSTNAME: '127.0.0.1',\n NEAT_API_URL: apiUrl,\n }\n // The standalone bundle is self-contained — its own `node_modules` and\n // `package.json` sit alongside `server.js`. Spawn `node` against it\n // directly; no `next start` (which needs the source tree + build cache)\n // and no `npm exec` (which is monorepo-only in practice).\n // `detached: false` keeps the child in our process group so signals reach it.\n child = spawn(process.execPath, [serverEntry], {\n cwd: path.dirname(serverEntry),\n env,\n stdio: ['ignore', 'inherit', 'inherit'],\n detached: false,\n })\n child.on('error', (err) => {\n console.error(`neatd: web UI spawn error — ${err.message}`)\n })\n await waitForListening(internalPort)\n console.log(`neatd: web UI ready on http://localhost:${port}`)\n })()\n return starting\n }\n\n // The public listener on the web port. It costs nothing until visited; the\n // first connection triggers ensureStarted(), then every socket is piped\n // through to the internal Next server. A raw TCP proxy forwards SSE and any\n // other streaming response transparently.\n // Live front-side sockets, so stop() can force them down. A raw net.Server\n // has no http.Server-style closeAllConnections(), and its close() waits for\n // every open connection to end on its own — which a browser dashboard's\n // keep-alive / EventSource sockets never do (#518).\n const liveSockets = new Set<net.Socket>()\n const front = net.createServer((socket) => {\n liveSockets.add(socket)\n socket.on('close', () => liveSockets.delete(socket))\n socket.on('error', () => socket.destroy())\n ensureStarted()\n .then(() => {\n if (stopped) {\n socket.destroy()\n return\n }\n const upstream = net.connect(internalPort, '127.0.0.1')\n upstream.on('error', () => socket.destroy())\n socket.pipe(upstream)\n upstream.pipe(socket)\n socket.on('close', () => upstream.destroy())\n upstream.on('close', () => socket.destroy())\n })\n .catch((err) => {\n console.error(`neatd: web UI failed to start — ${(err as Error).message}`)\n socket.destroy()\n })\n })\n\n await new Promise<void>((resolve, reject) => {\n front.once('error', reject)\n front.listen(port, '127.0.0.1', () => resolve())\n })\n\n console.log(`neatd: web UI listening on http://localhost:${port} (starts on first open)`)\n\n async function stop(): Promise<void> {\n if (stopped) return\n stopped = true\n await new Promise<void>((resolve) => {\n front.close(() => resolve())\n // `front.close()` only fires its callback once every existing connection\n // has ended. The dashboard holds long-lived keep-alive / EventSource (SSE)\n // sockets piped through this raw TCP front, and a live browser tab never\n // drops them on its own — so destroy the live sockets here and the\n // callback resolves promptly. Graceful stop then completes whether or not\n // a dashboard tab is open.\n for (const socket of liveSockets) socket.destroy()\n liveSockets.clear()\n })\n if (!child || !child.pid) return\n try {\n child.kill('SIGTERM')\n } catch {\n /* already gone */\n }\n // Give the child up to 3s to exit gracefully, then SIGKILL.\n await new Promise<void>((resolve) => {\n const t = setTimeout(() => {\n try {\n child?.kill('SIGKILL')\n } catch {\n /* gone */\n }\n resolve()\n }, 3000)\n child?.once('exit', () => {\n clearTimeout(t)\n resolve()\n })\n })\n }\n\n return {\n get child() {\n return child\n },\n port,\n started: () => child !== null,\n stop,\n }\n}\n\n// Poll the internal port until the Next server accepts a connection, so the\n// first proxied socket doesn't race the child's bind. Bounded so a child that\n// never comes up doesn't hang the request forever.\nasync function waitForListening(port: number, timeoutMs = 15000): Promise<void> {\n const deadline = Date.now() + timeoutMs\n for (;;) {\n const ok = await new Promise<boolean>((resolve) => {\n const s = net.connect(port, '127.0.0.1')\n s.once('connect', () => {\n s.destroy()\n resolve(true)\n })\n s.once('error', () => {\n s.destroy()\n resolve(false)\n })\n })\n if (ok) return\n if (Date.now() > deadline) {\n throw new Error(`neatd: web UI did not start listening on :${port} within ${timeoutMs}ms`)\n }\n await new Promise((r) => setTimeout(r, 150))\n }\n}\n","/**\n * Version-skew check for `neatd start` (ADR-049 §Lifecycle — non-fatal\n * advisory).\n *\n * A globally installed `neat.is` binary may run an older version than the\n * `neat.is` available on npm — happens when the operator has not run\n * `npm install -g neat.is@latest` since the last publish. The six packages\n * move in lockstep (publish-system contract / ADR-052), so the registry\n * version of `neat.is` mirrors the local `@neat.is/core` version.\n *\n * On daemon start we compare the two and log a single advisory line when\n * the registry is ahead. The check is fail-open by design — registry\n * unreachable, slow, or returning an unexpected payload all resolve to\n * \"no warning, daemon proceeds normally.\"\n */\n\nimport { setTimeout as delay } from 'node:timers/promises'\n\nconst NPM_REGISTRY_URL = 'https://registry.npmjs.org/neat.is/latest'\nconst DEFAULT_TIMEOUT_MS = 2000\n\nexport interface VersionSkewCheckOptions {\n // The local version the running daemon reports. Production: read from the\n // bundled @neat.is/core package.json. Tests pass a literal.\n localVersion: string\n // Override the registry URL (tests point at a mock server).\n registryUrl?: string\n // Timeout for the registry fetch. Defaults to 2s.\n timeoutMs?: number\n // Injected so tests can simulate slow responses without real timers.\n fetchImpl?: typeof fetch\n // Sink for the advisory line. Defaults to console.warn.\n warn?: (message: string) => void\n}\n\nexport interface VersionSkewCheckResult {\n // The version the registry reported, or null on any fetch / parse failure.\n remoteVersion: string | null\n // Whether the local version is behind the registry's.\n skewed: boolean\n // Whether the advisory was emitted.\n warned: boolean\n}\n\n/**\n * Fail-open registry lookup. Returns the version string or null on any\n * non-success path (network, timeout, parse, missing field).\n */\nasync function fetchRegistryVersion(\n url: string,\n timeoutMs: number,\n fetchImpl: typeof fetch,\n): Promise<string | null> {\n const controller = new AbortController()\n let timedOut = false\n const timer = delay(timeoutMs).then(() => {\n timedOut = true\n controller.abort()\n })\n try {\n const res = await Promise.race([\n fetchImpl(url, { signal: controller.signal, headers: { accept: 'application/json' } }),\n timer.then(() => null),\n ])\n if (timedOut || !res) return null\n if (!res.ok) return null\n const body = (await res.json()) as { version?: unknown }\n if (typeof body.version !== 'string' || body.version.length === 0) return null\n return body.version\n } catch {\n return null\n } finally {\n // Make sure the abort fires so the delay promise resolves even on\n // success — keeps the process from hanging on test teardown.\n if (!timedOut) controller.abort()\n }\n}\n\n/**\n * Compare two semver-shaped strings. Returns true when `local` is strictly\n * older than `remote`. We don't use a real semver library because the\n * advisory is intentionally coarse — any non-equal pair where the registry\n * looks newer is enough to suggest a re-install. The implementation does a\n * plain segment-by-segment integer compare and tolerates pre-release\n * suffixes by stripping them off.\n */\nexport function isLocalBehind(local: string, remote: string): boolean {\n if (local === remote) return false\n const parse = (v: string): number[] => {\n const core = v.split(/[-+]/)[0] ?? v\n return core.split('.').map((s) => {\n const n = Number.parseInt(s, 10)\n return Number.isFinite(n) ? n : 0\n })\n }\n const a = parse(local)\n const b = parse(remote)\n const len = Math.max(a.length, b.length)\n for (let i = 0; i < len; i++) {\n const av = a[i] ?? 0\n const bv = b[i] ?? 0\n if (av < bv) return true\n if (av > bv) return false\n }\n return false\n}\n\nexport async function checkVersionSkew(\n opts: VersionSkewCheckOptions,\n): Promise<VersionSkewCheckResult> {\n const url = opts.registryUrl ?? NPM_REGISTRY_URL\n const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS\n const fetchImpl = opts.fetchImpl ?? fetch\n const warn = opts.warn ?? ((m) => console.warn(m))\n\n const remote = await fetchRegistryVersion(url, timeoutMs, fetchImpl)\n if (!remote) return { remoteVersion: null, skewed: false, warned: false }\n\n if (!isLocalBehind(opts.localVersion, remote)) {\n return { remoteVersion: remote, skewed: false, warned: false }\n }\n\n warn(\n `[neatd] running neat.is@${opts.localVersion} but neat.is@${remote} is on npm — run \\`npm install -g neat.is@latest\\` to update`,\n )\n return { remoteVersion: remote, skewed: true, warned: true }\n}\n"],"mappings":"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","names":["path","req","path"]}
package/dist/server.cjs CHANGED
@@ -5851,7 +5851,9 @@ async function loadGraphFromDisk(graph, outPath) {
5851
5851
  graph.clear();
5852
5852
  graph.import(payload.graph);
5853
5853
  }
5854
- function startPersistLoop(graph, outPath, intervalMs = 6e4) {
5854
+ function startPersistLoop(graph, outPath, opts = {}) {
5855
+ const intervalMs = opts.intervalMs ?? 6e4;
5856
+ const exitOnSignal = opts.exitOnSignal ?? true;
5855
5857
  let stopped = false;
5856
5858
  const tick = async () => {
5857
5859
  if (stopped) return;
@@ -5864,7 +5866,7 @@ function startPersistLoop(graph, outPath, intervalMs = 6e4) {
5864
5866
  const interval = setInterval(() => {
5865
5867
  void tick();
5866
5868
  }, intervalMs);
5867
- const onSignal = (signal) => {
5869
+ const onSignal = exitOnSignal ? (signal) => {
5868
5870
  void (async () => {
5869
5871
  try {
5870
5872
  await saveGraphToDisk(graph, outPath);
@@ -5874,14 +5876,18 @@ function startPersistLoop(graph, outPath, intervalMs = 6e4) {
5874
5876
  process.exit(0);
5875
5877
  }
5876
5878
  })();
5877
- };
5878
- process.on("SIGTERM", onSignal);
5879
- process.on("SIGINT", onSignal);
5879
+ } : null;
5880
+ if (onSignal) {
5881
+ process.on("SIGTERM", onSignal);
5882
+ process.on("SIGINT", onSignal);
5883
+ }
5880
5884
  return () => {
5881
5885
  stopped = true;
5882
5886
  clearInterval(interval);
5883
- process.off("SIGTERM", onSignal);
5884
- process.off("SIGINT", onSignal);
5887
+ if (onSignal) {
5888
+ process.off("SIGTERM", onSignal);
5889
+ process.off("SIGINT", onSignal);
5890
+ }
5885
5891
  };
5886
5892
  }
5887
5893
 
@@ -6049,12 +6055,16 @@ function serializeGraph(graph) {
6049
6055
  });
6050
6056
  return { nodes, edges };
6051
6057
  }
6052
- function projectFromReq(req) {
6058
+ function projectFromReq(req, singleProject) {
6053
6059
  const params = req.params;
6054
- return params.project ?? DEFAULT_PROJECT;
6060
+ const named = params.project;
6061
+ if (singleProject) {
6062
+ return named === void 0 || named === DEFAULT_PROJECT ? singleProject : named;
6063
+ }
6064
+ return named ?? DEFAULT_PROJECT;
6055
6065
  }
6056
- function resolveProject(registry, req, reply, bootstrap) {
6057
- const name = projectFromReq(req);
6066
+ function resolveProject(registry, req, reply, bootstrap, singleProject) {
6067
+ const name = projectFromReq(req, singleProject);
6058
6068
  const ctx = registry.get(name);
6059
6069
  if (!ctx) {
6060
6070
  const phase = bootstrap?.status(name);
@@ -6095,13 +6105,13 @@ function buildLegacyRegistry(opts) {
6095
6105
  function registerRoutes(scope, ctx) {
6096
6106
  const { registry, startedAt, errorsPathFor, staleEventsPathFor } = ctx;
6097
6107
  scope.get("/events", (req, reply) => {
6098
- const proj = resolveProject(registry, req, reply, ctx.bootstrap);
6108
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
6099
6109
  if (!proj) return;
6100
6110
  handleSse(req, reply, { project: proj.name });
6101
6111
  });
6102
6112
  if (ctx.scope === "project") {
6103
6113
  scope.get("/health", async (req, reply) => {
6104
- const proj = resolveProject(registry, req, reply, ctx.bootstrap);
6114
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
6105
6115
  if (!proj) return;
6106
6116
  const uptimeMs = Date.now() - startedAt;
6107
6117
  return {
@@ -6119,14 +6129,14 @@ function registerRoutes(scope, ctx) {
6119
6129
  });
6120
6130
  }
6121
6131
  scope.get("/graph", async (req, reply) => {
6122
- const proj = resolveProject(registry, req, reply, ctx.bootstrap);
6132
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
6123
6133
  if (!proj) return;
6124
6134
  return serializeGraph(proj.graph);
6125
6135
  });
6126
6136
  scope.get(
6127
6137
  "/graph/node/:id",
6128
6138
  async (req, reply) => {
6129
- const proj = resolveProject(registry, req, reply, ctx.bootstrap);
6139
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
6130
6140
  if (!proj) return;
6131
6141
  const { id } = req.params;
6132
6142
  if (!proj.graph.hasNode(id)) {
@@ -6138,7 +6148,7 @@ function registerRoutes(scope, ctx) {
6138
6148
  scope.get(
6139
6149
  "/graph/edges/:id",
6140
6150
  async (req, reply) => {
6141
- const proj = resolveProject(registry, req, reply, ctx.bootstrap);
6151
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
6142
6152
  if (!proj) return;
6143
6153
  const { id } = req.params;
6144
6154
  if (!proj.graph.hasNode(id)) {
@@ -6150,7 +6160,7 @@ function registerRoutes(scope, ctx) {
6150
6160
  }
6151
6161
  );
6152
6162
  scope.get("/graph/dependencies/:nodeId", async (req, reply) => {
6153
- const proj = resolveProject(registry, req, reply, ctx.bootstrap);
6163
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
6154
6164
  if (!proj) return;
6155
6165
  const { nodeId } = req.params;
6156
6166
  if (!proj.graph.hasNode(nodeId)) {
@@ -6165,7 +6175,7 @@ function registerRoutes(scope, ctx) {
6165
6175
  return getTransitiveDependencies(proj.graph, nodeId, depth);
6166
6176
  });
6167
6177
  scope.get("/graph/divergences", async (req, reply) => {
6168
- const proj = resolveProject(registry, req, reply, ctx.bootstrap);
6178
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
6169
6179
  if (!proj) return;
6170
6180
  let typeFilter;
6171
6181
  if (req.query.type) {
@@ -6200,7 +6210,7 @@ function registerRoutes(scope, ctx) {
6200
6210
  });
6201
6211
  });
6202
6212
  scope.get("/incidents", async (req, reply) => {
6203
- const proj = resolveProject(registry, req, reply, ctx.bootstrap);
6213
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
6204
6214
  if (!proj) return;
6205
6215
  const epath = errorsPathFor(proj);
6206
6216
  if (!epath) return { count: 0, total: 0, events: [] };
@@ -6212,7 +6222,7 @@ function registerRoutes(scope, ctx) {
6212
6222
  return { count: sliced.length, total, events: sliced };
6213
6223
  });
6214
6224
  scope.get("/stale-events", async (req, reply) => {
6215
- const proj = resolveProject(registry, req, reply, ctx.bootstrap);
6225
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
6216
6226
  if (!proj) return;
6217
6227
  const spath = staleEventsPathFor(proj);
6218
6228
  if (!spath) return { count: 0, total: 0, events: [] };
@@ -6227,7 +6237,7 @@ function registerRoutes(scope, ctx) {
6227
6237
  scope.get(
6228
6238
  "/incidents/:nodeId",
6229
6239
  async (req, reply) => {
6230
- const proj = resolveProject(registry, req, reply, ctx.bootstrap);
6240
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
6231
6241
  if (!proj) return;
6232
6242
  const { nodeId } = req.params;
6233
6243
  if (!proj.graph.hasNode(nodeId)) {
@@ -6243,7 +6253,7 @@ function registerRoutes(scope, ctx) {
6243
6253
  }
6244
6254
  );
6245
6255
  scope.get("/graph/root-cause/:nodeId", async (req, reply) => {
6246
- const proj = resolveProject(registry, req, reply, ctx.bootstrap);
6256
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
6247
6257
  if (!proj) return;
6248
6258
  const { nodeId } = req.params;
6249
6259
  if (!proj.graph.hasNode(nodeId)) {
@@ -6263,7 +6273,7 @@ function registerRoutes(scope, ctx) {
6263
6273
  return result;
6264
6274
  });
6265
6275
  scope.get("/graph/blast-radius/:nodeId", async (req, reply) => {
6266
- const proj = resolveProject(registry, req, reply, ctx.bootstrap);
6276
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
6267
6277
  if (!proj) return;
6268
6278
  const { nodeId } = req.params;
6269
6279
  if (!proj.graph.hasNode(nodeId)) {
@@ -6276,7 +6286,7 @@ function registerRoutes(scope, ctx) {
6276
6286
  return getBlastRadius(proj.graph, nodeId, depth);
6277
6287
  });
6278
6288
  scope.get("/search", async (req, reply) => {
6279
- const proj = resolveProject(registry, req, reply, ctx.bootstrap);
6289
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
6280
6290
  if (!proj) return;
6281
6291
  const raw = (req.query.q ?? "").trim();
6282
6292
  if (!raw) return reply.code(400).send({ error: "query parameter `q` is required" });
@@ -6307,7 +6317,7 @@ function registerRoutes(scope, ctx) {
6307
6317
  scope.get(
6308
6318
  "/graph/diff",
6309
6319
  async (req, reply) => {
6310
- const proj = resolveProject(registry, req, reply, ctx.bootstrap);
6320
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
6311
6321
  if (!proj) return;
6312
6322
  const against = req.query.against;
6313
6323
  if (!against) {
@@ -6322,7 +6332,7 @@ function registerRoutes(scope, ctx) {
6322
6332
  }
6323
6333
  );
6324
6334
  scope.post("/snapshot", async (req, reply) => {
6325
- const proj = resolveProject(registry, req, reply, ctx.bootstrap);
6335
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
6326
6336
  if (!proj) return;
6327
6337
  const body = req.body;
6328
6338
  if (!body || typeof body !== "object" || !body.snapshot) {
@@ -6351,7 +6361,7 @@ function registerRoutes(scope, ctx) {
6351
6361
  }
6352
6362
  });
6353
6363
  scope.post("/graph/scan", async (req, reply) => {
6354
- const proj = resolveProject(registry, req, reply, ctx.bootstrap);
6364
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
6355
6365
  if (!proj) return;
6356
6366
  if (!proj.scanPath) {
6357
6367
  return reply.code(409).send({ error: "scan path not configured for this project", project: proj.name });
@@ -6367,7 +6377,7 @@ function registerRoutes(scope, ctx) {
6367
6377
  };
6368
6378
  });
6369
6379
  scope.get("/policies", async (req, reply) => {
6370
- const proj = resolveProject(registry, req, reply, ctx.bootstrap);
6380
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
6371
6381
  if (!proj) return;
6372
6382
  const policyPath = ctx.policyFilePathFor(proj);
6373
6383
  if (!policyPath) {
@@ -6384,7 +6394,7 @@ function registerRoutes(scope, ctx) {
6384
6394
  }
6385
6395
  });
6386
6396
  scope.get("/policies/violations", async (req, reply) => {
6387
- const proj = resolveProject(registry, req, reply, ctx.bootstrap);
6397
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
6388
6398
  if (!proj) return;
6389
6399
  const log = new PolicyViolationsLog(proj.paths.policyViolationsPath);
6390
6400
  let violations = await log.readAll();
@@ -6404,7 +6414,7 @@ function registerRoutes(scope, ctx) {
6404
6414
  return { violations };
6405
6415
  });
6406
6416
  scope.post("/policies/check", async (req, reply) => {
6407
- const proj = resolveProject(registry, req, reply, ctx.bootstrap);
6417
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
6408
6418
  if (!proj) return;
6409
6419
  const parsed = import_types25.PoliciesCheckBodySchema.safeParse(req.body ?? {});
6410
6420
  if (!parsed.success) {
@@ -6440,7 +6450,7 @@ function registerRoutes(scope, ctx) {
6440
6450
  };
6441
6451
  });
6442
6452
  scope.get("/extend/list-uninstrumented", async (req, reply) => {
6443
- const proj = resolveProject(registry, req, reply, ctx.bootstrap);
6453
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
6444
6454
  if (!proj) return;
6445
6455
  if (!proj.scanPath) {
6446
6456
  return reply.code(409).send({ error: "scan path not configured for this project", project: proj.name });
@@ -6453,7 +6463,7 @@ function registerRoutes(scope, ctx) {
6453
6463
  }
6454
6464
  });
6455
6465
  scope.get("/extend/lookup", async (req, reply) => {
6456
- const proj = resolveProject(registry, req, reply, ctx.bootstrap);
6466
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
6457
6467
  if (!proj) return;
6458
6468
  const { library, version } = req.query;
6459
6469
  if (!library) {
@@ -6466,7 +6476,7 @@ function registerRoutes(scope, ctx) {
6466
6476
  return result;
6467
6477
  });
6468
6478
  scope.get("/extend/describe", async (req, reply) => {
6469
- const proj = resolveProject(registry, req, reply, ctx.bootstrap);
6479
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
6470
6480
  if (!proj) return;
6471
6481
  if (!proj.scanPath) {
6472
6482
  return reply.code(409).send({ error: "scan path not configured for this project", project: proj.name });
@@ -6479,7 +6489,7 @@ function registerRoutes(scope, ctx) {
6479
6489
  }
6480
6490
  });
6481
6491
  scope.post("/extend/apply", async (req, reply) => {
6482
- const proj = resolveProject(registry, req, reply, ctx.bootstrap);
6492
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
6483
6493
  if (!proj) return;
6484
6494
  if (!proj.scanPath) {
6485
6495
  return reply.code(409).send({ error: "scan path not configured for this project", project: proj.name });
@@ -6499,7 +6509,7 @@ function registerRoutes(scope, ctx) {
6499
6509
  }
6500
6510
  });
6501
6511
  scope.post("/extend/dry-run", async (req, reply) => {
6502
- const proj = resolveProject(registry, req, reply, ctx.bootstrap);
6512
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
6503
6513
  if (!proj) return;
6504
6514
  if (!proj.scanPath) {
6505
6515
  return reply.code(409).send({ error: "scan path not configured for this project", project: proj.name });
@@ -6519,7 +6529,7 @@ function registerRoutes(scope, ctx) {
6519
6529
  }
6520
6530
  });
6521
6531
  scope.post("/extend/rollback", async (req, reply) => {
6522
- const proj = resolveProject(registry, req, reply, ctx.bootstrap);
6532
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
6523
6533
  if (!proj) return;
6524
6534
  if (!proj.scanPath) {
6525
6535
  return reply.code(409).send({ error: "scan path not configured for this project", project: proj.name });
@@ -6582,7 +6592,8 @@ async function buildApi(opts) {
6582
6592
  errorsPathFor,
6583
6593
  staleEventsPathFor,
6584
6594
  policyFilePathFor,
6585
- bootstrap: opts.bootstrap
6595
+ bootstrap: opts.bootstrap,
6596
+ singleProject: opts.singleProject?.name
6586
6597
  };
6587
6598
  app.get("/health", async () => {
6588
6599
  const uptimeMs = Date.now() - startedAt;
@@ -6605,10 +6616,29 @@ async function buildApi(opts) {
6605
6616
  return {
6606
6617
  ok: true,
6607
6618
  uptimeMs,
6619
+ // ADR-096 §7 — a per-project daemon carries its project at the top level
6620
+ // so a spawn can confirm a daemon found on a reused port is serving THIS
6621
+ // project before reusing it. The legacy multi-project daemon omits it and
6622
+ // is matched against the `projects` array instead.
6623
+ ...opts.singleProject ? { project: opts.singleProject.name } : {},
6608
6624
  projects
6609
6625
  };
6610
6626
  });
6611
6627
  app.get("/projects", async (_req, reply) => {
6628
+ if (opts.singleProject) {
6629
+ const phase = opts.bootstrap?.status(opts.singleProject.name);
6630
+ const entry = {
6631
+ name: opts.singleProject.name,
6632
+ path: opts.singleProject.path,
6633
+ registeredAt: new Date(startedAt).toISOString(),
6634
+ languages: [],
6635
+ // The daemon is serving this project, so it's active unless its
6636
+ // bootstrap broke. ('bootstrapping' still reads as active — the project
6637
+ // is the one to land on; its graph route answers 503 until ready.)
6638
+ status: phase === "broken" ? "broken" : "active"
6639
+ };
6640
+ return [entry];
6641
+ }
6612
6642
  try {
6613
6643
  return await listProjects();
6614
6644
  } catch (err) {