@neat.is/core 0.4.17 → 0.4.18

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();
@@ -6850,6 +6853,59 @@ function unroutedErrorsPath(neatHome3) {
6850
6853
  }
6851
6854
 
6852
6855
  // src/daemon.ts
6856
+ function daemonJsonPath(scanPath) {
6857
+ return import_node_path42.default.join(scanPath, "neat-out", "daemon.json");
6858
+ }
6859
+ function daemonsDiscoveryDir(home) {
6860
+ const base = home && home.length > 0 ? home : neatHomeFromEnv();
6861
+ return import_node_path42.default.join(base, "daemons");
6862
+ }
6863
+ function daemonDiscoveryPath(project, home) {
6864
+ return import_node_path42.default.join(daemonsDiscoveryDir(home), `${sanitizeDiscoveryName(project)}.json`);
6865
+ }
6866
+ function sanitizeDiscoveryName(project) {
6867
+ return project.replace(/[^A-Za-z0-9._-]/g, "_");
6868
+ }
6869
+ function neatHomeFromEnv() {
6870
+ const env = process.env.NEAT_HOME;
6871
+ if (env && env.length > 0) return import_node_path42.default.resolve(env);
6872
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
6873
+ return import_node_path42.default.join(home, ".neat");
6874
+ }
6875
+ function resolveNeatVersion() {
6876
+ if (process.env.NEAT_LOCAL_VERSION && process.env.NEAT_LOCAL_VERSION.length > 0) {
6877
+ return process.env.NEAT_LOCAL_VERSION;
6878
+ }
6879
+ try {
6880
+ const req2 = (0, import_node_module.createRequire)(importMetaUrl);
6881
+ const pkg = req2("../package.json");
6882
+ return typeof pkg.version === "string" ? pkg.version : "0.0.0";
6883
+ } catch {
6884
+ return "0.0.0";
6885
+ }
6886
+ }
6887
+ async function writeDaemonRecord(record, home) {
6888
+ const body = JSON.stringify(record, null, 2) + "\n";
6889
+ await writeAtomically(daemonJsonPath(record.projectPath), body);
6890
+ try {
6891
+ await writeAtomically(daemonDiscoveryPath(record.project, home), body);
6892
+ } catch (err) {
6893
+ console.warn(
6894
+ `neatd: could not write discovery copy for "${record.project}" \u2014 ${err.message}`
6895
+ );
6896
+ }
6897
+ }
6898
+ async function clearDaemonRecord(record, home) {
6899
+ try {
6900
+ const stopped = { ...record, status: "stopped" };
6901
+ await writeAtomically(daemonJsonPath(record.projectPath), JSON.stringify(stopped, null, 2) + "\n");
6902
+ } catch {
6903
+ }
6904
+ try {
6905
+ await import_node_fs25.promises.unlink(daemonDiscoveryPath(record.project, home));
6906
+ } catch {
6907
+ }
6908
+ }
6853
6909
  function teardownSlot(slot) {
6854
6910
  try {
6855
6911
  slot.stopPersist();
@@ -6966,6 +7022,23 @@ function resolveOtlpPort(opts) {
6966
7022
  }
6967
7023
  return 4318;
6968
7024
  }
7025
+ function resolveWebPort() {
7026
+ const env = process.env.NEAT_WEB_PORT;
7027
+ if (env && env.length > 0) {
7028
+ const n = Number.parseInt(env, 10);
7029
+ if (Number.isFinite(n)) return n;
7030
+ }
7031
+ return 6328;
7032
+ }
7033
+ function portFromListenAddress(address, fallback) {
7034
+ try {
7035
+ const port = new URL(address).port;
7036
+ const n = Number.parseInt(port, 10);
7037
+ if (Number.isFinite(n) && n > 0) return n;
7038
+ } catch {
7039
+ }
7040
+ return fallback;
7041
+ }
6969
7042
  function resolveHost(opts, authTokenSet) {
6970
7043
  if (opts.host && opts.host.length > 0) return opts.host;
6971
7044
  const env = process.env.HOST;
@@ -6976,13 +7049,24 @@ function resolveHost(opts, authTokenSet) {
6976
7049
  async function startDaemon(opts = {}) {
6977
7050
  const home = neatHomeFor(opts);
6978
7051
  const regPath = registryPath();
6979
- try {
6980
- await import_node_fs25.promises.access(regPath);
6981
- } catch {
7052
+ 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;
7053
+ 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;
7054
+ const singleProject = projectArg;
7055
+ const singleProjectPath = singleProject && projectPathArg ? import_node_path42.default.resolve(projectPathArg) : null;
7056
+ if (singleProject && !singleProjectPath) {
6982
7057
  throw new Error(
6983
- `neatd: registry not found at ${regPath}. Run \`neat init <path>\` to register a project before starting the daemon.`
7058
+ `neatd: project "${singleProject}" given without a projectPath; pass NEAT_PROJECT_PATH alongside NEAT_PROJECT.`
6984
7059
  );
6985
7060
  }
7061
+ if (!singleProject) {
7062
+ try {
7063
+ await import_node_fs25.promises.access(regPath);
7064
+ } catch {
7065
+ throw new Error(
7066
+ `neatd: registry not found at ${regPath}. Run \`neat init <path>\` to register a project before starting the daemon.`
7067
+ );
7068
+ }
7069
+ }
6986
7070
  const pidPath = import_node_path42.default.join(home, "neatd.pid");
6987
7071
  await writeAtomically(pidPath, `${process.pid}
6988
7072
  `);
@@ -7071,21 +7155,37 @@ async function startDaemon(opts = {}) {
7071
7155
  });
7072
7156
  }
7073
7157
  }
7158
+ async function enumerateProjects() {
7159
+ if (singleProject && singleProjectPath) {
7160
+ return [
7161
+ {
7162
+ name: singleProject,
7163
+ path: singleProjectPath,
7164
+ registeredAt: (/* @__PURE__ */ new Date()).toISOString(),
7165
+ languages: [],
7166
+ status: "active"
7167
+ }
7168
+ ];
7169
+ }
7170
+ return listProjects();
7171
+ }
7074
7172
  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);
7173
+ if (!singleProject) {
7174
+ try {
7175
+ const pruned = await pruneRegistry();
7176
+ for (const entry2 of pruned) {
7177
+ console.log(
7178
+ `neatd: pruned project "${entry2.name}" \u2014 registered path ${entry2.path} is gone`
7179
+ );
7180
+ slots.delete(entry2.name);
7181
+ bootstrapStatus.delete(entry2.name);
7182
+ bootstrapStartedAt.delete(entry2.name);
7183
+ }
7184
+ } catch (err) {
7185
+ console.warn(`neatd: registry prune skipped \u2014 ${err.message}`);
7084
7186
  }
7085
- } catch (err) {
7086
- console.warn(`neatd: registry prune skipped \u2014 ${err.message}`);
7087
7187
  }
7088
- const projects = await listProjects();
7188
+ const projects = await enumerateProjects();
7089
7189
  const seen = /* @__PURE__ */ new Set();
7090
7190
  const pending = [];
7091
7191
  for (const entry2 of projects) {
@@ -7110,7 +7210,7 @@ async function startDaemon(opts = {}) {
7110
7210
  }
7111
7211
  await Promise.allSettled(pending);
7112
7212
  }
7113
- const initialEntries = await listProjects().catch(() => []);
7213
+ const initialEntries = await enumerateProjects().catch(() => []);
7114
7214
  for (const entry2 of initialEntries) {
7115
7215
  bootstrapStatus.set(entry2.name, "bootstrapping");
7116
7216
  bootstrapStartedAt.set(entry2.name, Date.now());
@@ -7120,6 +7220,7 @@ async function startDaemon(opts = {}) {
7120
7220
  let otlpApp = null;
7121
7221
  let restAddress = "";
7122
7222
  let otlpAddress = "";
7223
+ let daemonRecord = null;
7123
7224
  if (bind) {
7124
7225
  const auth = readAuthEnv();
7125
7226
  const host = resolveHost(opts, Boolean(auth.authToken));
@@ -7144,6 +7245,20 @@ async function startDaemon(opts = {}) {
7144
7245
  }
7145
7246
  }
7146
7247
  });
7248
+ if (singleProject) {
7249
+ restApp.addHook("onSend", async (req2, _reply, payload) => {
7250
+ if (req2.url.split("?")[0] !== "/health") return payload;
7251
+ if (typeof payload !== "string") return payload;
7252
+ try {
7253
+ const body = JSON.parse(payload);
7254
+ if (typeof body !== "object" || body === null) return payload;
7255
+ body.project = singleProject;
7256
+ return JSON.stringify(body);
7257
+ } catch {
7258
+ return payload;
7259
+ }
7260
+ });
7261
+ }
7147
7262
  restAddress = await restApp.listen({ port: restPort, host });
7148
7263
  console.log(`neatd: REST listening on ${restAddress}`);
7149
7264
  } catch (err) {
@@ -7159,6 +7274,25 @@ async function startDaemon(opts = {}) {
7159
7274
  );
7160
7275
  }
