@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.js CHANGED
@@ -39,13 +39,13 @@ import {
39
39
  setStatus,
40
40
  startPersistLoop,
41
41
  startStalenessLoop
42
- } from "./chunk-BUB3ASD5.js";
42
+ } from "./chunk-ZU2RQRCN.js";
43
43
  import {
44
44
  startOtelGrpcReceiver
45
- } from "./chunk-G3PDTGOW.js";
45
+ } from "./chunk-CY67YKNO.js";
46
46
  import {
47
47
  buildOtelReceiver
48
- } from "./chunk-4ASCXBZF.js";
48
+ } from "./chunk-75IBA4UL.js";
49
49
 
50
50
  // src/cli.ts
51
51
  import path4 from "path";
@@ -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,134 @@ 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_EXTENSIONS = [".ts", ".tsx", ".js", ".mjs", ".cjs"];
488
+ var INDEX_CANDIDATES = INDEX_EXTENSIONS.map((ext) => `index${ext}`);
489
+ var SRC_INDEX_CANDIDATES = INDEX_EXTENSIONS.map((ext) => `src/index${ext}`);
490
+ var SRC_NAMED_CANDIDATES = ["server", "main", "app"].flatMap(
491
+ (name) => INDEX_EXTENSIONS.map((ext) => `src/${name}${ext}`)
492
+ );
493
+ var SCRIPT_LAUNCHERS = /* @__PURE__ */ new Set([
494
+ "node",
495
+ "ts-node",
496
+ "tsx",
497
+ "ts-node-dev",
498
+ "nodemon",
499
+ "npx",
500
+ "pnpm",
501
+ "yarn",
502
+ "npm",
503
+ "cross-env",
504
+ "dotenv",
505
+ "--"
506
+ ]);
507
+ function looksLikeEntryPath(token) {
508
+ if (token.length === 0) return false;
509
+ if (token.startsWith("-")) return false;
510
+ if (token.includes("=")) return false;
511
+ if (token.includes("/")) return true;
512
+ return /\.(?:m?[jt]sx?|c[jt]s)$/.test(token);
513
+ }
514
+ function scriptHasShellChain(script) {
515
+ return /(?:&&|\|\||;|\|(?!\|))/.test(script);
516
+ }
517
+ function entryFromScript(script) {
518
+ if (!script) return void 0;
519
+ if (scriptHasShellChain(script)) return void 0;
520
+ const tokens = script.split(/\s+/).filter((t) => t.length > 0);
521
+ for (const token of tokens) {
522
+ const lower = token.toLowerCase();
523
+ if (SCRIPT_LAUNCHERS.has(lower)) continue;
524
+ const cleaned = token.startsWith("./") ? token.slice(2) : token;
525
+ if (looksLikeEntryPath(cleaned)) return cleaned;
430
526
  }
431
- return `node ${AUTO_INSTRUMENT_REQUIRE} -- ${start}`;
527
+ return void 0;
528
+ }
529
+ async function resolveEntry(serviceDir, pkg) {
530
+ if (typeof pkg.main === "string" && pkg.main.length > 0) {
531
+ const candidate = path2.resolve(serviceDir, pkg.main);
532
+ if (await exists(candidate)) return candidate;
533
+ }
534
+ if (pkg.bin) {
535
+ let binEntry;
536
+ if (typeof pkg.bin === "string") {
537
+ binEntry = pkg.bin;
538
+ } else if (pkg.name && typeof pkg.bin[pkg.name] === "string") {
539
+ binEntry = pkg.bin[pkg.name];
540
+ } else {
541
+ const first = Object.values(pkg.bin)[0];
542
+ if (typeof first === "string") binEntry = first;
543
+ }
544
+ if (binEntry) {
545
+ const candidate = path2.resolve(serviceDir, binEntry);
546
+ if (await exists(candidate)) return candidate;
547
+ }
548
+ }
549
+ const startEntry = entryFromScript(pkg.scripts?.start);
550
+ if (startEntry) {
551
+ const candidate = path2.resolve(serviceDir, startEntry);
552
+ if (await exists(candidate)) return candidate;
553
+ }
554
+ const devEntry = entryFromScript(pkg.scripts?.dev);
555
+ if (devEntry) {
556
+ const candidate = path2.resolve(serviceDir, devEntry);
557
+ if (await exists(candidate)) return candidate;
558
+ }
559
+ for (const rel of SRC_INDEX_CANDIDATES) {
560
+ const candidate = path2.join(serviceDir, rel);
561
+ if (await exists(candidate)) return candidate;
562
+ }
563
+ for (const rel of SRC_NAMED_CANDIDATES) {
564
+ const candidate = path2.join(serviceDir, rel);
565
+ if (await exists(candidate)) return candidate;
566
+ }
567
+ for (const name of INDEX_CANDIDATES) {
568
+ const candidate = path2.join(serviceDir, name);
569
+ if (await exists(candidate)) return candidate;
570
+ }
571
+ return null;
572
+ }
573
+ function dispatchEntry(entryFile, pkg) {
574
+ const ext = path2.extname(entryFile).toLowerCase();
575
+ if (ext === ".ts" || ext === ".tsx") return "ts";
576
+ if (ext === ".mjs") return "esm";
577
+ if (ext === ".cjs") return "cjs";
578
+ return pkg.type === "module" ? "esm" : "cjs";
579
+ }
580
+ function otelInitFilename(flavor) {
581
+ if (flavor === "ts") return "otel-init.ts";
582
+ if (flavor === "esm") return "otel-init.mjs";
583
+ return "otel-init.cjs";
584
+ }
585
+ function otelInitContents(flavor) {
586
+ if (flavor === "ts") return OTEL_INIT_TS;
587
+ if (flavor === "esm") return OTEL_INIT_ESM;
588
+ return OTEL_INIT_CJS;
589
+ }
590
+ function injectionLine(flavor, entryFile, otelInitFile) {
591
+ let rel = path2.relative(path2.dirname(entryFile), otelInitFile);
592
+ if (!rel.startsWith(".")) rel = `./${rel}`;
593
+ rel = rel.split(path2.sep).join("/");
594
+ if (flavor === "cjs") return `require('${rel}')`;
595
+ if (flavor === "esm") return `import '${rel}'`;
596
+ const tsRel = rel.replace(/\.ts$/, "");
597
+ return `import '${tsRel}'`;
598
+ }
599
+ function lineIsOtelInjection(line) {
600
+ const trimmed = line.trim();
601
+ if (trimmed.length === 0) return false;
602
+ return /(?:require\(|import\s+)['"]\.\/otel-init[^'"]*['"]/.test(trimmed);
432
603
  }
433
604
  async function plan(serviceDir) {
434
605
  const pkg = await readPackageJson(serviceDir);
@@ -438,9 +609,17 @@ async function plan(serviceDir) {
438
609
  serviceDir,
439
610
  dependencyEdits: [],
440
611
  entrypointEdits: [],
441
- envEdits: []
612
+ envEdits: [],
613
+ generatedFiles: []
442
614
  };
443
615
  if (!pkg) return empty;
616
+ const entryFile = await resolveEntry(serviceDir, pkg);
617
+ if (!entryFile) {
618
+ return { ...empty, libOnly: true };
619
+ }
620
+ const flavor = dispatchEntry(entryFile, pkg);
621
+ const otelInitFile = path2.join(path2.dirname(entryFile), otelInitFilename(flavor));
622
+ const envNeatFile = path2.join(serviceDir, ".env.neat");
444
623
  const existingDeps = { ...pkg.dependencies ?? {}, ...pkg.devDependencies ?? {} };
445
624
  const dependencyEdits = [];
446
625
  for (const sdk of SDK_PACKAGES) {
@@ -453,14 +632,37 @@ async function plan(serviceDir) {
453
632
  });
454
633
  }
455
634
  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 });
