@neat.is/core 0.2.5
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/compat.json +120 -0
- package/dist/chunk-6JT6L2OV.js +164 -0
- package/dist/chunk-6JT6L2OV.js.map +1 -0
- package/dist/chunk-6SFEITLJ.js +3371 -0
- package/dist/chunk-6SFEITLJ.js.map +1 -0
- package/dist/chunk-I5IMCXRO.js +325 -0
- package/dist/chunk-I5IMCXRO.js.map +1 -0
- package/dist/chunk-T2U4U256.js +462 -0
- package/dist/chunk-T2U4U256.js.map +1 -0
- package/dist/chunk-WX55TLUT.js +184 -0
- package/dist/chunk-WX55TLUT.js.map +1 -0
- package/dist/chunk-XOOCA5T7.js +290 -0
- package/dist/chunk-XOOCA5T7.js.map +1 -0
- package/dist/cli.cjs +5754 -0
- package/dist/cli.cjs.map +1 -0
- package/dist/cli.d.cts +36 -0
- package/dist/cli.d.ts +36 -0
- package/dist/cli.js +1175 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.cjs +4552 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +408 -0
- package/dist/index.d.ts +408 -0
- package/dist/index.js +93 -0
- package/dist/index.js.map +1 -0
- package/dist/neatd.cjs +3070 -0
- package/dist/neatd.cjs.map +1 -0
- package/dist/neatd.d.cts +1 -0
- package/dist/neatd.d.ts +1 -0
- package/dist/neatd.js +114 -0
- package/dist/neatd.js.map +1 -0
- package/dist/otel-grpc-B4XBSI4W.js +9 -0
- package/dist/otel-grpc-B4XBSI4W.js.map +1 -0
- package/dist/server.cjs +4499 -0
- package/dist/server.cjs.map +1 -0
- package/dist/server.d.cts +2 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +97 -0
- package/dist/server.js.map +1 -0
- package/package.json +77 -0
- package/proto/opentelemetry/proto/collector/trace/v1/trace_service.proto +31 -0
- package/proto/opentelemetry/proto/common/v1/common.proto +46 -0
- package/proto/opentelemetry/proto/resource/v1/resource.proto +19 -0
- package/proto/opentelemetry/proto/trace/v1/trace.proto +93 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1175 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
ProjectNameCollisionError,
|
|
4
|
+
addProject,
|
|
5
|
+
listProjects,
|
|
6
|
+
removeProject,
|
|
7
|
+
setStatus
|
|
8
|
+
} from "./chunk-WX55TLUT.js";
|
|
9
|
+
import {
|
|
10
|
+
buildSearchIndex
|
|
11
|
+
} from "./chunk-XOOCA5T7.js";
|
|
12
|
+
import {
|
|
13
|
+
buildApi
|
|
14
|
+
} from "./chunk-T2U4U256.js";
|
|
15
|
+
import {
|
|
16
|
+
DEFAULT_PROJECT,
|
|
17
|
+
PolicyViolationsLog,
|
|
18
|
+
Projects,
|
|
19
|
+
addCallEdges,
|
|
20
|
+
addConfigNodes,
|
|
21
|
+
addDatabasesAndCompat,
|
|
22
|
+
addInfra,
|
|
23
|
+
addServiceAliases,
|
|
24
|
+
addServiceNodes,
|
|
25
|
+
discoverServices,
|
|
26
|
+
ensureCompatLoaded,
|
|
27
|
+
evaluateAllPolicies,
|
|
28
|
+
extractFromDirectory,
|
|
29
|
+
getGraph,
|
|
30
|
+
loadGraphFromDisk,
|
|
31
|
+
loadPolicyFile,
|
|
32
|
+
makeErrorSpanWriter,
|
|
33
|
+
makeSpanHandler,
|
|
34
|
+
pathsForProject,
|
|
35
|
+
promoteFrontierNodes,
|
|
36
|
+
resetGraph,
|
|
37
|
+
saveGraphToDisk,
|
|
38
|
+
startPersistLoop,
|
|
39
|
+
startStalenessLoop
|
|
40
|
+
} from "./chunk-6SFEITLJ.js";
|
|
41
|
+
import {
|
|
42
|
+
buildOtelReceiver,
|
|
43
|
+
startOtelGrpcReceiver
|
|
44
|
+
} from "./chunk-I5IMCXRO.js";
|
|
45
|
+
|
|
46
|
+
// src/cli.ts
|
|
47
|
+
import path4 from "path";
|
|
48
|
+
import { promises as fs3 } from "fs";
|
|
49
|
+
|
|
50
|
+
// src/watch.ts
|
|
51
|
+
import path from "path";
|
|
52
|
+
import chokidar from "chokidar";
|
|
53
|
+
|
|
54
|
+
// src/extract/retire.ts
|
|
55
|
+
import { Provenance } from "@neat.is/types";
|
|
56
|
+
function retireEdgesByFile(graph, file) {
|
|
57
|
+
const normalized = file.split("\\").join("/");
|
|
58
|
+
const toDrop = [];
|
|
59
|
+
graph.forEachEdge((id, attrs) => {
|
|
60
|
+
const edge = attrs;
|
|
61
|
+
if (edge.provenance !== Provenance.EXTRACTED) return;
|
|
62
|
+
if (!edge.evidence?.file) return;
|
|
63
|
+
if (edge.evidence.file === normalized) toDrop.push(id);
|
|
64
|
+
});
|
|
65
|
+
for (const id of toDrop) graph.dropEdge(id);
|
|
66
|
+
return toDrop.length;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// src/watch.ts
|
|
70
|
+
var ALL_PHASES = [
|
|
71
|
+
"services",
|
|
72
|
+
"aliases",
|
|
73
|
+
"databases",
|
|
74
|
+
"configs",
|
|
75
|
+
"calls",
|
|
76
|
+
"infra"
|
|
77
|
+
];
|
|
78
|
+
function classifyChange(relPath) {
|
|
79
|
+
const phases = /* @__PURE__ */ new Set();
|
|
80
|
+
const base = path.basename(relPath).toLowerCase();
|
|
81
|
+
const segments = relPath.split(path.sep).map((s) => s.toLowerCase());
|
|
82
|
+
if (base === "package.json" || base === "requirements.txt" || base === "pyproject.toml" || base === "setup.py") {
|
|
83
|
+
phases.add("services");
|
|
84
|
+
phases.add("aliases");
|
|
85
|
+
phases.add("databases");
|
|
86
|
+
}
|
|
87
|
+
if (base === ".env" || base.startsWith(".env.") || base === "schema.prisma" || /^knexfile\.(?:js|ts|cjs|mjs)$/.test(base) || /^ormconfig\.(?:js|ts|json|ya?ml)$/.test(base)) {
|
|
88
|
+
phases.add("databases");
|
|
89
|
+
phases.add("configs");
|
|
90
|
+
}
|
|
91
|
+
if (base === "dockerfile" || /^docker-compose.*\.ya?ml$/.test(base) || base.endsWith(".tf") || segments.includes("k8s") || segments.includes("kustomize") || segments.includes("manifests")) {
|
|
92
|
+
phases.add("infra");
|
|
93
|
+
phases.add("aliases");
|
|
94
|
+
}
|
|
95
|
+
if (/\.(?:js|jsx|mjs|cjs|ts|tsx|py)$/.test(base)) {
|
|
96
|
+
phases.add("calls");
|
|
97
|
+
}
|
|
98
|
+
if (/\.ya?ml$/.test(base) && !/^docker-compose.*\.ya?ml$/.test(base)) {
|
|
99
|
+
phases.add("databases");
|
|
100
|
+
phases.add("configs");
|
|
101
|
+
}
|
|
102
|
+
return phases;
|
|
103
|
+
}
|
|
104
|
+
async function runExtractPhases(graph, scanPath, phases) {
|
|
105
|
+
const started = Date.now();
|
|
106
|
+
await ensureCompatLoaded();
|
|
107
|
+
const services = await discoverServices(scanPath);
|
|
108
|
+
let nodesAdded = 0;
|
|
109
|
+
let edgesAdded = 0;
|
|
110
|
+
if (phases.has("services")) {
|
|
111
|
+
nodesAdded += addServiceNodes(graph, services);
|
|
112
|
+
}
|
|
113
|
+
if (phases.has("aliases")) {
|
|
114
|
+
await addServiceAliases(graph, scanPath, services);
|
|
115
|
+
}
|
|
116
|
+
if (phases.has("databases")) {
|
|
117
|
+
const r = await addDatabasesAndCompat(graph, services, scanPath);
|
|
118
|
+
nodesAdded += r.nodesAdded;
|
|
119
|
+
edgesAdded += r.edgesAdded;
|
|
120
|
+
}
|
|
121
|
+
if (phases.has("configs")) {
|
|
122
|
+
const r = await addConfigNodes(graph, services, scanPath);
|
|
123
|
+
nodesAdded += r.nodesAdded;
|
|
124
|
+
edgesAdded += r.edgesAdded;
|
|
125
|
+
}
|
|
126
|
+
if (phases.has("calls")) {
|
|
127
|
+
const r = await addCallEdges(graph, services);
|
|
128
|
+
nodesAdded += r.nodesAdded;
|
|
129
|
+
edgesAdded += r.edgesAdded;
|
|
130
|
+
}
|
|
131
|
+
if (phases.has("infra")) {
|
|
132
|
+
const r = await addInfra(graph, scanPath, services);
|
|
133
|
+
nodesAdded += r.nodesAdded;
|
|
134
|
+
edgesAdded += r.edgesAdded;
|
|
135
|
+
}
|
|
136
|
+
const frontiersPromoted = promoteFrontierNodes(graph);
|
|
137
|
+
return {
|
|
138
|
+
phases: ALL_PHASES.filter((p) => phases.has(p)),
|
|
139
|
+
nodesAdded,
|
|
140
|
+
edgesAdded,
|
|
141
|
+
frontiersPromoted,
|
|
142
|
+
durationMs: Date.now() - started
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
var IGNORED_WATCH_PATHS = [
|
|
146
|
+
/(?:^|[\\/])node_modules[\\/]/,
|
|
147
|
+
/(?:^|[\\/])\.git[\\/]/,
|
|
148
|
+
/(?:^|[\\/])dist[\\/]/,
|
|
149
|
+
/(?:^|[\\/])build[\\/]/,
|
|
150
|
+
/(?:^|[\\/])\.turbo[\\/]/,
|
|
151
|
+
/(?:^|[\\/])\.next[\\/]/,
|
|
152
|
+
/(?:^|[\\/])neat-out[\\/]/,
|
|
153
|
+
/[\\/]?\.DS_Store$/
|
|
154
|
+
];
|
|
155
|
+
function shouldIgnore(absPath) {
|
|
156
|
+
return IGNORED_WATCH_PATHS.some((re) => re.test(absPath));
|
|
157
|
+
}
|
|
158
|
+
async function startWatch(graph, opts) {
|
|
159
|
+
const debounceMs = opts.debounceMs ?? 1e3;
|
|
160
|
+
await loadGraphFromDisk(graph, opts.outPath);
|
|
161
|
+
const policyFilePath = path.join(opts.scanPath, "policy.json");
|
|
162
|
+
const policyViolationsPath = path.join(path.dirname(opts.outPath), "policy-violations.ndjson");
|
|
163
|
+
let policies = [];
|
|
164
|
+
try {
|
|
165
|
+
policies = await loadPolicyFile(policyFilePath);
|
|
166
|
+
if (policies.length > 0) {
|
|
167
|
+
console.log(`policies: loaded ${policies.length} from ${policyFilePath}`);
|
|
168
|
+
}
|
|
169
|
+
} catch (err) {
|
|
170
|
+
console.warn(`policies: failed to load ${policyFilePath} \u2014 ${err.message}`);
|
|
171
|
+
}
|
|
172
|
+
const policyLog = new PolicyViolationsLog(policyViolationsPath);
|
|
173
|
+
const onPolicyTrigger = async (g) => {
|
|
174
|
+
if (policies.length === 0) return;
|
|
175
|
+
try {
|
|
176
|
+
const violations = evaluateAllPolicies(g, policies, { now: () => Date.now() });
|
|
177
|
+
for (const v of violations) await policyLog.append(v);
|
|
178
|
+
} catch (err) {
|
|
179
|
+
console.warn(`policies: evaluation failed \u2014 ${err.message}`);
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
const initial = await runExtractPhases(graph, opts.scanPath, new Set(ALL_PHASES));
|
|
183
|
+
console.log(
|
|
184
|
+
`extract: ${initial.nodesAdded} new nodes, ${initial.edgesAdded} new edges (graph total ${graph.order}/${graph.size})`
|
|
185
|
+
);
|
|
186
|
+
await onPolicyTrigger(graph);
|
|
187
|
+
const stopPersist = startPersistLoop(graph, opts.outPath);
|
|
188
|
+
const stopStaleness = startStalenessLoop(graph, {
|
|
189
|
+
staleEventsPath: opts.staleEventsPath,
|
|
190
|
+
onPolicyTrigger
|
|
191
|
+
});
|
|
192
|
+
const host = opts.host ?? "0.0.0.0";
|
|
193
|
+
const port = opts.port ?? 8080;
|
|
194
|
+
const otelPort = opts.otelPort ?? 4318;
|
|
195
|
+
const cachePath = opts.embeddingsCachePath ?? path.join(path.dirname(opts.outPath), "embeddings.json");
|
|
196
|
+
let searchIndex;
|
|
197
|
+
try {
|
|
198
|
+
searchIndex = await buildSearchIndex(graph, { cachePath });
|
|
199
|
+
console.log(`semantic_search: ${searchIndex.provider} provider`);
|
|
200
|
+
} catch (err) {
|
|
201
|
+
console.warn(
|
|
202
|
+
`semantic_search: index build failed (${err.message}); falling back to inline substring`
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
const projectName = opts.project ?? DEFAULT_PROJECT;
|
|
206
|
+
const registry = new Projects();
|
|
207
|
+
registry.set(projectName, {
|
|
208
|
+
graph,
|
|
209
|
+
scanPath: opts.scanPath,
|
|
210
|
+
paths: {
|
|
211
|
+
// Paths are derived from the explicit options the watch caller passes
|
|
212
|
+
// — pathsForProject is only used to fill in the embeddings/snapshot
|
|
213
|
+
// fields so the registry shape is complete.
|
|
214
|
+
...pathsForProject(projectName, path.dirname(opts.outPath)),
|
|
215
|
+
snapshotPath: opts.outPath,
|
|
216
|
+
errorsPath: opts.errorsPath,
|
|
217
|
+
staleEventsPath: opts.staleEventsPath
|
|
218
|
+
},
|
|
219
|
+
searchIndex
|
|
220
|
+
});
|
|
221
|
+
const api = await buildApi({ projects: registry });
|
|
222
|
+
await api.listen({ port, host });
|
|
223
|
+
console.log(`neat-core listening on http://${host}:${port}`);
|
|
224
|
+
console.log(` scan path: ${opts.scanPath} (watching for changes)`);
|
|
225
|
+
console.log(` snapshot path: ${opts.outPath}`);
|
|
226
|
+
console.log(` errors log: ${opts.errorsPath}`);
|
|
227
|
+
const onSpan = makeSpanHandler({
|
|
228
|
+
graph,
|
|
229
|
+
errorsPath: opts.errorsPath,
|
|
230
|
+
writeErrorEventInline: false,
|
|
231
|
+
onPolicyTrigger
|
|
232
|
+
});
|
|
233
|
+
const onErrorSpanSync = makeErrorSpanWriter(opts.errorsPath);
|
|
234
|
+
const otelHttp = await buildOtelReceiver({ onSpan, onErrorSpanSync });
|
|
235
|
+
await otelHttp.listen({ port: otelPort, host });
|
|
236
|
+
console.log(`neat-core OTLP receiver on http://${host}:${otelPort}/v1/traces`);
|
|
237
|
+
let grpcReceiver = null;
|
|
238
|
+
if (opts.otelGrpc) {
|
|
239
|
+
const grpcPort = opts.otelGrpcPort ?? 4317;
|
|
240
|
+
const onSpanGrpc = makeSpanHandler({
|
|
241
|
+
graph,
|
|
242
|
+
errorsPath: opts.errorsPath,
|
|
243
|
+
onPolicyTrigger
|
|
244
|
+
});
|
|
245
|
+
const r = await startOtelGrpcReceiver({ onSpan: onSpanGrpc, host, port: grpcPort });
|
|
246
|
+
console.log(`neat-core OTLP/gRPC receiver on ${r.address}`);
|
|
247
|
+
grpcReceiver = r;
|
|
248
|
+
}
|
|
249
|
+
const pending = /* @__PURE__ */ new Set();
|
|
250
|
+
const pendingPaths = /* @__PURE__ */ new Set();
|
|
251
|
+
let timer = null;
|
|
252
|
+
let inflight = null;
|
|
253
|
+
const flush = async () => {
|
|
254
|
+
if (pending.size === 0) return;
|
|
255
|
+
const phases = new Set(pending);
|
|
256
|
+
const paths = new Set(pendingPaths);
|
|
257
|
+
pending.clear();
|
|
258
|
+
pendingPaths.clear();
|
|
259
|
+
try {
|
|
260
|
+
let retired = 0;
|
|
261
|
+
for (const p of paths) retired += retireEdgesByFile(graph, p);
|
|
262
|
+
const result = await runExtractPhases(graph, opts.scanPath, phases);
|
|
263
|
+
console.log(
|
|
264
|
+
`[watch] re-extract phases=${result.phases.join(",")} retired=${retired} +${result.nodesAdded}n/+${result.edgesAdded}e in ${result.durationMs}ms`
|
|
265
|
+
);
|
|
266
|
+
if (searchIndex) {
|
|
267
|
+
try {
|
|
268
|
+
await searchIndex.refresh(graph);
|
|
269
|
+
} catch (err) {
|
|
270
|
+
console.warn("[watch] semantic_search refresh failed", err);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
await onPolicyTrigger(graph);
|
|
274
|
+
} catch (err) {
|
|
275
|
+
console.error("[watch] re-extract failed", err);
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
const schedule = () => {
|
|
279
|
+
if (timer) clearTimeout(timer);
|
|
280
|
+
timer = setTimeout(() => {
|
|
281
|
+
timer = null;
|
|
282
|
+
inflight = (inflight ?? Promise.resolve()).then(flush);
|
|
283
|
+
}, debounceMs);
|
|
284
|
+
};
|
|
285
|
+
const onPath = (absPath) => {
|
|
286
|
+
if (shouldIgnore(absPath)) return;
|
|
287
|
+
const rel = path.relative(opts.scanPath, absPath);
|
|
288
|
+
if (!rel || rel.startsWith("..")) return;
|
|
289
|
+
pendingPaths.add(rel.split(path.sep).join("/"));
|
|
290
|
+
const phases = classifyChange(rel);
|
|
291
|
+
if (phases.size === 0) {
|
|
292
|
+
for (const p of ALL_PHASES) pending.add(p);
|
|
293
|
+
} else {
|
|
294
|
+
for (const p of phases) pending.add(p);
|
|
295
|
+
}
|
|
296
|
+
schedule();
|
|
297
|
+
};
|
|
298
|
+
const watcher = chokidar.watch(opts.scanPath, {
|
|
299
|
+
ignoreInitial: true,
|
|
300
|
+
ignored: (p) => shouldIgnore(p),
|
|
301
|
+
persistent: true,
|
|
302
|
+
awaitWriteFinish: { stabilityThreshold: 200, pollInterval: 50 }
|
|
303
|
+
});
|
|
304
|
+
watcher.on("add", onPath);
|
|
305
|
+
watcher.on("change", onPath);
|
|
306
|
+
watcher.on("unlink", onPath);
|
|
307
|
+
watcher.on("addDir", onPath);
|
|
308
|
+
watcher.on("unlinkDir", onPath);
|
|
309
|
+
let stopped = false;
|
|
310
|
+
const stop = async () => {
|
|
311
|
+
if (stopped) return;
|
|
312
|
+
stopped = true;
|
|
313
|
+
if (timer) clearTimeout(timer);
|
|
314
|
+
timer = null;
|
|
315
|
+
if (inflight) {
|
|
316
|
+
try {
|
|
317
|
+
await inflight;
|
|
318
|
+
} catch {
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
await watcher.close();
|
|
322
|
+
stopStaleness();
|
|
323
|
+
stopPersist();
|
|
324
|
+
await api.close();
|
|
325
|
+
await otelHttp.close();
|
|
326
|
+
if (grpcReceiver) await grpcReceiver.stop();
|
|
327
|
+
};
|
|
328
|
+
return { api, stop };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// src/installers/javascript.ts
|
|
332
|
+
import { promises as fs } from "fs";
|
|
333
|
+
import path2 from "path";
|
|
334
|
+
var SDK_PACKAGES = [
|
|
335
|
+
{ name: "@opentelemetry/api", version: "^1.9.0" },
|
|
336
|
+
{ name: "@opentelemetry/sdk-node", version: "^0.57.0" },
|
|
337
|
+
{ name: "@opentelemetry/auto-instrumentations-node", version: "^0.55.0" }
|
|
338
|
+
];
|
|
339
|
+
var AUTO_INSTRUMENT_REQUIRE = "--require @opentelemetry/auto-instrumentations-node/register";
|
|
340
|
+
var OTEL_ENV = {
|
|
341
|
+
// null target — NEAT does not write `.env` itself; the user sets the env
|
|
342
|
+
// var in their orchestration layer.
|
|
343
|
+
file: null,
|
|
344
|
+
key: "OTEL_EXPORTER_OTLP_ENDPOINT",
|
|
345
|
+
value: "http://localhost:4318"
|
|
346
|
+
};
|
|
347
|
+
async function readPackageJson(serviceDir) {
|
|
348
|
+
try {
|
|
349
|
+
const raw = await fs.readFile(path2.join(serviceDir, "package.json"), "utf8");
|
|
350
|
+
return JSON.parse(raw);
|
|
351
|
+
} catch {
|
|
352
|
+
return null;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
async function detect(serviceDir) {
|
|
356
|
+
const pkg = await readPackageJson(serviceDir);
|
|
357
|
+
return pkg !== null && typeof pkg.name === "string";
|
|
358
|
+
}
|
|
359
|
+
function rewriteStartScript(start) {
|
|
360
|
+
if (start.includes(AUTO_INSTRUMENT_REQUIRE)) return start;
|
|
361
|
+
if (/^\s*node\b/.test(start)) {
|
|
362
|
+
return start.replace(/^\s*node\b\s*/, `node ${AUTO_INSTRUMENT_REQUIRE} `);
|
|
363
|
+
}
|
|
364
|
+
return `node ${AUTO_INSTRUMENT_REQUIRE} -- ${start}`;
|
|
365
|
+
}
|
|
366
|
+
async function plan(serviceDir) {
|
|
367
|
+
const pkg = await readPackageJson(serviceDir);
|
|
368
|
+
const manifestPath = path2.join(serviceDir, "package.json");
|
|
369
|
+
const empty = {
|
|
370
|
+
language: "javascript",
|
|
371
|
+
serviceDir,
|
|
372
|
+
dependencyEdits: [],
|
|
373
|
+
entrypointEdits: [],
|
|
374
|
+
envEdits: []
|
|
375
|
+
};
|
|
376
|
+
if (!pkg) return empty;
|
|
377
|
+
const existingDeps = { ...pkg.dependencies ?? {}, ...pkg.devDependencies ?? {} };
|
|
378
|
+
const dependencyEdits = [];
|
|
379
|
+
for (const sdk of SDK_PACKAGES) {
|
|
380
|
+
if (sdk.name in existingDeps) continue;
|
|
381
|
+
dependencyEdits.push({
|
|
382
|
+
file: manifestPath,
|
|
383
|
+
kind: "add",
|
|
384
|
+
name: sdk.name,
|
|
385
|
+
version: sdk.version
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
const entrypointEdits = [];
|
|
389
|
+
const startScript = pkg.scripts?.start;
|
|
390
|
+
if (typeof startScript === "string" && startScript.trim().length > 0) {
|
|
391
|
+
const rewritten = rewriteStartScript(startScript);
|
|
392
|
+
if (rewritten !== startScript) {
|
|
393
|
+
entrypointEdits.push({ file: manifestPath, before: startScript, after: rewritten });
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
if (dependencyEdits.length === 0 && entrypointEdits.length === 0) {
|
|
397
|
+
return empty;
|
|
398
|
+
}
|
|
399
|
+
return {
|
|
400
|
+
language: "javascript",
|
|
401
|
+
serviceDir,
|
|
402
|
+
dependencyEdits,
|
|
403
|
+
entrypointEdits,
|
|
404
|
+
envEdits: [OTEL_ENV]
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
async function apply(installPlan) {
|
|
408
|
+
const touched = /* @__PURE__ */ new Set();
|
|
409
|
+
for (const e of installPlan.dependencyEdits) touched.add(e.file);
|
|
410
|
+
for (const e of installPlan.entrypointEdits) touched.add(e.file);
|
|
411
|
+
if (touched.size === 0) return;
|
|
412
|
+
const originals = /* @__PURE__ */ new Map();
|
|
413
|
+
for (const file of touched) {
|
|
414
|
+
try {
|
|
415
|
+
originals.set(file, await fs.readFile(file, "utf8"));
|
|
416
|
+
} catch {
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
try {
|
|
420
|
+
for (const file of touched) {
|
|
421
|
+
const raw = originals.get(file) ?? "";
|
|
422
|
+
const pkg = JSON.parse(raw);
|
|
423
|
+
pkg.dependencies = pkg.dependencies ?? {};
|
|
424
|
+
for (const dep of installPlan.dependencyEdits) {
|
|
425
|
+
if (dep.file !== file) continue;
|
|
426
|
+
if (dep.kind === "add") {
|
|
427
|
+
pkg.dependencies[dep.name] = dep.version;
|
|
428
|
+
} else {
|
|
429
|
+
delete pkg.dependencies[dep.name];
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
for (const ep of installPlan.entrypointEdits) {
|
|
433
|
+
if (ep.file !== file) continue;
|
|
434
|
+
pkg.scripts = pkg.scripts ?? {};
|
|
435
|
+
if (pkg.scripts.start === ep.before) {
|
|
436
|
+
pkg.scripts.start = ep.after;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
const newRaw = JSON.stringify(pkg, null, 2) + "\n";
|
|
440
|
+
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
441
|
+
await fs.writeFile(tmp, newRaw, "utf8");
|
|
442
|
+
await fs.rename(tmp, file);
|
|
443
|
+
}
|
|
444
|
+
} catch (err) {
|
|
445
|
+
await rollback(installPlan, originals);
|
|
446
|
+
throw err;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
async function rollback(installPlan, originals) {
|
|
450
|
+
const restored = [];
|
|
451
|
+
for (const [file, raw] of originals.entries()) {
|
|
452
|
+
try {
|
|
453
|
+
await fs.writeFile(file, raw, "utf8");
|
|
454
|
+
restored.push(file);
|
|
455
|
+
} catch {
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
const lines = [
|
|
459
|
+
"# neat-rollback.patch",
|
|
460
|
+
"",
|
|
461
|
+
`# Generated after a partial apply failure in the ${installPlan.language} installer.`,
|
|
462
|
+
"# Files listed below were restored to their pre-apply contents.",
|
|
463
|
+
"",
|
|
464
|
+
...restored.map((f) => `restored: ${f}`),
|
|
465
|
+
""
|
|
466
|
+
];
|
|
467
|
+
const rollbackPath = path2.join(installPlan.serviceDir, "neat-rollback.patch");
|
|
468
|
+
await fs.writeFile(rollbackPath, lines.join("\n"), "utf8");
|
|
469
|
+
}
|
|
470
|
+
var javascriptInstaller = {
|
|
471
|
+
name: "javascript",
|
|
472
|
+
detect,
|
|
473
|
+
plan,
|
|
474
|
+
apply
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
// src/installers/python.ts
|
|
478
|
+
import { promises as fs2 } from "fs";
|
|
479
|
+
import path3 from "path";
|
|
480
|
+
var SDK_PACKAGES2 = [
|
|
481
|
+
{ name: "opentelemetry-distro", version: ">=0.49b0" },
|
|
482
|
+
{ name: "opentelemetry-exporter-otlp", version: ">=1.28.0" }
|
|
483
|
+
];
|
|
484
|
+
var OTEL_ENV2 = {
|
|
485
|
+
file: null,
|
|
486
|
+
key: "OTEL_EXPORTER_OTLP_ENDPOINT",
|
|
487
|
+
value: "http://localhost:4318"
|
|
488
|
+
};
|
|
489
|
+
async function exists(p) {
|
|
490
|
+
try {
|
|
491
|
+
await fs2.stat(p);
|
|
492
|
+
return true;
|
|
493
|
+
} catch {
|
|
494
|
+
return false;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
async function detect2(serviceDir) {
|
|
498
|
+
const markers = ["requirements.txt", "pyproject.toml", "setup.py"];
|
|
499
|
+
for (const m of markers) {
|
|
500
|
+
if (await exists(path3.join(serviceDir, m))) return true;
|
|
501
|
+
}
|
|
502
|
+
return false;
|
|
503
|
+
}
|
|
504
|
+
function reqPackageName(line) {
|
|
505
|
+
const stripped = line.split("#")[0]?.trim() ?? "";
|
|
506
|
+
const head = stripped.split(/[\s;]/)[0] ?? "";
|
|
507
|
+
return head.replace(/[<>=!~].*$/, "").toLowerCase();
|
|
508
|
+
}
|
|
509
|
+
async function planRequirementsTxtEdits(serviceDir) {
|
|
510
|
+
const file = path3.join(serviceDir, "requirements.txt");
|
|
511
|
+
if (!await exists(file)) return null;
|
|
512
|
+
const raw = await fs2.readFile(file, "utf8");
|
|
513
|
+
const presentNames = new Set(
|
|
514
|
+
raw.split(/\r?\n/).map(reqPackageName).filter((n) => n.length > 0)
|
|
515
|
+
);
|
|
516
|
+
const missing = SDK_PACKAGES2.filter((p) => !presentNames.has(p.name.toLowerCase()));
|
|
517
|
+
return { manifest: file, missing: [...missing] };
|
|
518
|
+
}
|
|
519
|
+
async function planProcfileEdits(serviceDir) {
|
|
520
|
+
const procfile = path3.join(serviceDir, "Procfile");
|
|
521
|
+
if (!await exists(procfile)) return [];
|
|
522
|
+
const raw = await fs2.readFile(procfile, "utf8");
|
|
523
|
+
const edits = [];
|
|
524
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
525
|
+
if (line.length === 0) continue;
|
|
526
|
+
const m = line.match(/^([a-zA-Z0-9_-]+):\s*(.+)$/);
|
|
527
|
+
if (!m) continue;
|
|
528
|
+
const cmd = m[2];
|
|
529
|
+
if (!/^python\b/.test(cmd)) continue;
|
|
530
|
+
if (cmd.startsWith("opentelemetry-instrument ")) continue;
|
|
531
|
+
const after = `${m[1]}: opentelemetry-instrument ${cmd}`;
|
|
532
|
+
edits.push({ file: procfile, before: line, after });
|
|
533
|
+
}
|
|
534
|
+
return edits;
|
|
535
|
+
}
|
|
536
|
+
async function plan2(serviceDir) {
|
|
537
|
+
const empty = {
|
|
538
|
+
language: "python",
|
|
539
|
+
serviceDir,
|
|
540
|
+
dependencyEdits: [],
|
|
541
|
+
entrypointEdits: [],
|
|
542
|
+
envEdits: []
|
|
543
|
+
};
|
|
544
|
+
const dependencyEdits = [];
|
|
545
|
+
const reqs = await planRequirementsTxtEdits(serviceDir);
|
|
546
|
+
if (reqs) {
|
|
547
|
+
for (const sdk of reqs.missing) {
|
|
548
|
+
dependencyEdits.push({
|
|
549
|
+
file: reqs.manifest,
|
|
550
|
+
kind: "add",
|
|
551
|
+
name: sdk.name,
|
|
552
|
+
version: sdk.version
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
const entrypointEdits = await planProcfileEdits(serviceDir);
|
|
557
|
+
if (dependencyEdits.length === 0 && entrypointEdits.length === 0) {
|
|
558
|
+
return empty;
|
|
559
|
+
}
|
|
560
|
+
return {
|
|
561
|
+
language: "python",
|
|
562
|
+
serviceDir,
|
|
563
|
+
dependencyEdits,
|
|
564
|
+
entrypointEdits,
|
|
565
|
+
envEdits: [OTEL_ENV2]
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
async function applyRequirementsTxt(manifest, edits, original) {
|
|
569
|
+
const newlines = edits.filter((e) => e.kind === "add").map((e) => `${e.name}${e.version}`);
|
|
570
|
+
const trailing = original.endsWith("\n") ? "" : "\n";
|
|
571
|
+
const next = `${original}${trailing}${newlines.join("\n")}
|
|
572
|
+
`;
|
|
573
|
+
const tmp = `${manifest}.${process.pid}.${Date.now()}.tmp`;
|
|
574
|
+
await fs2.writeFile(tmp, next, "utf8");
|
|
575
|
+
await fs2.rename(tmp, manifest);
|
|
576
|
+
}
|
|
577
|
+
async function applyProcfile(procfile, edits, original) {
|
|
578
|
+
let next = original;
|
|
579
|
+
for (const e of edits) {
|
|
580
|
+
if (!next.includes(e.before)) continue;
|
|
581
|
+
next = next.replace(e.before, e.after);
|
|
582
|
+
}
|
|
583
|
+
const tmp = `${procfile}.${process.pid}.${Date.now()}.tmp`;
|
|
584
|
+
await fs2.writeFile(tmp, next, "utf8");
|
|
585
|
+
await fs2.rename(tmp, procfile);
|
|
586
|
+
}
|
|
587
|
+
async function apply2(installPlan) {
|
|
588
|
+
const touched = /* @__PURE__ */ new Set();
|
|
589
|
+
for (const e of installPlan.dependencyEdits) touched.add(e.file);
|
|
590
|
+
for (const e of installPlan.entrypointEdits) touched.add(e.file);
|
|
591
|
+
if (touched.size === 0) return;
|
|
592
|
+
const originals = /* @__PURE__ */ new Map();
|
|
593
|
+
for (const file of touched) {
|
|
594
|
+
try {
|
|
595
|
+
originals.set(file, await fs2.readFile(file, "utf8"));
|
|
596
|
+
} catch {
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
try {
|
|
600
|
+
for (const file of touched) {
|
|
601
|
+
const raw = originals.get(file);
|
|
602
|
+
if (raw === void 0) {
|
|
603
|
+
throw new Error(`python installer: cannot read ${file} during apply`);
|
|
604
|
+
}
|
|
605
|
+
const base = path3.basename(file);
|
|
606
|
+
if (base === "requirements.txt") {
|
|
607
|
+
const edits = installPlan.dependencyEdits.filter((e) => e.file === file);
|
|
608
|
+
if (edits.length > 0) await applyRequirementsTxt(file, edits, raw);
|
|
609
|
+
} else if (base === "Procfile") {
|
|
610
|
+
const edits = installPlan.entrypointEdits.filter((e) => e.file === file);
|
|
611
|
+
if (edits.length > 0) await applyProcfile(file, edits, raw);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
} catch (err) {
|
|
615
|
+
await rollback2(installPlan, originals);
|
|
616
|
+
throw err;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
async function rollback2(installPlan, originals) {
|
|
620
|
+
const restored = [];
|
|
621
|
+
for (const [file, raw] of originals.entries()) {
|
|
622
|
+
try {
|
|
623
|
+
await fs2.writeFile(file, raw, "utf8");
|
|
624
|
+
restored.push(file);
|
|
625
|
+
} catch {
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
const lines = [
|
|
629
|
+
"# neat-rollback.patch",
|
|
630
|
+
"",
|
|
631
|
+
`# Generated after a partial apply failure in the ${installPlan.language} installer.`,
|
|
632
|
+
"# Files listed below were restored to their pre-apply contents.",
|
|
633
|
+
"",
|
|
634
|
+
...restored.map((f) => `restored: ${f}`),
|
|
635
|
+
""
|
|
636
|
+
];
|
|
637
|
+
const rollbackPath = path3.join(installPlan.serviceDir, "neat-rollback.patch");
|
|
638
|
+
await fs2.writeFile(rollbackPath, lines.join("\n"), "utf8");
|
|
639
|
+
}
|
|
640
|
+
var pythonInstaller = {
|
|
641
|
+
name: "python",
|
|
642
|
+
detect: detect2,
|
|
643
|
+
plan: plan2,
|
|
644
|
+
apply: apply2
|
|
645
|
+
};
|
|
646
|
+
|
|
647
|
+
// src/installers/shared.ts
|
|
648
|
+
function isEmptyPlan(plan3) {
|
|
649
|
+
return plan3.dependencyEdits.length === 0 && plan3.entrypointEdits.length === 0 && plan3.envEdits.length === 0;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// src/installers/index.ts
|
|
653
|
+
var FORBIDDEN_LOCKFILES = /* @__PURE__ */ new Set([
|
|
654
|
+
"package-lock.json",
|
|
655
|
+
"pnpm-lock.yaml",
|
|
656
|
+
"yarn.lock",
|
|
657
|
+
"poetry.lock",
|
|
658
|
+
"Pipfile.lock",
|
|
659
|
+
"Gemfile.lock",
|
|
660
|
+
"Cargo.lock",
|
|
661
|
+
"go.sum"
|
|
662
|
+
]);
|
|
663
|
+
var INSTALLERS = [javascriptInstaller, pythonInstaller];
|
|
664
|
+
async function pickInstaller(serviceDir) {
|
|
665
|
+
for (const inst of INSTALLERS) {
|
|
666
|
+
if (await inst.detect(serviceDir)) return inst;
|
|
667
|
+
}
|
|
668
|
+
return null;
|
|
669
|
+
}
|
|
670
|
+
function renderPatch(sections) {
|
|
671
|
+
if (sections.length === 0) {
|
|
672
|
+
return [
|
|
673
|
+
"# neat install plan",
|
|
674
|
+
"",
|
|
675
|
+
"No SDK installers matched the discovered services. Two reasons this",
|
|
676
|
+
"normally happens:",
|
|
677
|
+
" - the project uses a language NEAT does not yet instrument",
|
|
678
|
+
" (Java / Ruby / .NET / Go / Rust are out of MVP scope per ADR-047);",
|
|
679
|
+
" - the SDK is already installed, so the installer returned an empty",
|
|
680
|
+
" plan.",
|
|
681
|
+
"",
|
|
682
|
+
"You can re-run `neat init --apply` later to pick up new services.",
|
|
683
|
+
""
|
|
684
|
+
].join("\n");
|
|
685
|
+
}
|
|
686
|
+
const lines = ["# neat install plan", ""];
|
|
687
|
+
for (const section of sections) {
|
|
688
|
+
const { installer, plan: plan3 } = section;
|
|
689
|
+
lines.push(`## ${installer} (${plan3.language}) \u2014 ${plan3.serviceDir}`);
|
|
690
|
+
lines.push("");
|
|
691
|
+
if (plan3.dependencyEdits.length > 0) {
|
|
692
|
+
lines.push("### dependencies");
|
|
693
|
+
for (const dep of plan3.dependencyEdits) {
|
|
694
|
+
const base = dep.file.split(/[\\/]/).pop() ?? dep.file;
|
|
695
|
+
if (FORBIDDEN_LOCKFILES.has(base)) {
|
|
696
|
+
throw new Error(
|
|
697
|
+
`installer "${installer}" produced a dependency edit against a lockfile (${dep.file}); lockfiles must never be touched (ADR-047).`
|
|
698
|
+
);
|
|
699
|
+
}
|
|
700
|
+
lines.push(`- ${dep.kind} ${dep.name}@${dep.version} in ${dep.file}`);
|
|
701
|
+
}
|
|
702
|
+
lines.push("");
|
|
703
|
+
}
|
|
704
|
+
if (plan3.entrypointEdits.length > 0) {
|
|
705
|
+
lines.push("### entrypoint");
|
|
706
|
+
for (const e of plan3.entrypointEdits) {
|
|
707
|
+
lines.push(`- ${e.file}`);
|
|
708
|
+
lines.push(` - before: ${e.before}`);
|
|
709
|
+
lines.push(` - after: ${e.after}`);
|
|
710
|
+
}
|
|
711
|
+
lines.push("");
|
|
712
|
+
}
|
|
713
|
+
if (plan3.envEdits.length > 0) {
|
|
714
|
+
lines.push("### env");
|
|
715
|
+
for (const env of plan3.envEdits) {
|
|
716
|
+
const target = env.file ?? "(set in your orchestration layer)";
|
|
717
|
+
lines.push(`- ${env.key}=${env.value} \u2192 ${target}`);
|
|
718
|
+
}
|
|
719
|
+
lines.push("");
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
return lines.join("\n");
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// src/cli.ts
|
|
726
|
+
function usage() {
|
|
727
|
+
console.log("usage: neat <command> [args] [--project <name>]");
|
|
728
|
+
console.log("");
|
|
729
|
+
console.log("commands:");
|
|
730
|
+
console.log(" init <path> One-time install: discover, extract, register, plan SDK install.");
|
|
731
|
+
console.log(" Snapshot lands in <path>/neat-out/graph.json by default");
|
|
732
|
+
console.log(" (or <path>/neat-out/<project>.json for non-default).");
|
|
733
|
+
console.log(" Flags:");
|
|
734
|
+
console.log(" --apply run the SDK install patch in place");
|
|
735
|
+
console.log(" --dry-run write only neat.patch; do not register or snapshot");
|
|
736
|
+
console.log(" --no-install skip SDK install planning entirely");
|
|
737
|
+
console.log(" watch <path> Start neat-core, watch <path>, re-extract on changes.");
|
|
738
|
+
console.log(" PORT (default 8080), OTEL_PORT (4318), HOST (0.0.0.0)");
|
|
739
|
+
console.log(" control listeners. NEAT_OTLP_GRPC=true also opens 4317.");
|
|
740
|
+
console.log(" list List every project registered in the machine-level registry.");
|
|
741
|
+
console.log(" pause <name> Mark a project paused \u2014 daemon stops watching until resumed.");
|
|
742
|
+
console.log(" resume <name> Mark a project active again.");
|
|
743
|
+
console.log(" uninstall <name>");
|
|
744
|
+
console.log(" Remove a project from the registry. Does not touch");
|
|
745
|
+
console.log(" neat-out/, policy.json, or any user file.");
|
|
746
|
+
console.log(" skill Install or print the Claude Code MCP drop-in.");
|
|
747
|
+
console.log(" Flags:");
|
|
748
|
+
console.log(" --print-config print the JSON snippet to stdout");
|
|
749
|
+
console.log(" --apply merge mcpServers.neat into ~/.claude.json");
|
|
750
|
+
console.log("");
|
|
751
|
+
console.log("flags:");
|
|
752
|
+
console.log(' --project <name> Name the project this command targets. Default: "default".');
|
|
753
|
+
}
|
|
754
|
+
function parseArgs(rest) {
|
|
755
|
+
const positional = [];
|
|
756
|
+
let project = null;
|
|
757
|
+
let apply3 = false;
|
|
758
|
+
let dryRun = false;
|
|
759
|
+
let noInstall = false;
|
|
760
|
+
let printConfig = false;
|
|
761
|
+
for (let i = 0; i < rest.length; i++) {
|
|
762
|
+
const arg = rest[i];
|
|
763
|
+
if (arg === "--project") {
|
|
764
|
+
const next = rest[i + 1];
|
|
765
|
+
if (!next) {
|
|
766
|
+
console.error("neat: --project requires a value");
|
|
767
|
+
process.exit(2);
|
|
768
|
+
}
|
|
769
|
+
project = next;
|
|
770
|
+
i++;
|
|
771
|
+
continue;
|
|
772
|
+
}
|
|
773
|
+
if (arg.startsWith("--project=")) {
|
|
774
|
+
project = arg.slice("--project=".length);
|
|
775
|
+
continue;
|
|
776
|
+
}
|
|
777
|
+
if (arg === "--apply") {
|
|
778
|
+
apply3 = true;
|
|
779
|
+
continue;
|
|
780
|
+
}
|
|
781
|
+
if (arg === "--dry-run") {
|
|
782
|
+
dryRun = true;
|
|
783
|
+
continue;
|
|
784
|
+
}
|
|
785
|
+
if (arg === "--no-install") {
|
|
786
|
+
noInstall = true;
|
|
787
|
+
continue;
|
|
788
|
+
}
|
|
789
|
+
if (arg === "--print-config") {
|
|
790
|
+
printConfig = true;
|
|
791
|
+
continue;
|
|
792
|
+
}
|
|
793
|
+
positional.push(arg);
|
|
794
|
+
}
|
|
795
|
+
return { project, apply: apply3, dryRun, noInstall, printConfig, positional };
|
|
796
|
+
}
|
|
797
|
+
function summarise(nodes, edges) {
|
|
798
|
+
const byNode = /* @__PURE__ */ new Map();
|
|
799
|
+
for (const n of nodes) byNode.set(n.type, (byNode.get(n.type) ?? 0) + 1);
|
|
800
|
+
const byEdge = /* @__PURE__ */ new Map();
|
|
801
|
+
for (const e of edges) byEdge.set(e.type, (byEdge.get(e.type) ?? 0) + 1);
|
|
802
|
+
const nodeLines = [...byNode.entries()].sort((a, b) => a[0].localeCompare(b[0])).map(([t, c]) => ` ${t}: ${c}`);
|
|
803
|
+
const edgeLines = [...byEdge.entries()].sort((a, b) => a[0].localeCompare(b[0])).map(([t, c]) => ` ${t}: ${c}`);
|
|
804
|
+
return ["nodes:", ...nodeLines, "edges:", ...edgeLines].join("\n");
|
|
805
|
+
}
|
|
806
|
+
function formatIncompat(inc) {
|
|
807
|
+
if (inc.kind === "node-engine") {
|
|
808
|
+
const range = inc.declaredNodeEngine ? ` (engines.node="${inc.declaredNodeEngine}")` : "";
|
|
809
|
+
return `${inc.package}@${inc.packageVersion ?? "?"} requires Node ${inc.requiredNodeVersion}${range} \u2014 ${inc.reason}`;
|
|
810
|
+
}
|
|
811
|
+
if (inc.kind === "package-conflict") {
|
|
812
|
+
const found = inc.foundVersion ? `@${inc.foundVersion}` : " (missing)";
|
|
813
|
+
return `${inc.package}@${inc.packageVersion ?? "?"} requires ${inc.requires.name}>=${inc.requires.minVersion}; found ${inc.requires.name}${found} \u2014 ${inc.reason}`;
|
|
814
|
+
}
|
|
815
|
+
if (inc.kind === "deprecated-api") {
|
|
816
|
+
return `${inc.package}@${inc.packageVersion ?? "?"} is deprecated \u2014 ${inc.reason}`;
|
|
817
|
+
}
|
|
818
|
+
return `${inc.driver}@${inc.driverVersion} vs ${inc.engine} ${inc.engineVersion} \u2014 ${inc.reason}`;
|
|
819
|
+
}
|
|
820
|
+
function findIncompatibilities(nodes) {
|
|
821
|
+
return nodes.filter(
|
|
822
|
+
(n) => n.type === "ServiceNode" && Array.isArray(n.incompatibilities) && (n.incompatibilities ?? []).length > 0
|
|
823
|
+
);
|
|
824
|
+
}
|
|
825
|
+
function printBanner() {
|
|
826
|
+
console.log("\u2588\u2588\u2588\u2557 \u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557");
|
|
827
|
+
console.log("\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255D\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557\u255A\u2550\u2550\u2588\u2588\u2554\u2550\u2550\u255D");
|
|
828
|
+
console.log("\u2588\u2588\u2554\u2588\u2588\u2557 \u2588\u2588\u2551\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2551 \u2588\u2588\u2551 ");
|
|
829
|
+
console.log("\u2588\u2588\u2551\u255A\u2588\u2588\u2557\u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u255D \u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2551 \u2588\u2588\u2551 ");
|
|
830
|
+
console.log("\u2588\u2588\u2551 \u255A\u2588\u2588\u2588\u2588\u2551\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551 ");
|
|
831
|
+
console.log("\u255A\u2550\u255D \u255A\u2550\u2550\u2550\u255D\u255A\u2550\u2550\u2550\u2550\u2550\u2550\u255D\u255A\u2550\u255D \u255A\u2550\u255D \u255A\u2550\u255D ");
|
|
832
|
+
console.log("");
|
|
833
|
+
console.log(" Network Expressive Architecting Tool");
|
|
834
|
+
console.log(" neat.is \xB7 v0.2.5 \xB7 BSL 1.1");
|
|
835
|
+
console.log("");
|
|
836
|
+
}
|
|
837
|
+
function printDiscoveryReport(opts, services) {
|
|
838
|
+
const languages = [...new Set(services.map((s) => s.node.language))].sort();
|
|
839
|
+
const mode = opts.dryRun ? "dry-run" : opts.apply ? "apply" : "patch-only";
|
|
840
|
+
printBanner();
|
|
841
|
+
console.log("=== neat init: discovery ===");
|
|
842
|
+
console.log(`scan path: ${opts.scanPath}`);
|
|
843
|
+
console.log(`project: ${opts.project}`);
|
|
844
|
+
console.log(`mode: ${mode}`);
|
|
845
|
+
console.log(`services: ${services.length}`);
|
|
846
|
+
for (const s of services) {
|
|
847
|
+
const where = s.node.repoPath && s.node.repoPath.length > 0 ? s.node.repoPath : ".";
|
|
848
|
+
console.log(` - ${s.node.name} (${s.node.language}) \u2014 ${where}`);
|
|
849
|
+
}
|
|
850
|
+
console.log(`languages: ${languages.length > 0 ? languages.join(", ") : "(none)"}`);
|
|
851
|
+
if (opts.noInstall) {
|
|
852
|
+
console.log("install: skipped (--no-install)");
|
|
853
|
+
} else if (opts.dryRun) {
|
|
854
|
+
console.log("install: patch will be written to neat.patch; nothing else.");
|
|
855
|
+
} else if (opts.apply) {
|
|
856
|
+
console.log("install: patch will be applied in place. Run `npm install` afterwards.");
|
|
857
|
+
} else {
|
|
858
|
+
console.log("install: patch will be written to neat.patch for review.");
|
|
859
|
+
}
|
|
860
|
+
console.log("");
|
|
861
|
+
}
|
|
862
|
+
async function buildPatchSections(services) {
|
|
863
|
+
const sections = [];
|
|
864
|
+
for (const svc of services) {
|
|
865
|
+
const installer = await pickInstaller(svc.dir);
|
|
866
|
+
if (!installer) continue;
|
|
867
|
+
const plan3 = await installer.plan(svc.dir);
|
|
868
|
+
if (isEmptyPlan(plan3)) continue;
|
|
869
|
+
sections.push({ installer: installer.name, plan: plan3 });
|
|
870
|
+
}
|
|
871
|
+
return sections;
|
|
872
|
+
}
|
|
873
|
+
async function runInit(opts) {
|
|
874
|
+
const written = [];
|
|
875
|
+
const stat = await fs3.stat(opts.scanPath).catch(() => null);
|
|
876
|
+
if (!stat || !stat.isDirectory()) {
|
|
877
|
+
console.error(`neat init: ${opts.scanPath} is not a directory`);
|
|
878
|
+
return { exitCode: 2, writtenFiles: written };
|
|
879
|
+
}
|
|
880
|
+
const services = await discoverServices(opts.scanPath);
|
|
881
|
+
printDiscoveryReport(opts, services);
|
|
882
|
+
const sections = opts.noInstall ? [] : await buildPatchSections(services);
|
|
883
|
+
const patch = renderPatch(sections);
|
|
884
|
+
const patchPath = path4.join(opts.scanPath, "neat.patch");
|
|
885
|
+
if (opts.dryRun) {
|
|
886
|
+
await fs3.writeFile(patchPath, patch, "utf8");
|
|
887
|
+
written.push(patchPath);
|
|
888
|
+
console.log(`dry-run: patch written to ${patchPath}`);
|
|
889
|
+
console.log("rerun without --dry-run to register and snapshot.");
|
|
890
|
+
return { exitCode: 0, writtenFiles: written };
|
|
891
|
+
}
|
|
892
|
+
const graphKey = opts.projectExplicit ? opts.project : DEFAULT_PROJECT;
|
|
893
|
+
resetGraph(graphKey);
|
|
894
|
+
const graph = getGraph(graphKey);
|
|
895
|
+
const result = await extractFromDirectory(graph, opts.scanPath);
|
|
896
|
+
await saveGraphToDisk(graph, opts.outPath);
|
|
897
|
+
written.push(opts.outPath);
|
|
898
|
+
const languages = [...new Set(services.map((s) => s.node.language))].sort();
|
|
899
|
+
try {
|
|
900
|
+
await addProject({
|
|
901
|
+
name: opts.project,
|
|
902
|
+
path: opts.scanPath,
|
|
903
|
+
languages,
|
|
904
|
+
status: "active"
|
|
905
|
+
});
|
|
906
|
+
} catch (err) {
|
|
907
|
+
if (err instanceof ProjectNameCollisionError) {
|
|
908
|
+
console.error(`neat init: ${err.message}`);
|
|
909
|
+
console.error("pass --project <other-name> to register under a different name.");
|
|
910
|
+
return { exitCode: 1, writtenFiles: written };
|
|
911
|
+
}
|
|
912
|
+
throw err;
|
|
913
|
+
}
|
|
914
|
+
if (!opts.noInstall) {
|
|
915
|
+
if (opts.apply) {
|
|
916
|
+
for (const section of sections) {
|
|
917
|
+
const installer = INSTALLERS.find((i) => i.name === section.installer);
|
|
918
|
+
if (!installer) continue;
|
|
919
|
+
await installer.apply(section.plan);
|
|
920
|
+
}
|
|
921
|
+
if (sections.length > 0) {
|
|
922
|
+
console.log("");
|
|
923
|
+
console.log("patch applied. Run `npm install` (or your language equivalent) to refresh lockfiles.");
|
|
924
|
+
}
|
|
925
|
+
} else {
|
|
926
|
+
await fs3.writeFile(patchPath, patch, "utf8");
|
|
927
|
+
written.push(patchPath);
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
const nodes = [];
|
|
931
|
+
graph.forEachNode((_id, attrs) => nodes.push(attrs));
|
|
932
|
+
const edges = [];
|
|
933
|
+
graph.forEachEdge((_id, attrs) => edges.push(attrs));
|
|
934
|
+
console.log("");
|
|
935
|
+
console.log("=== neat init: summary ===");
|
|
936
|
+
console.log(`snapshot: ${opts.outPath}`);
|
|
937
|
+
console.log(`added: ${result.nodesAdded} nodes, ${result.edgesAdded} edges`);
|
|
938
|
+
console.log(`total: ${graph.order} nodes, ${graph.size} edges`);
|
|
939
|
+
console.log(summarise(nodes, edges));
|
|
940
|
+
const incompatibilities = findIncompatibilities(nodes);
|
|
941
|
+
if (incompatibilities.length > 0) {
|
|
942
|
+
console.log("");
|
|
943
|
+
console.log(`incompatibilities found in ${incompatibilities.length} service(s):`);
|
|
944
|
+
for (const svc of incompatibilities) {
|
|
945
|
+
for (const inc of svc.incompatibilities ?? []) {
|
|
946
|
+
console.log(` ${svc.name}: ${formatIncompat(inc)}`);
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
return { exitCode: 0, writtenFiles: written };
|
|
951
|
+
}
|
|
952
|
+
var CLAUDE_SKILL_CONFIG = {
|
|
953
|
+
mcpServers: {
|
|
954
|
+
neat: {
|
|
955
|
+
type: "stdio",
|
|
956
|
+
command: "npx",
|
|
957
|
+
args: ["-y", "@neat.is/mcp"],
|
|
958
|
+
env: {
|
|
959
|
+
NEAT_API_URL: "http://localhost:8080"
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
};
|
|
964
|
+
function claudeConfigPath() {
|
|
965
|
+
const override = process.env.NEAT_CLAUDE_CONFIG;
|
|
966
|
+
if (override && override.length > 0) return path4.resolve(override);
|
|
967
|
+
const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
|
|
968
|
+
return path4.join(home, ".claude.json");
|
|
969
|
+
}
|
|
970
|
+
async function runSkill(opts) {
|
|
971
|
+
const snippet = JSON.stringify(CLAUDE_SKILL_CONFIG, null, 2) + "\n";
|
|
972
|
+
if (opts.printConfig) {
|
|
973
|
+
process.stdout.write(snippet);
|
|
974
|
+
return { exitCode: 0 };
|
|
975
|
+
}
|
|
976
|
+
if (opts.apply) {
|
|
977
|
+
const target = claudeConfigPath();
|
|
978
|
+
let existing = {};
|
|
979
|
+
try {
|
|
980
|
+
existing = JSON.parse(await fs3.readFile(target, "utf8"));
|
|
981
|
+
} catch (err) {
|
|
982
|
+
if (err.code !== "ENOENT") {
|
|
983
|
+
console.error(`neat skill: failed to read ${target} \u2014 ${err.message}`);
|
|
984
|
+
return { exitCode: 1 };
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
const mcp = existing.mcpServers ?? {};
|
|
988
|
+
const merged = {
|
|
989
|
+
...existing,
|
|
990
|
+
mcpServers: { ...mcp, neat: CLAUDE_SKILL_CONFIG.mcpServers.neat }
|
|
991
|
+
};
|
|
992
|
+
await fs3.mkdir(path4.dirname(target), { recursive: true });
|
|
993
|
+
await fs3.writeFile(target, JSON.stringify(merged, null, 2) + "\n", "utf8");
|
|
994
|
+
console.log(`neat skill: wrote mcpServers.neat to ${target}`);
|
|
995
|
+
console.log("restart Claude Code to pick up the new MCP server.");
|
|
996
|
+
return { exitCode: 0 };
|
|
997
|
+
}
|
|
998
|
+
console.log("neat skill \u2014 Claude Code MCP drop-in for NEAT");
|
|
999
|
+
console.log("");
|
|
1000
|
+
console.log(" --print-config print the JSON snippet to stdout");
|
|
1001
|
+
console.log(" --apply merge mcpServers.neat into ~/.claude.json");
|
|
1002
|
+
console.log("");
|
|
1003
|
+
console.log("Manual install: copy mcpServers.neat from --print-config into ~/.claude.json,");
|
|
1004
|
+
console.log("then restart Claude Code. See packages/claude-skill/SKILL.md for the tool list.");
|
|
1005
|
+
return { exitCode: 0 };
|
|
1006
|
+
}
|
|
1007
|
+
async function main() {
|
|
1008
|
+
const [, , cmd, ...rest] = process.argv;
|
|
1009
|
+
if (!cmd || cmd === "-h" || cmd === "--help") {
|
|
1010
|
+
usage();
|
|
1011
|
+
process.exit(0);
|
|
1012
|
+
}
|
|
1013
|
+
const parsed = parseArgs(rest);
|
|
1014
|
+
const { positional, apply: apply3, dryRun, noInstall } = parsed;
|
|
1015
|
+
const project = parsed.project ?? DEFAULT_PROJECT;
|
|
1016
|
+
if (cmd === "init") {
|
|
1017
|
+
const target = positional[0];
|
|
1018
|
+
if (!target) {
|
|
1019
|
+
console.error("neat init: missing <path>");
|
|
1020
|
+
usage();
|
|
1021
|
+
process.exit(2);
|
|
1022
|
+
}
|
|
1023
|
+
if (apply3 && dryRun) {
|
|
1024
|
+
console.error("neat init: --apply and --dry-run are mutually exclusive");
|
|
1025
|
+
process.exit(2);
|
|
1026
|
+
}
|
|
1027
|
+
const scanPath = path4.resolve(target);
|
|
1028
|
+
const projectExplicit = parsed.project !== null;
|
|
1029
|
+
const projectName = projectExplicit ? project : path4.basename(scanPath);
|
|
1030
|
+
const projectKey = projectExplicit ? project : DEFAULT_PROJECT;
|
|
1031
|
+
const fallback = pathsForProject(projectKey, path4.join(scanPath, "neat-out")).snapshotPath;
|
|
1032
|
+
const outPath = path4.resolve(process.env.NEAT_OUT_PATH ?? fallback);
|
|
1033
|
+
const result = await runInit({
|
|
1034
|
+
scanPath,
|
|
1035
|
+
outPath,
|
|
1036
|
+
project: projectName,
|
|
1037
|
+
projectExplicit,
|
|
1038
|
+
apply: apply3,
|
|
1039
|
+
dryRun,
|
|
1040
|
+
noInstall
|
|
1041
|
+
});
|
|
1042
|
+
if (result.exitCode !== 0) process.exit(result.exitCode);
|
|
1043
|
+
return;
|
|
1044
|
+
}
|
|
1045
|
+
if (cmd === "watch") {
|
|
1046
|
+
const target = positional[0];
|
|
1047
|
+
if (!target) {
|
|
1048
|
+
console.error("neat watch: missing <path>");
|
|
1049
|
+
usage();
|
|
1050
|
+
process.exit(2);
|
|
1051
|
+
}
|
|
1052
|
+
const scanPath = path4.resolve(target);
|
|
1053
|
+
const stat = await fs3.stat(scanPath).catch(() => null);
|
|
1054
|
+
if (!stat || !stat.isDirectory()) {
|
|
1055
|
+
console.error(`neat watch: ${scanPath} is not a directory`);
|
|
1056
|
+
process.exit(2);
|
|
1057
|
+
}
|
|
1058
|
+
const projectPaths = pathsForProject(project, path4.join(scanPath, "neat-out"));
|
|
1059
|
+
const outPath = path4.resolve(process.env.NEAT_OUT_PATH ?? projectPaths.snapshotPath);
|
|
1060
|
+
const errorsPath = path4.resolve(
|
|
1061
|
+
process.env.NEAT_ERRORS_PATH ?? path4.join(path4.dirname(outPath), path4.basename(projectPaths.errorsPath))
|
|
1062
|
+
);
|
|
1063
|
+
const staleEventsPath = path4.resolve(
|
|
1064
|
+
process.env.NEAT_STALE_EVENTS_PATH ?? path4.join(path4.dirname(outPath), path4.basename(projectPaths.staleEventsPath))
|
|
1065
|
+
);
|
|
1066
|
+
const embeddingsCachePath = process.env.NEAT_EMBEDDINGS_CACHE_PATH ? path4.resolve(process.env.NEAT_EMBEDDINGS_CACHE_PATH) : void 0;
|
|
1067
|
+
const handle = await startWatch(getGraph(project), {
|
|
1068
|
+
scanPath,
|
|
1069
|
+
outPath,
|
|
1070
|
+
errorsPath,
|
|
1071
|
+
staleEventsPath,
|
|
1072
|
+
project,
|
|
1073
|
+
...embeddingsCachePath ? { embeddingsCachePath } : {},
|
|
1074
|
+
host: process.env.HOST ?? "0.0.0.0",
|
|
1075
|
+
port: Number(process.env.PORT ?? 8080),
|
|
1076
|
+
otelPort: Number(process.env.OTEL_PORT ?? 4318),
|
|
1077
|
+
otelGrpc: process.env.NEAT_OTLP_GRPC === "true",
|
|
1078
|
+
otelGrpcPort: process.env.NEAT_OTLP_GRPC_PORT ? Number(process.env.NEAT_OTLP_GRPC_PORT) : void 0
|
|
1079
|
+
});
|
|
1080
|
+
let shuttingDown = false;
|
|
1081
|
+
const shutdown = (signal) => {
|
|
1082
|
+
if (shuttingDown) return;
|
|
1083
|
+
shuttingDown = true;
|
|
1084
|
+
console.log(`neat watch: ${signal} received, stopping\u2026`);
|
|
1085
|
+
void handle.stop().catch((err) => {
|
|
1086
|
+
console.error("neat watch: shutdown error", err);
|
|
1087
|
+
});
|
|
1088
|
+
};
|
|
1089
|
+
process.on("SIGTERM", shutdown);
|
|
1090
|
+
process.on("SIGINT", shutdown);
|
|
1091
|
+
return;
|
|
1092
|
+
}
|
|
1093
|
+
if (cmd === "list") {
|
|
1094
|
+
const projects = await listProjects();
|
|
1095
|
+
if (projects.length === 0) {
|
|
1096
|
+
console.log("no projects registered. run `neat init <path>` to register one.");
|
|
1097
|
+
return;
|
|
1098
|
+
}
|
|
1099
|
+
for (const p of projects) {
|
|
1100
|
+
const seen = p.lastSeenAt ? p.lastSeenAt : "never";
|
|
1101
|
+
const langs = p.languages.length > 0 ? p.languages.join(",") : "-";
|
|
1102
|
+
console.log(`${p.name} ${p.status} ${langs} ${p.path} last-seen=${seen}`);
|
|
1103
|
+
}
|
|
1104
|
+
return;
|
|
1105
|
+
}
|
|
1106
|
+
if (cmd === "pause") {
|
|
1107
|
+
const name = positional[0];
|
|
1108
|
+
if (!name) {
|
|
1109
|
+
console.error("neat pause: missing <name>");
|
|
1110
|
+
usage();
|
|
1111
|
+
process.exit(2);
|
|
1112
|
+
}
|
|
1113
|
+
try {
|
|
1114
|
+
const entry2 = await setStatus(name, "paused");
|
|
1115
|
+
console.log(`paused: ${entry2.name} (${entry2.path})`);
|
|
1116
|
+
} catch (err) {
|
|
1117
|
+
console.error(err.message);
|
|
1118
|
+
process.exit(1);
|
|
1119
|
+
}
|
|
1120
|
+
return;
|
|
1121
|
+
}
|
|
1122
|
+
if (cmd === "resume") {
|
|
1123
|
+
const name = positional[0];
|
|
1124
|
+
if (!name) {
|
|
1125
|
+
console.error("neat resume: missing <name>");
|
|
1126
|
+
usage();
|
|
1127
|
+
process.exit(2);
|
|
1128
|
+
}
|
|
1129
|
+
try {
|
|
1130
|
+
const entry2 = await setStatus(name, "active");
|
|
1131
|
+
console.log(`resumed: ${entry2.name} (${entry2.path})`);
|
|
1132
|
+
} catch (err) {
|
|
1133
|
+
console.error(err.message);
|
|
1134
|
+
process.exit(1);
|
|
1135
|
+
}
|
|
1136
|
+
return;
|
|
1137
|
+
}
|
|
1138
|
+
if (cmd === "skill") {
|
|
1139
|
+
const result = await runSkill({ apply: parsed.apply, printConfig: parsed.printConfig });
|
|
1140
|
+
if (result.exitCode !== 0) process.exit(result.exitCode);
|
|
1141
|
+
return;
|
|
1142
|
+
}
|
|
1143
|
+
if (cmd === "uninstall") {
|
|
1144
|
+
const name = positional[0];
|
|
1145
|
+
if (!name) {
|
|
1146
|
+
console.error("neat uninstall: missing <name>");
|
|
1147
|
+
usage();
|
|
1148
|
+
process.exit(2);
|
|
1149
|
+
}
|
|
1150
|
+
const removed = await removeProject(name);
|
|
1151
|
+
if (!removed) {
|
|
1152
|
+
console.error(`neat uninstall: no project named "${name}"`);
|
|
1153
|
+
process.exit(1);
|
|
1154
|
+
}
|
|
1155
|
+
console.log(`unregistered: ${removed.name} (${removed.path})`);
|
|
1156
|
+
console.log("note: neat-out/, policy.json, and other files at the project path were left in place.");
|
|
1157
|
+
return;
|
|
1158
|
+
}
|
|
1159
|
+
console.error(`neat: unknown command "${cmd}"`);
|
|
1160
|
+
usage();
|
|
1161
|
+
process.exit(1);
|
|
1162
|
+
}
|
|
1163
|
+
var entry = process.argv[1] ?? "";
|
|
1164
|
+
if (/[\\/]cli\.(?:cjs|js)$/.test(entry) || entry.endsWith("/cli") || entry.endsWith("/neat")) {
|
|
1165
|
+
main().catch((err) => {
|
|
1166
|
+
console.error(err);
|
|
1167
|
+
process.exit(1);
|
|
1168
|
+
});
|
|
1169
|
+
}
|
|
1170
|
+
export {
|
|
1171
|
+
CLAUDE_SKILL_CONFIG,
|
|
1172
|
+
runInit,
|
|
1173
|
+
runSkill
|
|
1174
|
+
};
|
|
1175
|
+
//# sourceMappingURL=cli.js.map
|