7161
7276
  async function resolveTargetSlot(serviceName, traceId) {
7277
+ if (singleProject) {
7278
+ let slot2 = slots.get(singleProject);
7279
+ if (!slot2) {
7280
+ slot2 = await tryRecoverSlot({
7281
+ name: singleProject,
7282
+ path: singleProjectPath,
7283
+ registeredAt: (/* @__PURE__ */ new Date()).toISOString(),
7284
+ languages: [],
7285
+ status: "active"
7286
+ });
7287
+ } else if (slot2.status === "broken") {
7288
+ slot2 = await tryRecoverSlot(slot2.entry);
7289
+ }
7290
+ if (!slot2 || slot2.status !== "active") {
7291
+ warnDroppedSpan(singleProject, slot2?.errorReason ?? "unknown");
7292
+ return null;
7293
+ }
7294
+ return slot2;
7295
+ }
7162
7296
  const liveEntries = await listProjects().catch(() => []);
7163
7297
  const target = routeSpanToProject(serviceName, liveEntries);
7164
7298
  let slot = slots.get(target) ?? slots.get(DEFAULT_PROJECT);
@@ -7261,6 +7395,42 @@ async function startDaemon(opts = {}) {
7261
7395
  `neatd: failed to bind OTLP on port ${otlpPort} \u2014 ${err.message}`
7262
7396
  );
7263
7397
  }
