@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/index.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(
@@ -5347,7 +5349,9 @@ async function loadGraphFromDisk(graph, outPath) {
5347
5349
  graph.clear();
5348
5350
  graph.import(payload.graph);
5349
5351
  }
5350
- function startPersistLoop(graph, outPath, intervalMs = 6e4) {
5352
+ function startPersistLoop(graph, outPath, opts = {}) {
5353
+ const intervalMs = opts.intervalMs ?? 6e4;
5354
+ const exitOnSignal = opts.exitOnSignal ?? true;
5351
5355
  let stopped = false;
5352
5356
  const tick = async () => {
5353
5357
  if (stopped) return;
@@ -5360,7 +5364,7 @@ function startPersistLoop(graph, outPath, intervalMs = 6e4) {
5360
5364
  const interval = setInterval(() => {
5361
5365
  void tick();
5362
5366
  }, intervalMs);
5363
- const onSignal = (signal) => {
5367
+ const onSignal = exitOnSignal ? (signal) => {
5364
5368
  void (async () => {
5365
5369
  try {
5366
5370
  await saveGraphToDisk(graph, outPath);
@@ -5370,14 +5374,18 @@ function startPersistLoop(graph, outPath, intervalMs = 6e4) {
5370
5374
  process.exit(0);
5371
5375
  }
5372
5376
  })();
5373
- };
5374
- process.on("SIGTERM", onSignal);
5375
- process.on("SIGINT", onSignal);
5377
+ } : null;
5378
+ if (onSignal) {
5379
+ process.on("SIGTERM", onSignal);
5380
+ process.on("SIGINT", onSignal);
5381
+ }
5376
5382
  return () => {
5377
5383
  stopped = true;
5378
5384
  clearInterval(interval);
5379
- process.off("SIGTERM", onSignal);
5380
- process.off("SIGINT", onSignal);
5385
+ if (onSignal) {
5386
+ process.off("SIGTERM", onSignal);
5387
+ process.off("SIGINT", onSignal);
5388
+ }
5381
5389
  };
5382
5390
  }
5383
5391
 
@@ -6428,12 +6436,16 @@ function serializeGraph(graph) {
6428
6436
  });
6429
6437
  return { nodes, edges };
6430
6438
  }
6431
- function projectFromReq(req) {
6439
+ function projectFromReq(req, singleProject) {
6432
6440
  const params = req.params;
6433
- return params.project ?? DEFAULT_PROJECT;
6441
+ const named = params.project;
6442
+ if (singleProject) {
6443
+ return named === void 0 || named === DEFAULT_PROJECT ? singleProject : named;
6444
+ }
6445
+ return named ?? DEFAULT_PROJECT;
6434
6446
  }
6435
- function resolveProject(registry, req, reply, bootstrap) {
6436
- const name = projectFromReq(req);
6447
+ function resolveProject(registry, req, reply, bootstrap, singleProject) {
6448
+ const name = projectFromReq(req, singleProject);
6437
6449
  const ctx = registry.get(name);
6438
6450
  if (!ctx) {
6439
6451
  const phase = bootstrap?.status(name);
@@ -6474,13 +6486,13 @@ function buildLegacyRegistry(opts) {
6474
6486
  function registerRoutes(scope, ctx) {
6475
6487
  const { registry, startedAt, errorsPathFor, staleEventsPathFor } = ctx;
6476
6488
  scope.get("/events", (req, reply) => {
6477
- const proj = resolveProject(registry, req, reply, ctx.bootstrap);
6489
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
6478
6490
  if (!proj) return;
6479
6491
  handleSse(req, reply, { project: proj.name });
6480
6492
  });
6481
6493
  if (ctx.scope === "project") {
6482
6494
  scope.get("/health", async (req, reply) => {
6483
- const proj = resolveProject(registry, req, reply, ctx.bootstrap);
6495
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
6484
6496
  if (!proj) return;
6485
6497
  const uptimeMs = Date.now() - startedAt;
6486
6498
  return {
@@ -6498,14 +6510,14 @@ function registerRoutes(scope, ctx) {
6498
6510
  });
6499
6511
  }
6500
6512
  scope.get("/graph", async (req, reply) => {
6501
- const proj = resolveProject(registry, req, reply, ctx.bootstrap);
6513
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
6502
6514
  if (!proj) return;
6503
6515
  return serializeGraph(proj.graph);
6504
6516
  });
6505
6517
  scope.get(
6506
6518
  "/graph/node/:id",
6507
6519
  async (req, reply) => {
6508
- const proj = resolveProject(registry, req, reply, ctx.bootstrap);
6520
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
6509
6521
  if (!proj) return;
6510
6522
  const { id } = req.params;
6511
6523
  if (!proj.graph.hasNode(id)) {
@@ -6517,7 +6529,7 @@ function registerRoutes(scope, ctx) {
6517
6529
  scope.get(
6518
6530
  "/graph/edges/:id",
6519
6531
  async (req, reply) => {
6520
- const proj = resolveProject(registry, req, reply, ctx.bootstrap);
6532
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
6521
6533
  if (!proj) return;
6522
6534
  const { id } = req.params;
6523
6535
  if (!proj.graph.hasNode(id)) {
@@ -6529,7 +6541,7 @@ function registerRoutes(scope, ctx) {
6529
6541
  }
6530
6542
  );
6531
6543
  scope.get("/graph/dependencies/:nodeId", async (req, reply) => {
6532
- const proj = resolveProject(registry, req, reply, ctx.bootstrap);
6544
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
6533
6545
  if (!proj) return;
6534
6546
  const { nodeId } = req.params;
6535
6547
  if (!proj.graph.hasNode(nodeId)) {
@@ -6544,7 +6556,7 @@ function registerRoutes(scope, ctx) {
6544
6556
  return getTransitiveDependencies(proj.graph, nodeId, depth);
6545
6557
  });
6546
6558
  scope.get("/graph/divergences", async (req, reply) => {
6547
- const proj = resolveProject(registry, req, reply, ctx.bootstrap);
6559
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
6548
6560
  if (!proj) return;
6549
6561
  let typeFilter;
6550
6562
  if (req.query.type) {
@@ -6579,7 +6591,7 @@ function registerRoutes(scope, ctx) {
6579
6591
  });
6580
6592
  });
6581
6593
  scope.get("/incidents", async (req, reply) => {
6582
- const proj = resolveProject(registry, req, reply, ctx.bootstrap);
6594
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
6583
6595
  if (!proj) return;
6584
6596
  const epath = errorsPathFor(proj);
6585
6597
  if (!epath) return { count: 0, total: 0, events: [] };
@@ -6591,7 +6603,7 @@ function registerRoutes(scope, ctx) {
6591
6603
  return { count: sliced.length, total, events: sliced };
6592
6604
  });
6593
6605
  scope.get("/stale-events", async (req, reply) => {
6594
- const proj = resolveProject(registry, req, reply, ctx.bootstrap);
6606
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
6595
6607
  if (!proj) return;
6596
6608
  const spath = staleEventsPathFor(proj);
6597
6609
  if (!spath) return { count: 0, total: 0, events: [] };
@@ -6606,7 +6618,7 @@ function registerRoutes(scope, ctx) {
6606
6618
  scope.get(
6607
6619
  "/incidents/:nodeId",
6608
6620
  async (req, reply) => {
6609
- const proj = resolveProject(registry, req, reply, ctx.bootstrap);
6621
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
6610
6622
  if (!proj) return;
6611
6623
  const { nodeId } = req.params;
6612
6624
  if (!proj.graph.hasNode(nodeId)) {
@@ -6622,7 +6634,7 @@ function registerRoutes(scope, ctx) {
6622
6634
  }
6623
6635
  );
6624
6636
  scope.get("/graph/root-cause/:nodeId", async (req, reply) => {
6625
- const proj = resolveProject(registry, req, reply, ctx.bootstrap);
6637
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
6626
6638
  if (!proj) return;
6627
6639
  const { nodeId } = req.params;
6628
6640
  if (!proj.graph.hasNode(nodeId)) {
@@ -6642,7 +6654,7 @@ function registerRoutes(scope, ctx) {
6642
6654
  return result;
6643
6655
  });
6644
6656
  scope.get("/graph/blast-radius/:nodeId", async (req, reply) => {
6645
- const proj = resolveProject(registry, req, reply, ctx.bootstrap);
6657
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
6646
6658
  if (!proj) return;
6647
6659
  const { nodeId } = req.params;
6648
6660
  if (!proj.graph.hasNode(nodeId)) {
@@ -6655,7 +6667,7 @@ function registerRoutes(scope, ctx) {
6655
6667
  return getBlastRadius(proj.graph, nodeId, depth);
6656
6668
  });
6657
6669
  scope.get("/search", async (req, reply) => {
6658
- const proj = resolveProject(registry, req, reply, ctx.bootstrap);
6670
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
6659
6671
  if (!proj) return;
6660
6672
  const raw = (req.query.q ?? "").trim();
6661
6673
  if (!raw) return reply.code(400).send({ error: "query parameter `q` is required" });
@@ -6686,7 +6698,7 @@ function registerRoutes(scope, ctx) {
6686
6698
  scope.get(
6687
6699
  "/graph/diff",
6688
6700
  async (req, reply) => {
6689
- const proj = resolveProject(registry, req, reply, ctx.bootstrap);
6701
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
6690
6702
  if (!proj) return;
6691
6703
  const against = req.query.against;
6692
6704
  if (!against) {
@@ -6701,7 +6713,7 @@ function registerRoutes(scope, ctx) {
6701
6713
  }
6702
6714
  );
6703
6715
  scope.post("/snapshot", async (req, reply) => {
6704
- const proj = resolveProject(registry, req, reply, ctx.bootstrap);
6716
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
6705
6717
  if (!proj) return;
6706
6718
  const body = req.body;
6707
6719
  if (!body || typeof body !== "object" || !body.snapshot) {
@@ -6730,7 +6742,7 @@ function registerRoutes(scope, ctx) {
6730
6742
  }
6731
6743
  });
6732
6744
  scope.post("/graph/scan", async (req, reply) => {
6733
- const proj = resolveProject(registry, req, reply, ctx.bootstrap);
6745
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
6734
6746
  if (!proj) return;
6735
6747
  if (!proj.scanPath) {
6736
6748
  return reply.code(409).send({ error: "scan path not configured for this project", project: proj.name });
@@ -6746,7 +6758,7 @@ function registerRoutes(scope, ctx) {
6746
6758
  };
6747
6759
  });
6748
6760
  scope.get("/policies", async (req, reply) => {
6749
- const proj = resolveProject(registry, req, reply, ctx.bootstrap);
6761
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
6750
6762
  if (!proj) return;
6751
6763
  const policyPath = ctx.policyFilePathFor(proj);
6752
6764
  if (!policyPath) {
@@ -6763,7 +6775,7 @@ function registerRoutes(scope, ctx) {
6763
6775
  }
6764
6776
  });
6765
6777
  scope.get("/policies/violations", async (req, reply) => {
6766
- const proj = resolveProject(registry, req, reply, ctx.bootstrap);
6778
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
6767
6779
  if (!proj) return;
6768
6780
  const log = new PolicyViolationsLog(proj.paths.policyViolationsPath);
6769
6781
  let violations = await log.readAll();
@@ -6783,7 +6795,7 @@ function registerRoutes(scope, ctx) {
6783
6795
  return { violations };
6784
6796
  });
6785
6797
  scope.post("/policies/check", async (req, reply) => {
6786
- const proj = resolveProject(registry, req, reply, ctx.bootstrap);
6798
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
6787
6799
  if (!proj) return;
6788
6800
  const parsed = import_types25.PoliciesCheckBodySchema.safeParse(req.body ?? {});
6789
6801
  if (!parsed.success) {
@@ -6819,7 +6831,7 @@ function registerRoutes(scope, ctx) {
6819
6831
  };
6820
6832
  });
6821
6833
  scope.get("/extend/list-uninstrumented", async (req, reply) => {
6822
- const proj = resolveProject(registry, req, reply, ctx.bootstrap);
6834
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
6823
6835
  if (!proj) return;
6824
6836
  if (!proj.scanPath) {
6825
6837
  return reply.code(409).send({ error: "scan path not configured for this project", project: proj.name });
@@ -6832,7 +6844,7 @@ function registerRoutes(scope, ctx) {
6832
6844
  }
6833
6845
  });
6834
6846
  scope.get("/extend/lookup", async (req, reply) => {
6835
- const proj = resolveProject(registry, req, reply, ctx.bootstrap);
6847
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
6836
6848
  if (!proj) return;
6837
6849
  const { library, version } = req.query;
6838
6850
  if (!library) {
@@ -6845,7 +6857,7 @@ function registerRoutes(scope, ctx) {
6845
6857
  return result;
6846
6858
  });
6847
6859
  scope.get("/extend/describe", async (req, reply) => {
6848
- const proj = resolveProject(registry, req, reply, ctx.bootstrap);
6860
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
6849
6861
  if (!proj) return;
6850
6862
  if (!proj.scanPath) {
6851
6863
  return reply.code(409).send({ error: "scan path not configured for this project", project: proj.name });
@@ -6858,7 +6870,7 @@ function registerRoutes(scope, ctx) {
6858
6870
  }
6859
6871
  });
6860
6872
  scope.post("/extend/apply", async (req, reply) => {
6861
- const proj = resolveProject(registry, req, reply, ctx.bootstrap);
6873
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
6862
6874
  if (!proj) return;
6863
6875
  if (!proj.scanPath) {
6864
6876
  return reply.code(409).send({ error: "scan path not configured for this project", project: proj.name });
@@ -6878,7 +6890,7 @@ function registerRoutes(scope, ctx) {
6878
6890
  }
6879
6891
  });
6880
6892
  scope.post("/extend/dry-run", async (req, reply) => {
6881
- const proj = resolveProject(registry, req, reply, ctx.bootstrap);
6893
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
6882
6894
  if (!proj) return;
6883
6895
  if (!proj.scanPath) {
6884
6896
  return reply.code(409).send({ error: "scan path not configured for this project", project: proj.name });
@@ -6898,7 +6910,7 @@ function registerRoutes(scope, ctx) {
6898
6910
  }
6899
6911
  });
6900
6912
  scope.post("/extend/rollback", async (req, reply) => {
6901
- const proj = resolveProject(registry, req, reply, ctx.bootstrap);
6913
+ const proj = resolveProject(registry, req, reply, ctx.bootstrap, ctx.singleProject);
6902
6914
  if (!proj) return;
6903
6915
  if (!proj.scanPath) {
6904
6916
  return reply.code(409).send({ error: "scan path not configured for this project", project: proj.name });
@@ -6961,7 +6973,8 @@ async function buildApi(opts) {
6961
6973
  errorsPathFor,
6962
6974
  staleEventsPathFor,
6963
6975
  policyFilePathFor,
6964
- bootstrap: opts.bootstrap
6976
+ bootstrap: opts.bootstrap,
6977
+ singleProject: opts.singleProject?.name
6965
6978
  };
6966
6979
  app.get("/health", async () => {
6967
6980
  const uptimeMs = Date.now() - startedAt;
@@ -6984,10 +6997,29 @@ async function buildApi(opts) {
6984
6997
  return {
6985
6998
  ok: true,
6986
6999
  uptimeMs,
7000
+ // ADR-096 §7 — a per-project daemon carries its project at the top level
7001
+ // so a spawn can confirm a daemon found on a reused port is serving THIS
7002
+ // project before reusing it. The legacy multi-project daemon omits it and
7003
+ // is matched against the `projects` array instead.
7004
+ ...opts.singleProject ? { project: opts.singleProject.name } : {},
6987
7005
  projects
6988
7006
  };
6989
7007
  });
6990
7008
  app.get("/projects", async (_req, reply) => {
7009
+ if (opts.singleProject) {
7010
+ const phase = opts.bootstrap?.status(opts.singleProject.name);
7011
+ const entry = {
7012
+ name: opts.singleProject.name,
7013
+ path: opts.singleProject.path,
7014
+ registeredAt: new Date(startedAt).toISOString(),
7015
+ languages: [],
7016
+ // The daemon is serving this project, so it's active unless its
7017
+ // bootstrap broke. ('bootstrapping' still reads as active — the project
7018
+ // is the one to land on; its graph route answers 503 until ready.)
7019
+ status: phase === "broken" ? "broken" : "active"
7020
+ };
7021
+ return [entry];
7022
+ }
6991
7023
  try {
6992
7024
  return await listProjects();
6993
7025
  } catch (err) {
@@ -7029,6 +7061,7 @@ init_otel_grpc();
7029
7061
  init_cjs_shims();
7030
7062
  var import_node_fs25 = require("fs");
7031
7063
  var import_node_path42 = __toESM(require("path"), 1);
7064
+ var import_node_module = require("module");
7032
7065
  init_otel();
7033
7066
  init_auth();
7034
7067
 
@@ -7054,6 +7087,59 @@ function unroutedErrorsPath(neatHome2) {
7054
7087
  }
7055
7088
 
7056
7089
  // src/daemon.ts
7090
+ function daemonJsonPath(scanPath) {
7091
+ return import_node_path42.default.join(scanPath, "neat-out", "daemon.json");
7092
+ }
7093
+ function daemonsDiscoveryDir(home) {
7094
+ const base = home && home.length > 0 ? home : neatHomeFromEnv();
7095
+ return import_node_path42.default.join(base, "daemons");
7096
+ }
7097
+ function daemonDiscoveryPath(project, home) {
7098
+ return import_node_path42.default.join(daemonsDiscoveryDir(home), `${sanitizeDiscoveryName(project)}.json`);
7099
+ }
7100
+ function sanitizeDiscoveryName(project) {
7101
+ return project.replace(/[^A-Za-z0-9._-]/g, "_");
7102
+ }
7103
+ function neatHomeFromEnv() {
7104
+ const env = process.env.NEAT_HOME;
7105
+ if (env && env.length > 0) return import_node_path42.default.resolve(env);
7106
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
7107
+ return import_node_path42.default.join(home, ".neat");
7108
+ }
7109
+ function resolveNeatVersion() {
7110
+ if (process.env.NEAT_LOCAL_VERSION && process.env.NEAT_LOCAL_VERSION.length > 0) {
7111
+ return process.env.NEAT_LOCAL_VERSION;
7112
+ }
7113
+ try {
7114
+ const req = (0, import_node_module.createRequire)(importMetaUrl);
7115
+ const pkg = req("../package.json");
7116
+ return typeof pkg.version === "string" ? pkg.version : "0.0.0";
7117
+ } catch {
7118
+ return "0.0.0";
7119
+ }
7120
+ }
7121
+ async function writeDaemonRecord(record, home) {
7122
+ const body = JSON.stringify(record, null, 2) + "\n";
7123
+ await writeAtomically(daemonJsonPath(record.projectPath), body);
7124
+ try {
7125
+ await writeAtomically(daemonDiscoveryPath(record.project, home), body);
7126
+ } catch (err) {
7127
+ console.warn(
7128
+ `neatd: could not write discovery copy for "${record.project}" \u2014 ${err.message}`
7129
+ );
7130
+ }
7131
+ }
7132
+ async function clearDaemonRecord(record, home) {
7133
+ try {
7134
+ const stopped = { ...record, status: "stopped" };
7135
+ await writeAtomically(daemonJsonPath(record.projectPath), JSON.stringify(stopped, null, 2) + "\n");
7136
+ } catch {
7137
+ }
7138
+ try {
7139
+ await import_node_fs25.promises.unlink(daemonDiscoveryPath(record.project, home));
7140
+ } catch {
7141
+ }
7142
+ }
7057
7143
  function teardownSlot(slot) {
7058
7144
  try {
7059
7145
  slot.stopPersist();
@@ -7135,7 +7221,7 @@ async function bootstrapProject(entry) {
7135
7221
  const detachEvents = attachGraphToEventBus(graph, { project: entry.name });
7136
7222
  try {
7137
7223
  await extractFromDirectory(graph, entry.path);
7138
- const stopPersist = startPersistLoop(graph, outPath);
7224
+ const stopPersist = startPersistLoop(graph, outPath, { exitOnSignal: false });
7139
7225
  await touchLastSeen(entry.name).catch(() => {
7140
7226
  });
7141
7227
  return {
@@ -7170,6 +7256,23 @@ function resolveOtlpPort(opts) {
7170
7256
  }
7171
7257
  return 4318;
7172
7258
  }
7259
+ function resolveWebPort() {
7260
+ const env = process.env.NEAT_WEB_PORT;
7261
+ if (env && env.length > 0) {
7262
+ const n = Number.parseInt(env, 10);
7263
+ if (Number.isFinite(n)) return n;
7264
+ }
7265
+ return 6328;
7266
+ }
7267
+ function portFromListenAddress(address, fallback) {
7268
+ try {
7269
+ const port = new URL(address).port;
7270
+ const n = Number.parseInt(port, 10);
7271
+ if (Number.isFinite(n) && n > 0) return n;
7272
+ } catch {
7273
+ }
7274
+ return fallback;
7275
+ }
7173
7276
  function resolveHost(opts, authTokenSet) {
7174
7277
  if (opts.host && opts.host.length > 0) return opts.host;
7175
7278
  const env = process.env.HOST;
@@ -7180,13 +7283,24 @@ function resolveHost(opts, authTokenSet) {
7180
7283
  async function startDaemon(opts = {}) {
7181
7284
  const home = neatHomeFor(opts);
7182
7285
  const regPath = registryPath();
7183
- try {
7184
- await import_node_fs25.promises.access(regPath);
7185
- } catch {
7286
+ 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;
7287
+ 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;
7288
+ const singleProject = projectArg;
7289
+ const singleProjectPath = singleProject && projectPathArg ? import_node_path42.default.resolve(projectPathArg) : null;
7290
+ if (singleProject && !singleProjectPath) {
7186
7291
  throw new Error(
7187
- `neatd: registry not found at ${regPath}. Run \`neat init <path>\` to register a project before starting the daemon.`
7292
+ `neatd: project "${singleProject}" given without a projectPath; pass NEAT_PROJECT_PATH alongside NEAT_PROJECT.`
7188
7293
  );
7189
7294
  }
7295
+ if (!singleProject) {
7296
+ try {
7297
+ await import_node_fs25.promises.access(regPath);
7298
+ } catch {
7299
+ throw new Error(
7300
+ `neatd: registry not found at ${regPath}. Run \`neat init <path>\` to register a project before starting the daemon.`
7301
+ );
7302
+ }
7303
+ }
7190
7304
  const pidPath = import_node_path42.default.join(home, "neatd.pid");
7191
7305
  await writeAtomically(pidPath, `${process.pid}
7192
7306
  `);
@@ -7275,21 +7389,37 @@ async function startDaemon(opts = {}) {
7275
7389
  });
7276
7390
  }
7277
7391
  }
7392
+ async function enumerateProjects() {
7393
+ if (singleProject && singleProjectPath) {
7394
+ return [
7395
+ {
7396
+ name: singleProject,
7397
+ path: singleProjectPath,
7398
+ registeredAt: (/* @__PURE__ */ new Date()).toISOString(),
7399
+ languages: [],
7400
+ status: "active"
7401
+ }
7402
+ ];
7403
+ }
7404
+ return listProjects();
7405
+ }
7278
7406
  async function loadAll() {
7279
- try {
7280
- const pruned = await pruneRegistry();
7281
- for (const entry of pruned) {
7282
- console.log(
7283
- `neatd: pruned project "${entry.name}" \u2014 registered path ${entry.path} is gone`
7284
- );
7285
- slots.delete(entry.name);
7286
- bootstrapStatus.delete(entry.name);
7287
- bootstrapStartedAt.delete(entry.name);
7407
+ if (!singleProject) {
7408
+ try {
7409
+ const pruned = await pruneRegistry();
7410
+ for (const entry of pruned) {
7411
+ console.log(
7412
+ `neatd: pruned project "${entry.name}" \u2014 registered path ${entry.path} is gone`
7413
+ );
7414
+ slots.delete(entry.name);
7415
+ bootstrapStatus.delete(entry.name);
7416
+ bootstrapStartedAt.delete(entry.name);
7417
+ }
7418
+ } catch (err) {
7419
+ console.warn(`neatd: registry prune skipped \u2014 ${err.message}`);
7288
7420
  }
7289
- } catch (err) {
7290
- console.warn(`neatd: registry prune skipped \u2014 ${err.message}`);
7291
7421
  }
7292
- const projects = await listProjects();
7422
+ const projects = await enumerateProjects();
7293
7423
  const seen = /* @__PURE__ */ new Set();
7294
7424
  const pending = [];
7295
7425
  for (const entry of projects) {
@@ -7314,7 +7444,7 @@ async function startDaemon(opts = {}) {
7314
7444
  }
7315
7445
  await Promise.allSettled(pending);
7316
7446
  }
7317
- const initialEntries = await listProjects().catch(() => []);
7447
+ const initialEntries = await enumerateProjects().catch(() => []);
7318
7448
  for (const entry of initialEntries) {
7319
7449
  bootstrapStatus.set(entry.name, "bootstrapping");
7320
7450
  bootstrapStartedAt.set(entry.name, Date.now());
@@ -7324,6 +7454,7 @@ async function startDaemon(opts = {}) {
7324
7454
  let otlpApp = null;
7325
7455
  let restAddress = "";
7326
7456
  let otlpAddress = "";
7457
+ let daemonRecord = null;
7327
7458
  if (bind) {
7328
7459
  const auth = readAuthEnv();
7329
7460
  const host = resolveHost(opts, Boolean(auth.authToken));
@@ -7346,7 +7477,13 @@ async function startDaemon(opts = {}) {
7346
7477
  elapsedMs: now - (bootstrapStartedAt.get(name) ?? now)
7347
7478
  }));
7348
7479
  }
7349
- }
7480
+ },
7481
+ // ADR-096 §4/§5/§7 — hand the daemon's identity to buildApi so the REST
7482
+ // surface reflects "the daemon is the project": `GET /projects` reports
7483
+ // only this project (the dashboard pins to it), and the daemon-wide
7484
+ // `/health` carries it at the top level for the spawn-reuse identity
7485
+ // check. Absent for the legacy multi-project daemon.
7486
+ singleProject: singleProject && singleProjectPath ? { name: singleProject, path: singleProjectPath } : void 0
7350
7487
  });
7351
7488
  restAddress = await restApp.listen({ port: restPort, host });
7352
7489
  console.log(`neatd: REST listening on ${restAddress}`);
@@ -7363,6 +7500,25 @@ async function startDaemon(opts = {}) {
7363
7500
  );
7364
7501
  }
7365
7502
  async function resolveTargetSlot(serviceName, traceId) {
7503
+ if (singleProject) {
7504
+ let slot2 = slots.get(singleProject);
7505
+ if (!slot2) {
7506
+ slot2 = await tryRecoverSlot({
7507
+ name: singleProject,
7508
+ path: singleProjectPath,
7509
+ registeredAt: (/* @__PURE__ */ new Date()).toISOString(),
7510
+ languages: [],
7511
+ status: "active"
7512
+ });
7513
+ } else if (slot2.status === "broken") {
7514
+ slot2 = await tryRecoverSlot(slot2.entry);
7515
+ }
7516
+ if (!slot2 || slot2.status !== "active") {
7517
+ warnDroppedSpan(singleProject, slot2?.errorReason ?? "unknown");
7518
+ return null;
7519
+ }
7520
+ return slot2;
7521
+ }
7366
7522
  const liveEntries = await listProjects().catch(() => []);
7367
7523
  const target = routeSpanToProject(serviceName, liveEntries);
7368
7524
  let slot = slots.get(target) ?? slots.get(DEFAULT_PROJECT);
@@ -7465,6 +7621,42 @@ async function startDaemon(opts = {}) {
7465
7621
  `neatd: failed to bind OTLP on port ${otlpPort} \u2014 ${err.message}`
7466
7622
  );
7467
7623
  }
7624
+ if (singleProject && singleProjectPath) {
7625
+ const ports = {
7626
+ rest: portFromListenAddress(restAddress, restPort),
7627
+ otlp: portFromListenAddress(otlpAddress, otlpPort),
7628
+ // The daemon doesn't bind the web port itself (neatd spawns the web
7629
+ // child); it records the allocated value passed through so the
7630
+ // dashboard and `neat ps` agree on where to look.
7631
+ web: typeof opts.webPort === "number" ? opts.webPort : resolveWebPort()
7632
+ };
7633
+ daemonRecord = {
7634
+ project: singleProject,
7635
+ projectPath: singleProjectPath,
7636
+ pid: process.pid,
7637
+ status: "running",
7638
+ ports,
7639
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
7640
+ neatVersion: resolveNeatVersion()
7641
+ };
7642
+ try {
7643
+ await writeDaemonRecord(daemonRecord, home);
7644
+ console.log(
7645
+ `neatd: project "${singleProject}" \u2192 REST ${ports.rest} / OTLP ${ports.otlp} / web ${ports.web} (daemon.json written)`
7646
+ );
7647
+ } catch (err) {
7648
+ for (const slot of slots.values()) teardownSlot(slot);
7649
+ if (restApp) await restApp.close().catch(() => {
7650
+ });
7651
+ if (otlpApp) await otlpApp.close().catch(() => {
7652
+ });
7653
+ await import_node_fs25.promises.unlink(pidPath).catch(() => {
7654
+ });
7655
+ throw new Error(
7656
+ `neatd: failed to write daemon.json for "${singleProject}" \u2014 ${err.message}`
7657
+ );
7658
+ }
7659
+ }
7468
7660
  }
7469
7661
  const initialBootstrap = loadAll().catch((err) => {
7470
7662
  console.warn(`neatd: initial bootstrap pass failed \u2014 ${err.message}`);
@@ -7504,7 +7696,7 @@ async function startDaemon(opts = {}) {
7504
7696
  const REGISTRY_RELOAD_DEBOUNCE_MS = 500;
7505
7697
  let registryWatcher = null;
7506
7698
  let reloadTimer = null;
7507
- try {
7699
+ if (!singleProject) try {
7508
7700
  const regDir = import_node_path42.default.dirname(regPath);
7509
7701
  const regBase = import_node_path42.default.basename(regPath);
7510
7702
  registryWatcher = (0, import_node_fs25.watch)(regDir, (_eventType, filename) => {
@@ -7544,9 +7736,18 @@ async function startDaemon(opts = {}) {
7544
7736
  });
7545
7737
  if (restApp) await restApp.close().catch(() => {
7546
7738
  });
7739
+ for (const slot of slots.values()) {
7740
+ if (slot.status === "active" && slot.outPath) {
7741
+ await saveGraphToDisk(slot.graph, slot.outPath).catch(() => {
7742
+ });
7743
+ }
7744
+ }
7547
7745
  for (const slot of slots.values()) {
7548
7746
  teardownSlot(slot);
7549
7747
  }
7748
+ if (daemonRecord) {
7749
+ await clearDaemonRecord(daemonRecord, home);
7750
+ }
7550
7751
  await import_node_fs25.promises.unlink(pidPath).catch(() => {
7551
7752
  });
7552
7753
  };
@@ -7558,7 +7759,8 @@ async function startDaemon(opts = {}) {
7558
7759
  restAddress,
7559
7760
  otlpAddress,
7560
7761
  bootstrap: tracker,
7561
- initialBootstrap
7762
+ initialBootstrap,
7763
+ daemonRecord
7562
7764
  };
7563
7765
  }
7564
7766
  // Annotate the CommonJS export names for ESM import in node: