@neat.is/core 0.3.5 → 0.3.7

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.cjs CHANGED
@@ -258,8 +258,7 @@ function parseOtlpRequest(body) {
258
258
  }
259
259
  return out;
260
260
  }
261
- function loadProtobufDecoder() {
262
- if (exportTraceServiceRequestType) return exportTraceServiceRequestType;
261
+ function loadProtoRoot() {
263
262
  const here = import_node_path35.default.dirname((0, import_node_url2.fileURLToPath)(importMetaUrl));
264
263
  const protoRoot = import_node_path35.default.resolve(here, "..", "proto");
265
264
  const root = new import_protobufjs.default.Root();
@@ -268,11 +267,32 @@ function loadProtobufDecoder() {
268
267
  "opentelemetry/proto/collector/trace/v1/trace_service.proto",
269
268
  { keepCase: true }
270
269
  );
270
+ return root;
271
+ }
272
+ function loadProtobufDecoder() {
273
+ if (exportTraceServiceRequestType) return exportTraceServiceRequestType;
274
+ const root = loadProtoRoot();
271
275
  exportTraceServiceRequestType = root.lookupType(
272
276
  "opentelemetry.proto.collector.trace.v1.ExportTraceServiceRequest"
273
277
  );
274
278
  return exportTraceServiceRequestType;
275
279
  }
280
+ function loadProtobufResponseEncoder() {
281
+ if (exportTraceServiceResponseType) return exportTraceServiceResponseType;
282
+ const root = loadProtoRoot();
283
+ exportTraceServiceResponseType = root.lookupType(
284
+ "opentelemetry.proto.collector.trace.v1.ExportTraceServiceResponse"
285
+ );
286
+ return exportTraceServiceResponseType;
287
+ }
288
+ function encodeProtobufResponseBody() {
289
+ if (cachedProtobufResponseBody) return cachedProtobufResponseBody;
290
+ const Type = loadProtobufResponseEncoder();
291
+ const msg = Type.create({});
292
+ const encoded = Type.encode(msg).finish();
293
+ cachedProtobufResponseBody = Buffer.from(encoded);
294
+ return cachedProtobufResponseBody;
295
+ }
276
296
  async function decodeProtobufBody(buf) {
277
297
  const Type = loadProtobufDecoder();
278
298
  const decoded = Type.decode(buf).toJSON();
@@ -319,7 +339,9 @@ async function buildOtelReceiver(opts) {
319
339
  app.post("/v1/traces", async (req, reply) => {
320
340
  const ct = (req.headers["content-type"] ?? "").toString().split(";")[0].trim().toLowerCase();
321
341
  let body;
342
+ let responseFlavor;
322
343
  if (ct === "application/x-protobuf") {
344
+ responseFlavor = "protobuf";
323
345
  try {
324
346
  body = await decodeProtobufBody(req.body);
325
347
  } catch (err) {
@@ -328,6 +350,7 @@ async function buildOtelReceiver(opts) {
328
350
  });
329
351
  }
330
352
  } else if (!ct || ct === "application/json") {
353
+ responseFlavor = "json";
331
354
  body = req.body ?? {};
332
355
  } else {
333
356
  return reply.code(415).send({ error: `unsupported content-type: ${ct}` });
@@ -345,7 +368,11 @@ async function buildOtelReceiver(opts) {
345
368
  }
346
369
  }
347
370
  enqueue(spans);
348
- return reply.code(200).send({ partialSuccess: {} });
371
+ if (responseFlavor === "protobuf") {
372
+ const buf = encodeProtobufResponseBody();
373
+ return reply.code(200).header("content-type", "application/x-protobuf").send(buf);
374
+ }
375
+ return reply.code(200).header("content-type", "application/json").send({ partialSuccess: {} });
349
376
  });
350
377
  const decorated = app;
351
378
  decorated.flushPending = async () => {
@@ -355,7 +382,7 @@ async function buildOtelReceiver(opts) {
355
382
  };
356
383
  return decorated;
357
384
  }
358
- var import_node_path35, import_node_url2, import_fastify2, import_protobufjs, exportTraceServiceRequestType;
385
+ var import_node_path35, import_node_url2, import_fastify2, import_protobufjs, exportTraceServiceRequestType, exportTraceServiceResponseType, cachedProtobufResponseBody;
359
386
  var init_otel = __esm({
360
387
  "src/otel.ts"() {
361
388
  "use strict";
@@ -365,6 +392,8 @@ var init_otel = __esm({
365
392
  import_fastify2 = __toESM(require("fastify"), 1);
366
393
  import_protobufjs = __toESM(require("protobufjs"), 1);
367
394
  exportTraceServiceRequestType = null;
395
+ exportTraceServiceResponseType = null;
396
+ cachedProtobufResponseBody = null;
368
397
  }
369
398
  });
370
399
 
@@ -1663,7 +1692,7 @@ function buildErrorEventForReceiver(span) {
1663
1692
  service: span.service,
1664
1693
  traceId: span.traceId,
1665
1694
  spanId: span.spanId,
1666
- errorMessage: span.exception?.message ?? span.name ?? "unknown error",
1695
+ errorMessage: span.exception?.message ?? "unknown error",
1667
1696
  ...span.exception?.type ? { exceptionType: span.exception.type } : {},
1668
1697
  ...span.exception?.stacktrace ? { exceptionStacktrace: span.exception.stacktrace } : {},
1669
1698
  ...Object.keys(attrs).length > 0 ? { attributes: attrs } : {},
@@ -1755,7 +1784,7 @@ async function handleSpan(ctx, span) {
1755
1784
  service: span.service,
1756
1785
  traceId: span.traceId,
1757
1786
  spanId: span.spanId,
1758
- errorMessage: span.exception?.message ?? span.name ?? "unknown error",
1787
+ errorMessage: span.exception?.message ?? "unknown error",
1759
1788
  ...span.exception?.type ? { exceptionType: span.exception.type } : {},
1760
1789
  ...span.exception?.stacktrace ? { exceptionStacktrace: span.exception.stacktrace } : {},
1761
1790
  ...Object.keys(attrs).length > 0 ? { attributes: attrs } : {},
@@ -5911,15 +5940,69 @@ init_cjs_shims();
5911
5940
  init_cjs_shims();
5912
5941
  var import_node_fs23 = require("fs");
5913
5942
  var import_node_path38 = __toESM(require("path"), 1);
5943
+
5944
+ // src/installers/templates.ts
5945
+ init_cjs_shims();
5946
+ var OTEL_INIT_HEADER = "// Generated by `neat init --apply` (ADR-069). OpenTelemetry auto-instrumentation hook.";
5947
+ var OTEL_INIT_CJS = `${OTEL_INIT_HEADER}
5948
+ // Loads .env.neat (OTEL_SERVICE_NAME, OTEL_EXPORTER_OTLP_ENDPOINT) before
5949
+ // the auto-instrumentation hook attaches. Configure via the env file.
5950
+ const path = require('node:path')
5951
+ try {
5952
+ require('dotenv').config({ path: path.join(__dirname, '.env.neat') })
5953
+ } catch (err) {
5954
+ // dotenv unavailable \u2014 fall through to process.env.
5955
+ }
5956
+ require('@opentelemetry/auto-instrumentations-node/register')
5957
+ `;
5958
+ var OTEL_INIT_ESM = `${OTEL_INIT_HEADER}
5959
+ // Loads .env.neat (OTEL_SERVICE_NAME, OTEL_EXPORTER_OTLP_ENDPOINT) before
5960
+ // the auto-instrumentation hook attaches. Configure via the env file.
5961
+ import { fileURLToPath } from 'node:url'
5962
+ import path from 'node:path'
5963
+ import dotenv from 'dotenv'
5964
+
5965
+ const here = path.dirname(fileURLToPath(import.meta.url))
5966
+ dotenv.config({ path: path.join(here, '.env.neat') })
5967
+
5968
+ await import('@opentelemetry/auto-instrumentations-node/register')
5969
+ `;
5970
+ var OTEL_INIT_TS = `${OTEL_INIT_HEADER}
5971
+ // Loads .env.neat (OTEL_SERVICE_NAME, OTEL_EXPORTER_OTLP_ENDPOINT) before
5972
+ // the auto-instrumentation hook attaches. Configure via the env file.
5973
+ import { fileURLToPath } from 'node:url'
5974
+ import path from 'node:path'
5975
+ import dotenv from 'dotenv'
5976
+
5977
+ const here = path.dirname(fileURLToPath(import.meta.url))
5978
+ dotenv.config({ path: path.join(here, '.env.neat') })
5979
+
5980
+ await import('@opentelemetry/auto-instrumentations-node/register')
5981
+ `;
5982
+ function renderEnvNeat(serviceName) {
5983
+ return [
5984
+ "# Generated by `neat init --apply` (ADR-069).",
5985
+ `OTEL_SERVICE_NAME=${serviceName}`,
5986
+ "OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318",
5987
+ ""
5988
+ ].join("\n");
5989
+ }
5990
+
5991
+ // src/installers/javascript.ts
5914
5992
  var SDK_PACKAGES = [
5915
5993
  { name: "@opentelemetry/api", version: "^1.9.0" },
5916
5994
  { name: "@opentelemetry/sdk-node", version: "^0.57.0" },
5917
- { name: "@opentelemetry/auto-instrumentations-node", version: "^0.55.0" }
5995
+ { name: "@opentelemetry/auto-instrumentations-node", version: "^0.55.0" },
5996
+ // ADR-069 §5 — dotenv is the fourth dep. The generated otel-init loads
5997
+ // .env.neat through it so OTEL_SERVICE_NAME and the endpoint are in scope
5998
+ // before the auto-instrumentation hook attaches.
5999
+ { name: "dotenv", version: "^16.4.5" }
5918
6000
  ];
5919
- var AUTO_INSTRUMENT_REQUIRE = "--require @opentelemetry/auto-instrumentations-node/register";
5920
6001
  var OTEL_ENV = {
5921
- // null targetNEAT does not write `.env` itself; the user sets the env
5922
- // var in their orchestration layer.
6002
+ // ADR-069 §4endpoint moves into the per-package .env.neat (written
6003
+ // by the apply phase). The envEdits surface stays for the dry-run
6004
+ // patch render: it documents the key/value the user can inspect in the
6005
+ // generated .env.neat.
5923
6006
  file: null,
5924
6007
  key: "OTEL_EXPORTER_OTLP_ENDPOINT",
5925
6008
  value: "http://localhost:4318"
@@ -5932,16 +6015,134 @@ async function readPackageJson(serviceDir) {
5932
6015
  return null;
5933
6016
  }
5934
6017
  }
6018
+ async function exists2(p) {
6019
+ try {
6020
+ await import_node_fs23.promises.stat(p);
6021
+ return true;
6022
+ } catch {
6023
+ return false;
6024
+ }
6025
+ }
5935
6026
  async function detect(serviceDir) {
5936
6027
  const pkg = await readPackageJson(serviceDir);
5937
6028
  return pkg !== null && typeof pkg.name === "string";
5938
6029
  }
5939
- function rewriteStartScript(start) {
5940
- if (start.includes(AUTO_INSTRUMENT_REQUIRE)) return start;
5941
- if (/^\s*node\b/.test(start)) {
5942
- return start.replace(/^\s*node\b\s*/, `node ${AUTO_INSTRUMENT_REQUIRE} `);
6030
+ var INDEX_EXTENSIONS = [".ts", ".tsx", ".js", ".mjs", ".cjs"];
6031
+ var INDEX_CANDIDATES = INDEX_EXTENSIONS.map((ext) => `index${ext}`);
6032
+ var SRC_INDEX_CANDIDATES = INDEX_EXTENSIONS.map((ext) => `src/index${ext}`);
6033
+ var SRC_NAMED_CANDIDATES = ["server", "main", "app"].flatMap(
6034
+ (name) => INDEX_EXTENSIONS.map((ext) => `src/${name}${ext}`)
6035
+ );
6036
+ var SCRIPT_LAUNCHERS = /* @__PURE__ */ new Set([
6037
+ "node",
6038
+ "ts-node",
6039
+ "tsx",
6040
+ "ts-node-dev",
6041
+ "nodemon",
6042
+ "npx",
6043
+ "pnpm",
6044
+ "yarn",
6045
+ "npm",
6046
+ "cross-env",
6047
+ "dotenv",
6048
+ "--"
6049
+ ]);
6050
+ function looksLikeEntryPath(token) {
6051
+ if (token.length === 0) return false;
6052
+ if (token.startsWith("-")) return false;
6053
+ if (token.includes("=")) return false;
6054
+ if (token.includes("/")) return true;
6055
+ return /\.(?:m?[jt]sx?|c[jt]s)$/.test(token);
6056
+ }
6057
+ function scriptHasShellChain(script) {
6058
+ return /(?:&&|\|\||;|\|(?!\|))/.test(script);
6059
+ }
6060
+ function entryFromScript(script) {
6061
+ if (!script) return void 0;
6062
+ if (scriptHasShellChain(script)) return void 0;
6063
+ const tokens = script.split(/\s+/).filter((t) => t.length > 0);
6064
+ for (const token of tokens) {
6065
+ const lower = token.toLowerCase();
6066
+ if (SCRIPT_LAUNCHERS.has(lower)) continue;
6067
+ const cleaned = token.startsWith("./") ? token.slice(2) : token;
6068
+ if (looksLikeEntryPath(cleaned)) return cleaned;
6069
+ }
6070
+ return void 0;
6071
+ }
6072
+ async function resolveEntry(serviceDir, pkg) {
6073
+ if (typeof pkg.main === "string" && pkg.main.length > 0) {
6074
+ const candidate = import_node_path38.default.resolve(serviceDir, pkg.main);
6075
+ if (await exists2(candidate)) return candidate;
6076
+ }
6077
+ if (pkg.bin) {
6078
+ let binEntry;
6079
+ if (typeof pkg.bin === "string") {
6080
+ binEntry = pkg.bin;
6081
+ } else if (pkg.name && typeof pkg.bin[pkg.name] === "string") {
6082
+ binEntry = pkg.bin[pkg.name];
6083
+ } else {
6084
+ const first = Object.values(pkg.bin)[0];
6085
+ if (typeof first === "string") binEntry = first;
6086
+ }
6087
+ if (binEntry) {
6088
+ const candidate = import_node_path38.default.resolve(serviceDir, binEntry);
6089
+ if (await exists2(candidate)) return candidate;
6090
+ }
6091
+ }
6092
+ const startEntry = entryFromScript(pkg.scripts?.start);
6093
+ if (startEntry) {
6094
+ const candidate = import_node_path38.default.resolve(serviceDir, startEntry);
6095
+ if (await exists2(candidate)) return candidate;
6096
+ }
6097
+ const devEntry = entryFromScript(pkg.scripts?.dev);
6098
+ if (devEntry) {
6099
+ const candidate = import_node_path38.default.resolve(serviceDir, devEntry);
6100
+ if (await exists2(candidate)) return candidate;
6101
+ }
6102
+ for (const rel of SRC_INDEX_CANDIDATES) {
6103
+ const candidate = import_node_path38.default.join(serviceDir, rel);
6104
+ if (await exists2(candidate)) return candidate;
6105
+ }
6106
+ for (const rel of SRC_NAMED_CANDIDATES) {
6107
+ const candidate = import_node_path38.default.join(serviceDir, rel);
6108
+ if (await exists2(candidate)) return candidate;
6109
+ }
6110
+ for (const name of INDEX_CANDIDATES) {
6111
+ const candidate = import_node_path38.default.join(serviceDir, name);
6112
+ if (await exists2(candidate)) return candidate;
5943
6113
  }
5944
- return `node ${AUTO_INSTRUMENT_REQUIRE} -- ${start}`;
6114
+ return null;
6115
+ }
6116
+ function dispatchEntry(entryFile, pkg) {
6117
+ const ext = import_node_path38.default.extname(entryFile).toLowerCase();
6118
+ if (ext === ".ts" || ext === ".tsx") return "ts";
6119
+ if (ext === ".mjs") return "esm";
6120
+ if (ext === ".cjs") return "cjs";
6121
+ return pkg.type === "module" ? "esm" : "cjs";
6122
+ }
6123
+ function otelInitFilename(flavor) {
6124
+ if (flavor === "ts") return "otel-init.ts";
6125
+ if (flavor === "esm") return "otel-init.mjs";
6126
+ return "otel-init.cjs";
6127
+ }
6128
+ function otelInitContents(flavor) {
6129
+ if (flavor === "ts") return OTEL_INIT_TS;
6130
+ if (flavor === "esm") return OTEL_INIT_ESM;
6131
+ return OTEL_INIT_CJS;
6132
+ }
6133
+ function injectionLine(flavor, entryFile, otelInitFile) {
6134
+ let rel = import_node_path38.default.relative(import_node_path38.default.dirname(entryFile), otelInitFile);
6135
+ if (!rel.startsWith(".")) rel = `./${rel}`;
6136
+ rel = rel.split(import_node_path38.default.sep).join("/");
6137
+ if (flavor === "cjs") return `require('${rel}')`;
6138
+ if (flavor === "esm") return `import '${rel}'`;
6139
+ const tsRel = rel.replace(/\.ts$/, "");
6140
+ return `import '${tsRel}'`;
6141
+ }
6142
+ function lineIsOtelInjection(line) {
6143
+ const trimmed = line.trim();
6144
+ if (trimmed.length === 0) return false;
6145
+ return /(?:require\(|import\s+)['"]\.\/otel-init[^'"]*['"]/.test(trimmed);
5945
6146
  }
5946
6147
  async function plan(serviceDir) {
5947
6148
  const pkg = await readPackageJson(serviceDir);
@@ -5951,9 +6152,17 @@ async function plan(serviceDir) {
5951
6152
  serviceDir,
5952
6153
  dependencyEdits: [],
5953
6154
  entrypointEdits: [],
5954
- envEdits: []
6155
+ envEdits: [],
6156
+ generatedFiles: []
5955
6157
  };
5956
6158
  if (!pkg) return empty;
6159
+ const entryFile = await resolveEntry(serviceDir, pkg);
6160
+ if (!entryFile) {
6161
+ return { ...empty, libOnly: true };
6162
+ }
6163
+ const flavor = dispatchEntry(entryFile, pkg);
6164
+ const otelInitFile = import_node_path38.default.join(import_node_path38.default.dirname(entryFile), otelInitFilename(flavor));
6165
+ const envNeatFile = import_node_path38.default.join(serviceDir, ".env.neat");
5957
6166
  const existingDeps = { ...pkg.dependencies ?? {}, ...pkg.devDependencies ?? {} };
5958
6167
  const dependencyEdits = [];
5959
6168
  for (const sdk of SDK_PACKAGES) {
@@ -5966,14 +6175,37 @@ async function plan(serviceDir) {
5966
6175
  });
5967
6176
  }
5968
6177
  const entrypointEdits = [];
5969
- const startScript = pkg.scripts?.start;
5970
- if (typeof startScript === "string" && startScript.trim().length > 0) {
5971
- const rewritten = rewriteStartScript(startScript);
5972
- if (rewritten !== startScript) {
5973
- entrypointEdits.push({ file: manifestPath, before: startScript, after: rewritten });
6178
+ try {
6179
+ const raw = await import_node_fs23.promises.readFile(entryFile, "utf8");
6180
+ const lines = raw.split(/\r?\n/);
6181
+ const firstReal = lines[0]?.startsWith("#!") ? lines[1] ?? "" : lines[0] ?? "";
6182
+ if (!lineIsOtelInjection(firstReal)) {
6183
+ const inject = injectionLine(flavor, entryFile, otelInitFile);
6184
+ entrypointEdits.push({
6185
+ file: entryFile,
6186
+ before: firstReal,
6187
+ after: inject
6188
+ });
5974
6189
  }
6190
+ } catch {
6191
+ return { ...empty, libOnly: true };
6192
+ }
6193
+ const generatedFiles = [];
6194
+ if (!await exists2(otelInitFile)) {
6195
+ generatedFiles.push({
6196
+ file: otelInitFile,
6197
+ contents: otelInitContents(flavor),
6198
+ skipIfExists: true
6199
+ });
5975
6200
  }
5976
- if (dependencyEdits.length === 0 && entrypointEdits.length === 0) {
6201
+ if (!await exists2(envNeatFile)) {
6202
+ generatedFiles.push({
6203
+ file: envNeatFile,
6204
+ contents: renderEnvNeat(pkg.name ?? import_node_path38.default.basename(serviceDir)),
6205
+ skipIfExists: true
6206
+ });
6207
+ }
6208
+ if (dependencyEdits.length === 0 && entrypointEdits.length === 0 && generatedFiles.length === 0) {
5977
6209
  return empty;
5978
6210
  }
5979
6211
  return {
@@ -5981,53 +6213,129 @@ async function plan(serviceDir) {
5981
6213
  serviceDir,
5982
6214
  dependencyEdits,
5983
6215
  entrypointEdits,
5984
- envEdits: [OTEL_ENV]
6216
+ envEdits: [OTEL_ENV],
6217
+ generatedFiles,
6218
+ entryFile,
6219
+ libOnly: false
5985
6220
  };
5986
6221
  }
6222
+ function isAllowedWritePath(serviceDir, target) {
6223
+ const rel = import_node_path38.default.relative(serviceDir, target);
6224
+ if (rel.startsWith("..")) return false;
6225
+ const base = import_node_path38.default.basename(target);
6226
+ if (base === "package.json") return true;
6227
+ if (base === ".env.neat") return true;
6228
+ if (/^otel-init\.(?:js|cjs|mjs|ts)$/.test(base)) return true;
6229
+ return false;
6230
+ }
6231
+ async function writeAtomic(file, contents) {
6232
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
6233
+ await import_node_fs23.promises.writeFile(tmp, contents, "utf8");
6234
+ await import_node_fs23.promises.rename(tmp, file);
6235
+ }
5987
6236
  async function apply(installPlan) {
5988
- const touched = /* @__PURE__ */ new Set();
5989
- for (const e of installPlan.dependencyEdits) touched.add(e.file);
5990
- for (const e of installPlan.entrypointEdits) touched.add(e.file);
5991
- if (touched.size === 0) return;
6237
+ const { serviceDir } = installPlan;
6238
+ if (installPlan.libOnly) {
6239
+ return {
6240
+ serviceDir,
6241
+ outcome: "lib-only",
6242
+ reason: "no resolvable entry point",
6243
+ writtenFiles: []
6244
+ };
6245
+ }
6246
+ if (installPlan.dependencyEdits.length === 0 && installPlan.entrypointEdits.length === 0 && (installPlan.generatedFiles?.length ?? 0) === 0) {
6247
+ return {
6248
+ serviceDir,
6249
+ outcome: "already-instrumented",
6250
+ writtenFiles: []
6251
+ };
6252
+ }
6253
+ const allTargets = /* @__PURE__ */ new Set();
6254
+ for (const d of installPlan.dependencyEdits) allTargets.add(d.file);
6255
+ for (const e of installPlan.entrypointEdits) allTargets.add(e.file);
6256
+ for (const g of installPlan.generatedFiles ?? []) allTargets.add(g.file);
6257
+ for (const target of allTargets) {
6258
+ const isEntryEdit = installPlan.entrypointEdits.some((e) => e.file === target);
6259
+ if (isEntryEdit) continue;
6260
+ if (!isAllowedWritePath(serviceDir, target)) {
6261
+ throw new Error(
6262
+ `javascript installer: refusing to write outside the allowed path set (ADR-069 \xA77): ${target}`
6263
+ );
6264
+ }
6265
+ }
5992
6266
  const originals = /* @__PURE__ */ new Map();
5993
- for (const file of touched) {
5994
- try {
5995
- originals.set(file, await import_node_fs23.promises.readFile(file, "utf8"));
5996
- } catch {
6267
+ const createdFiles = [];
6268
+ for (const target of allTargets) {
6269
+ if (await exists2(target)) {
6270
+ try {
6271
+ originals.set(target, await import_node_fs23.promises.readFile(target, "utf8"));
6272
+ } catch {
6273
+ }
5997
6274
  }
5998
6275
  }
6276
+ const writtenFiles = [];
5999
6277
  try {
6000
- for (const file of touched) {
6001
- const raw = originals.get(file) ?? "";
6278
+ const manifestTargets = installPlan.dependencyEdits.reduce((acc, e) => {
6279
+ acc.add(e.file);
6280
+ return acc;
6281
+ }, /* @__PURE__ */ new Set());
6282
+ for (const file of manifestTargets) {
6283
+ const raw = originals.get(file);
6284
+ if (raw === void 0) {
6285
+ throw new Error(`javascript installer: cannot read ${file} during apply`);
6286
+ }
6002
6287
  const pkg = JSON.parse(raw);
6003
6288
  pkg.dependencies = pkg.dependencies ?? {};
6004
6289
  for (const dep of installPlan.dependencyEdits) {
6005
6290
  if (dep.file !== file) continue;
6006
6291
  if (dep.kind === "add") {
6007
- pkg.dependencies[dep.name] = dep.version;
6292
+ if (!(dep.name in (pkg.dependencies ?? {}))) {
6293
+ pkg.dependencies[dep.name] = dep.version;
6294
+ }
6008
6295
  } else {
6009
6296
  delete pkg.dependencies[dep.name];
6010
6297
  }
6011
6298
  }
6012
- for (const ep of installPlan.entrypointEdits) {
6013
- if (ep.file !== file) continue;
6014
- pkg.scripts = pkg.scripts ?? {};
6015
- if (pkg.scripts.start === ep.before) {
6016
- pkg.scripts.start = ep.after;
6017
- }
6018
- }
6019
6299
  const newRaw = JSON.stringify(pkg, null, 2) + "\n";
6020
- const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
6021
- await import_node_fs23.promises.writeFile(tmp, newRaw, "utf8");
6022
- await import_node_fs23.promises.rename(tmp, file);
6300
+ await writeAtomic(file, newRaw);
6301
+ writtenFiles.push(file);
6302
+ }
6303
+ for (const gen of installPlan.generatedFiles ?? []) {
6304
+ if (gen.skipIfExists && await exists2(gen.file)) {
6305
+ continue;
6306
+ }
6307
+ await writeAtomic(gen.file, gen.contents);
6308
+ if (!originals.has(gen.file)) createdFiles.push(gen.file);
6309
+ writtenFiles.push(gen.file);
6310
+ }
6311
+ for (const ep of installPlan.entrypointEdits) {
6312
+ const raw = originals.get(ep.file);
6313
+ if (raw === void 0) {
6314
+ throw new Error(`javascript installer: cannot read entry ${ep.file} during apply`);
6315
+ }
6316
+ const lines = raw.split(/\r?\n/);
6317
+ const hasShebang = lines[0]?.startsWith("#!") ?? false;
6318
+ const insertAt = hasShebang ? 1 : 0;
6319
+ const firstReal = lines[insertAt] ?? "";
6320
+ if (lineIsOtelInjection(firstReal)) continue;
6321
+ lines.splice(insertAt, 0, ep.after);
6322
+ const newRaw = lines.join("\n");
6323
+ await writeAtomic(ep.file, newRaw);
6324
+ writtenFiles.push(ep.file);
6023
6325
  }
6024
6326
  } catch (err) {
6025
- await rollback(installPlan, originals);
6327
+ await rollback(installPlan, originals, createdFiles);
6026
6328
  throw err;
6027
6329
  }
6330
+ return {
6331
+ serviceDir,
6332
+ outcome: "instrumented",
6333
+ writtenFiles
6334
+ };
6028
6335
  }
6029
- async function rollback(installPlan, originals) {
6336
+ async function rollback(installPlan, originals, createdFiles) {
6030
6337
  const restored = [];
6338
+ const removed = [];
6031
6339
  for (const [file, raw] of originals.entries()) {
6032
6340
  try {
6033
6341
  await import_node_fs23.promises.writeFile(file, raw, "utf8");
@@ -6035,6 +6343,13 @@ async function rollback(installPlan, originals) {
6035
6343
  } catch {
6036
6344
  }
6037
6345
  }
6346
+ for (const file of createdFiles) {
6347
+ try {
6348
+ await import_node_fs23.promises.unlink(file);
6349
+ removed.push(file);
6350
+ } catch {
6351
+ }
6352
+ }
6038
6353
  const lines = [
6039
6354
  "# neat-rollback.patch",
6040
6355
  "",
@@ -6042,6 +6357,7 @@ async function rollback(installPlan, originals) {
6042
6357
  "# Files listed below were restored to their pre-apply contents.",
6043
6358
  "",
6044
6359
  ...restored.map((f) => `restored: ${f}`),
6360
+ ...removed.map((f) => `removed: ${f}`),
6045
6361
  ""
6046
6362
  ];
6047
6363
  const rollbackPath = import_node_path38.default.join(installPlan.serviceDir, "neat-rollback.patch");
@@ -6067,7 +6383,7 @@ var OTEL_ENV2 = {
6067
6383
  key: "OTEL_EXPORTER_OTLP_ENDPOINT",
6068
6384
  value: "http://localhost:4318"
6069
6385
  };
6070
- async function exists2(p) {
6386
+ async function exists3(p) {
6071
6387
  try {
6072
6388
  await import_node_fs24.promises.stat(p);
6073
6389
  return true;
@@ -6078,7 +6394,7 @@ async function exists2(p) {
6078
6394
  async function detect2(serviceDir) {
6079
6395
  const markers = ["requirements.txt", "pyproject.toml", "setup.py"];
6080
6396
  for (const m of markers) {
6081
- if (await exists2(import_node_path39.default.join(serviceDir, m))) return true;
6397
+ if (await exists3(import_node_path39.default.join(serviceDir, m))) return true;
6082
6398
  }
6083
6399
  return false;
6084
6400
  }
@@ -6089,7 +6405,7 @@ function reqPackageName(line) {
6089
6405
  }
6090
6406
  async function planRequirementsTxtEdits(serviceDir) {
6091
6407
  const file = import_node_path39.default.join(serviceDir, "requirements.txt");
6092
- if (!await exists2(file)) return null;
6408
+ if (!await exists3(file)) return null;
6093
6409
  const raw = await import_node_fs24.promises.readFile(file, "utf8");
6094
6410
  const presentNames = new Set(
6095
6411
  raw.split(/\r?\n/).map(reqPackageName).filter((n) => n.length > 0)
@@ -6099,7 +6415,7 @@ async function planRequirementsTxtEdits(serviceDir) {
6099
6415
  }
6100
6416
  async function planProcfileEdits(serviceDir) {
6101
6417
  const procfile = import_node_path39.default.join(serviceDir, "Procfile");
6102
- if (!await exists2(procfile)) return [];
6418
+ if (!await exists3(procfile)) return [];
6103
6419
  const raw = await import_node_fs24.promises.readFile(procfile, "utf8");
6104
6420
  const edits = [];
6105
6421
  for (const line of raw.split(/\r?\n/)) {
@@ -6166,10 +6482,13 @@ async function applyProcfile(procfile, edits, original) {
6166
6482
  await import_node_fs24.promises.rename(tmp, procfile);
6167
6483
  }
6168
6484
  async function apply2(installPlan) {
6485
+ const { serviceDir } = installPlan;
6169
6486
  const touched = /* @__PURE__ */ new Set();
6170
6487
  for (const e of installPlan.dependencyEdits) touched.add(e.file);
6171
6488
  for (const e of installPlan.entrypointEdits) touched.add(e.file);
6172
- if (touched.size === 0) return;
6489
+ if (touched.size === 0) {
6490
+ return { serviceDir, outcome: "already-instrumented", writtenFiles: [] };
6491
+ }
6173
6492
  const originals = /* @__PURE__ */ new Map();
6174
6493
  for (const file of touched) {
6175
6494
  try {
@@ -6177,6 +6496,7 @@ async function apply2(installPlan) {
6177
6496
  } catch {
6178
6497
  }
6179
6498
  }
6499
+ const writtenFiles = [];
6180
6500
  try {
6181
6501
  for (const file of touched) {
6182
6502
  const raw = originals.get(file);
@@ -6186,16 +6506,23 @@ async function apply2(installPlan) {
6186
6506
  const base = import_node_path39.default.basename(file);
6187
6507
  if (base === "requirements.txt") {
6188
6508
  const edits = installPlan.dependencyEdits.filter((e) => e.file === file);
6189
- if (edits.length > 0) await applyRequirementsTxt(file, edits, raw);
6509
+ if (edits.length > 0) {
6510
+ await applyRequirementsTxt(file, edits, raw);
6511
+ writtenFiles.push(file);
6512
+ }
6190
6513
  } else if (base === "Procfile") {
6191
6514
  const edits = installPlan.entrypointEdits.filter((e) => e.file === file);
6192
- if (edits.length > 0) await applyProcfile(file, edits, raw);
6515
+ if (edits.length > 0) {
6516
+ await applyProcfile(file, edits, raw);
6517
+ writtenFiles.push(file);
6518
+ }
6193
6519
  }
6194
6520
  }
6195
6521
  } catch (err) {
6196
6522
  await rollback2(installPlan, originals);
6197
6523
  throw err;
6198
6524
  }
6525
+ return { serviceDir, outcome: "instrumented", writtenFiles };
6199
6526
  }
6200
6527
  async function rollback2(installPlan, originals) {
6201
6528
  const restored = [];
@@ -6228,7 +6555,7 @@ var pythonInstaller = {
6228
6555
  // src/installers/shared.ts
6229
6556
  init_cjs_shims();
6230
6557
  function isEmptyPlan(plan3) {
6231
- return plan3.dependencyEdits.length === 0 && plan3.entrypointEdits.length === 0 && plan3.envEdits.length === 0;
6558
+ return plan3.dependencyEdits.length === 0 && plan3.entrypointEdits.length === 0 && plan3.envEdits.length === 0 && (plan3.generatedFiles?.length ?? 0) === 0;
6232
6559
  }
6233
6560
 
6234
6561
  // src/installers/index.ts
@@ -6270,8 +6597,18 @@ function renderPatch(sections) {
6270
6597
  const { installer, plan: plan3 } = section;
6271
6598
  lines.push(`## ${installer} (${plan3.language}) \u2014 ${plan3.serviceDir}`);
6272
6599
  lines.push("");
6600
+ if (plan3.libOnly) {
6601
+ lines.push("### skipped \u2014 no resolvable entry point (lib-only)");
6602
+ lines.push("");
6603
+ continue;
6604
+ }
6605
+ if (plan3.entryFile) {
6606
+ lines.push(`entry: ${plan3.entryFile}`);
6607
+ lines.push("");
6608
+ }
6273
6609
  if (plan3.dependencyEdits.length > 0) {
6274
6610
  lines.push("### dependencies");
6611
+ const byFile = /* @__PURE__ */ new Map();
6275
6612
  for (const dep of plan3.dependencyEdits) {
6276
6613
  const base = dep.file.split(/[\\/]/).pop() ?? dep.file;
6277
6614
  if (FORBIDDEN_LOCKFILES.has(base)) {
@@ -6279,24 +6616,41 @@ function renderPatch(sections) {
6279
6616
  `installer "${installer}" produced a dependency edit against a lockfile (${dep.file}); lockfiles must never be touched (ADR-047).`
6280
6617
  );
6281
6618
  }
6282
- lines.push(`- ${dep.kind} ${dep.name}@${dep.version} in ${dep.file}`);
6619
+ const existing = byFile.get(dep.file) ?? [];
6620
+ existing.push(dep);
6621
+ byFile.set(dep.file, existing);
6622
+ }
6623
+ for (const [file, deps] of byFile) {
6624
+ lines.push(`--- ${file}`);
6625
+ for (const dep of deps) {
6626
+ lines.push(`+ "${dep.name}": "${dep.version}"`);
6627
+ }
6628
+ }
6629
+ lines.push("");
6630
+ }
6631
+ if (plan3.generatedFiles && plan3.generatedFiles.length > 0) {
6632
+ lines.push("### generated files");
6633
+ for (const gen of plan3.generatedFiles) {
6634
+ lines.push(`--- (new file) ${gen.file}`);
6635
+ for (const ln of gen.contents.split(/\r?\n/)) {
6636
+ lines.push(`+ ${ln}`);
6637
+ }
6283
6638
  }
6284
6639
  lines.push("");
6285
6640
  }
6286
6641
  if (plan3.entrypointEdits.length > 0) {
6287
- lines.push("### entrypoint");
6642
+ lines.push("### entry-point injection");
6288
6643
  for (const e of plan3.entrypointEdits) {
6289
- lines.push(`- ${e.file}`);
6290
- lines.push(` - before: ${e.before}`);
6291
- lines.push(` - after: ${e.after}`);
6644
+ lines.push(`--- ${e.file}`);
6645
+ lines.push(`+ ${e.after}`);
6646
+ lines.push(` ${e.before}`);
6292
6647
  }
6293
6648
  lines.push("");
6294
6649
  }
6295
6650
  if (plan3.envEdits.length > 0) {
6296
- lines.push("### env");
6651
+ lines.push("### env (written to <package-dir>/.env.neat)");
6297
6652
  for (const env of plan3.envEdits) {
6298
- const target = env.file ?? "(set in your orchestration layer)";
6299
- lines.push(`- ${env.key}=${env.value} \u2192 ${target}`);
6653
+ lines.push(`- ${env.key}=${env.value}`);
6300
6654
  }
6301
6655
  lines.push("");
6302
6656
  }
@@ -7045,7 +7399,7 @@ async function buildPatchSections(services) {
7045
7399
  const installer = await pickInstaller(svc.dir);
7046
7400
  if (!installer) continue;
7047
7401
  const plan3 = await installer.plan(svc.dir);
7048
- if (isEmptyPlan(plan3)) continue;
7402
+ if (isEmptyPlan(plan3) && !plan3.libOnly) continue;
7049
7403
  sections.push({ installer: installer.name, plan: plan3 });
7050
7404
  }
7051
7405
  return sections;
@@ -7098,14 +7452,28 @@ async function runInit(opts) {
7098
7452
  }
7099
7453
  if (!opts.noInstall) {
7100
7454
  if (opts.apply) {
7455
+ let instrumented = 0;
7456
+ let alreadyInstrumented = 0;
7457
+ let libOnly = 0;
7101
7458
  for (const section of sections) {
7102
7459
  const installer = INSTALLERS.find((i) => i.name === section.installer);
7103
7460
  if (!installer) continue;
7104
- await installer.apply(section.plan);
7461
+ const outcome = await installer.apply(section.plan);
7462
+ if (outcome.outcome === "instrumented") {
7463
+ instrumented++;
7464
+ for (const f of outcome.writtenFiles) written.push(f);
7465
+ } else if (outcome.outcome === "already-instrumented") {
7466
+ alreadyInstrumented++;
7467
+ } else if (outcome.outcome === "lib-only") {
7468
+ libOnly++;
7469
+ }
7105
7470
  }
7106
7471
  if (sections.length > 0) {
7107
7472
  console.log("");
7108
- console.log("patch applied. Run `npm install` (or your language equivalent) to refresh lockfiles.");
7473
+ console.log(
7474
+ `apply: instrumented ${instrumented}, already-instrumented ${alreadyInstrumented}, lib-only ${libOnly}`
7475
+ );
7476
+ console.log("Run `npm install` (or your language equivalent) to refresh lockfiles.");
7109
7477
  }
7110
7478
  } else {
7111
7479
  await import_node_fs25.promises.writeFile(patchPath, patch, "utf8");