7398
+ if (singleProject && singleProjectPath) {
7399
+ const ports = {
7400
+ rest: portFromListenAddress(restAddress, restPort),
7401
+ otlp: portFromListenAddress(otlpAddress, otlpPort),
7402
+ // The daemon doesn't bind the web port itself (neatd spawns the web
7403
+ // child); it records the allocated value passed through so the
7404
+ // dashboard and `neat ps` agree on where to look.
7405
+ web: typeof opts.webPort === "number" ? opts.webPort : resolveWebPort()
7406
+ };
7407
+ daemonRecord = {
7408
+ project: singleProject,
7409
+ projectPath: singleProjectPath,
7410
+ pid: process.pid,
7411
+ status: "running",
7412
+ ports,
7413
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
7414
+ neatVersion: resolveNeatVersion()
7415
+ };
7416
+ try {
7417
+ await writeDaemonRecord(daemonRecord, home);
7418
+ console.log(
7419
+ `neatd: project "${singleProject}" \u2192 REST ${ports.rest} / OTLP ${ports.otlp} / web ${ports.web} (daemon.json written)`
7420
+ );
7421
+ } catch (err) {
7422
+ for (const slot of slots.values()) teardownSlot(slot);
7423
+ if (restApp) await restApp.close().catch(() => {
7424
+ });
7425
+ if (otlpApp) await otlpApp.close().catch(() => {
7426
+ });
7427
+ await import_node_fs25.promises.unlink(pidPath).catch(() => {
7428
+ });
7429
+ throw new Error(
7430
+ `neatd: failed to write daemon.json for "${singleProject}" \u2014 ${err.message}`
7431
+ );
7432
+ }
7433
+ }
7264
7434
  }
