@neat.is/core 0.7.8 → 0.7.10

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/cli.js CHANGED
@@ -6,10 +6,10 @@ import {
6
6
  resolveHost,
7
7
  resolveNeatVersion,
8
8
  writeDaemonRecord
9
- } from "./chunk-OVRVDSUO.js";
9
+ } from "./chunk-D4OX6MUX.js";
10
10
  import {
11
11
  buildSearchIndex
12
- } from "./chunk-BIY46Q6U.js";
12
+ } from "./chunk-BC53SCT7.js";
13
13
  import {
14
14
  DEFAULT_PROJECT,
15
15
  PolicyViolationsLog,
@@ -74,10 +74,10 @@ import {
74
74
  startStalenessLoop,
75
75
  upsertConnectorEntry,
76
76
  validateConnectorEntry
77
- } from "./chunk-LN75Z624.js";
77
+ } from "./chunk-6T7ZHODF.js";
78
78
  import {
79
79
  startOtelGrpcReceiver
80
- } from "./chunk-4GCV46AP.js";
80
+ } from "./chunk-FCO5Z3RW.js";
81
81
  import {
82
82
  __dirname,
83
83
  __require,
@@ -85,12 +85,12 @@ import {
85
85
  buildOtelReceiver,
86
86
  listenSteppingOtlp,
87
87
  readAuthEnv
88
- } from "./chunk-Y43UCVZS.js";
88
+ } from "./chunk-UUYCTH2E.js";
89
89
 
90
90
  // src/cli.ts
91
- import path13 from "path";
91
+ import path15 from "path";
92
92
  import os4 from "os";
93
- import { promises as fs11 } from "fs";
93
+ import { promises as fs13 } from "fs";
94
94
 
95
95
  // src/banner.ts
96
96
  import path from "path";
@@ -487,6 +487,9 @@ async function startWatch(graph, opts) {
487
487
  project: projectName,
488
488
  graph,
489
489
  projectDir: opts.scanPath,
490
+ // Incident ledger for an incident-emitting connector (ADR-185), same path
491
+ // `neat watch`'s own error-span writer already uses.
492
+ errorsPath: opts.errorsPath,
490
493
  ...opts.neatHome ? { home: opts.neatHome } : {},
491
494
  onSkip: (skipped, reason) => console.warn(
492
495
  `neat watch: connector "${skipped.id}" (${skipped.provider}) skipped for project "${projectName}" \u2014 ${reason}`
@@ -854,7 +857,8 @@ var OTEL_ENDPOINT_RESOLVER_CJS = `;(function () {
854
857
  try {
855
858
  const __rec = JSON.parse(__neatFs.readFileSync(__neatPath.join(__neatDir, 'neat-out', 'daemon.json'), 'utf8'))
856
859
  if (__rec && __rec.ports && typeof __rec.ports.otlp === 'number') {
857
- process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT = 'http://localhost:' + __rec.ports.otlp + '/v1/traces'
860
+ const __neatProj = (typeof __rec.project === 'string' && __rec.project) || '__PROJECT__'
861
+ process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT = 'http://localhost:' + __rec.ports.otlp + '/projects/' + __neatProj + '/v1/traces'
858
862
  break
859
863
  }
860
864
  } catch (_e) {}
@@ -863,7 +867,7 @@ var OTEL_ENDPOINT_RESOLVER_CJS = `;(function () {
863
867
  __neatDir = __parent
864
868
  }
865
869
  } catch (_e) {}
866
- process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT ||= 'http://localhost:4318/v1/traces'
870
+ process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT ||= 'http://localhost:4318/projects/__PROJECT__/v1/traces'
867
871
  })()`;
868
872
  var OTEL_ESM_NODE_IMPORTS = "import { readFileSync as __neatReadFileSync } from 'node:fs'\nimport { join as __neatJoin, dirname as __neatDirname } from 'node:path'";
869
873
  var OTEL_ENDPOINT_RESOLVER_ESM = `;(function () {
@@ -874,7 +878,8 @@ var OTEL_ENDPOINT_RESOLVER_ESM = `;(function () {
874
878
  try {
875
879
  const __rec = JSON.parse(__neatReadFileSync(__neatJoin(__neatDir, 'neat-out', 'daemon.json'), 'utf8'))
876
880
  if (__rec && __rec.ports && typeof __rec.ports.otlp === 'number') {
877
- process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT = 'http://localhost:' + __rec.ports.otlp + '/v1/traces'
881
+ const __neatProj = (typeof __rec.project === 'string' && __rec.project) || '__PROJECT__'
882
+ process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT = 'http://localhost:' + __rec.ports.otlp + '/projects/' + __neatProj + '/v1/traces'
878
883
  break
879
884
  }
880
885
  } catch (_e) {}
@@ -883,7 +888,7 @@ var OTEL_ENDPOINT_RESOLVER_ESM = `;(function () {
883
888
  __neatDir = __parent
884
889
  }
885
890
  } catch (_e) {}
886
- process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT ||= 'http://localhost:4318/v1/traces'
891
+ process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT ||= 'http://localhost:4318/projects/__PROJECT__/v1/traces'
887
892
  })()`;
888
893
  function neatCaptureSource(ts) {
889
894
  const spanT = ts ? ": any" : "";
@@ -1285,14 +1290,15 @@ ${registrations.join("\n")}
1285
1290
  `;
1286
1291
  return template.replace(/__SERVICE_NAME__/g, serviceName).replace(/__PROJECT__/g, projectName).replace(/__INSTRUMENTATION_BLOCK__\n?/g, block);
1287
1292
  }
1288
- function renderEnvNeat(serviceName, _projectName) {
1293
+ function renderEnvNeat(serviceName, projectName) {
1289
1294
  return [
1290
1295
  "# Generated by `neat init --apply` (ADR-069).",
1291
1296
  `OTEL_SERVICE_NAME=${serviceName}`,
1292
1297
  "# Advisory only \u2014 the generated otel-init resolves the live endpoint from",
1293
- "# <project>/neat-out/daemon.json (ports.otlp) at boot (ADR-096). This is the",
1294
- "# canonical default the daemon takes when its first-choice OTLP port is free.",
1295
- "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=http://localhost:4318/v1/traces",
1298
+ "# <project>/neat-out/daemon.json (ports.otlp + project) at boot (ADR-096).",
1299
+ "# This is the canonical default the daemon takes when its first-choice OTLP",
1300
+ "# port is free; the project scope routes the span without service.name guessing (#879).",
1301
+ `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=http://localhost:4318/projects/${projectName}/v1/traces`,
1296
1302
  "OTEL_EXPORTER_OTLP_PROTOCOL=http/json",
1297
1303
  "# Set NEAT_OTEL_TOKEN to the daemon's OTLP secret to authenticate exported spans (#410).",
1298
1304
  "# NEAT_OTEL_TOKEN=",
@@ -1358,7 +1364,7 @@ var NEXT_INSTRUMENTATION_EDGE_TS = `${NEXT_INSTRUMENTATION_EDGE_HEADER}
1358
1364
  import { registerOTel } from '@vercel/otel'
1359
1365
 
1360
1366
  process.env.OTEL_SERVICE_NAME ||= '__SERVICE_NAME__'
1361
- process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT ||= 'http://localhost:4318/v1/traces'
1367
+ process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT ||= 'http://localhost:4318/projects/__PROJECT__/v1/traces'
1362
1368
  ${OTEL_OTLP_PROTOCOL_JS}
1363
1369
  ${OTEL_OTLP_HEADERS_JS}
1364
1370
 
@@ -3104,13 +3110,516 @@ async function apply3(installPlan) {
3104
3110
  await fs6.writeFile(generated.file, generated.contents, "utf8");
3105
3111
  writtenFiles.push(generated.file);
3106
3112
  }
3107
- return { serviceDir: installPlan.serviceDir, outcome: writtenFiles.length ? "instrumented" : "already-instrumented", writtenFiles };
3113
+ const wroteManifest = writtenFiles.some((f) => f.split(/[\\/]/).pop() === "go.mod");
3114
+ return {
3115
+ serviceDir: installPlan.serviceDir,
3116
+ outcome: writtenFiles.length ? "instrumented" : "already-instrumented",
3117
+ writtenFiles,
3118
+ ...wroteManifest ? { followUpInstall: "go mod download" } : {}
3119
+ };
3108
3120
  }
3109
3121
  var goInstaller = { name: "go", detect: detect3, plan: plan3, apply: apply3 };
3110
3122
 
3123
+ // src/installers/ruby.ts
3124
+ import { promises as fs7 } from "fs";
3125
+ import path8 from "path";
3126
+ var RUBY_MARKERS = [
3127
+ "Gemfile",
3128
+ "Gemfile.lock"
3129
+ ];
3130
+ var RUBY_GEMS = [
3131
+ { name: "opentelemetry-sdk", version: "~> 1.5" },
3132
+ { name: "opentelemetry-exporter-otlp", version: "~> 0.29" },
3133
+ { name: "opentelemetry-instrumentation-all", version: "~> 0.62" }
3134
+ ];
3135
+ var NEAT_OTEL_STAMP2 = "neat-otel-init v1";
3136
+ var INITIALIZER_REL = path8.join("config", "initializers", "neat_otel.rb");
3137
+ function neatOtelRb(opts = {}) {
3138
+ const service = opts.project ?? "ruby-service";
3139
+ const endpoint = opts.project ? `http://localhost:4318/projects/${opts.project}/v1/traces` : "http://localhost:4318/v1/traces";
3140
+ return `# ${NEAT_OTEL_STAMP2} \u2014 generated by NEAT. Safe to re-generate; do not edit.
3141
+ # Rails auto-loads this at boot (config/initializers/*). It points the
3142
+ # OpenTelemetry SDK at your NEAT daemon, enables the Ruby auto-instrumentation
3143
+ # set, and installs a span processor that stamps code.file.path /
3144
+ # code.line.number / code.function.name on the CLIENT/PRODUCER spans your app
3145
+ # issues, so NEAT fuses each runtime span onto the source file that made the
3146
+ # call (docs/contracts/file-awareness.md). Absolute paths are emitted here;
3147
+ # ingest anchors them against the service root. If the OpenTelemetry gems are
3148
+ # not installed this file degrades to a no-op rather than breaking boot.
3149
+
3150
+ begin
3151
+ require 'opentelemetry/sdk'
3152
+ require 'opentelemetry/exporter/otlp'
3153
+ require 'opentelemetry/instrumentation/all'
3154
+ _neat_otel_loaded = true
3155
+ rescue LoadError
3156
+ _neat_otel_loaded = false
3157
+ end
3158
+
3159
+ if _neat_otel_loaded && ENV['NEAT_CALLSITE_DISABLED'] != '1'
3160
+ # Walk the Ruby call stack to the first application frame and stamp the stable
3161
+ # OTel source attributes on CLIENT/PRODUCER spans. SERVER spans are created
3162
+ # before the handler runs, so they stay route/service-grained, honestly.
3163
+ class NeatCallSiteSpanProcessor
3164
+ def initialize(root)
3165
+ @root = root.to_s.end_with?(File::SEPARATOR) ? root.to_s : root.to_s + File::SEPARATOR
3166
+ end
3167
+
3168
+ def on_start(span, _parent_context)
3169
+ kind = span.kind
3170
+ return unless kind == OpenTelemetry::Trace::SpanKind::CLIENT ||
3171
+ kind == OpenTelemetry::Trace::SpanKind::PRODUCER
3172
+ caller_locations(1).each do |loc|
3173
+ file = loc.absolute_path || loc.path
3174
+ next if file.nil?
3175
+ next unless file.start_with?(@root)
3176
+ next if file.include?('/vendor/') || file.include?('/.bundle/')
3177
+ next if file.end_with?('neat_otel.rb')
3178
+ span.set_attribute('code.file.path', file)
3179
+ span.set_attribute('code.line.number', loc.lineno)
3180
+ span.set_attribute('code.function.name', loc.label.to_s)
3181
+ break
3182
+ end
3183
+ rescue StandardError
3184
+ # never break the host application
3185
+ end
3186
+
3187
+ def on_finish(_span); end
3188
+
3189
+ def force_flush(timeout: nil)
3190
+ OpenTelemetry::SDK::Trace::Export::SUCCESS
3191
+ end
3192
+
3193
+ def shutdown(timeout: nil)
3194
+ OpenTelemetry::SDK::Trace::Export::SUCCESS
3195
+ end
3196
+ end
3197
+
3198
+ _neat_root = defined?(Rails) ? Rails.root.to_s : Dir.pwd
3199
+ _neat_service = ENV.fetch('OTEL_SERVICE_NAME', '${service}')
3200
+ _neat_endpoint = ENV.fetch('OTEL_EXPORTER_OTLP_TRACES_ENDPOINT', '${endpoint}')
3201
+
3202
+ OpenTelemetry::SDK.configure do |c|
3203
+ c.service_name = _neat_service
3204
+ c.use_all
3205
+ c.add_span_processor(
3206
+ OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(
3207
+ OpenTelemetry::Exporter::OTLP::Exporter.new(endpoint: _neat_endpoint)
3208
+ )
3209
+ )
3210
+ c.add_span_processor(NeatCallSiteSpanProcessor.new(_neat_root))
3211
+ end
3212
+ end
3213
+ `;
3214
+ }
3215
+ async function exists4(p) {
3216
+ try {
3217
+ await fs7.stat(p);
3218
+ return true;
3219
+ } catch {
3220
+ return false;
3221
+ }
3222
+ }
3223
+ async function detect4(serviceDir) {
3224
+ for (const marker of RUBY_MARKERS) {
3225
+ if (await exists4(path8.join(serviceDir, marker))) return true;
3226
+ }
3227
+ return false;
3228
+ }
3229
+ async function isRailsApp(serviceDir, gemfile) {
3230
+ if (gemfile && /^\s*gem\s+['"]rails['"]/m.test(gemfile)) return true;
3231
+ for (const marker of ["config/application.rb", "config/environment.rb", "bin/rails"]) {
3232
+ if (await exists4(path8.join(serviceDir, marker))) return true;
3233
+ }
3234
+ return false;
3235
+ }
3236
+ function gemPresent(gemfile, name) {
3237
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3238
+ return new RegExp(`^\\s*gem\\s+['"]${escaped}['"]`, "m").test(gemfile);
3239
+ }
3240
+ async function readGemfile(serviceDir) {
3241
+ const file = path8.join(serviceDir, "Gemfile");
3242
+ if (!await exists4(file)) return null;
3243
+ return { file, body: await fs7.readFile(file, "utf8") };
3244
+ }
3245
+ async function plan4(serviceDir, opts) {
3246
+ const empty = {
3247
+ language: "ruby",
3248
+ serviceDir,
3249
+ dependencyEdits: [],
3250
+ entrypointEdits: [],
3251
+ envEdits: []
3252
+ };
3253
+ const gemfile = await readGemfile(serviceDir);
3254
+ const dependencyEdits = [];
3255
+ if (gemfile) {
3256
+ for (const gem of RUBY_GEMS) {
3257
+ if (!gemPresent(gemfile.body, gem.name)) {
3258
+ dependencyEdits.push({ file: gemfile.file, kind: "add", name: gem.name, version: gem.version });
3259
+ }
3260
+ }
3261
+ }
3262
+ const rails = await isRailsApp(serviceDir, gemfile?.body ?? null);
3263
+ const initializer = path8.join(serviceDir, INITIALIZER_REL);
3264
+ const generatedFiles = [];
3265
+ if (rails && !await exists4(initializer)) {
3266
+ generatedFiles.push({
3267
+ file: initializer,
3268
+ contents: neatOtelRb({ project: opts?.project }),
3269
+ skipIfExists: true
3270
+ });
3271
+ }
3272
+ if (dependencyEdits.length === 0 && generatedFiles.length === 0) {
3273
+ return empty;
3274
+ }
3275
+ const envEdits = [
3276
+ { file: null, key: "OTEL_EXPORTER_OTLP_ENDPOINT", value: "http://localhost:4318" }
3277
+ ];
3278
+ return {
3279
+ language: "ruby",
3280
+ serviceDir,
3281
+ dependencyEdits,
3282
+ entrypointEdits: [],
3283
+ envEdits,
3284
+ ...generatedFiles.length > 0 ? { generatedFiles } : {}
3285
+ };
3286
+ }
3287
+ async function writeFileAtomic2(file, contents) {
3288
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
3289
+ await fs7.writeFile(tmp, contents, "utf8");
3290
+ await fs7.rename(tmp, file);
3291
+ }
3292
+ async function applyGemfile(file, edits, original) {
3293
+ const lines = edits.filter((e) => e.kind === "add").map((e) => `gem '${e.name}', '${e.version}'`);
3294
+ const banner = `
3295
+ # ${NEAT_OTEL_STAMP2} \u2014 OpenTelemetry gems added by NEAT
3296
+ `;
3297
+ const trailing = original.endsWith("\n") ? "" : "\n";
3298
+ await writeFileAtomic2(file, `${original}${trailing}${banner}${lines.join("\n")}
3299
+ `);
3300
+ }
3301
+ async function rollback3(serviceDir, language, originals, created) {
3302
+ const restored = [];
3303
+ for (const [file, raw] of originals.entries()) {
3304
+ try {
3305
+ await fs7.writeFile(file, raw, "utf8");
3306
+ restored.push(file);
3307
+ } catch {
3308
+ }
3309
+ }
3310
+ const removed = [];
3311
+ for (const file of created) {
3312
+ try {
3313
+ await fs7.rm(file, { force: true });
3314
+ removed.push(file);
3315
+ } catch {
3316
+ }
3317
+ }
3318
+ const body = [
3319
+ "# neat-rollback.patch",
3320
+ "",
3321
+ `# Generated after a partial apply failure in the ${language} installer.`,
3322
+ "# Files listed below were restored to their pre-apply contents.",
3323
+ "",
3324
+ ...restored.map((f) => `restored: ${f}`),
3325
+ ...removed.map((f) => `removed: ${f}`),
3326
+ ""
3327
+ ];
3328
+ await fs7.writeFile(path8.join(serviceDir, "neat-rollback.patch"), body.join("\n"), "utf8");
3329
+ }
3330
+ async function apply4(installPlan) {
3331
+ const { serviceDir } = installPlan;
3332
+ const generatedFiles = installPlan.generatedFiles ?? [];
3333
+ const manifests = new Set(installPlan.dependencyEdits.map((e) => e.file));
3334
+ if (manifests.size === 0 && generatedFiles.length === 0) {
3335
+ return { serviceDir, outcome: "already-instrumented", writtenFiles: [] };
3336
+ }
3337
+ const originals = /* @__PURE__ */ new Map();
3338
+ for (const file of manifests) {
3339
+ try {
3340
+ originals.set(file, await fs7.readFile(file, "utf8"));
3341
+ } catch {
3342
+ }
3343
+ }
3344
+ const writtenFiles = [];
3345
+ const created = [];
3346
+ try {
3347
+ for (const gf of generatedFiles) {
3348
+ if (await exists4(gf.file)) continue;
3349
+ await fs7.mkdir(path8.dirname(gf.file), { recursive: true });
3350
+ await writeFileAtomic2(gf.file, gf.contents);
3351
+ writtenFiles.push(gf.file);
3352
+ created.push(gf.file);
3353
+ }
3354
+ for (const file of manifests) {
3355
+ const raw = originals.get(file);
3356
+ if (raw === void 0) throw new Error(`ruby installer: cannot read ${file} during apply`);
3357
+ const edits = installPlan.dependencyEdits.filter((e) => e.file === file);
3358
+ if (edits.length > 0) {
3359
+ await applyGemfile(file, edits, raw);
3360
+ writtenFiles.push(file);
3361
+ }
3362
+ }
3363
+ } catch (err) {
3364
+ await rollback3(serviceDir, installPlan.language, originals, created);
3365
+ throw err;
3366
+ }
3367
+ const wroteManifest = writtenFiles.some((f) => path8.basename(f) === "Gemfile");
3368
+ return {
3369
+ serviceDir,
3370
+ outcome: writtenFiles.length > 0 ? "instrumented" : "already-instrumented",
3371
+ writtenFiles,
3372
+ ...wroteManifest ? { followUpInstall: "bundle install" } : {}
3373
+ };
3374
+ }
3375
+ var rubyInstaller = { name: "ruby", detect: detect4, plan: plan4, apply: apply4 };
3376
+
3377
+ // src/installers/php.ts
3378
+ import { promises as fs8 } from "fs";
3379
+ import path9 from "path";
3380
+ var PHP_MARKERS = [
3381
+ "composer.json",
3382
+ "composer.lock"
3383
+ ];
3384
+ var NEAT_OTEL_FILENAME2 = "neat_otel.php";
3385
+ var NEAT_OTEL_STAMP3 = "neat-otel-init v1";
3386
+ var LARAVEL_PACKAGE = "open-telemetry/opentelemetry-auto-laravel";
3387
+ var PHP_PACKAGES = [
3388
+ { name: "open-telemetry/sdk", version: "^1.0" },
3389
+ { name: "open-telemetry/exporter-otlp", version: "^1.0" },
3390
+ { name: "php-http/guzzle7-adapter", version: "^1.0" },
3391
+ { name: LARAVEL_PACKAGE, version: "^0.1" }
3392
+ ];
3393
+ var PHP_PECL_CAVEAT = "PHP auto-instrumentation requires the `opentelemetry` PECL extension (`pecl install opentelemetry`, then `extension=opentelemetry.so` in php.ini). NEAT cannot install a PECL extension via composer \u2014 until it is loaded no spans are produced. See neat_otel.php and ADR-186.";
3394
+ function neatOtelPhp(opts = {}) {
3395
+ const service = opts.project ?? "php-service";
3396
+ const endpoint = opts.project ? `http://localhost:4318/projects/${opts.project}/v1/traces` : "http://localhost:4318/v1/traces";
3397
+ return `<?php
3398
+ // ${NEAT_OTEL_STAMP3} \u2014 generated by NEAT. Safe to re-generate; do not edit.
3399
+ //
3400
+ // REQUIRED SYSTEM STEP \u2014 NEAT CANNOT DO THIS FOR YOU:
3401
+ // PHP OpenTelemetry auto-instrumentation needs the \`opentelemetry\` PECL
3402
+ // extension, a system-level install composer cannot provide:
3403
+ // pecl install opentelemetry
3404
+ // then enable it in your php.ini:
3405
+ // extension=opentelemetry.so
3406
+ // Verify with \`php -m | grep opentelemetry\`. Until the extension is loaded
3407
+ // the Laravel auto-instrumentation hooks never fire and no spans are emitted.
3408
+ //
3409
+ // Wire this file so it runs before the framework boots \u2014 either set
3410
+ // auto_prepend_file = /absolute/path/to/neat_otel.php
3411
+ // in php.ini / .user.ini, or require it at the very top of public/index.php and
3412
+ // artisan. It points the exporter at your NEAT daemon and turns on the SDK
3413
+ // autoloader; the auto-laravel instrumentation then produces route, DB, cache,
3414
+ // and queue spans that fuse onto your extracted routes and Eloquent tables.
3415
+ //
3416
+ // FILE-GRAIN (code.file.path call-site attribution) is a documented follow-up
3417
+ // for PHP \u2014 see ADR-186. Route, table, and service grain land now.
3418
+
3419
+ declare(strict_types=1);
3420
+
3421
+ // Degrade to a no-op when the extension isn't present, so a bare app still
3422
+ // boots (never break the host application).
3423
+ if (!extension_loaded('opentelemetry')) {
3424
+ return;
3425
+ }
3426
+
3427
+ // Point the exporter at NEAT unless the operator already set these. The
3428
+ // endpoint is NEAT's project-scoped traces path (ADR-183).
3429
+ $neat_defaults = [
3430
+ 'OTEL_PHP_AUTOLOAD_ENABLED' => 'true',
3431
+ 'OTEL_SERVICE_NAME' => '${service}',
3432
+ 'OTEL_TRACES_EXPORTER' => 'otlp',
3433
+ 'OTEL_EXPORTER_OTLP_PROTOCOL' => 'http/json',
3434
+ 'OTEL_EXPORTER_OTLP_TRACES_ENDPOINT' => '${endpoint}',
3435
+ 'OTEL_PROPAGATORS' => 'baggage,tracecontext',
3436
+ ];
3437
+ foreach ($neat_defaults as $neat_key => $neat_value) {
3438
+ if (getenv($neat_key) === false && !isset($_SERVER[$neat_key]) && !isset($_ENV[$neat_key])) {
3439
+ putenv($neat_key . '=' . $neat_value);
3440
+ $_SERVER[$neat_key] = $neat_value;
3441
+ $_ENV[$neat_key] = $neat_value;
3442
+ }
3443
+ }
3444
+ `;
3445
+ }
3446
+ async function exists5(p) {
3447
+ try {
3448
+ await fs8.stat(p);
3449
+ return true;
3450
+ } catch {
3451
+ return false;
3452
+ }
3453
+ }
3454
+ async function detect5(serviceDir) {
3455
+ for (const marker of PHP_MARKERS) {
3456
+ if (await exists5(path9.join(serviceDir, marker))) return true;
3457
+ }
3458
+ return false;
3459
+ }
3460
+ function readComposerObject(body) {
3461
+ let parsed = null;
3462
+ try {
3463
+ parsed = JSON.parse(body);
3464
+ } catch {
3465
+ parsed = null;
3466
+ }
3467
+ const obj = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
3468
+ const require2 = obj.require && typeof obj.require === "object" && !Array.isArray(obj.require) ? obj.require : {};
3469
+ const requireDev = obj["require-dev"] && typeof obj["require-dev"] === "object" && !Array.isArray(obj["require-dev"]) ? obj["require-dev"] : {};
3470
+ return { require: require2, requireDev };
3471
+ }
3472
+ async function plan5(serviceDir, opts) {
3473
+ const empty = {
3474
+ language: "php",
3475
+ serviceDir,
3476
+ dependencyEdits: [],
3477
+ entrypointEdits: [],
3478
+ envEdits: []
3479
+ };
3480
+ const composerPath = path9.join(serviceDir, "composer.json");
3481
+ const hasComposer = await exists5(composerPath);
3482
+ const dependencyEdits = [];
3483
+ if (hasComposer) {
3484
+ const body = await fs8.readFile(composerPath, "utf8");
3485
+ const { require: require2, requireDev } = readComposerObject(body);
3486
+ const laravel = "laravel/framework" in require2 || "laravel/framework" in requireDev || await exists5(path9.join(serviceDir, "artisan"));
3487
+ const wanted = laravel ? PHP_PACKAGES : PHP_PACKAGES.filter((p) => p.name !== LARAVEL_PACKAGE);
3488
+ for (const pkg of wanted) {
3489
+ if (!(pkg.name in require2)) {
3490
+ dependencyEdits.push({ file: composerPath, kind: "add", name: pkg.name, version: pkg.version });
3491
+ }
3492
+ }
3493
+ }
3494
+ const bootstrap = path9.join(serviceDir, NEAT_OTEL_FILENAME2);
3495
+ const generatedFiles = [];
3496
+ if (hasComposer && !await exists5(bootstrap)) {
3497
+ generatedFiles.push({ file: bootstrap, contents: neatOtelPhp({ project: opts?.project }), skipIfExists: true });
3498
+ }
3499
+ if (dependencyEdits.length === 0 && generatedFiles.length === 0) {
3500
+ return empty;
3501
+ }
3502
+ const envEdits = [
3503
+ { file: null, key: "OTEL_PHP_AUTOLOAD_ENABLED", value: "true" },
3504
+ { file: null, key: "OTEL_EXPORTER_OTLP_ENDPOINT", value: "http://localhost:4318" }
3505
+ ];
3506
+ return {
3507
+ language: "php",
3508
+ serviceDir,
3509
+ dependencyEdits,
3510
+ entrypointEdits: [],
3511
+ envEdits,
3512
+ ...generatedFiles.length > 0 ? { generatedFiles } : {}
3513
+ };
3514
+ }
3515
+ async function writeFileAtomic3(file, contents) {
3516
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
3517
+ await fs8.writeFile(tmp, contents, "utf8");
3518
+ await fs8.rename(tmp, file);
3519
+ }
3520
+ async function applyComposerJson(file, edits, original) {
3521
+ let parsed;
3522
+ try {
3523
+ parsed = JSON.parse(original);
3524
+ } catch {
3525
+ throw new Error(`php installer: composer.json at ${file} is not valid JSON`);
3526
+ }
3527
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
3528
+ throw new Error(`php installer: composer.json at ${file} is not a JSON object`);
3529
+ }
3530
+ const obj = parsed;
3531
+ const require2 = obj.require && typeof obj.require === "object" && !Array.isArray(obj.require) ? obj.require : {};
3532
+ for (const e of edits) {
3533
+ if (e.kind !== "add") continue;
3534
+ if (!(e.name in require2)) require2[e.name] = e.version;
3535
+ }
3536
+ obj.require = require2;
3537
+ await writeFileAtomic3(file, JSON.stringify(obj, null, 2) + "\n");
3538
+ }
3539
+ async function rollback4(serviceDir, language, originals, created) {
3540
+ const restored = [];
3541
+ for (const [file, raw] of originals.entries()) {
3542
+ try {
3543
+ await fs8.writeFile(file, raw, "utf8");
3544
+ restored.push(file);
3545
+ } catch {
3546
+ }
3547
+ }
3548
+ const removed = [];
3549
+ for (const file of created) {
3550
+ try {
3551
+ await fs8.rm(file, { force: true });
3552
+ removed.push(file);
3553
+ } catch {
3554
+ }
3555
+ }
3556
+ const body = [
3557
+ "# neat-rollback.patch",
3558
+ "",
3559
+ `# Generated after a partial apply failure in the ${language} installer.`,
3560
+ "# Files listed below were restored to their pre-apply contents.",
3561
+ "",
3562
+ ...restored.map((f) => `restored: ${f}`),
3563
+ ...removed.map((f) => `removed: ${f}`),
3564
+ ""
3565
+ ];
3566
+ await fs8.writeFile(path9.join(serviceDir, "neat-rollback.patch"), body.join("\n"), "utf8");
3567
+ }
3568
+ async function apply5(installPlan) {
3569
+ const { serviceDir } = installPlan;
3570
+ const generatedFiles = installPlan.generatedFiles ?? [];
3571
+ const manifests = new Set(installPlan.dependencyEdits.map((e) => e.file));
3572
+ if (manifests.size === 0 && generatedFiles.length === 0) {
3573
+ return { serviceDir, outcome: "already-instrumented", writtenFiles: [], reason: PHP_PECL_CAVEAT };
3574
+ }
3575
+ const originals = /* @__PURE__ */ new Map();
3576
+ for (const file of manifests) {
3577
+ try {
3578
+ originals.set(file, await fs8.readFile(file, "utf8"));
3579
+ } catch {
3580
+ }
3581
+ }
3582
+ const writtenFiles = [];
3583
+ const created = [];
3584
+ try {
3585
+ for (const gf of generatedFiles) {
3586
+ if (await exists5(gf.file)) continue;
3587
+ await fs8.mkdir(path9.dirname(gf.file), { recursive: true });
3588
+ await writeFileAtomic3(gf.file, gf.contents);
3589
+ writtenFiles.push(gf.file);
3590
+ created.push(gf.file);
3591
+ }
3592
+ for (const file of manifests) {
3593
+ const raw = originals.get(file);
3594
+ if (raw === void 0) throw new Error(`php installer: cannot read ${file} during apply`);
3595
+ const edits = installPlan.dependencyEdits.filter((e) => e.file === file);
3596
+ if (edits.length > 0) {
3597
+ await applyComposerJson(file, edits, raw);
3598
+ writtenFiles.push(file);
3599
+ }
3600
+ }
3601
+ } catch (err) {
3602
+ await rollback4(serviceDir, installPlan.language, originals, created);
3603
+ throw err;
3604
+ }
3605
+ if (writtenFiles.length > 0) {
3606
+ console.warn(`neat: PHP instrumentation staged in ${path9.basename(serviceDir)}, but a system step remains:
3607
+ ${PHP_PECL_CAVEAT}`);
3608
+ }
3609
+ const wroteManifest = writtenFiles.some((f) => path9.basename(f) === "composer.json");
3610
+ return {
3611
+ serviceDir,
3612
+ outcome: writtenFiles.length > 0 ? "instrumented" : "already-instrumented",
3613
+ writtenFiles,
3614
+ reason: PHP_PECL_CAVEAT,
3615
+ ...wroteManifest ? { followUpInstall: "composer install" } : {}
3616
+ };
3617
+ }
3618
+ var phpInstaller = { name: "php", detect: detect5, plan: plan5, apply: apply5 };
3619
+
3111
3620
  // src/installers/shared.ts
3112
- function isEmptyPlan(plan4) {
3113
- return plan4.dependencyEdits.length === 0 && plan4.entrypointEdits.length === 0 && plan4.envEdits.length === 0 && (plan4.generatedFiles?.length ?? 0) === 0 && plan4.nextConfigEdit === void 0;
3621
+ function isEmptyPlan(plan6) {
3622
+ return plan6.dependencyEdits.length === 0 && plan6.entrypointEdits.length === 0 && plan6.envEdits.length === 0 && (plan6.generatedFiles?.length ?? 0) === 0 && plan6.nextConfigEdit === void 0;
3114
3623
  }
3115
3624
 
3116
3625
  // src/installers/index.ts
@@ -3122,9 +3631,16 @@ var FORBIDDEN_LOCKFILES = /* @__PURE__ */ new Set([
3122
3631
  "Pipfile.lock",
3123
3632
  "Gemfile.lock",
3124
3633
  "Cargo.lock",
3125
- "go.sum"
3634
+ "go.sum",
3635
+ "composer.lock"
3126
3636
  ]);
3127
- var INSTALLERS = [javascriptInstaller, pythonInstaller, goInstaller];
3637
+ var INSTALLERS = [
3638
+ javascriptInstaller,
3639
+ pythonInstaller,
3640
+ goInstaller,
3641
+ rubyInstaller,
3642
+ phpInstaller
3643
+ ];
3128
3644
  async function pickInstaller(serviceDir) {
3129
3645
  for (const inst of INSTALLERS) {
3130
3646
  if (await inst.detect(serviceDir)) return inst;
@@ -3139,7 +3655,7 @@ function renderPatch(sections) {
3139
3655
  "No SDK installers matched the discovered services. Two reasons this",
3140
3656
  "normally happens:",
3141
3657
  " - the project uses a language NEAT does not yet instrument",
3142
- " (Java / Ruby / .NET / Rust are out of MVP scope per ADR-047);",
3658
+ " (Java / .NET / Rust are out of scope per ADR-047);",
3143
3659
  " - the SDK is already installed, so the installer returned an empty",
3144
3660
  " plan.",
3145
3661
  "",
@@ -3149,22 +3665,22 @@ function renderPatch(sections) {
3149
3665
  }
3150
3666
  const lines = ["# neat install plan", ""];
3151
3667
  for (const section of sections) {
3152
- const { installer, plan: plan4 } = section;
3153
- lines.push(`## ${installer} (${plan4.language}) \u2014 ${plan4.serviceDir}`);
3668
+ const { installer, plan: plan6 } = section;
3669
+ lines.push(`## ${installer} (${plan6.language}) \u2014 ${plan6.serviceDir}`);
3154
3670
  lines.push("");
3155
- if (plan4.libOnly) {
3671
+ if (plan6.libOnly) {
3156
3672
  lines.push("### skipped \u2014 no resolvable entry point (lib-only)");
3157
3673
  lines.push("");
3158
3674
  continue;
3159
3675
  }
3160
- if (plan4.entryFile) {
3161
- lines.push(`entry: ${plan4.entryFile}`);
3676
+ if (plan6.entryFile) {
3677
+ lines.push(`entry: ${plan6.entryFile}`);
3162
3678
  lines.push("");
3163
3679
  }
3164
- if (plan4.dependencyEdits.length > 0) {
3680
+ if (plan6.dependencyEdits.length > 0) {
3165
3681
  lines.push("### dependencies");
3166
3682
  const byFile = /* @__PURE__ */ new Map();
3167
- for (const dep of plan4.dependencyEdits) {
3683
+ for (const dep of plan6.dependencyEdits) {
3168
3684
  const base = dep.file.split(/[\\/]/).pop() ?? dep.file;
3169
3685
  if (FORBIDDEN_LOCKFILES.has(base)) {
3170
3686
  throw new Error(
@@ -3183,9 +3699,9 @@ function renderPatch(sections) {
3183
3699
  }
3184
3700
  lines.push("");
3185
3701
  }
3186
- if (plan4.generatedFiles && plan4.generatedFiles.length > 0) {
3702
+ if (plan6.generatedFiles && plan6.generatedFiles.length > 0) {
3187
3703
  lines.push("### generated files");
3188
- for (const gen of plan4.generatedFiles) {
3704
+ for (const gen of plan6.generatedFiles) {
3189
3705
  lines.push(`--- (new file) ${gen.file}`);
3190
3706
  for (const ln of gen.contents.split(/\r?\n/)) {
3191
3707
  lines.push(`+ ${ln}`);
@@ -3193,26 +3709,26 @@ function renderPatch(sections) {
3193
3709
  }
3194
3710
  lines.push("");
3195
3711
  }
3196
- if (plan4.entrypointEdits.length > 0) {
3712
+ if (plan6.entrypointEdits.length > 0) {
3197
3713
  lines.push("### entry-point injection");
3198
- for (const e of plan4.entrypointEdits) {
3714
+ for (const e of plan6.entrypointEdits) {
3199
3715
  lines.push(`--- ${e.file}`);
3200
3716
  lines.push(`+ ${e.after}`);
3201
3717
  lines.push(` ${e.before}`);
3202
3718
  }
3203
3719
  lines.push("");
3204
3720
  }
3205
- if (plan4.envEdits.length > 0) {
3721
+ if (plan6.envEdits.length > 0) {
3206
3722
  lines.push("### env (written to <package-dir>/.env.neat)");
3207
- for (const env of plan4.envEdits) {
3723
+ for (const env of plan6.envEdits) {
3208
3724
  lines.push(`- ${env.key}=${env.value}`);
3209
3725
  }
3210
3726
  lines.push("");
3211
3727
  }
3212
- if (plan4.nextConfigEdit) {
3728
+ if (plan6.nextConfigEdit) {
3213
3729
  lines.push("### next.config (framework flag)");
3214
- lines.push(`--- ${plan4.nextConfigEdit.file}`);
3215
- lines.push(`+ experimental: { instrumentationHook: true }, // ${plan4.nextConfigEdit.reason}`);
3730
+ lines.push(`--- ${plan6.nextConfigEdit.file}`);
3731
+ lines.push(`+ experimental: { instrumentationHook: true }, // ${plan6.nextConfigEdit.reason}`);
3216
3732
  lines.push("");
3217
3733
  }
3218
3734
  }
@@ -3220,10 +3736,10 @@ function renderPatch(sections) {
3220
3736
  }
3221
3737
 
3222
3738
  // src/orchestrator.ts
3223
- import { promises as fs7 } from "fs";
3739
+ import { promises as fs9 } from "fs";
3224
3740
  import http from "http";
3225
3741
  import net from "net";
3226
- import path8 from "path";
3742
+ import path10 from "path";
3227
3743
  import { fileURLToPath as fileURLToPath2 } from "url";
3228
3744
  import { spawn as spawn2 } from "child_process";
3229
3745
  import readline from "readline";
@@ -3233,7 +3749,7 @@ async function extractAndPersist(opts) {
3233
3749
  const graphKey = opts.projectExplicit ? opts.project : DEFAULT_PROJECT;
3234
3750
  resetGraph(graphKey);
3235
3751
  const graph = getGraph(graphKey);
3236
- const projectPaths = pathsForProject(graphKey, path8.join(opts.scanPath, "neat-out"));
3752
+ const projectPaths = pathsForProject(graphKey, path10.join(opts.scanPath, "neat-out"));
3237
3753
  const extraction = await extractFromDirectory(graph, opts.scanPath, {
3238
3754
  errorsPath: projectPaths.errorsPath
3239
3755
  });
@@ -3264,28 +3780,34 @@ async function applyInstallersOver(services, project, options = {}) {
3264
3780
  let cloudflareWorkers = 0;
3265
3781
  let electron = 0;
3266
3782
  const installPlans = /* @__PURE__ */ new Map();
3783
+ const dependencyInstructions = /* @__PURE__ */ new Map();
3267
3784
  for (const svc of services) {
3268
3785
  const installer = await pickInstaller(svc.dir);
3269
3786
  if (!installer) continue;
3270
- const plan4 = await installer.plan(svc.dir, { project });
3271
- if (isEmptyPlan(plan4) && !plan4.libOnly && plan4.runtimeKind === void 0) {
3787
+ const plan6 = await installer.plan(svc.dir, { project });
3788
+ if (isEmptyPlan(plan6) && !plan6.libOnly && plan6.runtimeKind === void 0) {
3272
3789
  already++;
3273
3790
  continue;
3274
3791
  }
3275
- const outcome = await installer.apply(plan4);
3792
+ const outcome = await installer.apply(plan6);
3276
3793
  if (outcome.outcome === "instrumented") {
3277
3794
  instrumented++;
3278
- if (plan4.dependencyEdits.length > 0) {
3279
- const cmd = await resolveManager(svc.dir);
3280
- const key = `${cmd.pm}:${cmd.cwd}`;
3281
- if (!installPlans.has(key)) installPlans.set(key, cmd);
3795
+ if (plan6.dependencyEdits.length > 0) {
3796
+ const manifest = path10.basename(plan6.dependencyEdits[0].file);
3797
+ if (manifest === "package.json") {
3798
+ const cmd = await resolveManager(svc.dir);
3799
+ const key = `${cmd.pm}:${cmd.cwd}`;
3800
+ if (!installPlans.has(key)) installPlans.set(key, cmd);
3801
+ } else if (outcome.followUpInstall) {
3802
+ dependencyInstructions.set(svc.dir, outcome.followUpInstall);
3803
+ }
3282
3804
  }
3283
3805
  } else if (outcome.outcome === "already-instrumented") already++;
3284
3806
  else if (outcome.outcome === "lib-only") {
3285
3807
  libOnly++;
3286
3808
  const appDeps = svc.pkg ? appFrameworkDependencies(svc.pkg) : [];
3287
3809
  if (appDeps.length > 0) {
3288
- const svcName = path8.basename(svc.dir);
3810
+ const svcName = path10.basename(svc.dir);
3289
3811
  const list = appDeps.join(", ");
3290
3812
  console.warn(
3291
3813
  `neat: runtime layer won't engage for ${svcName}: no entry point found.
@@ -3298,7 +3820,7 @@ async function applyInstallersOver(services, project, options = {}) {
3298
3820
  console.log(`skipping ${svc.dir}: browser bundle; browser-OTel support lands in a future release.`);
3299
3821
  } else if (outcome.outcome === "react-native") {
3300
3822
  reactNative++;
3301
- const svcName = path8.basename(svc.dir);
3823
+ const svcName = path10.basename(svc.dir);
3302
3824
  console.log(
3303
3825
  `neat: ${svc.dir} detected as React Native / Expo
3304
3826
  The installer doesn't cover this runtime deterministically.
@@ -3309,7 +3831,7 @@ async function applyInstallersOver(services, project, options = {}) {
3309
3831
  );
3310
3832
  } else if (outcome.outcome === "bun") {
3311
3833
  bun++;
3312
- const svcName = path8.basename(svc.dir);
3834
+ const svcName = path10.basename(svc.dir);
3313
3835
  console.log(
3314
3836
  `neat: ${svc.dir} detected as Bun
3315
3837
  The installer doesn't cover this runtime deterministically.
@@ -3320,7 +3842,7 @@ async function applyInstallersOver(services, project, options = {}) {
3320
3842
  );
3321
3843
  } else if (outcome.outcome === "deno") {
3322
3844
  deno++;
3323
- const svcName = path8.basename(svc.dir);
3845
+ const svcName = path10.basename(svc.dir);
3324
3846
  console.log(
3325
3847
  `neat: ${svc.dir} detected as Deno
3326
3848
  The installer doesn't cover this runtime deterministically.
@@ -3331,7 +3853,7 @@ async function applyInstallersOver(services, project, options = {}) {
3331
3853
  );
3332
3854
  } else if (outcome.outcome === "cloudflare-workers") {
3333
3855
  cloudflareWorkers++;
3334
- const svcName = path8.basename(svc.dir);
3856
+ const svcName = path10.basename(svc.dir);
3335
3857
  console.log(
3336
3858
  `neat: ${svc.dir} detected as Cloudflare Workers
3337
3859
  The installer doesn't cover this runtime deterministically.
@@ -3342,7 +3864,7 @@ async function applyInstallersOver(services, project, options = {}) {
3342
3864
  );
3343
3865
  } else if (outcome.outcome === "electron") {
3344
3866
  electron++;
3345
- const svcName = path8.basename(svc.dir);
3867
+ const svcName = path10.basename(svc.dir);
3346
3868
  console.log(
3347
3869
  `neat: ${svc.dir} detected as Electron
3348
3870
  The installer doesn't cover this runtime deterministically.
@@ -3355,7 +3877,7 @@ async function applyInstallersOver(services, project, options = {}) {
3355
3877
  if (svc.pkg && (outcome.outcome === "instrumented" || outcome.outcome === "already-instrumented")) {
3356
3878
  const gaps = uninstrumentedLibraries(svc.pkg);
3357
3879
  if (gaps.length > 0) {
3358
- const svcName = path8.basename(svc.dir);
3880
+ const svcName = path10.basename(svc.dir);
3359
3881
  const list = gaps.join(", ");
3360
3882
  const subject = gaps.length === 1 ? "this library" : "these libraries";
3361
3883
  const aux = gaps.length === 1 ? "isn't" : "aren't";
@@ -3383,6 +3905,11 @@ async function applyInstallersOver(services, project, options = {}) {
3383
3905
  }
3384
3906
  }
3385
3907
  }
3908
+ for (const [dir, command] of dependencyInstructions) {
3909
+ console.log(
3910
+ `neat: dependencies staged in ${dir}; run \`${command}\` to install them \u2014 NEAT does not run it for you.`
3911
+ );
3912
+ }
3386
3913
  return {
3387
3914
  instrumented,
3388
3915
  alreadyInstrumented: already,
@@ -3393,7 +3920,8 @@ async function applyInstallersOver(services, project, options = {}) {
3393
3920
  deno,
3394
3921
  cloudflareWorkers,
3395
3922
  electron,
3396
- packageManagerInstalls
3923
+ packageManagerInstalls,
3924
+ dependencyInstructions: [...dependencyInstructions].map(([dir, command]) => ({ dir, command }))
3397
3925
  };
3398
3926
  }
3399
3927
  async function promptYesNo(question) {
@@ -3561,24 +4089,24 @@ async function persistedPortsFor(scanPath) {
3561
4089
  return { rest: record.ports.rest, otlp: record.ports.otlp, web: record.ports.web };
3562
4090
  }
3563
4091
  async function acquireSpawnLock(scanPath) {
3564
- const lockPath = path8.join(scanPath, "neat-out", "daemon.spawn.lock");
3565
- await fs7.mkdir(path8.dirname(lockPath), { recursive: true });
4092
+ const lockPath = path10.join(scanPath, "neat-out", "daemon.spawn.lock");
4093
+ await fs9.mkdir(path10.dirname(lockPath), { recursive: true });
3566
4094
  const STALE_LOCK_MS = 6e4;
3567
4095
  try {
3568
- const fd = await fs7.open(lockPath, "wx");
4096
+ const fd = await fs9.open(lockPath, "wx");
3569
4097
  await fd.writeFile(`${process.pid}
3570
4098
  `, "utf8");
3571
4099
  await fd.close();
3572
4100
  return async () => {
3573
- await fs7.unlink(lockPath).catch(() => {
4101
+ await fs9.unlink(lockPath).catch(() => {
3574
4102
  });
3575
4103
  };
3576
4104
  } catch (err) {
3577
4105
  if (err.code !== "EEXIST") return null;
3578
4106
  try {
3579
- const stat = await fs7.stat(lockPath);
4107
+ const stat = await fs9.stat(lockPath);
3580
4108
  if (Date.now() - stat.mtimeMs > STALE_LOCK_MS) {
3581
- await fs7.unlink(lockPath).catch(() => {
4109
+ await fs9.unlink(lockPath).catch(() => {
3582
4110
  });
3583
4111
  return acquireSpawnLock(scanPath);
3584
4112
  }
@@ -3607,13 +4135,13 @@ async function healthIsForProject(restPort, project) {
3607
4135
  return false;
3608
4136
  }
3609
4137
  function daemonLogPath(projectPath3) {
3610
- return path8.join(projectPath3, "neat-out", "daemon.log");
4138
+ return path10.join(projectPath3, "neat-out", "daemon.log");
3611
4139
  }
3612
4140
  function spawnDaemonDetached(spec) {
3613
- const here = path8.dirname(fileURLToPath2(import.meta.url));
4141
+ const here = path10.dirname(fileURLToPath2(import.meta.url));
3614
4142
  const candidates = [
3615
- path8.join(here, "neatd.cjs"),
3616
- path8.join(here, "neatd.js")
4143
+ path10.join(here, "neatd.cjs"),
4144
+ path10.join(here, "neatd.js")
3617
4145
  ];
3618
4146
  let entry2 = null;
3619
4147
  const fsSync = __require("fs");
@@ -3643,7 +4171,7 @@ function spawnDaemonDetached(spec) {
3643
4171
  let logFd = null;
3644
4172
  if (spec) {
3645
4173
  const logPath = daemonLogPath(spec.projectPath);
3646
- fsSync.mkdirSync(path8.dirname(logPath), { recursive: true });
4174
+ fsSync.mkdirSync(path10.dirname(logPath), { recursive: true });
3647
4175
  logFd = fsSync.openSync(logPath, "a");
3648
4176
  }
3649
4177
  const child = spawn2(process.execPath, [entry2, "start"], {
@@ -3682,7 +4210,7 @@ async function runOrchestrator(opts) {
3682
4210
  browser: "skipped"
3683
4211
  }
3684
4212
  };
3685
- const stat = await fs7.stat(opts.scanPath).catch(() => null);
4213
+ const stat = await fs9.stat(opts.scanPath).catch(() => null);
3686
4214
  if (!stat || !stat.isDirectory()) {
3687
4215
  console.error(`neat: ${opts.scanPath} is not a directory`);
3688
4216
  result.exitCode = 2;
@@ -3842,7 +4370,7 @@ async function runOrchestrator(opts) {
3842
4370
  result.steps.browser = openBrowser(dashboardUrl);
3843
4371
  }
3844
4372
  const daemonRunning = result.steps.daemon === "spawned" || result.steps.daemon === "already-running";
3845
- const daemonLog = daemonRunning ? path8.relative(opts.scanPath, daemonLogPath(opts.scanPath)) : null;
4373
+ const daemonLog = daemonRunning ? path10.relative(opts.scanPath, daemonLogPath(opts.scanPath)) : null;
3846
4374
  printSummary(result, graph, dashboardUrl, daemonLog);
3847
4375
  return result;
3848
4376
  }
@@ -4299,27 +4827,27 @@ async function runConnectorCommand(rawArgs, deps = {}) {
4299
4827
  }
4300
4828
 
4301
4829
  // src/hooks-cli.ts
4302
- import path9 from "path";
4830
+ import path11 from "path";
4303
4831
  import os from "os";
4304
- import { promises as fs8 } from "fs";
4832
+ import { promises as fs10 } from "fs";
4305
4833
  import { fileURLToPath as fileURLToPath3 } from "url";
4306
4834
  var HOOK_FILENAME = "neat-search-nudge.mjs";
4307
4835
  var GUIDE_FILENAME = "GRAPH_FIRST.md";
4308
4836
  var GUIDE_INSTALL_NAME = "neat-graph-first.md";
4309
4837
  var HOOK_MATCHER = "Grep|Glob|Bash";
4310
4838
  function moduleDir() {
4311
- return typeof __dirname !== "undefined" ? __dirname : path9.dirname(fileURLToPath3(import.meta.url));
4839
+ return typeof __dirname !== "undefined" ? __dirname : path11.dirname(fileURLToPath3(import.meta.url));
4312
4840
  }
4313
4841
  async function readSkillAsset(rel) {
4314
4842
  const here = moduleDir();
4315
4843
  const candidates = [
4316
- path9.resolve(here, "../../claude-skill", rel),
4317
- path9.resolve(here, "../../../claude-skill", rel),
4318
- path9.resolve(here, "../claude-skill", rel)
4844
+ path11.resolve(here, "../../claude-skill", rel),
4845
+ path11.resolve(here, "../../../claude-skill", rel),
4846
+ path11.resolve(here, "../claude-skill", rel)
4319
4847
  ];
4320
4848
  for (const candidate of candidates) {
4321
4849
  try {
4322
- return await fs8.readFile(candidate, "utf8");
4850
+ return await fs10.readFile(candidate, "utf8");
4323
4851
  } catch {
4324
4852
  }
4325
4853
  }
@@ -4329,17 +4857,17 @@ async function readSkillAsset(rel) {
4329
4857
  }
4330
4858
  function neatHome() {
4331
4859
  const override = process.env.NEAT_HOME;
4332
- if (override && override.length > 0) return path9.resolve(override);
4333
- return path9.join(os.homedir(), ".neat");
4860
+ if (override && override.length > 0) return path11.resolve(override);
4861
+ return path11.join(os.homedir(), ".neat");
4334
4862
  }
4335
4863
  function claudeSettingsPath() {
4336
4864
  const override = process.env.NEAT_CLAUDE_SETTINGS;
4337
- if (override && override.length > 0) return path9.resolve(override);
4865
+ if (override && override.length > 0) return path11.resolve(override);
4338
4866
  const home = process.env.HOME ?? process.env.USERPROFILE ?? os.homedir();
4339
- return path9.join(home, ".claude", "settings.json");
4867
+ return path11.join(home, ".claude", "settings.json");
4340
4868
  }
4341
4869
  function installedHookPath() {
4342
- return path9.join(neatHome(), "hooks", HOOK_FILENAME);
4870
+ return path11.join(neatHome(), "hooks", HOOK_FILENAME);
4343
4871
  }
4344
4872
  function isNeatSearchEntry(entry2) {
4345
4873
  return (entry2.hooks ?? []).some(
@@ -4372,14 +4900,14 @@ async function runHooks(opts) {
4372
4900
  const hookScript = await readSkillAsset(`hooks/${HOOK_FILENAME}`);
4373
4901
  const guide = await readSkillAsset(GUIDE_FILENAME);
4374
4902
  const scriptPath = installedHookPath();
4375
- await fs8.mkdir(path9.dirname(scriptPath), { recursive: true });
4376
- await fs8.writeFile(scriptPath, hookScript, { mode: 493 });
4377
- const guidePath = path9.join(neatHome(), GUIDE_INSTALL_NAME);
4378
- await fs8.writeFile(guidePath, guide, "utf8");
4903
+ await fs10.mkdir(path11.dirname(scriptPath), { recursive: true });
4904
+ await fs10.writeFile(scriptPath, hookScript, { mode: 493 });
4905
+ const guidePath = path11.join(neatHome(), GUIDE_INSTALL_NAME);
4906
+ await fs10.writeFile(guidePath, guide, "utf8");
4379
4907
  const settingsFile = claudeSettingsPath();
4380
4908
  let settings = {};
4381
4909
  try {
4382
- settings = JSON.parse(await fs8.readFile(settingsFile, "utf8"));
4910
+ settings = JSON.parse(await fs10.readFile(settingsFile, "utf8"));
4383
4911
  } catch (err) {
4384
4912
  if (err.code !== "ENOENT") {
4385
4913
  console.error(
@@ -4401,8 +4929,8 @@ async function runHooks(opts) {
4401
4929
  ...settings,
4402
4930
  hooks: { ...hooks, PreToolUse: preToolUse }
4403
4931
  };
4404
- await fs8.mkdir(path9.dirname(settingsFile), { recursive: true });
4405
- await fs8.writeFile(settingsFile, JSON.stringify(merged, null, 2) + "\n", "utf8");
4932
+ await fs10.mkdir(path11.dirname(settingsFile), { recursive: true });
4933
+ await fs10.writeFile(settingsFile, JSON.stringify(merged, null, 2) + "\n", "utf8");
4406
4934
  console.log(`neat hooks: installed the search-nudge hook`);
4407
4935
  console.log(` script: ${scriptPath}`);
4408
4936
  console.log(` settings: ${settingsFile} (PreToolUse \u2192 ${HOOK_MATCHER})`);
@@ -4472,9 +5000,9 @@ async function runHooksCommand(args) {
4472
5000
  }
4473
5001
 
4474
5002
  // src/codex-cli.ts
4475
- import path10 from "path";
5003
+ import path12 from "path";
4476
5004
  import os2 from "os";
4477
- import { promises as fs9 } from "fs";
5005
+ import { promises as fs11 } from "fs";
4478
5006
  import { isDeepStrictEqual } from "util";
4479
5007
  import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
4480
5008
  var CODEX_MCP_SERVER = {
@@ -4492,14 +5020,14 @@ var NEAT_GRAPH_FIRST_START = "<!-- neat:graph-first -->";
4492
5020
  var NEAT_GRAPH_FIRST_END = "<!-- /neat:graph-first -->";
4493
5021
  function codexConfigPath() {
4494
5022
  const override = process.env.NEAT_CODEX_CONFIG;
4495
- if (override && override.length > 0) return path10.resolve(override);
5023
+ if (override && override.length > 0) return path12.resolve(override);
4496
5024
  const home = process.env.HOME ?? process.env.USERPROFILE ?? os2.homedir();
4497
- return path10.join(home, ".codex", "config.toml");
5025
+ return path12.join(home, ".codex", "config.toml");
4498
5026
  }
4499
5027
  function agentsFilePath() {
4500
5028
  const override = process.env.NEAT_CODEX_AGENTS;
4501
- if (override && override.length > 0) return path10.resolve(override);
4502
- return path10.join(process.cwd(), "AGENTS.md");
5029
+ if (override && override.length > 0) return path12.resolve(override);
5030
+ return path12.join(process.cwd(), "AGENTS.md");
4503
5031
  }
4504
5032
  function isTableHeader(line) {
4505
5033
  return /^\s*\[\[?[^\]]+\]\]?\s*$/.test(line);
@@ -4633,7 +5161,7 @@ async function runCodex(opts) {
4633
5161
  const agentsPath = agentsFilePath();
4634
5162
  let configRaw = "";
4635
5163
  try {
4636
- configRaw = await fs9.readFile(configPath, "utf8");
5164
+ configRaw = await fs11.readFile(configPath, "utf8");
4637
5165
  } catch (err) {
4638
5166
  if (err.code !== "ENOENT") {
4639
5167
  console.error(`neat codex: failed to read ${configPath} \u2014 ${err.message}`);
@@ -4642,7 +5170,7 @@ async function runCodex(opts) {
4642
5170
  }
4643
5171
  let agentsRaw = "";
4644
5172
  try {
4645
- agentsRaw = await fs9.readFile(agentsPath, "utf8");
5173
+ agentsRaw = await fs11.readFile(agentsPath, "utf8");
4646
5174
  } catch (err) {
4647
5175
  if (err.code !== "ENOENT") {
4648
5176
  console.error(`neat codex: failed to read ${agentsPath} \u2014 ${err.message}`);
@@ -4682,15 +5210,15 @@ async function runCodex(opts) {
4682
5210
  return { exitCode: 0 };
4683
5211
  }
4684
5212
  if (config.changed) {
4685
- await fs9.mkdir(path10.dirname(configPath), { recursive: true });
4686
- await fs9.writeFile(configPath, config.text, "utf8");
5213
+ await fs11.mkdir(path12.dirname(configPath), { recursive: true });
5214
+ await fs11.writeFile(configPath, config.text, "utf8");
4687
5215
  console.log(`neat codex: wrote [mcp_servers.neat] to ${configPath}`);
4688
5216
  } else {
4689
5217
  console.log(`neat codex: ${configPath} already has NEAT's MCP server`);
4690
5218
  }
4691
5219
  if (agents.changed) {
4692
- await fs9.mkdir(path10.dirname(agentsPath), { recursive: true });
4693
- await fs9.writeFile(agentsPath, agents.text, "utf8");
5220
+ await fs11.mkdir(path12.dirname(agentsPath), { recursive: true });
5221
+ await fs11.writeFile(agentsPath, agents.text, "utf8");
4694
5222
  console.log(`neat codex: wrote the graph-first block to ${agentsPath}`);
4695
5223
  } else {
4696
5224
  console.log(`neat codex: ${agentsPath} already has the graph-first block`);
@@ -4746,9 +5274,9 @@ async function runCodexCommand(args) {
4746
5274
  }
4747
5275
 
4748
5276
  // src/editors-cli.ts
4749
- import path11 from "path";
5277
+ import path13 from "path";
4750
5278
  import os3 from "os";
4751
- import { promises as fs10 } from "fs";
5279
+ import { promises as fs12 } from "fs";
4752
5280
  import { isDeepStrictEqual as isDeepStrictEqual2 } from "util";
4753
5281
  import * as jsonc from "jsonc-parser";
4754
5282
  var NEAT_MCP_SERVER = {
@@ -4772,17 +5300,17 @@ function homeDir() {
4772
5300
  }
4773
5301
  function xdgConfigDir() {
4774
5302
  const xdg = process.env.XDG_CONFIG_HOME;
4775
- return xdg && xdg.length > 0 ? path11.resolve(xdg) : path11.join(homeDir(), ".config");
5303
+ return xdg && xdg.length > 0 ? path13.resolve(xdg) : path13.join(homeDir(), ".config");
4776
5304
  }
4777
5305
  function envOverride(name) {
4778
5306
  const v = process.env[name];
4779
- return v && v.length > 0 ? path11.resolve(v) : void 0;
5307
+ return v && v.length > 0 ? path13.resolve(v) : void 0;
4780
5308
  }
4781
5309
  var CURSOR_CLIENT = {
4782
5310
  id: "cursor",
4783
5311
  label: "Cursor",
4784
5312
  docsUrl: "https://docs.cursor.com/context/mcp",
4785
- mcpConfigPath: () => envOverride("NEAT_CURSOR_CONFIG") ?? path11.join(homeDir(), ".cursor", "mcp.json"),
5313
+ mcpConfigPath: () => envOverride("NEAT_CURSOR_CONFIG") ?? path13.join(homeDir(), ".cursor", "mcp.json"),
4786
5314
  mcpContainerKey: "mcpServers",
4787
5315
  format: "json",
4788
5316
  // Cursor still reads a single `.cursorrules` at the project root (the modern
@@ -4794,7 +5322,7 @@ var DEVIN_CLIENT = {
4794
5322
  id: "devin",
4795
5323
  label: "Devin Desktop (Cascade)",
4796
5324
  docsUrl: "https://docs.devin.ai/desktop/cascade/mcp",
4797
- mcpConfigPath: () => envOverride("NEAT_DEVIN_CONFIG") ?? path11.join(homeDir(), ".codeium", "windsurf", "mcp_config.json"),
5325
+ mcpConfigPath: () => envOverride("NEAT_DEVIN_CONFIG") ?? path13.join(homeDir(), ".codeium", "windsurf", "mcp_config.json"),
4798
5326
  mcpContainerKey: "mcpServers",
4799
5327
  format: "json",
4800
5328
  rulesFileName: ".windsurfrules"
@@ -4803,7 +5331,7 @@ var GEMINI_CLIENT = {
4803
5331
  id: "gemini",
4804
5332
  label: "Gemini CLI",
4805
5333
  docsUrl: "https://github.com/google-gemini/gemini-cli/blob/main/docs/tools/mcp-server.md",
4806
- mcpConfigPath: () => envOverride("NEAT_GEMINI_CONFIG") ?? path11.join(homeDir(), ".gemini", "settings.json"),
5334
+ mcpConfigPath: () => envOverride("NEAT_GEMINI_CONFIG") ?? path13.join(homeDir(), ".gemini", "settings.json"),
4807
5335
  mcpContainerKey: "mcpServers",
4808
5336
  format: "json",
4809
5337
  rulesFileName: "GEMINI.md"
@@ -4812,7 +5340,7 @@ var QWEN_CLIENT = {
4812
5340
  id: "qwen",
4813
5341
  label: "Qwen Code",
4814
5342
  docsUrl: "https://qwenlm.github.io/qwen-code-docs/en/users/features/mcp/",
4815
- mcpConfigPath: () => envOverride("NEAT_QWEN_CONFIG") ?? path11.join(homeDir(), ".qwen", "settings.json"),
5343
+ mcpConfigPath: () => envOverride("NEAT_QWEN_CONFIG") ?? path13.join(homeDir(), ".qwen", "settings.json"),
4816
5344
  mcpContainerKey: "mcpServers",
4817
5345
  format: "json",
4818
5346
  rulesFileName: "QWEN.md"
@@ -4821,7 +5349,7 @@ var AMAZONQ_CLIENT = {
4821
5349
  id: "amazonq",
4822
5350
  label: "Amazon Q Developer CLI",
4823
5351
  docsUrl: "https://docs.aws.amazon.com/amazonq/latest/qdeveloper-ug/command-line-mcp-configuration.html",
4824
- mcpConfigPath: () => envOverride("NEAT_AMAZONQ_CONFIG") ?? path11.join(homeDir(), ".aws", "amazonq", "mcp.json"),
5352
+ mcpConfigPath: () => envOverride("NEAT_AMAZONQ_CONFIG") ?? path13.join(homeDir(), ".aws", "amazonq", "mcp.json"),
4825
5353
  mcpContainerKey: "mcpServers",
4826
5354
  format: "json"
4827
5355
  };
@@ -4829,7 +5357,7 @@ var ROOCODE_CLIENT = {
4829
5357
  id: "roocode",
4830
5358
  label: "Roo Code",
4831
5359
  docsUrl: "https://roocodeinc.github.io/Roo-Code/features/mcp/using-mcp-in-roo",
4832
- mcpConfigPath: () => envOverride("NEAT_ROOCODE_CONFIG") ?? path11.join(process.cwd(), ".roo", "mcp.json"),
5360
+ mcpConfigPath: () => envOverride("NEAT_ROOCODE_CONFIG") ?? path13.join(process.cwd(), ".roo", "mcp.json"),
4833
5361
  mcpContainerKey: "mcpServers",
4834
5362
  format: "json"
4835
5363
  };
@@ -4842,9 +5370,9 @@ var ZED_CLIENT = {
4842
5370
  if (override) return override;
4843
5371
  if (process.platform === "win32") {
4844
5372
  const appData = process.env.APPDATA;
4845
- if (appData && appData.length > 0) return path11.join(appData, "Zed", "settings.json");
5373
+ if (appData && appData.length > 0) return path13.join(appData, "Zed", "settings.json");
4846
5374
  }
4847
- return path11.join(homeDir(), ".config", "zed", "settings.json");
5375
+ return path13.join(homeDir(), ".config", "zed", "settings.json");
4848
5376
  },
4849
5377
  mcpContainerKey: "context_servers",
4850
5378
  format: "jsonc",
@@ -4854,7 +5382,7 @@ var OPENCODE_CLIENT = {
4854
5382
  id: "opencode",
4855
5383
  label: "OpenCode",
4856
5384
  docsUrl: "https://opencode.ai/docs/mcp-servers/",
4857
- mcpConfigPath: () => envOverride("NEAT_OPENCODE_CONFIG") ?? path11.join(xdgConfigDir(), "opencode", "opencode.json"),
5385
+ mcpConfigPath: () => envOverride("NEAT_OPENCODE_CONFIG") ?? path13.join(xdgConfigDir(), "opencode", "opencode.json"),
4858
5386
  mcpContainerKey: "mcp",
4859
5387
  format: "json",
4860
5388
  serverEntry: NEAT_OPENCODE_SERVER,
@@ -4864,7 +5392,7 @@ var CRUSH_CLIENT = {
4864
5392
  id: "crush",
4865
5393
  label: "Crush",
4866
5394
  docsUrl: "https://charmbracelet-crush.mintlify.app/configuration/mcp",
4867
- mcpConfigPath: () => envOverride("NEAT_CRUSH_CONFIG") ?? path11.join(xdgConfigDir(), "crush", "crush.json"),
5395
+ mcpConfigPath: () => envOverride("NEAT_CRUSH_CONFIG") ?? path13.join(xdgConfigDir(), "crush", "crush.json"),
4868
5396
  mcpContainerKey: "mcp",
4869
5397
  format: "json",
4870
5398
  serverEntry: NEAT_CRUSH_SERVER,
@@ -4927,7 +5455,7 @@ async function planMcp(client, mcpPath) {
4927
5455
  const serverEntry = client.serverEntry ?? NEAT_MCP_SERVER;
4928
5456
  let raw = "";
4929
5457
  try {
4930
- raw = await fs10.readFile(mcpPath, "utf8");
5458
+ raw = await fs12.readFile(mcpPath, "utf8");
4931
5459
  } catch (err) {
4932
5460
  const e = err;
4933
5461
  if (e.code === "ENOENT") {
@@ -4969,7 +5497,7 @@ async function runEditorInstall(client, opts) {
4969
5497
  const mcpPath = client.mcpConfigPath();
4970
5498
  const serverEntry = client.serverEntry ?? NEAT_MCP_SERVER;
4971
5499
  const hasRules = typeof client.rulesFileName === "string";
4972
- const rulesPath = hasRules ? path11.join(opts.projectDir, client.rulesFileName) : "";
5500
+ const rulesPath = hasRules ? path13.join(opts.projectDir, client.rulesFileName) : "";
4973
5501
  const mcp = await planMcp(client, mcpPath);
4974
5502
  if (mcp === null) return { exitCode: 1 };
4975
5503
  let existingRules = "";
@@ -4978,7 +5506,7 @@ async function runEditorInstall(client, opts) {
4978
5506
  let block = "";
4979
5507
  if (hasRules) {
4980
5508
  try {
4981
- existingRules = await fs10.readFile(rulesPath, "utf8");
5509
+ existingRules = await fs12.readFile(rulesPath, "utf8");
4982
5510
  } catch (err) {
4983
5511
  if (err.code !== "ENOENT") {
4984
5512
  console.error(`neat ${client.id}: failed to read ${rulesPath} \u2014 ${err.message}`);
@@ -5012,11 +5540,11 @@ async function runEditorInstall(client, opts) {
5012
5540
  );
5013
5541
  return { exitCode: 0 };
5014
5542
  }
5015
- await fs10.mkdir(path11.dirname(mcpPath), { recursive: true });
5016
- await fs10.writeFile(mcpPath, mcp.text, "utf8");
5543
+ await fs12.mkdir(path13.dirname(mcpPath), { recursive: true });
5544
+ await fs12.writeFile(mcpPath, mcp.text, "utf8");
5017
5545
  if (hasRules) {
5018
- await fs10.mkdir(path11.dirname(rulesPath), { recursive: true });
5019
- await fs10.writeFile(rulesPath, newRules, "utf8");
5546
+ await fs12.mkdir(path13.dirname(rulesPath), { recursive: true });
5547
+ await fs12.writeFile(rulesPath, newRules, "utf8");
5020
5548
  }
5021
5549
  console.log(`neat ${client.id}: wired NEAT into ${client.label}`);
5022
5550
  console.log(` MCP server: ${mcpPath} (${client.mcpContainerKey}.neat \u2192 npx -y @neat.is/mcp)`);
@@ -5052,11 +5580,11 @@ function usage3(client) {
5052
5580
  }
5053
5581
  async function runEditorCommand(clientId, args, projectDir = process.cwd()) {
5054
5582
  const client = CLIENTS[clientId];
5055
- let apply4 = false;
5583
+ let apply6 = false;
5056
5584
  for (const arg of args) {
5057
5585
  switch (arg) {
5058
5586
  case "--apply":
5059
- apply4 = true;
5587
+ apply6 = true;
5060
5588
  break;
5061
5589
  case "-h":
5062
5590
  case "--help":
@@ -5069,7 +5597,7 @@ async function runEditorCommand(clientId, args, projectDir = process.cwd()) {
5069
5597
  }
5070
5598
  }
5071
5599
  try {
5072
- const { exitCode } = await runEditorInstall(client, { apply: apply4, projectDir });
5600
+ const { exitCode } = await runEditorInstall(client, { apply: apply6, projectDir });
5073
5601
  return exitCode;
5074
5602
  } catch (err) {
5075
5603
  console.error(err.message);
@@ -5106,10 +5634,10 @@ function createHttpClient(baseUrl, bearerToken) {
5106
5634
  const root = baseUrl.replace(/\/$/, "");
5107
5635
  const authHeader = bearerToken && bearerToken.length > 0 ? { authorization: `Bearer ${bearerToken}` } : {};
5108
5636
  return {
5109
- async get(path14) {
5637
+ async get(path16) {
5110
5638
  let res;
5111
5639
  try {
5112
- res = await fetch(`${root}${path14}`, {
5640
+ res = await fetch(`${root}${path16}`, {
5113
5641
  headers: { ...authHeader }
5114
5642
  });
5115
5643
  } catch (err) {
@@ -5121,16 +5649,16 @@ function createHttpClient(baseUrl, bearerToken) {
5121
5649
  const body = await res.text().catch(() => "");
5122
5650
  throw new HttpError(
5123
5651
  res.status,
5124
- `${res.status} ${res.statusText} on GET ${path14}: ${body}`,
5652
+ `${res.status} ${res.statusText} on GET ${path16}: ${body}`,
5125
5653
  body
5126
5654
  );
5127
5655
  }
5128
5656
  return await res.json();
5129
5657
  },
5130
- async post(path14, body) {
5658
+ async post(path16, body) {
5131
5659
  let res;
5132
5660
  try {
5133
- res = await fetch(`${root}${path14}`, {
5661
+ res = await fetch(`${root}${path16}`, {
5134
5662
  method: "POST",
5135
5663
  headers: { "content-type": "application/json", ...authHeader },
5136
5664
  body: JSON.stringify(body)
@@ -5144,7 +5672,7 @@ function createHttpClient(baseUrl, bearerToken) {
5144
5672
  const text = await res.text().catch(() => "");
5145
5673
  throw new HttpError(
5146
5674
  res.status,
5147
- `${res.status} ${res.statusText} on POST ${path14}: ${text}`,
5675
+ `${res.status} ${res.statusText} on POST ${path16}: ${text}`,
5148
5676
  text
5149
5677
  );
5150
5678
  }
@@ -5158,12 +5686,12 @@ function projectPath(project, suffix) {
5158
5686
  }
5159
5687
  async function runRootCause(client, input) {
5160
5688
  const qs = input.errorId ? `?errorId=${encodeURIComponent(input.errorId)}` : "";
5161
- const path14 = projectPath(
5689
+ const path16 = projectPath(
5162
5690
  input.project,
5163
5691
  `/graph/root-cause/${encodeURIComponent(input.errorNode)}${qs}`
5164
5692
  );
5165
5693
  try {
5166
- const result = await client.get(path14);
5694
+ const result = await client.get(path16);
5167
5695
  const arrowPath = result.traversalPath.join(" \u2190 ");
5168
5696
  const provenances = result.edgeProvenances.length ? result.edgeProvenances.join(", ") : "(direct, no edges traversed)";
5169
5697
  const summary = `Root cause for ${input.errorNode} is ${result.rootCauseNode}. ` + result.rootCauseReason + (result.fixRecommendation ? ` Recommended fix: ${result.fixRecommendation}.` : "");
@@ -5189,12 +5717,12 @@ async function runRootCause(client, input) {
5189
5717
  }
5190
5718
  async function runBlastRadius(client, input) {
5191
5719
  const qs = input.depth !== void 0 ? `?depth=${input.depth}` : "";
5192
- const path14 = projectPath(
5720
+ const path16 = projectPath(
5193
5721
  input.project,
5194
5722
  `/graph/blast-radius/${encodeURIComponent(input.nodeId)}${qs}`
5195
5723
  );
5196
5724
  try {
5197
- const result = await client.get(path14);
5725
+ const result = await client.get(path16);
5198
5726
  if (result.totalAffected === 0) {
5199
5727
  return {
5200
5728
  summary: `${result.origin} has no dependents. Nothing else would break if it failed.`
@@ -5228,12 +5756,12 @@ function formatBlastEntry(n) {
5228
5756
  }
5229
5757
  async function runDependencies(client, input) {
5230
5758
  const depth = input.depth ?? 3;
5231
- const path14 = projectPath(
5759
+ const path16 = projectPath(
5232
5760
  input.project,
5233
5761
  `/graph/dependencies/${encodeURIComponent(input.nodeId)}?depth=${depth}`
5234
5762
  );
5235
5763
  try {
5236
- const result = await client.get(path14);
5764
+ const result = await client.get(path16);
5237
5765
  if (result.total === 0) {
5238
5766
  return {
5239
5767
  summary: depth === 1 ? `${input.nodeId} has no direct dependencies in the graph.` : `${input.nodeId} has no dependencies (BFS to depth ${depth}).`
@@ -5325,9 +5853,9 @@ function formatDuration(ms) {
5325
5853
  return `${Math.round(h / 24)}d`;
5326
5854
  }
5327
5855
  async function runIncidents(client, input) {
5328
- const path14 = input.nodeId ? projectPath(input.project, `/incidents/${encodeURIComponent(input.nodeId)}`) : projectPath(input.project, "/incidents");
5856
+ const path16 = input.nodeId ? projectPath(input.project, `/incidents/${encodeURIComponent(input.nodeId)}`) : projectPath(input.project, "/incidents");
5329
5857
  try {
5330
- const body = await client.get(path14);
5858
+ const body = await client.get(path16);
5331
5859
  const events = body.events;
5332
5860
  if (events.length === 0) {
5333
5861
  return {
@@ -5980,7 +6508,7 @@ function sleep(ms, signal) {
5980
6508
  }
5981
6509
 
5982
6510
  // src/cli-verbs.ts
5983
- import path12 from "path";
6511
+ import path14 from "path";
5984
6512
  async function resolveProjectEntry(opts) {
5985
6513
  const entries = await listProjects();
5986
6514
  if (opts.project) {
@@ -5990,7 +6518,7 @@ async function resolveProjectEntry(opts) {
5990
6518
  const cwd = opts.cwd ?? process.cwd();
5991
6519
  const resolvedCwd = await normalizeProjectPath(cwd);
5992
6520
  for (const entry2 of entries) {
5993
- if (resolvedCwd === entry2.path || resolvedCwd.startsWith(`${entry2.path}${path12.sep}`)) {
6521
+ if (resolvedCwd === entry2.path || resolvedCwd.startsWith(`${entry2.path}${path14.sep}`)) {
5994
6522
  return entry2;
5995
6523
  }
5996
6524
  }
@@ -6497,15 +7025,15 @@ async function buildPatchSections(services, project) {
6497
7025
  for (const svc of services) {
6498
7026
  const installer = await pickInstaller(svc.dir);
6499
7027
  if (!installer) continue;
6500
- const plan4 = await installer.plan(svc.dir, { project });
6501
- if (isEmptyPlan(plan4) && !plan4.libOnly && plan4.runtimeKind === void 0) continue;
6502
- sections.push({ installer: installer.name, plan: plan4 });
7028
+ const plan6 = await installer.plan(svc.dir, { project });
7029
+ if (isEmptyPlan(plan6) && !plan6.libOnly && plan6.runtimeKind === void 0) continue;
7030
+ sections.push({ installer: installer.name, plan: plan6 });
6503
7031
  }
6504
7032
  return sections;
6505
7033
  }
6506
7034
  async function runInit(opts) {
6507
7035
  const written = [];
6508
- const stat = await fs11.stat(opts.scanPath).catch(() => null);
7036
+ const stat = await fs13.stat(opts.scanPath).catch(() => null);
6509
7037
  if (!stat || !stat.isDirectory()) {
6510
7038
  console.error(`neat init: ${opts.scanPath} is not a directory`);
6511
7039
  return { exitCode: 2, writtenFiles: written };
@@ -6514,13 +7042,13 @@ async function runInit(opts) {
6514
7042
  printDiscoveryReport(opts, services);
6515
7043
  const sections = opts.noInstall ? [] : await buildPatchSections(services, opts.project);
6516
7044
  const patch = renderPatch(sections);
6517
- const patchPath = path13.join(opts.scanPath, "neat.patch");
7045
+ const patchPath = path15.join(opts.scanPath, "neat.patch");
6518
7046
  if (opts.dryRun) {
6519
- await fs11.writeFile(patchPath, patch, "utf8");
7047
+ await fs13.writeFile(patchPath, patch, "utf8");
6520
7048
  written.push(patchPath);
6521
7049
  console.log(`dry-run: patch written to ${patchPath}`);
6522
- const gitignorePath = path13.join(opts.scanPath, ".gitignore");
6523
- const gitignoreExists = await fs11.stat(gitignorePath).then(() => true).catch(() => false);
7050
+ const gitignorePath = path15.join(opts.scanPath, ".gitignore");
7051
+ const gitignoreExists = await fs13.stat(gitignorePath).then(() => true).catch(() => false);
6524
7052
  const verb = gitignoreExists ? "append" : "create";
6525
7053
  console.log(`dry-run: would ${verb} ${gitignorePath} (add neat-out/)`);
6526
7054
  console.log("rerun without --dry-run to register and snapshot.");
@@ -6531,9 +7059,9 @@ async function runInit(opts) {
6531
7059
  const graph = getGraph(graphKey);
6532
7060
  const projectPaths = pathsForProject(
6533
7061
  graphKey,
6534
- path13.join(opts.scanPath, "neat-out")
7062
+ path15.join(opts.scanPath, "neat-out")
6535
7063
  );
6536
- const errorsPath = path13.join(path13.dirname(opts.outPath), path13.basename(projectPaths.errorsPath));
7064
+ const errorsPath = path15.join(path15.dirname(opts.outPath), path15.basename(projectPaths.errorsPath));
6537
7065
  const result = await extractFromDirectory(graph, opts.scanPath, { errorsPath });
6538
7066
  await saveGraphToDisk(graph, opts.outPath);
6539
7067
  written.push(opts.outPath);
@@ -6612,7 +7140,7 @@ async function runInit(opts) {
6612
7140
  console.log("Run `npm install` (or your language equivalent) to refresh lockfiles.");
6613
7141
  }
6614
7142
  } else {
6615
- await fs11.writeFile(patchPath, patch, "utf8");
7143
+ await fs13.writeFile(patchPath, patch, "utf8");
6616
7144
  written.push(patchPath);
6617
7145
  }
6618
7146
  }
@@ -6652,9 +7180,9 @@ var CLAUDE_SKILL_CONFIG = {
6652
7180
  };
6653
7181
  function claudeConfigPath() {
6654
7182
  const override = process.env.NEAT_CLAUDE_CONFIG;
6655
- if (override && override.length > 0) return path13.resolve(override);
7183
+ if (override && override.length > 0) return path15.resolve(override);
6656
7184
  const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
6657
- return path13.join(home, ".claude.json");
7185
+ return path15.join(home, ".claude.json");
6658
7186
  }
6659
7187
  async function runSkill(opts) {
6660
7188
  const snippet = JSON.stringify(CLAUDE_SKILL_CONFIG, null, 2) + "\n";
@@ -6666,7 +7194,7 @@ async function runSkill(opts) {
6666
7194
  const target = claudeConfigPath();
6667
7195
  let existing = {};
6668
7196
  try {
6669
- existing = JSON.parse(await fs11.readFile(target, "utf8"));
7197
+ existing = JSON.parse(await fs13.readFile(target, "utf8"));
6670
7198
  } catch (err) {
6671
7199
  if (err.code !== "ENOENT") {
6672
7200
  console.error(`neat skill: failed to read ${target} \u2014 ${err.message}`);
@@ -6678,8 +7206,8 @@ async function runSkill(opts) {
6678
7206
  ...existing,
6679
7207
  mcpServers: { ...mcp, neat: CLAUDE_SKILL_CONFIG.mcpServers.neat }
6680
7208
  };
6681
- await fs11.mkdir(path13.dirname(target), { recursive: true });
6682
- await fs11.writeFile(target, JSON.stringify(merged, null, 2) + "\n", "utf8");
7209
+ await fs13.mkdir(path15.dirname(target), { recursive: true });
7210
+ await fs13.writeFile(target, JSON.stringify(merged, null, 2) + "\n", "utf8");
6683
7211
  console.log(`neat skill: wrote mcpServers.neat to ${target}`);
6684
7212
  console.log("restart Claude Code to pick up the new MCP server.");
6685
7213
  console.log("");
@@ -6752,7 +7280,7 @@ async function main() {
6752
7280
  }
6753
7281
  const cmd = argvParsed.positional[0];
6754
7282
  const parsed = { ...argvParsed, positional: argvParsed.positional.slice(1) };
6755
- const { positional, apply: apply4, dryRun, noInstall } = parsed;
7283
+ const { positional, apply: apply6, dryRun, noInstall } = parsed;
6756
7284
  const project = parsed.project ?? DEFAULT_PROJECT;
6757
7285
  if (cmd === "init") {
6758
7286
  const target = positional[0];
@@ -6761,22 +7289,22 @@ async function main() {
6761
7289
  usage4();
6762
7290
  process.exit(2);
6763
7291
  }
6764
- if (apply4 && dryRun) {
7292
+ if (apply6 && dryRun) {
6765
7293
  console.error("neat init: --apply and --dry-run are mutually exclusive");
6766
7294
  process.exit(2);
6767
7295
  }
6768
- const scanPath = path13.resolve(target);
7296
+ const scanPath = path15.resolve(target);
6769
7297
  const projectExplicit = parsed.project !== null;
6770
- const projectName = projectExplicit ? project : path13.basename(scanPath);
7298
+ const projectName = projectExplicit ? project : path15.basename(scanPath);
6771
7299
  const projectKey = projectExplicit ? project : DEFAULT_PROJECT;
6772
- const fallback = pathsForProject(projectKey, path13.join(scanPath, "neat-out")).snapshotPath;
6773
- const outPath = path13.resolve(process.env.NEAT_OUT_PATH ?? fallback);
7300
+ const fallback = pathsForProject(projectKey, path15.join(scanPath, "neat-out")).snapshotPath;
7301
+ const outPath = path15.resolve(process.env.NEAT_OUT_PATH ?? fallback);
6774
7302
  const result = await runInit({
6775
7303
  scanPath,
6776
7304
  outPath,
6777
7305
  project: projectName,
6778
7306
  projectExplicit,
6779
- apply: apply4,
7307
+ apply: apply6,
6780
7308
  dryRun,
6781
7309
  noInstall,
6782
7310
  verbose: parsed.verbose
@@ -6791,21 +7319,21 @@ async function main() {
6791
7319
  usage4();
6792
7320
  process.exit(2);
6793
7321
  }
6794
- const scanPath = path13.resolve(target);
6795
- const stat = await fs11.stat(scanPath).catch(() => null);
7322
+ const scanPath = path15.resolve(target);
7323
+ const stat = await fs13.stat(scanPath).catch(() => null);
6796
7324
  if (!stat || !stat.isDirectory()) {
6797
7325
  console.error(`neat watch: ${scanPath} is not a directory`);
6798
7326
  process.exit(2);
6799
7327
  }
6800
- const projectPaths = pathsForProject(project, path13.join(scanPath, "neat-out"));
6801
- const outPath = path13.resolve(process.env.NEAT_OUT_PATH ?? projectPaths.snapshotPath);
6802
- const errorsPath = path13.resolve(
6803
- process.env.NEAT_ERRORS_PATH ?? path13.join(path13.dirname(outPath), path13.basename(projectPaths.errorsPath))
7328
+ const projectPaths = pathsForProject(project, path15.join(scanPath, "neat-out"));
7329
+ const outPath = path15.resolve(process.env.NEAT_OUT_PATH ?? projectPaths.snapshotPath);
7330
+ const errorsPath = path15.resolve(
7331
+ process.env.NEAT_ERRORS_PATH ?? path15.join(path15.dirname(outPath), path15.basename(projectPaths.errorsPath))
6804
7332
  );
6805
- const staleEventsPath = path13.resolve(
6806
- process.env.NEAT_STALE_EVENTS_PATH ?? path13.join(path13.dirname(outPath), path13.basename(projectPaths.staleEventsPath))
7333
+ const staleEventsPath = path15.resolve(
7334
+ process.env.NEAT_STALE_EVENTS_PATH ?? path15.join(path15.dirname(outPath), path15.basename(projectPaths.staleEventsPath))
6807
7335
  );
6808
- const embeddingsCachePath = process.env.NEAT_EMBEDDINGS_CACHE_PATH ? path13.resolve(process.env.NEAT_EMBEDDINGS_CACHE_PATH) : void 0;
7336
+ const embeddingsCachePath = process.env.NEAT_EMBEDDINGS_CACHE_PATH ? path15.resolve(process.env.NEAT_EMBEDDINGS_CACHE_PATH) : void 0;
6809
7337
  const handle = await startWatch(getGraph(project), {
6810
7338
  scanPath,
6811
7339
  outPath,
@@ -6814,7 +7342,7 @@ async function main() {
6814
7342
  project,
6815
7343
  // Resolve NEAT_HOME so a `neat watch` picks up connectors added to
6816
7344
  // ~/.neat/connectors.json (#871). Same resolution the rest of the CLI uses.
6817
- neatHome: process.env.NEAT_HOME ? path13.resolve(process.env.NEAT_HOME) : path13.join(os4.homedir(), ".neat"),
7345
+ neatHome: process.env.NEAT_HOME ? path15.resolve(process.env.NEAT_HOME) : path15.join(os4.homedir(), ".neat"),
6818
7346
  ...embeddingsCachePath ? { embeddingsCachePath } : {},
6819
7347
  host: process.env.HOST ?? "0.0.0.0",
6820
7348
  port: Number(process.env.PORT ?? 8080),
@@ -6996,11 +7524,11 @@ async function main() {
6996
7524
  process.exit(1);
6997
7525
  }
6998
7526
  async function tryOrchestrator(cmd, parsed) {
6999
- const scanPath = path13.resolve(cmd);
7000
- const stat = await fs11.stat(scanPath).catch(() => null);
7527
+ const scanPath = path15.resolve(cmd);
7528
+ const stat = await fs13.stat(scanPath).catch(() => null);
7001
7529
  if (!stat || !stat.isDirectory()) return null;
7002
7530
  const projectExplicit = parsed.project !== null;
7003
- const projectName = projectExplicit ? parsed.project : path13.basename(scanPath);
7531
+ const projectName = projectExplicit ? parsed.project : path15.basename(scanPath);
7004
7532
  const result = await runOrchestrator({
7005
7533
  scanPath,
7006
7534
  project: projectName,