@neat.is/core 0.3.4 → 0.3.6

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
@@ -39,7 +39,7 @@ import {
39
39
  setStatus,
40
40
  startPersistLoop,
41
41
  startStalenessLoop
42
- } from "./chunk-33ZZ2ZID.js";
42
+ } from "./chunk-ZU2RQRCN.js";
43
43
  import {
44
44
  startOtelGrpcReceiver
45
45
  } from "./chunk-G3PDTGOW.js";
@@ -398,15 +398,68 @@ async function startWatch(graph, opts) {
398
398
  // src/installers/javascript.ts
399
399
  import { promises as fs2 } from "fs";
400
400
  import path2 from "path";
401
+
402
+ // src/installers/templates.ts
403
+ var OTEL_INIT_HEADER = "// Generated by `neat init --apply` (ADR-069). OpenTelemetry auto-instrumentation hook.";
404
+ var OTEL_INIT_CJS = `${OTEL_INIT_HEADER}
405
+ // Loads .env.neat (OTEL_SERVICE_NAME, OTEL_EXPORTER_OTLP_ENDPOINT) before
406
+ // the auto-instrumentation hook attaches. Configure via the env file.
407
+ const path = require('node:path')
408
+ try {
409
+ require('dotenv').config({ path: path.join(__dirname, '.env.neat') })
410
+ } catch (err) {
411
+ // dotenv unavailable \u2014 fall through to process.env.
412
+ }
413
+ require('@opentelemetry/auto-instrumentations-node/register')
414
+ `;
415
+ var OTEL_INIT_ESM = `${OTEL_INIT_HEADER}
416
+ // Loads .env.neat (OTEL_SERVICE_NAME, OTEL_EXPORTER_OTLP_ENDPOINT) before
417
+ // the auto-instrumentation hook attaches. Configure via the env file.
418
+ import { fileURLToPath } from 'node:url'
419
+ import path from 'node:path'
420
+ import dotenv from 'dotenv'
421
+
422
+ const here = path.dirname(fileURLToPath(import.meta.url))
423
+ dotenv.config({ path: path.join(here, '.env.neat') })
424
+
425
+ await import('@opentelemetry/auto-instrumentations-node/register')
426
+ `;
427
+ var OTEL_INIT_TS = `${OTEL_INIT_HEADER}
428
+ // Loads .env.neat (OTEL_SERVICE_NAME, OTEL_EXPORTER_OTLP_ENDPOINT) before
429
+ // the auto-instrumentation hook attaches. Configure via the env file.
430
+ import { fileURLToPath } from 'node:url'
431
+ import path from 'node:path'
432
+ import dotenv from 'dotenv'
433
+
434
+ const here = path.dirname(fileURLToPath(import.meta.url))
435
+ dotenv.config({ path: path.join(here, '.env.neat') })
436
+
437
+ await import('@opentelemetry/auto-instrumentations-node/register')
438
+ `;
439
+ function renderEnvNeat(serviceName) {
440
+ return [
441
+ "# Generated by `neat init --apply` (ADR-069).",
442
+ `OTEL_SERVICE_NAME=${serviceName}`,
443
+ "OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318",
444
+ ""
445
+ ].join("\n");
446
+ }
447
+
448
+ // src/installers/javascript.ts
401
449
  var SDK_PACKAGES = [
402
450
  { name: "@opentelemetry/api", version: "^1.9.0" },
403
451
  { name: "@opentelemetry/sdk-node", version: "^0.57.0" },
404
- { name: "@opentelemetry/auto-instrumentations-node", version: "^0.55.0" }
452
+ { name: "@opentelemetry/auto-instrumentations-node", version: "^0.55.0" },
453
+ // ADR-069 §5 — dotenv is the fourth dep. The generated otel-init loads
454
+ // .env.neat through it so OTEL_SERVICE_NAME and the endpoint are in scope
455
+ // before the auto-instrumentation hook attaches.
456
+ { name: "dotenv", version: "^16.4.5" }
405
457
  ];
406
- var AUTO_INSTRUMENT_REQUIRE = "--require @opentelemetry/auto-instrumentations-node/register";
407
458
  var OTEL_ENV = {
408
- // null targetNEAT does not write `.env` itself; the user sets the env
409
- // var in their orchestration layer.
459
+ // ADR-069 §4endpoint moves into the per-package .env.neat (written
460
+ // by the apply phase). The envEdits surface stays for the dry-run
461
+ // patch render: it documents the key/value the user can inspect in the
462
+ // generated .env.neat.
410
463
  file: null,
411
464
  key: "OTEL_EXPORTER_OTLP_ENDPOINT",
412
465
  value: "http://localhost:4318"
@@ -419,16 +472,75 @@ async function readPackageJson(serviceDir) {
419
472
  return null;
420
473
  }
421
474
  }
475
+ async function exists(p) {
476
+ try {
477
+ await fs2.stat(p);
478
+ return true;
479
+ } catch {
480
+ return false;
481
+ }
482
+ }
422
483
  async function detect(serviceDir) {
423
484
  const pkg = await readPackageJson(serviceDir);
424
485
  return pkg !== null && typeof pkg.name === "string";
425
486
  }
426
- function rewriteStartScript(start) {
427
- if (start.includes(AUTO_INSTRUMENT_REQUIRE)) return start;
428
- if (/^\s*node\b/.test(start)) {
429
- return start.replace(/^\s*node\b\s*/, `node ${AUTO_INSTRUMENT_REQUIRE} `);
487
+ var INDEX_CANDIDATES = ["index.ts", "index.tsx", "index.js", "index.mjs", "index.cjs"];
488
+ async function resolveEntry(serviceDir, pkg) {
489
+ if (typeof pkg.main === "string" && pkg.main.length > 0) {
490
+ const candidate = path2.resolve(serviceDir, pkg.main);
491
+ if (await exists(candidate)) return candidate;
492
+ }
493
+ if (pkg.bin) {
494
+ let binEntry;
495
+ if (typeof pkg.bin === "string") {
496
+ binEntry = pkg.bin;
497
+ } else if (pkg.name && typeof pkg.bin[pkg.name] === "string") {
498
+ binEntry = pkg.bin[pkg.name];
499
+ } else {
500
+ const first = Object.values(pkg.bin)[0];
501
+ if (typeof first === "string") binEntry = first;
502
+ }
503
+ if (binEntry) {
504
+ const candidate = path2.resolve(serviceDir, binEntry);
505
+ if (await exists(candidate)) return candidate;
506
+ }
507
+ }
508
+ for (const name of INDEX_CANDIDATES) {
509
+ const candidate = path2.join(serviceDir, name);
510
+ if (await exists(candidate)) return candidate;
430
511
  }
431
- return `node ${AUTO_INSTRUMENT_REQUIRE} -- ${start}`;
512
+ return null;
513
+ }
514
+ function dispatchEntry(entryFile, pkg) {
515
+ const ext = path2.extname(entryFile).toLowerCase();
516
+ if (ext === ".ts" || ext === ".tsx") return "ts";
517
+ if (ext === ".mjs") return "esm";
518
+ if (ext === ".cjs") return "cjs";
519
+ return pkg.type === "module" ? "esm" : "cjs";
520
+ }
521
+ function otelInitFilename(flavor) {
522
+ if (flavor === "ts") return "otel-init.ts";
523
+ if (flavor === "esm") return "otel-init.mjs";
524
+ return "otel-init.cjs";
525
+ }
526
+ function otelInitContents(flavor) {
527
+ if (flavor === "ts") return OTEL_INIT_TS;
528
+ if (flavor === "esm") return OTEL_INIT_ESM;
529
+ return OTEL_INIT_CJS;
530
+ }
531
+ function injectionLine(flavor, entryFile, otelInitFile) {
532
+ let rel = path2.relative(path2.dirname(entryFile), otelInitFile);
533
+ if (!rel.startsWith(".")) rel = `./${rel}`;
534
+ rel = rel.split(path2.sep).join("/");
535
+ if (flavor === "cjs") return `require('${rel}')`;
536
+ if (flavor === "esm") return `import '${rel}'`;
537
+ const tsRel = rel.replace(/\.ts$/, "");
538
+ return `import '${tsRel}'`;
539
+ }
540
+ function lineIsOtelInjection(line) {
541
+ const trimmed = line.trim();
542
+ if (trimmed.length === 0) return false;
543
+ return /(?:require\(|import\s+)['"]\.\/otel-init[^'"]*['"]/.test(trimmed);
432
544
  }
433
545
  async function plan(serviceDir) {
434
546
  const pkg = await readPackageJson(serviceDir);
@@ -438,9 +550,17 @@ async function plan(serviceDir) {
438
550
  serviceDir,
439
551
  dependencyEdits: [],
440
552
  entrypointEdits: [],
441
- envEdits: []
553
+ envEdits: [],
554
+ generatedFiles: []
442
555
  };
443
556
  if (!pkg) return empty;
557
+ const entryFile = await resolveEntry(serviceDir, pkg);
558
+ if (!entryFile) {
559
+ return { ...empty, libOnly: true };
560
+ }
561
+ const flavor = dispatchEntry(entryFile, pkg);
562
+ const otelInitFile = path2.join(path2.dirname(entryFile), otelInitFilename(flavor));
563
+ const envNeatFile = path2.join(serviceDir, ".env.neat");
444
564
  const existingDeps = { ...pkg.dependencies ?? {}, ...pkg.devDependencies ?? {} };
445
565
  const dependencyEdits = [];
446
566
  for (const sdk of SDK_PACKAGES) {
@@ -453,14 +573,37 @@ async function plan(serviceDir) {
453
573
  });
454
574
  }
455
575
  const entrypointEdits = [];
456
- const startScript = pkg.scripts?.start;
457
- if (typeof startScript === "string" && startScript.trim().length > 0) {
458
- const rewritten = rewriteStartScript(startScript);
459
- if (rewritten !== startScript) {
460
- entrypointEdits.push({ file: manifestPath, before: startScript, after: rewritten });
576
+ try {
577
+ const raw = await fs2.readFile(entryFile, "utf8");
578
+ const lines = raw.split(/\r?\n/);
579
+ const firstReal = lines[0]?.startsWith("#!") ? lines[1] ?? "" : lines[0] ?? "";
580
+ if (!lineIsOtelInjection(firstReal)) {
581
+ const inject = injectionLine(flavor, entryFile, otelInitFile);
582
+ entrypointEdits.push({
583
+ file: entryFile,
584
+ before: firstReal,
585
+ after: inject
586
+ });
461
587
  }
588
+ } catch {
589
+ return { ...empty, libOnly: true };
590
+ }
591
+ const generatedFiles = [];
592
+ if (!await exists(otelInitFile)) {
593
+ generatedFiles.push({
594
+ file: otelInitFile,
595
+ contents: otelInitContents(flavor),
596
+ skipIfExists: true
597
+ });
462
598
  }
463
- if (dependencyEdits.length === 0 && entrypointEdits.length === 0) {
599
+ if (!await exists(envNeatFile)) {
600
+ generatedFiles.push({
601
+ file: envNeatFile,
602
+ contents: renderEnvNeat(pkg.name ?? path2.basename(serviceDir)),
603
+ skipIfExists: true
604
+ });
605
+ }
606
+ if (dependencyEdits.length === 0 && entrypointEdits.length === 0 && generatedFiles.length === 0) {
464
607
  return empty;
465
608
  }
466
609
  return {
@@ -468,53 +611,129 @@ async function plan(serviceDir) {
468
611
  serviceDir,
469
612
  dependencyEdits,
470
613
  entrypointEdits,
471
- envEdits: [OTEL_ENV]
614
+ envEdits: [OTEL_ENV],
615
+ generatedFiles,
616
+ entryFile,
617
+ libOnly: false
472
618
  };
473
619
  }
620
+ function isAllowedWritePath(serviceDir, target) {
621
+ const rel = path2.relative(serviceDir, target);
622
+ if (rel.startsWith("..")) return false;
623
+ const base = path2.basename(target);
624
+ if (base === "package.json") return true;
625
+ if (base === ".env.neat") return true;
626
+ if (/^otel-init\.(?:js|cjs|mjs|ts)$/.test(base)) return true;
627
+ return false;
628
+ }
629
+ async function writeAtomic(file, contents) {
630
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
631
+ await fs2.writeFile(tmp, contents, "utf8");
632
+ await fs2.rename(tmp, file);
633
+ }
474
634
  async function apply(installPlan) {
475
- const touched = /* @__PURE__ */ new Set();
476
- for (const e of installPlan.dependencyEdits) touched.add(e.file);
477
- for (const e of installPlan.entrypointEdits) touched.add(e.file);
478
- if (touched.size === 0) return;
635
+ const { serviceDir } = installPlan;
636
+ if (installPlan.libOnly) {
637
+ return {
638
+ serviceDir,
639
+ outcome: "lib-only",
640
+ reason: "no resolvable entry point",
641
+ writtenFiles: []
642
+ };
643
+ }
644
+ if (installPlan.dependencyEdits.length === 0 && installPlan.entrypointEdits.length === 0 && (installPlan.generatedFiles?.length ?? 0) === 0) {
645
+ return {
646
+ serviceDir,
647
+ outcome: "already-instrumented",
648
+ writtenFiles: []
649
+ };
650
+ }
651
+ const allTargets = /* @__PURE__ */ new Set();
652
+ for (const d of installPlan.dependencyEdits) allTargets.add(d.file);
653
+ for (const e of installPlan.entrypointEdits) allTargets.add(e.file);
654
+ for (const g of installPlan.generatedFiles ?? []) allTargets.add(g.file);
655
+ for (const target of allTargets) {
656
+ const isEntryEdit = installPlan.entrypointEdits.some((e) => e.file === target);
657
+ if (isEntryEdit) continue;
658
+ if (!isAllowedWritePath(serviceDir, target)) {
659
+ throw new Error(
660
+ `javascript installer: refusing to write outside the allowed path set (ADR-069 \xA77): ${target}`
661
+ );
662
+ }
663
+ }
479
664
  const originals = /* @__PURE__ */ new Map();
480
- for (const file of touched) {
481
- try {
482
- originals.set(file, await fs2.readFile(file, "utf8"));
483
- } catch {
665
+ const createdFiles = [];
666
+ for (const target of allTargets) {
667
+ if (await exists(target)) {
668
+ try {
669
+ originals.set(target, await fs2.readFile(target, "utf8"));
670
+ } catch {
671
+ }
484
672
  }
485
673
  }
674
+ const writtenFiles = [];
486
675
  try {
487
- for (const file of touched) {
488
- const raw = originals.get(file) ?? "";
676
+ const manifestTargets = installPlan.dependencyEdits.reduce((acc, e) => {
677
+ acc.add(e.file);
678
+ return acc;
679
+ }, /* @__PURE__ */ new Set());
680
+ for (const file of manifestTargets) {
681
+ const raw = originals.get(file);
682
+ if (raw === void 0) {
683
+ throw new Error(`javascript installer: cannot read ${file} during apply`);
684
+ }
489
685
  const pkg = JSON.parse(raw);
490
686
  pkg.dependencies = pkg.dependencies ?? {};
491
687
  for (const dep of installPlan.dependencyEdits) {
492
688
  if (dep.file !== file) continue;
493
689
  if (dep.kind === "add") {
494
- pkg.dependencies[dep.name] = dep.version;
690
+ if (!(dep.name in (pkg.dependencies ?? {}))) {
691
+ pkg.dependencies[dep.name] = dep.version;
692
+ }
495
693
  } else {
496
694
  delete pkg.dependencies[dep.name];
497
695
  }
498
696
  }
499
- for (const ep of installPlan.entrypointEdits) {
500
- if (ep.file !== file) continue;
501
- pkg.scripts = pkg.scripts ?? {};
502
- if (pkg.scripts.start === ep.before) {
503
- pkg.scripts.start = ep.after;
504
- }
505
- }
506
697
  const newRaw = JSON.stringify(pkg, null, 2) + "\n";
507
- const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
508
- await fs2.writeFile(tmp, newRaw, "utf8");
509
- await fs2.rename(tmp, file);
698
+ await writeAtomic(file, newRaw);
699
+ writtenFiles.push(file);
700
+ }
701
+ for (const gen of installPlan.generatedFiles ?? []) {
702
+ if (gen.skipIfExists && await exists(gen.file)) {
703
+ continue;
704
+ }
705
+ await writeAtomic(gen.file, gen.contents);
706
+ if (!originals.has(gen.file)) createdFiles.push(gen.file);
707
+ writtenFiles.push(gen.file);
708
+ }
709
+ for (const ep of installPlan.entrypointEdits) {
710
+ const raw = originals.get(ep.file);
711
+ if (raw === void 0) {
712
+ throw new Error(`javascript installer: cannot read entry ${ep.file} during apply`);
713
+ }
714
+ const lines = raw.split(/\r?\n/);
715
+ const hasShebang = lines[0]?.startsWith("#!") ?? false;
716
+ const insertAt = hasShebang ? 1 : 0;
717
+ const firstReal = lines[insertAt] ?? "";
718
+ if (lineIsOtelInjection(firstReal)) continue;
719
+ lines.splice(insertAt, 0, ep.after);
720
+ const newRaw = lines.join("\n");
721
+ await writeAtomic(ep.file, newRaw);
722
+ writtenFiles.push(ep.file);
510
723
  }
511
724
  } catch (err) {
512
- await rollback(installPlan, originals);
725
+ await rollback(installPlan, originals, createdFiles);
513
726
  throw err;
514
727
  }
728
+ return {
729
+ serviceDir,
730
+ outcome: "instrumented",
731
+ writtenFiles
732
+ };
515
733
  }
516
- async function rollback(installPlan, originals) {
734
+ async function rollback(installPlan, originals, createdFiles) {
517
735
  const restored = [];
736
+ const removed = [];
518
737
  for (const [file, raw] of originals.entries()) {
519
738
  try {
520
739
  await fs2.writeFile(file, raw, "utf8");
@@ -522,6 +741,13 @@ async function rollback(installPlan, originals) {
522
741
  } catch {
523
742
  }
524
743
  }
744
+ for (const file of createdFiles) {
745
+ try {
746
+ await fs2.unlink(file);
747
+ removed.push(file);
748
+ } catch {
749
+ }
750
+ }
525
751
  const lines = [
526
752
  "# neat-rollback.patch",
527
753
  "",
@@ -529,6 +755,7 @@ async function rollback(installPlan, originals) {
529
755
  "# Files listed below were restored to their pre-apply contents.",
530
756
  "",
531
757
  ...restored.map((f) => `restored: ${f}`),
758
+ ...removed.map((f) => `removed: ${f}`),
532
759
  ""
533
760
  ];
534
761
  const rollbackPath = path2.join(installPlan.serviceDir, "neat-rollback.patch");
@@ -553,7 +780,7 @@ var OTEL_ENV2 = {
553
780
  key: "OTEL_EXPORTER_OTLP_ENDPOINT",
554
781
  value: "http://localhost:4318"
555
782
  };
556
- async function exists(p) {
783
+ async function exists2(p) {
557
784
  try {
558
785
  await fs3.stat(p);
559
786
  return true;
@@ -564,7 +791,7 @@ async function exists(p) {
564
791
  async function detect2(serviceDir) {
565
792
  const markers = ["requirements.txt", "pyproject.toml", "setup.py"];
566
793
  for (const m of markers) {
567
- if (await exists(path3.join(serviceDir, m))) return true;
794
+ if (await exists2(path3.join(serviceDir, m))) return true;
568
795
  }
569
796
  return false;
570
797
  }
@@ -575,7 +802,7 @@ function reqPackageName(line) {
575
802
  }
576
803
  async function planRequirementsTxtEdits(serviceDir) {
577
804
  const file = path3.join(serviceDir, "requirements.txt");
578
- if (!await exists(file)) return null;
805
+ if (!await exists2(file)) return null;
579
806
  const raw = await fs3.readFile(file, "utf8");
580
807
  const presentNames = new Set(
581
808
  raw.split(/\r?\n/).map(reqPackageName).filter((n) => n.length > 0)
@@ -585,7 +812,7 @@ async function planRequirementsTxtEdits(serviceDir) {
585
812
  }
586
813
  async function planProcfileEdits(serviceDir) {
587
814
  const procfile = path3.join(serviceDir, "Procfile");
588
- if (!await exists(procfile)) return [];
815
+ if (!await exists2(procfile)) return [];
589
816
  const raw = await fs3.readFile(procfile, "utf8");
590
817
  const edits = [];
591
818
  for (const line of raw.split(/\r?\n/)) {
@@ -652,10 +879,13 @@ async function applyProcfile(procfile, edits, original) {
652
879
  await fs3.rename(tmp, procfile);
653
880
  }
654
881
  async function apply2(installPlan) {
882
+ const { serviceDir } = installPlan;
655
883
  const touched = /* @__PURE__ */ new Set();
656
884
  for (const e of installPlan.dependencyEdits) touched.add(e.file);
657
885
  for (const e of installPlan.entrypointEdits) touched.add(e.file);
658
- if (touched.size === 0) return;
886
+ if (touched.size === 0) {
887
+ return { serviceDir, outcome: "already-instrumented", writtenFiles: [] };
888
+ }
659
889
  const originals = /* @__PURE__ */ new Map();
660
890
  for (const file of touched) {
661
891
  try {
@@ -663,6 +893,7 @@ async function apply2(installPlan) {
663
893
  } catch {
664
894
  }
665
895
  }
896
+ const writtenFiles = [];
666
897
  try {
667
898
  for (const file of touched) {
668
899
  const raw = originals.get(file);
@@ -672,16 +903,23 @@ async function apply2(installPlan) {
672
903
  const base = path3.basename(file);
673
904
  if (base === "requirements.txt") {
674
905
  const edits = installPlan.dependencyEdits.filter((e) => e.file === file);
675
- if (edits.length > 0) await applyRequirementsTxt(file, edits, raw);
906
+ if (edits.length > 0) {
907
+ await applyRequirementsTxt(file, edits, raw);
908
+ writtenFiles.push(file);
909
+ }
676
910
  } else if (base === "Procfile") {
677
911
  const edits = installPlan.entrypointEdits.filter((e) => e.file === file);
678
- if (edits.length > 0) await applyProcfile(file, edits, raw);
912
+ if (edits.length > 0) {
913
+ await applyProcfile(file, edits, raw);
914
+ writtenFiles.push(file);
915
+ }
679
916
  }
680
917
  }
681
918
  } catch (err) {
682
919
  await rollback2(installPlan, originals);
683
920
  throw err;
684
921
  }
922
+ return { serviceDir, outcome: "instrumented", writtenFiles };
685
923
  }
686
924
  async function rollback2(installPlan, originals) {
687
925
  const restored = [];
@@ -713,7 +951,7 @@ var pythonInstaller = {
713
951
 
714
952
  // src/installers/shared.ts
715
953
  function isEmptyPlan(plan3) {
716
- return plan3.dependencyEdits.length === 0 && plan3.entrypointEdits.length === 0 && plan3.envEdits.length === 0;
954
+ return plan3.dependencyEdits.length === 0 && plan3.entrypointEdits.length === 0 && plan3.envEdits.length === 0 && (plan3.generatedFiles?.length ?? 0) === 0;
717
955
  }
718
956
 
719
957
  // src/installers/index.ts
@@ -755,8 +993,18 @@ function renderPatch(sections) {
755
993
  const { installer, plan: plan3 } = section;
756
994
  lines.push(`## ${installer} (${plan3.language}) \u2014 ${plan3.serviceDir}`);
757
995
  lines.push("");
996
+ if (plan3.libOnly) {
997
+ lines.push("### skipped \u2014 no resolvable entry point (lib-only)");
998
+ lines.push("");
999
+ continue;
1000
+ }
1001
+ if (plan3.entryFile) {
1002
+ lines.push(`entry: ${plan3.entryFile}`);
1003
+ lines.push("");
1004
+ }
758
1005
  if (plan3.dependencyEdits.length > 0) {
759
1006
  lines.push("### dependencies");
1007
+ const byFile = /* @__PURE__ */ new Map();
760
1008
  for (const dep of plan3.dependencyEdits) {
761
1009
  const base = dep.file.split(/[\\/]/).pop() ?? dep.file;
762
1010
  if (FORBIDDEN_LOCKFILES.has(base)) {
@@ -764,24 +1012,41 @@ function renderPatch(sections) {
764
1012
  `installer "${installer}" produced a dependency edit against a lockfile (${dep.file}); lockfiles must never be touched (ADR-047).`
765
1013
  );
766
1014
  }
767
- lines.push(`- ${dep.kind} ${dep.name}@${dep.version} in ${dep.file}`);
1015
+ const existing = byFile.get(dep.file) ?? [];
1016
+ existing.push(dep);
1017
+ byFile.set(dep.file, existing);
1018
+ }
1019
+ for (const [file, deps] of byFile) {
1020
+ lines.push(`--- ${file}`);
1021
+ for (const dep of deps) {
1022
+ lines.push(`+ "${dep.name}": "${dep.version}"`);
1023
+ }
1024
+ }
1025
+ lines.push("");
1026
+ }
1027
+ if (plan3.generatedFiles && plan3.generatedFiles.length > 0) {
1028
+ lines.push("### generated files");
1029
+ for (const gen of plan3.generatedFiles) {
1030
+ lines.push(`--- (new file) ${gen.file}`);
1031
+ for (const ln of gen.contents.split(/\r?\n/)) {
1032
+ lines.push(`+ ${ln}`);
1033
+ }
768
1034
  }
769
1035
  lines.push("");
770
1036
  }
771
1037
  if (plan3.entrypointEdits.length > 0) {
772
- lines.push("### entrypoint");
1038
+ lines.push("### entry-point injection");
773
1039
  for (const e of plan3.entrypointEdits) {
774
- lines.push(`- ${e.file}`);
775
- lines.push(` - before: ${e.before}`);
776
- lines.push(` - after: ${e.after}`);
1040
+ lines.push(`--- ${e.file}`);
1041
+ lines.push(`+ ${e.after}`);
1042
+ lines.push(` ${e.before}`);
777
1043
  }
778
1044
  lines.push("");
779
1045
  }
780
1046
  if (plan3.envEdits.length > 0) {
781
- lines.push("### env");
1047
+ lines.push("### env (written to <package-dir>/.env.neat)");
782
1048
  for (const env of plan3.envEdits) {
783
- const target = env.file ?? "(set in your orchestration layer)";
784
- lines.push(`- ${env.key}=${env.value} \u2192 ${target}`);
1049
+ lines.push(`- ${env.key}=${env.value}`);
785
1050
  }
786
1051
  lines.push("");
787
1052
  }
@@ -1529,7 +1794,7 @@ async function buildPatchSections(services) {
1529
1794
  const installer = await pickInstaller(svc.dir);
1530
1795
  if (!installer) continue;
1531
1796
  const plan3 = await installer.plan(svc.dir);
1532
- if (isEmptyPlan(plan3)) continue;
1797
+ if (isEmptyPlan(plan3) && !plan3.libOnly) continue;
1533
1798
  sections.push({ installer: installer.name, plan: plan3 });
1534
1799
  }
1535
1800
  return sections;
@@ -1582,14 +1847,28 @@ async function runInit(opts) {
1582
1847
  }
1583
1848
  if (!opts.noInstall) {
1584
1849
  if (opts.apply) {
1850
+ let instrumented = 0;
1851
+ let alreadyInstrumented = 0;
1852
+ let libOnly = 0;
1585
1853
  for (const section of sections) {
1586
1854
  const installer = INSTALLERS.find((i) => i.name === section.installer);
1587
1855
  if (!installer) continue;
1588
- await installer.apply(section.plan);
1856
+ const outcome = await installer.apply(section.plan);
1857
+ if (outcome.outcome === "instrumented") {
1858
+ instrumented++;
1859
+ for (const f of outcome.writtenFiles) written.push(f);
1860
+ } else if (outcome.outcome === "already-instrumented") {
1861
+ alreadyInstrumented++;
1862
+ } else if (outcome.outcome === "lib-only") {
1863
+ libOnly++;
1864
+ }
1589
1865
  }
1590
1866
  if (sections.length > 0) {
1591
1867
  console.log("");
1592
- console.log("patch applied. Run `npm install` (or your language equivalent) to refresh lockfiles.");
1868
+ console.log(
1869
+ `apply: instrumented ${instrumented}, already-instrumented ${alreadyInstrumented}, lib-only ${libOnly}`
1870
+ );
1871
+ console.log("Run `npm install` (or your language equivalent) to refresh lockfiles.");
1593
1872
  }
1594
1873
  } else {
1595
1874
  await fs4.writeFile(patchPath, patch, "utf8");