@neat.is/core 0.4.17 → 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.cjs CHANGED
@@ -473,8 +473,10 @@ async function buildOtelReceiver(opts) {
473
473
  for (const s of spans) projectQueue.push({ project, span: s });
474
474
  projectDrainPromise = projectDrainPromise.then(() => drainProject());
475
475
  };
476
+ const offersProjectRouting = opts.onProjectSpan !== void 0;
476
477
  const legacyEndpointWarned = /* @__PURE__ */ new Set();
477
478
  function warnLegacyEndpoint(serviceName) {
479
+ if (!offersProjectRouting) return;
478
480
  if (legacyEndpointWarned.has(serviceName)) return;
479
481
  legacyEndpointWarned.add(serviceName);
480
482
  console.warn(
@@ -592,14 +594,15 @@ var init_otel = __esm({
592
594
 
593
595
  // src/neatd.ts
594
596
  init_cjs_shims();
595
- var import_node_fs26 = require("fs");
597
+ var import_node_fs27 = require("fs");
596
598
  var import_node_path44 = __toESM(require("path"), 1);
597
- var import_node_module = require("module");
599
+ var import_node_module2 = require("module");
598
600
 
599
601
  // src/daemon.ts
600
602
  init_cjs_shims();
601
603
  var import_node_fs25 = require("fs");
602
604
  var import_node_path42 = __toESM(require("path"), 1);
605
+ var import_node_module = require("module");
603
606
 
604
607
  // src/graph.ts
605
608
  init_cjs_shims();
@@ -5208,7 +5211,9 @@ async function loadGraphFromDisk(graph, outPath) {
5208
5211
  graph.clear();
5209
5212
  graph.import(payload.graph);
5210
5213
  }
5211
- function startPersistLoop(graph, outPath, intervalMs = 6e4) {
5214
+ function startPersistLoop(graph, outPath, opts = {}) {
5215
+ const intervalMs = opts.intervalMs ?? 6e4;
5216
+ const exitOnSignal = opts.exitOnSignal ?? true;
5212
5217
  let stopped = false;
5213
5218
  const tick = async () => {
5214
5219
  if (stopped) return;
@@ -5221,7 +5226,7 @@ function startPersistLoop(graph, outPath, intervalMs = 6e4) {
5221
5226
  const interval = setInterval(() => {
5222
5227
  void tick();
5223
5228
  }, intervalMs);
5224
- const onSignal = (signal) => {
5229
+ const onSignal = exitOnSignal ? (signal) => {
5225
5230
  void (async () => {
5226
5231
  try {
5227
5232
  await saveGraphToDisk(graph, outPath);
@@ -5231,14 +5236,18 @@ function startPersistLoop(graph, outPath, intervalMs = 6e4) {
5231
5236
  process.exit(0);
5232
5237
  }
5233
5238
  })();
5234
- };
5235
- process.on("SIGTERM", onSignal);
5236
- process.on("SIGINT", onSignal);
5239
+ } : null;
5240
+ if (onSignal) {
5241
+ process.on("SIGTERM", onSignal);
5242
+ process.on("SIGINT", onSignal);
5243
+ }
5237
5244
  return () => {
5238
5245
  stopped = true;
5239
5246
  clearInterval(interval);
5240
- process.off("SIGTERM", onSignal);
5241
- process.off("SIGINT", onSignal);
5247
+ if (onSignal) {
5248
+ process.off("SIGTERM", onSignal);
5249
+ process.off("SIGINT", onSignal);
5250
+ }
5242
5251
  };
5243
5252
  }
5244
5253
 
@@ -6231,12 +6240,16 @@ function serializeGraph(graph) {
6231
6240
  });
6232
6241
  return { nodes, edges };
6233
6242
  }
6234
- function projectFromReq(req2) {
6243
+ function projectFromReq(req2, singleProject) {
6235
6244
  const params = req2.params;
6236
- return params.project ?? DEFAULT_PROJECT;
6245
+ const named = params.project;
6246
+ if (singleProject) {
6247
+ return named === void 0 || named === DEFAULT_PROJECT ? singleProject : named;
6248
+ }
6249
+ return named ?? DEFAULT_PROJECT;
6237
6250
  }
6238
- function resolveProject(registry, req2, reply, bootstrap) {
6239
- const name = projectFromReq(req2);
6251
+ function resolveProject(registry, req2, reply, bootstrap, singleProject) {
6252
+ const name = projectFromReq(req2, singleProject);
6240
6253
  const ctx = registry.get(name);
6241
6254
  if (!ctx) {
6242
6255
  const phase = bootstrap?.status(name);
@@ -6277,13 +6290,13 @@ function buildLegacyRegistry(opts) {
6277
6290
  function registerRoutes(scope, ctx) {
6278
6291
  const { registry, startedAt, errorsPathFor, staleEventsPathFor } = ctx;
6279
6292
  scope.get("/events", (req2, reply) => {
6280
- const proj = resolveProject(registry, req2, reply, ctx.bootstrap);
6293
+ const proj = resolveProject(registry, req2, reply, ctx.bootstrap, ctx.singleProject);
6281
6294
  if (!proj) return;
6282
6295
  handleSse(req2, reply, { project: proj.name });
6283
6296
  });
6284
6297
  if (ctx.scope === "project") {
6285
6298
  scope.get("/health", async (req2, reply) => {
6286
- const proj = resolveProject(registry, req2, reply, ctx.bootstrap);
6299
+ const proj = resolveProject(registry, req2, reply, ctx.bootstrap, ctx.singleProject);
6287
6300
  if (!proj) return;
6288
6301
  const uptimeMs = Date.now() - startedAt;
6289
6302
  return {
@@ -6301,14 +6314,14 @@ function registerRoutes(scope, ctx) {
6301
6314
  });
6302
6315
  }
6303
6316
  scope.get("/graph", async (req2, reply) => {
6304
- const proj = resolveProject(registry, req2, reply, ctx.bootstrap);
6317
+ const proj = resolveProject(registry, req2, reply, ctx.bootstrap, ctx.singleProject);
6305
6318
  if (!proj) return;
6306
6319
  return serializeGraph(proj.graph);
6307
6320
  });
6308
6321
  scope.get(
6309
6322
  "/graph/node/:id",
6310
6323
  async (req2, reply) => {
6311
- const proj = resolveProject(registry, req2, reply, ctx.bootstrap);
6324
+ const proj = resolveProject(registry, req2, reply, ctx.bootstrap, ctx.singleProject);
6312
6325
  if (!proj) return;
6313
6326
  const { id } = req2.params;
6314
6327
  if (!proj.graph.hasNode(id)) {
@@ -6320,7 +6333,7 @@ function registerRoutes(scope, ctx) {
6320
6333
  scope.get(
6321
6334
  "/graph/edges/:id",
6322
6335
  async (req2, reply) => {
6323
- const proj = resolveProject(registry, req2, reply, ctx.bootstrap);
6336
+ const proj = resolveProject(registry, req2, reply, ctx.bootstrap, ctx.singleProject);
6324
6337
  if (!proj) return;
6325
6338
  const { id } = req2.params;
6326
6339
  if (!proj.graph.hasNode(id)) {
@@ -6332,7 +6345,7 @@ function registerRoutes(scope, ctx) {
6332
6345
  }
6333
6346
  );
6334
6347
  scope.get("/graph/dependencies/:nodeId", async (req2, reply) => {
6335
- const proj = resolveProject(registry, req2, reply, ctx.bootstrap);
6348
+ const proj = resolveProject(registry, req2, reply, ctx.bootstrap, ctx.singleProject);
6336
6349
  if (!proj) return;
6337
6350
  const { nodeId } = req2.params;
6338
6351
  if (!proj.graph.hasNode(nodeId)) {
@@ -6347,7 +6360,7 @@ function registerRoutes(scope, ctx) {
6347
6360
  return getTransitiveDependencies(proj.graph, nodeId, depth);
6348
6361
  });
6349
6362
  scope.get("/graph/divergences", async (req2, reply) => {
6350
- const proj = resolveProject(registry, req2, reply, ctx.bootstrap);
6363
+ const proj = resolveProject(registry, req2, reply, ctx.bootstrap, ctx.singleProject);
6351
6364
  if (!proj) return;
6352
6365
  let typeFilter;
6353
6366
  if (req2.query.type) {
@@ -6382,7 +6395,7 @@ function registerRoutes(scope, ctx) {
6382
6395
  });
6383
6396
  });
6384
6397
  scope.get("/incidents", async (req2, reply) => {
6385
- const proj = resolveProject(registry, req2, reply, ctx.bootstrap);
6398
+ const proj = resolveProject(registry, req2, reply, ctx.bootstrap, ctx.singleProject);
6386
6399
  if (!proj) return;
6387
6400
  const epath = errorsPathFor(proj);
6388
6401
  if (!epath) return { count: 0, total: 0, events: [] };
@@ -6394,7 +6407,7 @@ function registerRoutes(scope, ctx) {
6394
6407
  return { count: sliced.length, total, events: sliced };
6395
6408
  });
6396
6409
  scope.get("/stale-events", async (req2, reply) => {
6397
- const proj = resolveProject(registry, req2, reply, ctx.bootstrap);
6410
+ const proj = resolveProject(registry, req2, reply, ctx.bootstrap, ctx.singleProject);
6398
6411
  if (!proj) return;
6399
6412
  const spath = staleEventsPathFor(proj);
6400
6413
  if (!spath) return { count: 0, total: 0, events: [] };
@@ -6409,7 +6422,7 @@ function registerRoutes(scope, ctx) {
6409
6422
  scope.get(
6410
6423
  "/incidents/:nodeId",
6411
6424
  async (req2, reply) => {
6412
- const proj = resolveProject(registry, req2, reply, ctx.bootstrap);
6425
+ const proj = resolveProject(registry, req2, reply, ctx.bootstrap, ctx.singleProject);
6413
6426
  if (!proj) return;
6414
6427
  const { nodeId } = req2.params;
6415
6428
  if (!proj.graph.hasNode(nodeId)) {
@@ -6425,7 +6438,7 @@ function registerRoutes(scope, ctx) {
6425
6438
  }
6426
6439
  );
6427
6440
  scope.get("/graph/root-cause/:nodeId", async (req2, reply) => {
6428
- const proj = resolveProject(registry, req2, reply, ctx.bootstrap);
6441
+ const proj = resolveProject(registry, req2, reply, ctx.bootstrap, ctx.singleProject);
6429
6442
  if (!proj) return;
6430
6443
  const { nodeId } = req2.params;
6431
6444
  if (!proj.graph.hasNode(nodeId)) {
@@ -6445,7 +6458,7 @@ function registerRoutes(scope, ctx) {
6445
6458
  return result;
6446
6459
  });
6447
6460
  scope.get("/graph/blast-radius/:nodeId", async (req2, reply) => {
6448
- const proj = resolveProject(registry, req2, reply, ctx.bootstrap);
6461
+ const proj = resolveProject(registry, req2, reply, ctx.bootstrap, ctx.singleProject);
6449
6462
  if (!proj) return;
6450
6463
  const { nodeId } = req2.params;
6451
6464
  if (!proj.graph.hasNode(nodeId)) {
@@ -6458,7 +6471,7 @@ function registerRoutes(scope, ctx) {
6458
6471
  return getBlastRadius(proj.graph, nodeId, depth);
6459
6472
  });
6460
6473
  scope.get("/search", async (req2, reply) => {
6461
- const proj = resolveProject(registry, req2, reply, ctx.bootstrap);
6474
+ const proj = resolveProject(registry, req2, reply, ctx.bootstrap, ctx.singleProject);
6462
6475
  if (!proj) return;
6463
6476
  const raw = (req2.query.q ?? "").trim();
6464
6477
  if (!raw) return reply.code(400).send({ error: "query parameter `q` is required" });
@@ -6489,7 +6502,7 @@ function registerRoutes(scope, ctx) {
6489
6502
  scope.get(
6490
6503
  "/graph/diff",
6491
6504
  async (req2, reply) => {
6492
- const proj = resolveProject(registry, req2, reply, ctx.bootstrap);
6505
+ const proj = resolveProject(registry, req2, reply, ctx.bootstrap, ctx.singleProject);
6493
6506
  if (!proj) return;
6494
6507
  const against = req2.query.against;
6495
6508
  if (!against) {
@@ -6504,7 +6517,7 @@ function registerRoutes(scope, ctx) {
6504
6517
  }
6505
6518
  );
6506
6519
  scope.post("/snapshot", async (req2, reply) => {
6507
- const proj = resolveProject(registry, req2, reply, ctx.bootstrap);
6520
+ const proj = resolveProject(registry, req2, reply, ctx.bootstrap, ctx.singleProject);
6508
6521
  if (!proj) return;
6509
6522
  const body = req2.body;
6510
6523
  if (!body || typeof body !== "object" || !body.snapshot) {
@@ -6533,7 +6546,7 @@ function registerRoutes(scope, ctx) {
6533
6546
  }
6534
6547
  });
6535
6548
  scope.post("/graph/scan", async (req2, reply) => {
6536
- const proj = resolveProject(registry, req2, reply, ctx.bootstrap);
6549
+ const proj = resolveProject(registry, req2, reply, ctx.bootstrap, ctx.singleProject);
6537
6550
  if (!proj) return;
6538
6551
  if (!proj.scanPath) {
6539
6552
  return reply.code(409).send({ error: "scan path not configured for this project", project: proj.name });
@@ -6549,7 +6562,7 @@ function registerRoutes(scope, ctx) {
6549
6562
  };
6550
6563
  });
6551
6564
  scope.get("/policies", async (req2, reply) => {
6552
- const proj = resolveProject(registry, req2, reply, ctx.bootstrap);
6565
+ const proj = resolveProject(registry, req2, reply, ctx.bootstrap, ctx.singleProject);
6553
6566
  if (!proj) return;
6554
6567
  const policyPath = ctx.policyFilePathFor(proj);
6555
6568
  if (!policyPath) {
@@ -6566,7 +6579,7 @@ function registerRoutes(scope, ctx) {
6566
6579
  }
6567
6580
  });
6568
6581
  scope.get("/policies/violations", async (req2, reply) => {
6569
- const proj = resolveProject(registry, req2, reply, ctx.bootstrap);
6582
+ const proj = resolveProject(registry, req2, reply, ctx.bootstrap, ctx.singleProject);
6570
6583
  if (!proj) return;
6571
6584
  const log = new PolicyViolationsLog(proj.paths.policyViolationsPath);
6572
6585
  let violations = await log.readAll();
@@ -6586,7 +6599,7 @@ function registerRoutes(scope, ctx) {
6586
6599
  return { violations };
6587
6600
  });
6588
6601
  scope.post("/policies/check", async (req2, reply) => {
6589
- const proj = resolveProject(registry, req2, reply, ctx.bootstrap);
6602
+ const proj = resolveProject(registry, req2, reply, ctx.bootstrap, ctx.singleProject);
6590
6603
  if (!proj) return;
6591
6604
  const parsed = import_types25.PoliciesCheckBodySchema.safeParse(req2.body ?? {});
6592
6605
  if (!parsed.success) {
@@ -6622,7 +6635,7 @@ function registerRoutes(scope, ctx) {
6622
6635
  };
6623
6636
  });
6624
6637
  scope.get("/extend/list-uninstrumented", async (req2, reply) => {
6625
- const proj = resolveProject(registry, req2, reply, ctx.bootstrap);
6638
+ const proj = resolveProject(registry, req2, reply, ctx.bootstrap, ctx.singleProject);
6626
6639
  if (!proj) return;
6627
6640
  if (!proj.scanPath) {
6628
6641
  return reply.code(409).send({ error: "scan path not configured for this project", project: proj.name });
@@ -6635,7 +6648,7 @@ function registerRoutes(scope, ctx) {
6635
6648
  }
6636
6649
  });
6637
6650
  scope.get("/extend/lookup", async (req2, reply) => {
6638
- const proj = resolveProject(registry, req2, reply, ctx.bootstrap);
6651
+ const proj = resolveProject(registry, req2, reply, ctx.bootstrap, ctx.singleProject);
6639
6652
  if (!proj) return;
6640
6653
  const { library, version } = req2.query;
6641
6654
  if (!library) {
@@ -6648,7 +6661,7 @@ function registerRoutes(scope, ctx) {
6648
6661
  return result;
6649
6662
  });
6650
6663
  scope.get("/extend/describe", async (req2, reply) => {
6651
- const proj = resolveProject(registry, req2, reply, ctx.bootstrap);
6664
+ const proj = resolveProject(registry, req2, reply, ctx.bootstrap, ctx.singleProject);
6652
6665
  if (!proj) return;
6653
6666
  if (!proj.scanPath) {
6654
6667
  return reply.code(409).send({ error: "scan path not configured for this project", project: proj.name });
@@ -6661,7 +6674,7 @@ function registerRoutes(scope, ctx) {
6661
6674
  }
6662
6675
  });
6663
6676
  scope.post("/extend/apply", async (req2, reply) => {
6664
- const proj = resolveProject(registry, req2, reply, ctx.bootstrap);
6677
+ const proj = resolveProject(registry, req2, reply, ctx.bootstrap, ctx.singleProject);
6665
6678
  if (!proj) return;
6666
6679
  if (!proj.scanPath) {
6667
6680
  return reply.code(409).send({ error: "scan path not configured for this project", project: proj.name });
@@ -6681,7 +6694,7 @@ function registerRoutes(scope, ctx) {
6681
6694
  }
6682
6695
  });
6683
6696
  scope.post("/extend/dry-run", async (req2, reply) => {
6684
- const proj = resolveProject(registry, req2, reply, ctx.bootstrap);
6697
+ const proj = resolveProject(registry, req2, reply, ctx.bootstrap, ctx.singleProject);
6685
6698
  if (!proj) return;
6686
6699
  if (!proj.scanPath) {
6687
6700
  return reply.code(409).send({ error: "scan path not configured for this project", project: proj.name });
@@ -6701,7 +6714,7 @@ function registerRoutes(scope, ctx) {
6701
6714
  }
6702
6715
  });
6703
6716
  scope.post("/extend/rollback", async (req2, reply) => {
6704
- const proj = resolveProject(registry, req2, reply, ctx.bootstrap);
6717
+ const proj = resolveProject(registry, req2, reply, ctx.bootstrap, ctx.singleProject);
6705
6718
  if (!proj) return;
6706
6719
  if (!proj.scanPath) {
6707
6720
  return reply.code(409).send({ error: "scan path not configured for this project", project: proj.name });
@@ -6764,7 +6777,8 @@ async function buildApi(opts) {
6764
6777
  errorsPathFor,
6765
6778
  staleEventsPathFor,
6766
6779
  policyFilePathFor,
6767
- bootstrap: opts.bootstrap
6780
+ bootstrap: opts.bootstrap,
6781
+ singleProject: opts.singleProject?.name
6768
6782
  };
6769
6783
  app.get("/health", async () => {
6770
6784
  const uptimeMs = Date.now() - startedAt;
@@ -6787,10 +6801,29 @@ async function buildApi(opts) {
6787
6801
  return {
6788
6802
  ok: true,
6789
6803
  uptimeMs,
6804
+ // ADR-096 §7 — a per-project daemon carries its project at the top level
6805
+ // so a spawn can confirm a daemon found on a reused port is serving THIS
6806
+ // project before reusing it. The legacy multi-project daemon omits it and
6807
+ // is matched against the `projects` array instead.
6808
+ ...opts.singleProject ? { project: opts.singleProject.name } : {},
6790
6809
  projects
6791
6810
  };
6792
6811
  });
6793
6812
  app.get("/projects", async (_req, reply) => {
6813
+ if (opts.singleProject) {
6814
+ const phase = opts.bootstrap?.status(opts.singleProject.name);
6815
+ const entry2 = {
6816
+ name: opts.singleProject.name,
6817
+ path: opts.singleProject.path,
6818
+ registeredAt: new Date(startedAt).toISOString(),
6819
+ languages: [],
6820
+ // The daemon is serving this project, so it's active unless its
6821
+ // bootstrap broke. ('bootstrapping' still reads as active — the project
6822
+ // is the one to land on; its graph route answers 503 until ready.)
6823
+ status: phase === "broken" ? "broken" : "active"
6824
+ };
6825
+ return [entry2];
6826
+ }
6794
6827
  try {
6795
6828
  return await listProjects();
6796
6829
  } catch (err) {
@@ -6850,6 +6883,59 @@ function unroutedErrorsPath(neatHome3) {
6850
6883
  }
6851
6884
 
6852
6885
  // src/daemon.ts
6886
+ function daemonJsonPath(scanPath) {
6887
+ return import_node_path42.default.join(scanPath, "neat-out", "daemon.json");
6888
+ }
6889
+ function daemonsDiscoveryDir(home) {
6890
+ const base = home && home.length > 0 ? home : neatHomeFromEnv();
6891
+ return import_node_path42.default.join(base, "daemons");
6892
+ }
6893
+ function daemonDiscoveryPath(project, home) {
6894
+ return import_node_path42.default.join(daemonsDiscoveryDir(home), `${sanitizeDiscoveryName(project)}.json`);
6895
+ }
6896
+ function sanitizeDiscoveryName(project) {
6897
+ return project.replace(/[^A-Za-z0-9._-]/g, "_");
6898
+ }
6899
+ function neatHomeFromEnv() {
6900
+ const env = process.env.NEAT_HOME;
6901
+ if (env && env.length > 0) return import_node_path42.default.resolve(env);
6902
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
6903
+ return import_node_path42.default.join(home, ".neat");
6904
+ }
6905
+ function resolveNeatVersion() {
6906
+ if (process.env.NEAT_LOCAL_VERSION && process.env.NEAT_LOCAL_VERSION.length > 0) {
6907
+ return process.env.NEAT_LOCAL_VERSION;
6908
+ }
6909
+ try {
6910
+ const req2 = (0, import_node_module.createRequire)(importMetaUrl);
6911
+ const pkg = req2("../package.json");
6912
+ return typeof pkg.version === "string" ? pkg.version : "0.0.0";
6913
+ } catch {
6914
+ return "0.0.0";
6915
+ }
6916
+ }
6917
+ async function writeDaemonRecord(record, home) {
6918
+ const body = JSON.stringify(record, null, 2) + "\n";
6919
+ await writeAtomically(daemonJsonPath(record.projectPath), body);
6920
+ try {
6921
+ await writeAtomically(daemonDiscoveryPath(record.project, home), body);
6922
+ } catch (err) {
6923
+ console.warn(
6924
+ `neatd: could not write discovery copy for "${record.project}" \u2014 ${err.message}`
6925
+ );
6926
+ }
6927
+ }
6928
+ async function clearDaemonRecord(record, home) {
6929
+ try {
6930
+ const stopped = { ...record, status: "stopped" };
6931
+ await writeAtomically(daemonJsonPath(record.projectPath), JSON.stringify(stopped, null, 2) + "\n");
6932
+ } catch {
6933
+ }
6934
+ try {
6935
+ await import_node_fs25.promises.unlink(daemonDiscoveryPath(record.project, home));
6936
+ } catch {
6937
+ }
6938
+ }
6853
6939
  function teardownSlot(slot) {
6854
6940
  try {
6855
6941
  slot.stopPersist();
@@ -6931,7 +7017,7 @@ async function bootstrapProject(entry2) {
6931
7017
  const detachEvents = attachGraphToEventBus(graph, { project: entry2.name });
6932
7018
  try {
6933
7019
  await extractFromDirectory(graph, entry2.path);
6934
- const stopPersist = startPersistLoop(graph, outPath);
7020
+ const stopPersist = startPersistLoop(graph, outPath, { exitOnSignal: false });
6935
7021
  await touchLastSeen(entry2.name).catch(() => {
6936
7022
  });
6937
7023
  return {
@@ -6966,6 +7052,23 @@ function resolveOtlpPort(opts) {
6966
7052
  }
6967
7053
  return 4318;
6968
7054
  }
7055
+ function resolveWebPort() {
7056
+ const env = process.env.NEAT_WEB_PORT;
7057
+ if (env && env.length > 0) {
7058
+ const n = Number.parseInt(env, 10);
7059
+ if (Number.isFinite(n)) return n;
7060
+ }
7061
+ return 6328;
7062
+ }
7063
+ function portFromListenAddress(address, fallback) {
7064
+ try {
7065
+ const port = new URL(address).port;
7066
+ const n = Number.parseInt(port, 10);
7067
+ if (Number.isFinite(n) && n > 0) return n;
7068
+ } catch {
7069
+ }
7070
+ return fallback;
7071
+ }
6969
7072
  function resolveHost(opts, authTokenSet) {
6970
7073
  if (opts.host && opts.host.length > 0) return opts.host;
6971
7074
  const env = process.env.HOST;
@@ -6976,13 +7079,24 @@ function resolveHost(opts, authTokenSet) {
6976
7079
  async function startDaemon(opts = {}) {
6977
7080
  const home = neatHomeFor(opts);
6978
7081
  const regPath = registryPath();
6979
- try {
6980
- await import_node_fs25.promises.access(regPath);
6981
- } catch {
7082
+ const projectArg = typeof opts.project === "string" && opts.project.length > 0 ? opts.project : process.env.NEAT_PROJECT && process.env.NEAT_PROJECT.length > 0 ? process.env.NEAT_PROJECT : null;
7083
+ const projectPathArg = opts.projectPath && opts.projectPath.length > 0 ? opts.projectPath : process.env.NEAT_PROJECT_PATH && process.env.NEAT_PROJECT_PATH.length > 0 ? process.env.NEAT_PROJECT_PATH : null;
7084
+ const singleProject = projectArg;
7085
+ const singleProjectPath = singleProject && projectPathArg ? import_node_path42.default.resolve(projectPathArg) : null;
7086
+ if (singleProject && !singleProjectPath) {
6982
7087
  throw new Error(
6983
- `neatd: registry not found at ${regPath}. Run \`neat init <path>\` to register a project before starting the daemon.`
7088
+ `neatd: project "${singleProject}" given without a projectPath; pass NEAT_PROJECT_PATH alongside NEAT_PROJECT.`
6984
7089
  );
6985
7090
  }
7091
+ if (!singleProject) {
7092
+ try {
7093
+ await import_node_fs25.promises.access(regPath);
7094
+ } catch {
7095
+ throw new Error(
7096
+ `neatd: registry not found at ${regPath}. Run \`neat init <path>\` to register a project before starting the daemon.`
7097
+ );
7098
+ }
7099
+ }
6986
7100
  const pidPath = import_node_path42.default.join(home, "neatd.pid");
6987
7101
  await writeAtomically(pidPath, `${process.pid}
6988
7102
  `);
@@ -7071,21 +7185,37 @@ async function startDaemon(opts = {}) {
7071
7185
  });
7072
7186
  }
7073
7187
  }
7188
+ async function enumerateProjects() {
7189
+ if (singleProject && singleProjectPath) {
7190
+ return [
7191
+ {
7192
+ name: singleProject,
7193
+ path: singleProjectPath,
7194
+ registeredAt: (/* @__PURE__ */ new Date()).toISOString(),
7195
+ languages: [],
7196
+ status: "active"
7197
+ }
7198
+ ];
7199
+ }
7200
+ return listProjects();
7201
+ }
7074
7202
  async function loadAll() {
7075
- try {
7076
- const pruned = await pruneRegistry();
7077
- for (const entry2 of pruned) {
7078
- console.log(
7079
- `neatd: pruned project "${entry2.name}" \u2014 registered path ${entry2.path} is gone`
7080
- );
7081
- slots.delete(entry2.name);
7082
- bootstrapStatus.delete(entry2.name);
7083
- bootstrapStartedAt.delete(entry2.name);
7203
+ if (!singleProject) {
7204
+ try {
7205
+ const pruned = await pruneRegistry();
7206
+ for (const entry2 of pruned) {
7207
+ console.log(
7208
+ `neatd: pruned project "${entry2.name}" \u2014 registered path ${entry2.path} is gone`
7209
+ );
7210
+ slots.delete(entry2.name);
7211
+ bootstrapStatus.delete(entry2.name);
7212
+ bootstrapStartedAt.delete(entry2.name);
7213
+ }
7214
+ } catch (err) {
7215
+ console.warn(`neatd: registry prune skipped \u2014 ${err.message}`);
7084
7216
  }
7085
- } catch (err) {
7086
- console.warn(`neatd: registry prune skipped \u2014 ${err.message}`);
7087
7217
  }
7088
- const projects = await listProjects();
7218
+ const projects = await enumerateProjects();
7089
7219
  const seen = /* @__PURE__ */ new Set();
7090
7220
  const pending = [];
7091
7221
  for (const entry2 of projects) {
@@ -7110,7 +7240,7 @@ async function startDaemon(opts = {}) {
7110
7240
  }
7111
7241
  await Promise.allSettled(pending);
7112
7242
  }
7113
- const initialEntries = await listProjects().catch(() => []);
7243
+ const initialEntries = await enumerateProjects().catch(() => []);
7114
7244
  for (const entry2 of initialEntries) {
7115
7245
  bootstrapStatus.set(entry2.name, "bootstrapping");
7116
7246
  bootstrapStartedAt.set(entry2.name, Date.now());
@@ -7120,6 +7250,7 @@ async function startDaemon(opts = {}) {
7120
7250
  let otlpApp = null;
7121
7251
  let restAddress = "";
7122
7252
  let otlpAddress = "";
7253
+ let daemonRecord = null;
7123
7254
  if (bind) {
7124
7255
  const auth = readAuthEnv();
7125
7256
  const host = resolveHost(opts, Boolean(auth.authToken));
@@ -7142,7 +7273,13 @@ async function startDaemon(opts = {}) {
7142
7273
  elapsedMs: now - (bootstrapStartedAt.get(name) ?? now)
7143
7274
  }));
7144
7275
  }
7145
- }
7276
+ },
7277
+ // ADR-096 §4/§5/§7 — hand the daemon's identity to buildApi so the REST
7278
+ // surface reflects "the daemon is the project": `GET /projects` reports
7279
+ // only this project (the dashboard pins to it), and the daemon-wide
7280
+ // `/health` carries it at the top level for the spawn-reuse identity
7281
+ // check. Absent for the legacy multi-project daemon.
7282
+ singleProject: singleProject && singleProjectPath ? { name: singleProject, path: singleProjectPath } : void 0
7146
7283
  });
7147
7284
  restAddress = await restApp.listen({ port: restPort, host });
7148
7285
  console.log(`neatd: REST listening on ${restAddress}`);
@@ -7159,6 +7296,25 @@ async function startDaemon(opts = {}) {
7159
7296
  );
7160
7297
  }
7161
7298
  async function resolveTargetSlot(serviceName, traceId) {
7299
+ if (singleProject) {
7300
+ let slot2 = slots.get(singleProject);
7301
+ if (!slot2) {
7302
+ slot2 = await tryRecoverSlot({
7303
+ name: singleProject,
7304
+ path: singleProjectPath,
7305
+ registeredAt: (/* @__PURE__ */ new Date()).toISOString(),
7306
+ languages: [],
7307
+ status: "active"
7308
+ });
7309
+ } else if (slot2.status === "broken") {
7310
+ slot2 = await tryRecoverSlot(slot2.entry);
7311
+ }
7312
+ if (!slot2 || slot2.status !== "active") {
7313
+ warnDroppedSpan(singleProject, slot2?.errorReason ?? "unknown");
7314
+ return null;
7315
+ }
7316
+ return slot2;
7317
+ }
7162
7318
  const liveEntries = await listProjects().catch(() => []);
7163
7319
  const target = routeSpanToProject(serviceName, liveEntries);
7164
7320
  let slot = slots.get(target) ?? slots.get(DEFAULT_PROJECT);
@@ -7261,6 +7417,42 @@ async function startDaemon(opts = {}) {
7261
7417
  `neatd: failed to bind OTLP on port ${otlpPort} \u2014 ${err.message}`
7262
7418
  );
7263
7419
  }
7420
+ if (singleProject && singleProjectPath) {
7421
+ const ports = {
7422
+ rest: portFromListenAddress(restAddress, restPort),
7423
+ otlp: portFromListenAddress(otlpAddress, otlpPort),
7424
+ // The daemon doesn't bind the web port itself (neatd spawns the web
7425
+ // child); it records the allocated value passed through so the
7426
+ // dashboard and `neat ps` agree on where to look.
7427
+ web: typeof opts.webPort === "number" ? opts.webPort : resolveWebPort()
7428
+ };
7429
+ daemonRecord = {
7430
+ project: singleProject,
7431
+ projectPath: singleProjectPath,
7432
+ pid: process.pid,
7433
+ status: "running",
7434
+ ports,
7435
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
7436
+ neatVersion: resolveNeatVersion()
7437
+ };
7438
+ try {
7439
+ await writeDaemonRecord(daemonRecord, home);
7440
+ console.log(
7441
+ `neatd: project "${singleProject}" \u2192 REST ${ports.rest} / OTLP ${ports.otlp} / web ${ports.web} (daemon.json written)`
7442
+ );
7443
+ } catch (err) {
7444
+ for (const slot of slots.values()) teardownSlot(slot);
7445
+ if (restApp) await restApp.close().catch(() => {
7446
+ });
7447
+ if (otlpApp) await otlpApp.close().catch(() => {
7448
+ });
7449
+ await import_node_fs25.promises.unlink(pidPath).catch(() => {
7450
+ });
7451
+ throw new Error(
7452
+ `neatd: failed to write daemon.json for "${singleProject}" \u2014 ${err.message}`
7453
+ );
7454
+ }
7455
+ }
7264
7456
  }
7265
7457
  const initialBootstrap = loadAll().catch((err) => {
7266
7458
  console.warn(`neatd: initial bootstrap pass failed \u2014 ${err.message}`);
@@ -7300,7 +7492,7 @@ async function startDaemon(opts = {}) {
7300
7492
  const REGISTRY_RELOAD_DEBOUNCE_MS = 500;
7301
7493
  let registryWatcher = null;
7302
7494
  let reloadTimer = null;
7303
- try {
7495
+ if (!singleProject) try {
7304
7496
  const regDir = import_node_path42.default.dirname(regPath);
7305
7497
  const regBase = import_node_path42.default.basename(regPath);
7306
7498
  registryWatcher = (0, import_node_fs25.watch)(regDir, (_eventType, filename) => {
@@ -7340,9 +7532,18 @@ async function startDaemon(opts = {}) {
7340
7532
  });
7341
7533
  if (restApp) await restApp.close().catch(() => {
7342
7534
  });
7535
+ for (const slot of slots.values()) {
7536
+ if (slot.status === "active" && slot.outPath) {
7537
+ await saveGraphToDisk(slot.graph, slot.outPath).catch(() => {
7538
+ });
7539
+ }
7540
+ }
7343
7541
  for (const slot of slots.values()) {
7344
7542
  teardownSlot(slot);
7345
7543
  }
7544
+ if (daemonRecord) {
7545
+ await clearDaemonRecord(daemonRecord, home);
7546
+ }
7346
7547
  await import_node_fs25.promises.unlink(pidPath).catch(() => {
7347
7548
  });
7348
7549
  };
@@ -7354,7 +7555,8 @@ async function startDaemon(opts = {}) {
7354
7555
  restAddress,
7355
7556
  otlpAddress,
7356
7557
  bootstrap: tracker,
7357
- initialBootstrap
7558
+ initialBootstrap,
7559
+ daemonRecord
7358
7560
  };
7359
7561
  }
7360
7562
 
@@ -7364,9 +7566,40 @@ init_auth();
7364
7566
  // src/web-spawn.ts
7365
7567
  init_cjs_shims();
7366
7568
  var import_node_child_process2 = require("child_process");
7569
+ var import_node_fs26 = require("fs");
7367
7570
  var import_node_net = __toESM(require("net"), 1);
7368
7571
  var import_node_path43 = __toESM(require("path"), 1);
7369
7572
  var DEFAULT_WEB_PORT = 6328;
7573
+ var DEFAULT_REST_PORT = 8080;
7574
+ function asValidPort(value) {
7575
+ const n = typeof value === "number" ? value : Number.parseInt(String(value ?? ""), 10);
7576
+ return Number.isInteger(n) && n > 0 && n <= 65535 ? n : null;
7577
+ }
7578
+ function projectRoot() {
7579
+ const fromEnv = process.env.NEAT_SCAN_PATH;
7580
+ return import_node_path43.default.resolve(fromEnv && fromEnv.length > 0 ? fromEnv : process.cwd());
7581
+ }
7582
+ async function readDaemonPorts(root) {
7583
+ try {
7584
+ const raw = await import_node_fs26.promises.readFile(import_node_path43.default.join(root, "neat-out", "daemon.json"), "utf8");
7585
+ const parsed = JSON.parse(raw);
7586
+ const ports = parsed?.ports ?? {};
7587
+ return { web: asValidPort(ports.web), rest: asValidPort(ports.rest) };
7588
+ } catch {
7589
+ return { web: null, rest: null };
7590
+ }
7591
+ }
7592
+ function resolveWebPorts(args) {
7593
+ const { daemonWeb, daemonRest, webPortEnv, apiUrlEnv, restPortArg } = args;
7594
+ const portFromEnv = webPortEnv && webPortEnv.length > 0 ? Number.parseInt(webPortEnv, 10) : null;
7595
+ if (portFromEnv !== null && (!Number.isFinite(portFromEnv) || portFromEnv <= 0 || portFromEnv > 65535)) {
7596
+ throw new Error(`neatd: invalid NEAT_WEB_PORT="${webPortEnv}"`);
7597
+ }
7598
+ const webPort = daemonWeb ?? portFromEnv ?? DEFAULT_WEB_PORT;
7599
+ const restPort = daemonRest ?? asValidPort(restPortArg) ?? DEFAULT_REST_PORT;
7600
+ const apiUrl = apiUrlEnv ?? `http://localhost:${restPort}`;
7601
+ return { webPort, apiUrl };
7602
+ }
7370
7603
  async function assertPortFree(port) {
7371
7604
  await new Promise((resolve, reject) => {
7372
7605
  const tester = import_node_net.default.createServer();
@@ -7398,42 +7631,105 @@ function resolveWebPackageDir() {
7398
7631
  function resolveStandaloneServerEntry(webDir) {
7399
7632
  return import_node_path43.default.join(webDir, ".next/standalone/packages/web/server.js");
7400
7633
  }
7401
- async function spawnWebUI(restPort) {
7402
- const portRaw = process.env.NEAT_WEB_PORT;
7403
- const port = portRaw && portRaw.length > 0 ? Number.parseInt(portRaw, 10) : DEFAULT_WEB_PORT;
7404
- if (!Number.isFinite(port) || port <= 0 || port > 65535) {
7405
- throw new Error(`neatd: invalid NEAT_WEB_PORT="${portRaw}"`);
7406
- }
7634
+ async function pickInternalPort() {
7635
+ return new Promise((resolve, reject) => {
7636
+ const srv = import_node_net.default.createServer();
7637
+ srv.once("error", reject);
7638
+ srv.listen(0, "127.0.0.1", () => {
7639
+ const addr = srv.address();
7640
+ if (addr && typeof addr === "object") {
7641
+ const port = addr.port;
7642
+ srv.close(() => resolve(port));
7643
+ } else {
7644
+ srv.close(() => reject(new Error("neatd: could not pick an internal web port")));
7645
+ }
7646
+ });
7647
+ });
7648
+ }
7649
+ async function spawnWebUI(restPort, opts = {}) {
7650
+ const root = projectRoot();
7651
+ const fromDaemon = await readDaemonPorts(root);
7652
+ const { webPort: port, apiUrl } = resolveWebPorts({
7653
+ daemonWeb: fromDaemon.web,
7654
+ daemonRest: fromDaemon.rest,
7655
+ webPortEnv: process.env.NEAT_WEB_PORT,
7656
+ apiUrlEnv: process.env.NEAT_API_URL,
7657
+ restPortArg: restPort
7658
+ });
7407
7659
  await assertPortFree(port);
7408
- const cwd = resolveWebPackageDir();
7409
- const serverEntry = resolveStandaloneServerEntry(cwd);
7410
- try {
7411
- require.resolve(serverEntry);
7412
- } catch {
7413
- throw new Error(
7414
- `neatd: web UI standalone build missing at ${serverEntry}. The published @neat.is/web tarball should include it; if you're running from a monorepo checkout, run \`npm run build --workspace @neat.is/web\` first, or set NEAT_WEB_DISABLED=1 to skip the web UI.`
7415
- );
7660
+ const serverEntry = opts.serverEntry ?? resolveStandaloneServerEntry(resolveWebPackageDir());
7661
+ if (!opts.skipBuildCheck) {
7662
+ try {
7663
+ require.resolve(serverEntry);
7664
+ } catch {
7665
+ throw new Error(
7666
+ `neatd: web UI standalone build missing at ${serverEntry}. The published @neat.is/web tarball should include it; if you're running from a monorepo checkout, run \`npm run build --workspace @neat.is/web\` first, or set NEAT_WEB_DISABLED=1 to skip the web UI.`
7667
+ );
7668
+ }
7416
7669
  }
7417
- const env = {
7418
- ...process.env,
7419
- PORT: String(port),
7420
- HOSTNAME: process.env.HOSTNAME ?? "0.0.0.0",
7421
- NEAT_API_URL: process.env.NEAT_API_URL ?? `http://localhost:${restPort}`
7422
- };
7423
- const child = (0, import_node_child_process2.spawn)(process.execPath, [serverEntry], {
7424
- cwd: import_node_path43.default.dirname(serverEntry),
7425
- env,
7426
- stdio: ["ignore", "inherit", "inherit"],
7427
- detached: false
7670
+ let child = null;
7671
+ let internalPort = 0;
7672
+ let starting = null;
7673
+ let stopped = false;
7674
+ function ensureStarted() {
7675
+ if (starting) return starting;
7676
+ starting = (async () => {
7677
+ internalPort = await pickInternalPort();
7678
+ const env = {
7679
+ ...process.env,
7680
+ PORT: String(internalPort),
7681
+ HOSTNAME: "127.0.0.1",
7682
+ NEAT_API_URL: apiUrl
7683
+ };
7684
+ child = (0, import_node_child_process2.spawn)(process.execPath, [serverEntry], {
7685
+ cwd: import_node_path43.default.dirname(serverEntry),
7686
+ env,
7687
+ stdio: ["ignore", "inherit", "inherit"],
7688
+ detached: false
7689
+ });
7690
+ child.on("error", (err) => {
7691
+ console.error(`neatd: web UI spawn error \u2014 ${err.message}`);
7692
+ });
7693
+ await waitForListening(internalPort);
7694
+ console.log(`neatd: web UI ready on http://localhost:${port}`);
7695
+ })();
7696
+ return starting;
7697
+ }
7698
+ const liveSockets = /* @__PURE__ */ new Set();
7699
+ const front = import_node_net.default.createServer((socket) => {
7700
+ liveSockets.add(socket);
7701
+ socket.on("close", () => liveSockets.delete(socket));
7702
+ socket.on("error", () => socket.destroy());
7703
+ ensureStarted().then(() => {
7704
+ if (stopped) {
7705
+ socket.destroy();
7706
+ return;
7707
+ }
7708
+ const upstream = import_node_net.default.connect(internalPort, "127.0.0.1");
7709
+ upstream.on("error", () => socket.destroy());
7710
+ socket.pipe(upstream);
7711
+ upstream.pipe(socket);
7712
+ socket.on("close", () => upstream.destroy());
7713
+ upstream.on("close", () => socket.destroy());
7714
+ }).catch((err) => {
7715
+ console.error(`neatd: web UI failed to start \u2014 ${err.message}`);
7716
+ socket.destroy();
7717
+ });
7428
7718
  });
7429
- child.on("error", (err) => {
7430
- console.error(`neatd: web UI spawn error \u2014 ${err.message}`);
7719
+ await new Promise((resolve, reject) => {
7720
+ front.once("error", reject);
7721
+ front.listen(port, "127.0.0.1", () => resolve());
7431
7722
  });
7432
- console.log(`neatd: web UI listening on http://localhost:${port}`);
7433
- let stopped = false;
7723
+ console.log(`neatd: web UI listening on http://localhost:${port} (starts on first open)`);
7434
7724
  async function stop() {
7435
- if (stopped || !child.pid) return;
7725
+ if (stopped) return;
7436
7726
  stopped = true;
7727
+ await new Promise((resolve) => {
7728
+ front.close(() => resolve());
7729
+ for (const socket of liveSockets) socket.destroy();
7730
+ liveSockets.clear();
7731
+ });
7732
+ if (!child || !child.pid) return;
7437
7733
  try {
7438
7734
  child.kill("SIGTERM");
7439
7735
  } catch {
@@ -7441,18 +7737,46 @@ async function spawnWebUI(restPort) {
7441
7737
  await new Promise((resolve) => {
7442
7738
  const t = setTimeout(() => {
7443
7739
  try {
7444
- child.kill("SIGKILL");
7740
+ child?.kill("SIGKILL");
7445
7741
  } catch {
7446
7742
  }
7447
7743
  resolve();
7448
7744
  }, 3e3);
7449
- child.once("exit", () => {
7745
+ child?.once("exit", () => {
7450
7746
  clearTimeout(t);
7451
7747
  resolve();
7452
7748
  });
7453
7749
  });
7454
7750
  }
7455
- return { child, port, stop };
7751
+ return {
7752
+ get child() {
7753
+ return child;
7754
+ },
7755
+ port,
7756
+ started: () => child !== null,
7757
+ stop
7758
+ };
7759
+ }
7760
+ async function waitForListening(port, timeoutMs = 15e3) {
7761
+ const deadline = Date.now() + timeoutMs;
7762
+ for (; ; ) {
7763
+ const ok = await new Promise((resolve) => {
7764
+ const s = import_node_net.default.connect(port, "127.0.0.1");
7765
+ s.once("connect", () => {
7766
+ s.destroy();
7767
+ resolve(true);
7768
+ });
7769
+ s.once("error", () => {
7770
+ s.destroy();
7771
+ resolve(false);
7772
+ });
7773
+ });
7774
+ if (ok) return;
7775
+ if (Date.now() > deadline) {
7776
+ throw new Error(`neatd: web UI did not start listening on :${port} within ${timeoutMs}ms`);
7777
+ }
7778
+ await new Promise((r) => setTimeout(r, 150));
7779
+ }
7456
7780
  }
7457
7781
 
7458
7782
  // src/version-skew.ts
@@ -7525,7 +7849,7 @@ function localVersion() {
7525
7849
  return process.env.NEAT_LOCAL_VERSION;
7526
7850
  }
7527
7851
  try {
7528
- const req2 = (0, import_node_module.createRequire)(importMetaUrl);
7852
+ const req2 = (0, import_node_module2.createRequire)(importMetaUrl);
7529
7853
  const pkg = req2("../package.json");
7530
7854
  return typeof pkg.version === "string" ? pkg.version : "0.0.0";
7531
7855
  } catch {
@@ -7541,7 +7865,7 @@ function neatHome2() {
7541
7865
  }
7542
7866
  async function readPid() {
7543
7867
  try {
7544
- const raw = await import_node_fs26.promises.readFile(import_node_path44.default.join(neatHome2(), "neatd.pid"), "utf8");
7868
+ const raw = await import_node_fs27.promises.readFile(import_node_path44.default.join(neatHome2(), "neatd.pid"), "utf8");
7545
7869
  const n = Number.parseInt(raw.trim(), 10);
7546
7870
  return Number.isFinite(n) ? n : null;
7547
7871
  } catch {
@@ -7555,10 +7879,19 @@ function restPortFromEnv() {
7555
7879
  const raw = process.env.PORT;
7556
7880
  return raw && raw.length > 0 ? Number.parseInt(raw, 10) : 8080;
7557
7881
  }
7882
+ function webPortFromEnv() {
7883
+ const raw = process.env.NEAT_WEB_PORT;
7884
+ if (!raw || raw.length === 0) return void 0;
7885
+ const n = Number.parseInt(raw, 10);
7886
+ return Number.isFinite(n) ? n : void 0;
7887
+ }
7558
7888
  async function cmdStart() {
7889
+ const project = process.env.NEAT_PROJECT;
7890
+ const projectPath = process.env.NEAT_PROJECT_PATH;
7891
+ const startOpts = project && project.length > 0 ? { project, projectPath, webPort: webPortFromEnv() } : {};
7559
7892
  let handle;
7560
7893
  try {
7561
- handle = await startDaemon();
7894
+ handle = await startDaemon(startOpts);
7562
7895
  } catch (err) {
7563
7896
  if (err instanceof BindAuthorityError) {
7564
7897
  console.error(`neatd: ${err.message}`);