7265
7435
  const initialBootstrap = loadAll().catch((err) => {
7266
7436
  console.warn(`neatd: initial bootstrap pass failed \u2014 ${err.message}`);
@@ -7300,7 +7470,7 @@ async function startDaemon(opts = {}) {
7300
7470
  const REGISTRY_RELOAD_DEBOUNCE_MS = 500;
7301
7471
  let registryWatcher = null;
7302
7472
  let reloadTimer = null;
7303
- try {
7473
+ if (!singleProject) try {
7304
7474
  const regDir = import_node_path42.default.dirname(regPath);
7305
7475
  const regBase = import_node_path42.default.basename(regPath);
7306
7476
  registryWatcher = (0, import_node_fs25.watch)(regDir, (_eventType, filename) => {
@@ -7343,6 +7513,9 @@ async function startDaemon(opts = {}) {
7343
7513
  for (const slot of slots.values()) {
7344
7514
  teardownSlot(slot);
7345
7515
  }
7516
+ if (daemonRecord) {
7517
+ await clearDaemonRecord(daemonRecord, home);
7518
+ }
7346
7519
  await import_node_fs25.promises.unlink(pidPath).catch(() => {
7347
7520
  });
7348
7521
  };
@@ -7354,7 +7527,8 @@ async function startDaemon(opts = {}) {
7354
7527
  restAddress,
7355
7528
  otlpAddress,
7356
7529
  bootstrap: tracker,
7357
- initialBootstrap
7530
+ initialBootstrap,
7531
+ daemonRecord
7358
7532
  };
7359
7533
  }
7360
7534
 
@@ -7364,9 +7538,40 @@ init_auth();
7364
7538
  // src/web-spawn.ts
7365
7539
  init_cjs_shims();
7366
7540
  var import_node_child_process2 = require("child_process");
7541
+ var import_node_fs26 = require("fs");
7367
7542
  var import_node_net = __toESM(require("net"), 1);
7368
7543
  var import_node_path43 = __toESM(require("path"), 1);
7369
7544
  var DEFAULT_WEB_PORT = 6328;
7545
+ var DEFAULT_REST_PORT = 8080;
7546
+ function asValidPort(value) {
7547
+ const n = typeof value === "number" ? value : Number.parseInt(String(value ?? ""), 10);
7548
+ return Number.isInteger(n) && n > 0 && n <= 65535 ? n : null;
7549
+ }
7550
+ function projectRoot() {
7551
+ const fromEnv = process.env.NEAT_SCAN_PATH;
7552
+ return import_node_path43.default.resolve(fromEnv && fromEnv.length > 0 ? fromEnv : process.cwd());
7553
+ }
7554
+ async function readDaemonPorts(root) {
7555
+ try {
7556
+ const raw = await import_node_fs26.promises.readFile(import_node_path43.default.join(root, "neat-out", "daemon.json"), "utf8");
7557
+ const parsed = JSON.parse(raw);
7558
+ const ports = parsed?.ports ?? {};
7559
+ return { web: asValidPort(ports.web), rest: asValidPort(ports.rest) };
7560
+ } catch {
7561
+ return { web: null, rest: null };
7562
+ }
7563
+ }
7564
+ function resolveWebPorts(args) {
7565
+ const { daemonWeb, daemonRest, webPortEnv, apiUrlEnv, restPortArg } = args;
7566
+ const portFromEnv = webPortEnv && webPortEnv.length > 0 ? Number.parseInt(webPortEnv, 10) : null;
7567
+ if (portFromEnv !== null && (!Number.isFinite(portFromEnv) || portFromEnv <= 0 || portFromEnv > 65535)) {
7568
+ throw new Error(`neatd: invalid NEAT_WEB_PORT="${webPortEnv}"`);
7569
+ }
7570
+ const webPort = daemonWeb ?? portFromEnv ?? DEFAULT_WEB_PORT;
7571
+ const restPort = daemonRest ?? asValidPort(restPortArg) ?? DEFAULT_REST_PORT;
7572
+ const apiUrl = apiUrlEnv ?? `http://localhost:${restPort}`;
7573
+ return { webPort, apiUrl };
7574
+ }
7370
7575
  async function assertPortFree(port) {
7371
7576
  await new Promise((resolve, reject) => {
7372
7577
  const tester = import_node_net.default.createServer();
@@ -7398,42 +7603,98 @@ function resolveWebPackageDir() {
7398
7603
  function resolveStandaloneServerEntry(webDir) {
7399
7604
  return import_node_path43.default.join(webDir, ".next/standalone/packages/web/server.js");
7400
7605
  }
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
- }
7606
+ async function pickInternalPort() {
7607
+ return new Promise((resolve, reject) => {
7608
+ const srv = import_node_net.default.createServer();
7609
+ srv.once("error", reject);
7610
+ srv.listen(0, "127.0.0.1", () => {
7611
+ const addr = srv.address();
7612
+ if (addr && typeof addr === "object") {
7613
+ const port = addr.port;
7614
+ srv.close(() => resolve(port));
7615
+ } else {
7616
+ srv.close(() => reject(new Error("neatd: could not pick an internal web port")));
7617
+ }
7618
+ });
7619
+ });
7620
+ }
7621
+ async function spawnWebUI(restPort, opts = {}) {
7622
+ const root = projectRoot();
7623
+ const fromDaemon = await readDaemonPorts(root);
7624
+ const { webPort: port, apiUrl } = resolveWebPorts({
7625
+ daemonWeb: fromDaemon.web,
7626
+ daemonRest: fromDaemon.rest,
7627
+ webPortEnv: process.env.NEAT_WEB_PORT,
7628
+ apiUrlEnv: process.env.NEAT_API_URL,
7629
+ restPortArg: restPort
7630
+ });
7407
7631
  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
- );
7632
+ const serverEntry = opts.serverEntry ?? resolveStandaloneServerEntry(resolveWebPackageDir());
7633
+ if (!opts.skipBuildCheck) {
7634
+ try {
7635
+ require.resolve(serverEntry);
7636
+ } catch {
7637
+ throw new Error(
7638
+ `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.`
7639
+ );
7640
+ }
7416
7641
  }
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
7642
+ let child = null;
7643
+ let internalPort = 0;
7644
+ let starting = null;
7645
+ let stopped = false;
7646
+ function ensureStarted() {
7647
+ if (starting) return starting;
7648
+ starting = (async () => {
7649
+ internalPort = await pickInternalPort();
7650
+ const env = {
7651
+ ...process.env,
7652
+ PORT: String(internalPort),
7653
+ HOSTNAME: "127.0.0.1",
7654
+ NEAT_API_URL: apiUrl
7655
+ };
7656
+ child = (0, import_node_child_process2.spawn)(process.execPath, [serverEntry], {
7657
+ cwd: import_node_path43.default.dirname(serverEntry),
7658
+ env,
7659
+ stdio: ["ignore", "inherit", "inherit"],
7660
+ detached: false
7661
+ });
7662
+ child.on("error", (err) => {
7663
+ console.error(`neatd: web UI spawn error \u2014 ${err.message}`);
7664
+ });
7665
+ await waitForListening(internalPort);
7666
+ console.log(`neatd: web UI ready on http://localhost:${port}`);
7667
+ })();
7668
+ return starting;
7669
+ }
7670
+ const front = import_node_net.default.createServer((socket) => {
7671
+ socket.on("error", () => socket.destroy());
7672
+ ensureStarted().then(() => {
7673
+ if (stopped) {
7674
+ socket.destroy();
7675
+ return;
7676
+ }
7677
+ const upstream = import_node_net.default.connect(internalPort, "127.0.0.1");
7678
+ upstream.on("error", () => socket.destroy());
7679
+ socket.pipe(upstream);
7680
+ upstream.pipe(socket);
7681
+ socket.on("close", () => upstream.destroy());
7682
+ upstream.on("close", () => socket.destroy());
7683
+ }).catch((err) => {
7684
+ console.error(`neatd: web UI failed to start \u2014 ${err.message}`);
7685
+ socket.destroy();
7686
+ });
7428
7687
  });
7429
- child.on("error", (err) => {
7430
- console.error(`neatd: web UI spawn error \u2014 ${err.message}`);
7688
+ await new Promise((resolve, reject) => {
7689
+ front.once("error", reject);
7690
+ front.listen(port, "127.0.0.1", () => resolve());
7431
7691
  });
7432
- console.log(`neatd: web UI listening on http://localhost:${port}`);
7433
- let stopped = false;
7692
+ console.log(`neatd: web UI listening on http://localhost:${port} (starts on first open)`);
7434
7693
  async function stop() {
7435
- if (stopped || !child.pid) return;
7694
+ if (stopped) return;
7436
7695
  stopped = true;
7696
+ await new Promise((resolve) => front.close(() => resolve()));
7697
+ if (!child || !child.pid) return;
7437
7698
  try {
7438
7699
  child.kill("SIGTERM");
7439
7700
  } catch {
@@ -7441,18 +7702,46 @@ async function spawnWebUI(restPort) {
7441
7702
  await new Promise((resolve) => {
7442
7703
  const t = setTimeout(() => {
7443
7704
  try {
7444
- child.kill("SIGKILL");
7705
+ child?.kill("SIGKILL");
7445
7706
  } catch {
7446
7707
  }
7447
7708
  resolve();
7448
7709
  }, 3e3);
7449
- child.once("exit", () => {
7710
+ child?.once("exit", () => {
7450
7711
  clearTimeout(t);
7451
7712
  resolve();
7452
7713
  });
7453
7714
  });
7454
7715
  }
7455
- return { child, port, stop };
7716
+ return {
7717
+ get child() {
7718
+ return child;
7719
+ },
7720
+ port,
7721
+ started: () => child !== null,
7722
+ stop
7723
+ };
7724
+ }
7725
+ async function waitForListening(port, timeoutMs = 15e3) {
7726
+ const deadline = Date.now() + timeoutMs;
7727
+ for (; ; ) {
7728
+ const ok = await new Promise((resolve) => {
7729
+ const s = import_node_net.default.connect(port, "127.0.0.1");
7730
+ s.once("connect", () => {
7731
+ s.destroy();
7732
+ resolve(true);
7733
+ });
7734
+ s.once("error", () => {
7735
+ s.destroy();
7736
+ resolve(false);
7737
+ });
7738
+ });
7739
+ if (ok) return;
7740
+ if (Date.now() > deadline) {
7741
+ throw new Error(`neatd: web UI did not start listening on :${port} within ${timeoutMs}ms`);
7742
+ }
7743
+ await new Promise((r) => setTimeout(r, 150));
7744
+ }
7456
7745
  }
7457
7746
 
7458
7747
  // src/version-skew.ts
@@ -7525,7 +7814,7 @@ function localVersion() {
7525
7814
  return process.env.NEAT_LOCAL_VERSION;
7526
7815
  }
7527
7816
  try {
7528
- const req2 = (0, import_node_module.createRequire)(importMetaUrl);
7817
+ const req2 = (0, import_node_module2.createRequire)(importMetaUrl);
7529
7818
  const pkg = req2("../package.json");
7530
7819
  return typeof pkg.version === "string" ? pkg.version : "0.0.0";
7531
7820
  } catch {
@@ -7541,7 +7830,7 @@ function neatHome2() {
7541
7830
  }
7542
7831
  async function readPid() {
7543
7832
  try {
7544
- const raw = await import_node_fs26.promises.readFile(import_node_path44.default.join(neatHome2(), "neatd.pid"), "utf8");
7833
+ const raw = await import_node_fs27.promises.readFile(import_node_path44.default.join(neatHome2(), "neatd.pid"), "utf8");
7545
7834
  const n = Number.parseInt(raw.trim(), 10);
7546
7835
  return Number.isFinite(n) ? n : null;
7547
7836
  } catch {
@@ -7555,10 +7844,19 @@ function restPortFromEnv() {
7555
7844
  const raw = process.env.PORT;
7556
7845
  return raw && raw.length > 0 ? Number.parseInt(raw, 10) : 8080;
7557
7846
  }
7847
+ function webPortFromEnv() {
7848
+ const raw = process.env.NEAT_WEB_PORT;
7849
+ if (!raw || raw.length === 0) return void 0;
7850
+ const n = Number.parseInt(raw, 10);
7851
+ return Number.isFinite(n) ? n : void 0;
7852
+ }
7558
7853
  async function cmdStart() {
7854
+ const project = process.env.NEAT_PROJECT;
7855
+ const projectPath = process.env.NEAT_PROJECT_PATH;
7856
+ const startOpts = project && project.length > 0 ? { project, projectPath, webPort: webPortFromEnv() } : {};
7559
7857
  let handle;
7560
7858
  try {
7561
- handle = await startDaemon();
7859
+ handle = await startDaemon(startOpts);
7562
7860
  } catch (err) {
7563
7861
  if (err instanceof BindAuthorityError) {
7564
7862
  console.error(`neatd: ${err.message}`);