635
+ try {
636
+ const raw = await fs2.readFile(entryFile, "utf8");
637
+ const lines = raw.split(/\r?\n/);
638
+ const firstReal = lines[0]?.startsWith("#!") ? lines[1] ?? "" : lines[0] ?? "";
639
+ if (!lineIsOtelInjection(firstReal)) {
640
+ const inject = injectionLine(flavor, entryFile, otelInitFile);
641
+ entrypointEdits.push({
642
+ file: entryFile,
643
+ before: firstReal,
644
+ after: inject
645
+ });
461
646
  }
647
+ } catch {
648
+ return { ...empty, libOnly: true };
649
+ }
650
+ const generatedFiles = [];
651
+ if (!await exists(otelInitFile)) {
652
+ generatedFiles.push({
653
+ file: otelInitFile,
654
+ contents: otelInitContents(flavor),
655
+ skipIfExists: true
656
+ });
462
657
  }
463
- if (dependencyEdits.length === 0 && entrypointEdits.length === 0) {
658
+ if (!await exists(envNeatFile)) {
659
+ generatedFiles.push({
660
+ file: envNeatFile,
661
+ contents: renderEnvNeat(pkg.name ?? path2.basename(serviceDir)),
662
+ skipIfExists: true
663
+ });
664
+ }
665
+ if (dependencyEdits.length === 0 && entrypointEdits.length === 0 && generatedFiles.length === 0) {
464
666
  return empty;
465
667
  }
466
668
  return {
@@ -468,53 +670,129 @@ async function plan(serviceDir) {
468
670
  serviceDir,
469
671
  dependencyEdits,
470
672
  entrypointEdits,
471
- envEdits: [OTEL_ENV]
673
+ envEdits: [OTEL_ENV],
674
+ generatedFiles,
675
+ entryFile,
676
+ libOnly: false
472
677
  };
473
678
  }
679
+ function isAllowedWritePath(serviceDir, target) {
680
+ const rel = path2.relative(serviceDir, target);
681
+ if (rel.startsWith("..")) return false;
682
+ const base = path2.basename(target);
683
+ if (base === "package.json") return true;
684
+ if (base === ".env.neat") return true;
685
+ if (/^otel-init\.(?:js|cjs|mjs|ts)$/.test(base)) return true;
686
+ return false;
687
+ }
688
+ async function writeAtomic(file, contents) {
689
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
690
+ await fs2.writeFile(tmp, contents, "utf8");
691
+ await fs2.rename(tmp, file);
692
+ }
474
693
  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;
694
+ const { serviceDir } = installPlan;
695
+ if (installPlan.libOnly) {
696
+ return {
697
+ serviceDir,
698
+ outcome: "lib-only",
699
+ reason: "no resolvable entry point",
700
+ writtenFiles: []
701
+ };
702
+ }
703
+ if (installPlan.dependencyEdits.length === 0 && installPlan.entrypointEdits.length === 0 && (installPlan.generatedFiles?.length ?? 0) === 0) {
704
+ return {
705
+ serviceDir,
706
+ outcome: "already-instrumented",
707
+ writtenFiles: []
708
+ };
709
+ }
710
+ const allTargets = /* @__PURE__ */ new Set();
711
+ for (const d of installPlan.dependencyEdits) allTargets.add(d.file);
712
+ for (const e of installPlan.entrypointEdits) allTargets.add(e.file);
713
+ for (const g of installPlan.generatedFiles ?? []) allTargets.add(g.file);
714
+ for (const target of allTargets) {
715
+ const isEntryEdit = installPlan.entrypointEdits.some((e) => e.file === target);
716
+ if (isEntryEdit) continue;
717
+ if (!isAllowedWritePath(serviceDir, target)) {
718
+ throw new Error(
719
+ `javascript installer: refusing to write outside the allowed path set (ADR-069 \xA77): ${target}`
720
+ );
721
+ }
722
+ }
479
723
  const originals = /* @__PURE__ */ new Map();
480
- for (const file of touched) {
481
- try {
482
- originals.set(file, await fs2.readFile(file, "utf8"));
483
- } catch {
724
+ const createdFiles = [];
725
+ for (const target of allTargets) {
726
+ if (await exists(target)) {
727
+ try {
728
+ originals.set(target, await fs2.readFile(target, "utf8"));
729
+ } catch {
730
+ }
484
731
  }
485
732
  }
733
+ const writtenFiles = [];
486
734
  try {
487
- for (const file of touched) {
488
- const raw = originals.get(file) ?? "";
735
+ const manifestTargets = installPlan.dependencyEdits.reduce((acc, e) => {
736
+ acc.add(e.file);
737
+ return acc;
738
+ }, /* @__PURE__ */ new Set());
739
+ for (const file of manifestTargets) {
740
+ const raw = originals.get(file);
741
+ if (raw === void 0) {
742
+ throw new Error(`javascript installer: cannot read ${file} during apply`);
743
+ }
489
744
  const pkg = JSON.parse(raw);
490
745
  pkg.dependencies = pkg.dependencies ?? {};
491
746
  for (const dep of installPlan.dependencyEdits) {
492
747
  if (dep.file !== file) continue;
493
748
  if (dep.kind === "add") {
494
- pkg.dependencies[dep.name] = dep.version;
749
+ if (!(dep.name in (pkg.dependencies ?? {}))) {
750
+ pkg.dependencies[dep.name] = dep.version;
751
+ }
495
752
  } else {
496
753
  delete pkg.dependencies[dep.name];
497
754
  }
498
755
  }
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
756
  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);
757
+ await writeAtomic(file, newRaw);
758
+ writtenFiles.push(file);
759
+ }
760
+ for (const gen of installPlan.generatedFiles ?? []) {
761
+ if (gen.skipIfExists && await exists(gen.file)) {
762
+ continue;
763
+ }
764
+ await writeAtomic(gen.file, gen.contents);
765
+ if (!originals.has(gen.file)) createdFiles.push(gen.file);
766
+ writtenFiles.push(gen.file);
767
+ }
768
+ for (const ep of installPlan.entrypointEdits) {
769
+ const raw = originals.get(ep.file);
770
+ if (raw === void 0) {
771
+ throw new Error(`javascript installer: cannot read entry ${ep.file} during apply`);
772
+ }
773
+ const lines = raw.split(/\r?\n/);
774
+ const hasShebang = lines[0]?.startsWith("#!") ?? false;
775
+ const insertAt = hasShebang ? 1 : 0;
776
+ const firstReal = lines[insertAt] ?? "";
777
+ if (lineIsOtelInjection(firstReal)) continue;
778
+ lines.splice(insertAt, 0, ep.after);
779
+ const newRaw = lines.join("\n");
780
+ await writeAtomic(ep.file, newRaw);
781
+ writtenFiles.push(ep.file);
510
782
  }
511
783
  } catch (err) {
512
- await rollback(installPlan, originals);
784
+ await rollback(installPlan, originals, createdFiles);
513
785
  throw err;
514
786
  }
787
+ return {
788
+ serviceDir,
789
+ outcome: "instrumented",
790
+ writtenFiles
791
+ };
515
792
  }
516
- async function rollback(installPlan, originals) {
793
+ async function rollback(installPlan, originals, createdFiles) {
517
794
  const restored = [];
795
+ const removed = [];
518
796
  for (const [file, raw] of originals.entries()) {
519
797
  try {
520
798
  await fs2.writeFile(file, raw, "utf8");
@@ -522,6 +800,13 @@ async function rollback(installPlan, originals) {
522
800
  } catch {
523
801
  }
524
802
  }
803
+ for (const file of createdFiles) {
804
+ try {
805
+ await fs2.unlink(file);
806
+ removed.push(file);
807
+ } catch {
808
+ }
809
+ }
525
810
  const lines = [
526
811
  "# neat-rollback.patch",
527
812
  "",
@@ -529,6 +814,7 @@ async function rollback(installPlan, originals) {
529
814
  "# Files listed below were restored to their pre-apply contents.",
530
815
  "",
531
816
  ...restored.map((f) => `restored: ${f}`),
817
+ ...removed.map((f) => `removed: ${f}`),
532
818
  ""
533
819
  ];
534
820
  const rollbackPath = path2.join(installPlan.serviceDir, "neat-rollback.patch");
@@ -553,7 +839,7 @@ var OTEL_ENV2 = {
553
839
  key: "OTEL_EXPORTER_OTLP_ENDPOINT",
554
840
  value: "http://localhost:4318"
555
841
  };
556
- async function exists(p) {
842
+ async function exists2(p) {
557
843
  try {
558
844
  await fs3.stat(p);
559
845
  return true;
@@ -564,7 +850,7 @@ async function exists(p) {
564
850
  async function detect2(serviceDir) {
565
851
  const markers = ["requirements.txt", "pyproject.toml", "setup.py"];
566
852
  for (const m of markers) {
567
- if (await exists(path3.join(serviceDir, m))) return true;
853
+ if (await exists2(path3.join(serviceDir, m))) return true;
568
854
  }
569
855
  return false;
570
856
  }
@@ -575,7 +861,7 @@ function reqPackageName(line) {
575
861
  }
576
862
  async function planRequirementsTxtEdits(serviceDir) {
577
863
  const file = path3.join(serviceDir, "requirements.txt");
578
- if (!await exists(file)) return null;
864
+ if (!await exists2(file)) return null;
579
865
  const raw = await fs3.readFile(file, "utf8");
580
866
  const presentNames = new Set(
581
867
  raw.split(/\r?\n/).map(reqPackageName).filter((n) => n.length > 0)
@@ -585,7 +871,7 @@ async function planRequirementsTxtEdits(serviceDir) {
585
871
  }
586
872
  async function planProcfileEdits(serviceDir) {
587
873
  const procfile = path3.join(serviceDir, "Procfile");
588
- if (!await exists(procfile)) return [];
874
+ if (!await exists2(procfile)) return [];
589
875
  const raw = await fs3.readFile(procfile, "utf8");
590
876
  const edits = [];
591
877
  for (const line of raw.split(/\r?\n/)) {
@@ -652,10 +938,13 @@ async function applyProcfile(procfile, edits, original) {
652
938
  await fs3.rename(tmp, procfile);
653
939
  }
654
940
  async function apply2(installPlan) {
941
+ const { serviceDir } = installPlan;
655
942
  const touched = /* @__PURE__ */ new Set();
656
943
  for (const e of installPlan.dependencyEdits) touched.add(e.file);
657
944
  for (const e of installPlan.entrypointEdits) touched.add(e.file);
658
- if (touched.size === 0) return;
945
+ if (touched.size === 0) {
946
+ return { serviceDir, outcome: "already-instrumented", writtenFiles: [] };
947
+ }
659
948
  const originals = /* @__PURE__ */ new Map();
660
949
  for (const file of touched) {
661
950
  try {
@@ -663,6 +952,7 @@ async function apply2(installPlan) {
663
952
  } catch {
664
953
  }
665
954
  }
955
+ const writtenFiles = [];
666
956
  try {
667
957
  for (const file of touched) {
668
958
  const raw = originals.get(file);
@@ -672,16 +962,23 @@ async function apply2(installPlan) {
672
962
  const base = path3.basename(file);
673
963
  if (base === "requirements.txt") {
674
964
  const edits = installPlan.dependencyEdits.filter((e) => e.file === file);
675
- if (edits.length > 0) await applyRequirementsTxt(file, edits, raw);
965
+ if (edits.length > 0) {
966
+ await applyRequirementsTxt(file, edits, raw);
967
+ writtenFiles.push(file);
968
+ }
676
969
  } else if (base === "Procfile") {
677
970
  const edits = installPlan.entrypointEdits.filter((e) => e.file === file);
678
- if (edits.length > 0) await applyProcfile(file, edits, raw);
971
+ if (edits.length > 0) {
972
+ await applyProcfile(file, edits, raw);
973
+ writtenFiles.push(file);
974
+ }
679
975
  }
680
976
  }
681
977
  } catch (err) {
682
978
  await rollback2(installPlan, originals);
683
979
  throw err;
684
980
  }
981
+ return { serviceDir, outcome: "instrumented", writtenFiles };
685
982
  }
686
983
  async function rollback2(installPlan, originals) {
687
984
  const restored = [];
@@ -713,7 +1010,7 @@ var pythonInstaller = {
713
1010
 
714
1011
  // src/installers/shared.ts
715
1012
  function isEmptyPlan(plan3) {
716
- return plan3.dependencyEdits.length === 0 && plan3.entrypointEdits.length === 0 && plan3.envEdits.length === 0;
1013
+ return plan3.dependencyEdits.length === 0 && plan3.entrypointEdits.length === 0 && plan3.envEdits.length === 0 && (plan3.generatedFiles?.length ?? 0) === 0;
717
1014
  }
718
1015
 
719
1016
  // src/installers/index.ts
@@ -755,8 +1052,18 @@ function renderPatch(sections) {
755
1052
  const { installer, plan: plan3 } = section;
756
1053
  lines.push(`## ${installer} (${plan3.language}) \u2014 ${plan3.serviceDir}`);
757
1054
  lines.push("");
1055
+ if (plan3.libOnly) {
1056
+ lines.push("### skipped \u2014 no resolvable entry point (lib-only)");
1057
+ lines.push("");
1058
+ continue;
1059
+ }
1060
+ if (plan3.entryFile) {
1061
+ lines.push(`entry: ${plan3.entryFile}`);
1062
+ lines.push("");
1063
+ }
758
1064
  if (plan3.dependencyEdits.length > 0) {
759
1065
  lines.push("### dependencies");
1066
+ const byFile = /* @__PURE__ */ new Map();
760
1067
  for (const dep of plan3.dependencyEdits) {
761
1068
  const base = dep.file.split(/[\\/]/).pop() ?? dep.file;
762
1069
  if (FORBIDDEN_LOCKFILES.has(base)) {
@@ -764,24 +1071,41 @@ function renderPatch(sections) {
764
1071
  `installer "${installer}" produced a dependency edit against a lockfile (${dep.file}); lockfiles must never be touched (ADR-047).`
765
1072
  );
766
1073
  }
767
- lines.push(`- ${dep.kind} ${dep.name}@${dep.version} in ${dep.file}`);
1074
+ const existing = byFile.get(dep.file) ?? [];
1075
+ existing.push(dep);
1076
+ byFile.set(dep.file, existing);
1077
+ }
1078
+ for (const [file, deps] of byFile) {
1079
+ lines.push(`--- ${file}`);
1080
+ for (const dep of deps) {
1081
+ lines.push(`+ "${dep.name}": "${dep.version}"`);
1082
+ }
1083
+ }
1084
+ lines.push("");
1085
+ }
1086
+ if (plan3.generatedFiles && plan3.generatedFiles.length > 0) {
1087
+ lines.push("### generated files");
1088
+ for (const gen of plan3.generatedFiles) {
1089
+ lines.push(`--- (new file) ${gen.file}`);
1090
+ for (const ln of gen.contents.split(/\r?\n/)) {
1091
+ lines.push(`+ ${ln}`);
1092
+ }
768
1093
  }
769
1094
  lines.push("");
770
1095
  }
771
1096
  if (plan3.entrypointEdits.length > 0) {
772
- lines.push("### entrypoint");
1097
+ lines.push("### entry-point injection");
773
1098
  for (const e of plan3.entrypointEdits) {
774
- lines.push(`- ${e.file}`);
775
- lines.push(` - before: ${e.before}`);
776
- lines.push(` - after: ${e.after}`);
1099
+ lines.push(`--- ${e.file}`);
1100
+ lines.push(`+ ${e.after}`);
1101
+ lines.push(` ${e.before}`);
777
1102
  }
778
1103
  lines.push("");
779
1104
  }
780
1105
  if (plan3.envEdits.length > 0) {
781
- lines.push("### env");
1106
+ lines.push("### env (written to <package-dir>/.env.neat)");
782
1107
  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}`);
1108
+ lines.push(`- ${env.key}=${env.value}`);
785
1109
  }
786
1110
  lines.push("");
787
1111
  }
@@ -1529,7 +1853,7 @@ async function buildPatchSections(services) {
1529
1853
  const installer = await pickInstaller(svc.dir);
1530
1854
  if (!installer) continue;
1531
1855
  const plan3 = await installer.plan(svc.dir);
1532
- if (isEmptyPlan(plan3)) continue;
1856
+ if (isEmptyPlan(plan3) && !plan3.libOnly) continue;
1533
1857
  sections.push({ installer: installer.name, plan: plan3 });
1534
1858
  }
1535
1859
  return sections;
@@ -1582,14 +1906,28 @@ async function runInit(opts) {
1582
1906
  }
1583
1907
  if (!opts.noInstall) {
1584
1908
  if (opts.apply) {
1909
+ let instrumented = 0;
1910
+ let alreadyInstrumented = 0;
1911
+ let libOnly = 0;
1585
1912
  for (const section of sections) {
1586
1913
  const installer = INSTALLERS.find((i) => i.name === section.installer);
1587
1914
  if (!installer) continue;
1588
- await installer.apply(section.plan);
1915
+ const outcome = await installer.apply(section.plan);
1916
+ if (outcome.outcome === "instrumented") {
1917
+ instrumented++;
1918
+ for (const f of outcome.writtenFiles) written.push(f);
1919
+ } else if (outcome.outcome === "already-instrumented") {
1920
+ alreadyInstrumented++;
1921
+ } else if (outcome.outcome === "lib-only") {
1922
+ libOnly++;
1923
+ }
1589
1924
  }
1590
1925
  if (sections.length > 0) {
1591
1926
  console.log("");
1592
- console.log("patch applied. Run `npm install` (or your language equivalent) to refresh lockfiles.");
1927
+ console.log(
1928
+ `apply: instrumented ${instrumented}, already-instrumented ${alreadyInstrumented}, lib-only ${libOnly}`
1929
+ );
1930
+ console.log("Run `npm install` (or your language equivalent) to refresh lockfiles.");
1593
1931
  }
1594
1932
  } else {
1595
1933
  await fs4.writeFile(patchPath, patch, "utf8");