@datasynx/agentic-ai-cartography 2.0.0 → 2.3.0
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/AGENTS.md +32 -0
- package/README.md +115 -6
- package/dist/api-bin.js +24 -0
- package/dist/api-bin.js.map +1 -0
- package/dist/{bookmarks-VS56KVCO.js → bookmarks-WXHE7GN7.js} +6 -3
- package/dist/{chunk-CJ2PITFA.js → chunk-2SZ5QHGH.js} +71 -9
- package/dist/chunk-2SZ5QHGH.js.map +1 -0
- package/dist/chunk-7QEBFMN4.js +3278 -0
- package/dist/chunk-7QEBFMN4.js.map +1 -0
- package/dist/chunk-7VZH5PFV.js +1134 -0
- package/dist/chunk-7VZH5PFV.js.map +1 -0
- package/dist/chunk-B2AKONVW.js +2465 -0
- package/dist/chunk-B2AKONVW.js.map +1 -0
- package/dist/chunk-WCR47QA2.js +277 -0
- package/dist/chunk-WCR47QA2.js.map +1 -0
- package/dist/cli.js +2367 -663
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +9405 -57913
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +3048 -69
- package/dist/index.d.ts +3048 -69
- package/dist/index.js +9150 -2607
- package/dist/index.js.map +1 -1
- package/dist/mcp-bin.js +17 -26
- package/dist/mcp-bin.js.map +1 -1
- package/dist/types-TJWXAQ2L.js +66 -0
- package/llms-full.txt +758 -0
- package/llms.txt +24 -0
- package/package.json +27 -9
- package/scripts/build-llms.mjs +89 -0
- package/scripts/build-mcpb.mjs +31 -0
- package/scripts/gen-api-schemas.ts +29 -0
- package/scripts/gen-docs.ts +123 -0
- package/scripts/sync-version.mjs +51 -0
- package/scripts/validate-server-json.mjs +54 -0
- package/server.json +4 -4
- package/dist/chunk-CJ2PITFA.js.map +0 -1
- package/dist/chunk-D6SRSLBF.js +0 -48
- package/dist/chunk-J6FDZ6HZ.js +0 -142
- package/dist/chunk-J6FDZ6HZ.js.map +0 -1
- package/dist/chunk-UGSNG3QJ.js +0 -49
- package/dist/chunk-UGSNG3QJ.js.map +0 -1
- package/dist/chunk-W7YE6AAH.js +0 -1516
- package/dist/chunk-W7YE6AAH.js.map +0 -1
- package/dist/onnxruntime_binding-6Q6HXASN.node +0 -0
- package/dist/onnxruntime_binding-EKZT2NRK.node +0 -0
- package/dist/onnxruntime_binding-P6S7V3CI.node +0 -0
- package/dist/onnxruntime_binding-PJNNIIUO.node +0 -0
- package/dist/onnxruntime_binding-UN6SPTQK.node +0 -0
- package/dist/sdk-A6NLO3DJ.js +0 -12294
- package/dist/sdk-A6NLO3DJ.js.map +0 -1
- package/dist/sdk-G5D4WQZ4.js +0 -12293
- package/dist/sdk-G5D4WQZ4.js.map +0 -1
- package/dist/sdk-QSTAREST.js +0 -4869
- package/dist/sdk-QSTAREST.js.map +0 -1
- package/dist/sqlite-vec-EZN67B2V.js +0 -40
- package/dist/sqlite-vec-EZN67B2V.js.map +0 -1
- package/dist/sqlite-vec-UK5YYE5T.js +0 -39
- package/dist/sqlite-vec-UK5YYE5T.js.map +0 -1
- package/dist/transformers.node-BTYUTJK5.js +0 -42884
- package/dist/transformers.node-BTYUTJK5.js.map +0 -1
- package/dist/transformers.node-J6PRTTOX.js +0 -42883
- package/dist/transformers.node-J6PRTTOX.js.map +0 -1
- package/dist/types-JG27FR3E.js +0 -29
- package/dist/types-JG27FR3E.js.map +0 -1
- package/scripts/postinstall.mjs +0 -7
- /package/dist/{bookmarks-VS56KVCO.js.map → bookmarks-WXHE7GN7.js.map} +0 -0
- /package/dist/{chunk-D6SRSLBF.js.map → types-TJWXAQ2L.js.map} +0 -0
package/dist/cli.js
CHANGED
|
@@ -1,39 +1,55 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
|
-
|
|
3
|
+
getRuleset,
|
|
4
|
+
isPersonalHost,
|
|
5
|
+
listRulesets,
|
|
6
|
+
loadOrgKey,
|
|
7
|
+
pseudonymize,
|
|
8
|
+
pseudonymizeString,
|
|
9
|
+
reversePseudonym,
|
|
10
|
+
rotateOrgKey,
|
|
11
|
+
runDrift,
|
|
12
|
+
runLocalDiscovery,
|
|
4
13
|
startMcp
|
|
5
|
-
} from "./chunk-
|
|
14
|
+
} from "./chunk-B2AKONVW.js";
|
|
15
|
+
import {
|
|
16
|
+
startApi
|
|
17
|
+
} from "./chunk-7VZH5PFV.js";
|
|
18
|
+
import {
|
|
19
|
+
CartographyDB,
|
|
20
|
+
buildCartographyToolHandlers,
|
|
21
|
+
createCartographyTools,
|
|
22
|
+
deriveSessionName,
|
|
23
|
+
diffTopology,
|
|
24
|
+
normalizeTenant,
|
|
25
|
+
redactValue,
|
|
26
|
+
stableStringify,
|
|
27
|
+
stripSensitive
|
|
28
|
+
} from "./chunk-7QEBFMN4.js";
|
|
6
29
|
import {
|
|
30
|
+
ConfigFileSchema,
|
|
31
|
+
CostEntrySchema,
|
|
7
32
|
DOMAIN_COLORS,
|
|
8
33
|
DOMAIN_PALETTE,
|
|
9
|
-
|
|
10
|
-
NODE_TYPES,
|
|
34
|
+
DriftConfigSchema,
|
|
11
35
|
NODE_TYPE_GROUPS,
|
|
36
|
+
SharingLevelSchema,
|
|
37
|
+
centralDbFromEnv,
|
|
12
38
|
defaultConfig
|
|
13
|
-
} from "./chunk-
|
|
39
|
+
} from "./chunk-WCR47QA2.js";
|
|
14
40
|
import {
|
|
15
|
-
HOME,
|
|
16
|
-
IS_LINUX,
|
|
17
41
|
IS_MAC,
|
|
18
42
|
IS_WIN,
|
|
19
43
|
PLATFORM,
|
|
20
44
|
checkReadOnly,
|
|
21
45
|
cleanupTempFiles,
|
|
22
|
-
commandExists,
|
|
23
|
-
dbScanDirs,
|
|
24
|
-
findFiles,
|
|
25
46
|
logDebug,
|
|
26
47
|
logError,
|
|
27
48
|
logInfo,
|
|
28
49
|
logWarn,
|
|
29
50
|
run,
|
|
30
|
-
scanAllBookmarks,
|
|
31
|
-
scanAllHistory,
|
|
32
|
-
scanWindowsDbServices,
|
|
33
|
-
scanWindowsPrograms,
|
|
34
51
|
setVerbose
|
|
35
|
-
} from "./chunk-
|
|
36
|
-
import "./chunk-UGSNG3QJ.js";
|
|
52
|
+
} from "./chunk-2SZ5QHGH.js";
|
|
37
53
|
|
|
38
54
|
// src/cli.ts
|
|
39
55
|
import { Command } from "commander";
|
|
@@ -54,7 +70,30 @@ function isOAuthLoggedIn() {
|
|
|
54
70
|
return false;
|
|
55
71
|
}
|
|
56
72
|
}
|
|
57
|
-
function checkPrerequisites() {
|
|
73
|
+
function checkPrerequisites(provider = "claude") {
|
|
74
|
+
if (provider === "openai") {
|
|
75
|
+
checkOpenAIPrerequisites();
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (provider === "ollama") {
|
|
79
|
+
process.stderr.write(
|
|
80
|
+
`\u2713 Ollama provider selected (host: ${process.env.OLLAMA_HOST ?? "http://127.0.0.1:11434"})
|
|
81
|
+
`
|
|
82
|
+
);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
checkClaudePrerequisites();
|
|
86
|
+
}
|
|
87
|
+
function checkOpenAIPrerequisites() {
|
|
88
|
+
if (!process.env.OPENAI_API_KEY) {
|
|
89
|
+
process.stderr.write(
|
|
90
|
+
"\n\u274C OpenAI provider selected but OPENAI_API_KEY is not set.\n\n Set your key:\n export OPENAI_API_KEY=sk-...\n\n Install the SDK if needed:\n npm install openai\n\n Tip: pass a non-Claude model, e.g. --model gpt-4.1\n\n"
|
|
91
|
+
);
|
|
92
|
+
process.exitCode = 1;
|
|
93
|
+
throw new Error("OPENAI_API_KEY not set");
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function checkClaudePrerequisites() {
|
|
58
97
|
try {
|
|
59
98
|
execSync("claude --version", { stdio: "pipe" });
|
|
60
99
|
} catch {
|
|
@@ -75,515 +114,24 @@ function checkPrerequisites() {
|
|
|
75
114
|
}
|
|
76
115
|
}
|
|
77
116
|
|
|
78
|
-
// src/
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
return "(error or not available)";
|
|
97
|
-
}
|
|
98
|
-
consecutiveFailures = 0;
|
|
99
|
-
return result;
|
|
100
|
-
};
|
|
101
|
-
}
|
|
102
|
-
function stripSensitive(target) {
|
|
103
|
-
const raw = target.trim();
|
|
104
|
-
if (!raw) return raw;
|
|
105
|
-
try {
|
|
106
|
-
const url = new URL(raw.startsWith("http") ? raw : `tcp://${raw}`);
|
|
107
|
-
const stripped = `${url.hostname}${url.port ? ":" + url.port : ""}`;
|
|
108
|
-
return stripped || raw;
|
|
109
|
-
} catch {
|
|
110
|
-
const stripped = raw.replace(/\/.*$/, "").replace(/\?.*$/, "").replace(/@.*:/, ":");
|
|
111
|
-
return stripped || raw;
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
async function createCartographyTools(db, sessionId, opts = {}) {
|
|
115
|
-
const { tool, createSdkMcpServer } = await import("./sdk-A6NLO3DJ.js");
|
|
116
|
-
const tools = [
|
|
117
|
-
tool("save_node", "Save an infrastructure node to the catalog", {
|
|
118
|
-
id: z.string(),
|
|
119
|
-
type: z.enum(NODE_TYPES),
|
|
120
|
-
name: z.string(),
|
|
121
|
-
discoveredVia: z.string(),
|
|
122
|
-
confidence: z.number().min(0).max(1),
|
|
123
|
-
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
124
|
-
tags: z.array(z.string()).optional(),
|
|
125
|
-
domain: z.string().optional().describe('Business domain, e.g. "Marketing", "Finance"'),
|
|
126
|
-
subDomain: z.string().optional().describe('Sub-domain, e.g. "Forecast client orders"'),
|
|
127
|
-
qualityScore: z.number().min(0).max(100).optional().describe("Data quality score 0\u2013100")
|
|
128
|
-
}, async (args) => {
|
|
129
|
-
const node = {
|
|
130
|
-
id: stripSensitive(args["id"]),
|
|
131
|
-
type: args["type"],
|
|
132
|
-
name: args["name"],
|
|
133
|
-
discoveredVia: args["discoveredVia"],
|
|
134
|
-
confidence: args["confidence"],
|
|
135
|
-
metadata: args["metadata"] ?? {},
|
|
136
|
-
tags: args["tags"] ?? [],
|
|
137
|
-
domain: args["domain"],
|
|
138
|
-
subDomain: args["subDomain"],
|
|
139
|
-
qualityScore: args["qualityScore"]
|
|
140
|
-
};
|
|
141
|
-
db.upsertNode(sessionId, node);
|
|
142
|
-
return { content: [{ type: "text", text: `\u2713 Node: ${node.id}` }] };
|
|
143
|
-
}),
|
|
144
|
-
tool("save_edge", "Save a relationship (edge) between two nodes \u2014 ALWAYS save edges when connections are clear", {
|
|
145
|
-
sourceId: z.string(),
|
|
146
|
-
targetId: z.string(),
|
|
147
|
-
relationship: z.enum(EDGE_RELATIONSHIPS),
|
|
148
|
-
evidence: z.string(),
|
|
149
|
-
confidence: z.number().min(0).max(1)
|
|
150
|
-
}, async (args) => {
|
|
151
|
-
db.insertEdge(sessionId, {
|
|
152
|
-
sourceId: args["sourceId"],
|
|
153
|
-
targetId: args["targetId"],
|
|
154
|
-
relationship: args["relationship"],
|
|
155
|
-
evidence: args["evidence"],
|
|
156
|
-
confidence: args["confidence"]
|
|
157
|
-
});
|
|
158
|
-
return { content: [{ type: "text", text: `\u2713 ${args["sourceId"]}\u2192${args["targetId"]}` }] };
|
|
159
|
-
}),
|
|
160
|
-
tool("get_catalog", "Get the current catalog \u2014 use before save_node to avoid duplicates", {
|
|
161
|
-
includeEdges: z.boolean().default(true)
|
|
162
|
-
}, async (args) => {
|
|
163
|
-
const nodes = db.getNodes(sessionId);
|
|
164
|
-
const edges = args["includeEdges"] ? db.getEdges(sessionId) : [];
|
|
165
|
-
return {
|
|
166
|
-
content: [{
|
|
167
|
-
type: "text",
|
|
168
|
-
text: JSON.stringify({
|
|
169
|
-
count: { nodes: nodes.length, edges: edges.length },
|
|
170
|
-
nodeIds: nodes.map((n) => n.id)
|
|
171
|
-
})
|
|
172
|
-
}]
|
|
173
|
-
};
|
|
174
|
-
}),
|
|
175
|
-
tool("ask_user", "Ask the user a question \u2014 for clarifications, missing context, or consent (e.g. before scanning browser history)", {
|
|
176
|
-
question: z.string().describe("The question for the user (clear and specific)"),
|
|
177
|
-
context: z.string().optional().describe("Optional context explaining why this is relevant")
|
|
178
|
-
}, async (args) => {
|
|
179
|
-
const question = args["question"];
|
|
180
|
-
const context = args["context"];
|
|
181
|
-
if (opts.onAskUser) {
|
|
182
|
-
const answer = await opts.onAskUser(question, context);
|
|
183
|
-
return { content: [{ type: "text", text: answer }] };
|
|
184
|
-
}
|
|
185
|
-
return {
|
|
186
|
-
content: [{ type: "text", text: "(Non-interactive mode \u2014 please continue without this information)" }]
|
|
187
|
-
};
|
|
188
|
-
}),
|
|
189
|
-
tool("scan_bookmarks", "Scan all browser bookmarks \u2014 hostnames only, no personal data (Chrome, Chromium, Edge, Brave, Vivaldi, Opera, Firefox)", {
|
|
190
|
-
minConfidence: z.number().min(0).max(1).default(0.5).optional()
|
|
191
|
-
}, async () => {
|
|
192
|
-
const hosts = await scanAllBookmarks();
|
|
193
|
-
return {
|
|
194
|
-
content: [{
|
|
195
|
-
type: "text",
|
|
196
|
-
text: JSON.stringify({
|
|
197
|
-
count: hosts.length,
|
|
198
|
-
hosts: hosts.map((h) => ({
|
|
199
|
-
hostname: h.hostname,
|
|
200
|
-
port: h.port,
|
|
201
|
-
protocol: h.protocol,
|
|
202
|
-
source: h.source
|
|
203
|
-
})),
|
|
204
|
-
note: "Hostnames only \u2014 no paths, no personal data. Classify each as a business tool (save_node) or ignore (social media, news, shopping)."
|
|
205
|
-
})
|
|
206
|
-
}]
|
|
207
|
-
};
|
|
208
|
-
}),
|
|
209
|
-
tool("scan_browser_history", "Scan browser history \u2014 anonymized hostnames + visit frequency. ALWAYS call ask_user for consent before using this tool.", {
|
|
210
|
-
minVisits: z.number().min(1).default(3).optional().describe("Minimum visit count to include a host (filters rarely-visited sites)")
|
|
211
|
-
}, async (args) => {
|
|
212
|
-
const minVisits = args["minVisits"] ?? 3;
|
|
213
|
-
const hosts = await scanAllHistory();
|
|
214
|
-
const filtered = hosts.filter((h) => h.visitCount >= minVisits);
|
|
215
|
-
return {
|
|
216
|
-
content: [{
|
|
217
|
-
type: "text",
|
|
218
|
-
text: JSON.stringify({
|
|
219
|
-
count: filtered.length,
|
|
220
|
-
note: "Anonymized \u2014 hostnames only, no URLs, no paths, no personal data. Classify business tools as saas_tool nodes.",
|
|
221
|
-
hosts: filtered.map((h) => ({
|
|
222
|
-
hostname: h.hostname,
|
|
223
|
-
visitCount: h.visitCount,
|
|
224
|
-
protocol: h.protocol,
|
|
225
|
-
source: h.source
|
|
226
|
-
}))
|
|
227
|
-
})
|
|
228
|
-
}]
|
|
229
|
-
};
|
|
230
|
-
}),
|
|
231
|
-
tool("scan_local_databases", "Scan for local database files and running DB servers \u2014 PostgreSQL databases, MySQL, SQLite files from installed apps", {
|
|
232
|
-
deep: z.boolean().default(false).optional().describe("Also search home directory recursively for SQLite/DB files (slower)")
|
|
233
|
-
}, async (args) => {
|
|
234
|
-
const deep = args["deep"] ?? false;
|
|
235
|
-
const results = {};
|
|
236
|
-
results["PLATFORM"] = `${PLATFORM} (${IS_WIN ? "Windows" : IS_MAC ? "macOS" : "Linux"})`;
|
|
237
|
-
if (IS_WIN) {
|
|
238
|
-
results["DB_SERVICES"] = scanWindowsDbServices() || "(no database services found)";
|
|
239
|
-
}
|
|
240
|
-
if (commandExists("psql")) {
|
|
241
|
-
if (IS_WIN) {
|
|
242
|
-
results["POSTGRES_DATABASES"] = run("psql -lqt", { timeout: 1e4 }) || "(psql found but not running or requires auth)";
|
|
243
|
-
} else {
|
|
244
|
-
results["POSTGRES_DATABASES"] = run(`psql -lqt 2>/dev/null | grep -v "template0\\|template1" | awk '{print $1}' | grep -v "^$\\|^|"`) || "(psql not running or not available)";
|
|
245
|
-
results["POSTGRES_CLUSTERS"] = run("pg_lsclusters 2>/dev/null") || "(pg_lsclusters not available)";
|
|
246
|
-
}
|
|
247
|
-
} else {
|
|
248
|
-
results["POSTGRES_DATABASES"] = "(psql not installed)";
|
|
249
|
-
}
|
|
250
|
-
if (commandExists("mysql")) {
|
|
251
|
-
if (IS_WIN) {
|
|
252
|
-
results["MYSQL_DATABASES"] = run('mysql --connect-timeout=3 -e "SHOW DATABASES;"', { timeout: 1e4 }) || "(mysql not running or requires auth)";
|
|
253
|
-
} else {
|
|
254
|
-
results["MYSQL_DATABASES"] = run('mysql --connect-timeout=3 -e "SHOW DATABASES;" 2>/dev/null') || "(mysql not running or requires auth)";
|
|
255
|
-
}
|
|
256
|
-
} else {
|
|
257
|
-
results["MYSQL_DATABASES"] = "(mysql not installed)";
|
|
258
|
-
}
|
|
259
|
-
if (commandExists("mongosh")) {
|
|
260
|
-
if (IS_WIN) {
|
|
261
|
-
results["MONGODB_DATABASES"] = run(`mongosh --quiet --eval "db.adminCommand({listDatabases:1}).databases.map(d=>d.name).join('\\n')"`, { timeout: 1e4 }) || "(mongosh not available)";
|
|
262
|
-
} else {
|
|
263
|
-
results["MONGODB_DATABASES"] = run(`mongosh --quiet --eval "db.adminCommand({listDatabases:1}).databases.map(d=>d.name).join('\\n')" 2>/dev/null`) || "(mongosh not available)";
|
|
264
|
-
}
|
|
265
|
-
} else {
|
|
266
|
-
results["MONGODB_DATABASES"] = "(mongosh not installed)";
|
|
267
|
-
}
|
|
268
|
-
if (commandExists("redis-cli")) {
|
|
269
|
-
if (IS_WIN) {
|
|
270
|
-
results["REDIS_INFO"] = run("redis-cli info server", { timeout: 1e4 }).split("\n").slice(0, 5).join("\n") || "(redis-cli not available)";
|
|
271
|
-
} else {
|
|
272
|
-
results["REDIS_INFO"] = run("redis-cli info server 2>/dev/null | head -5") || "(redis-cli not available)";
|
|
273
|
-
}
|
|
274
|
-
} else {
|
|
275
|
-
results["REDIS_INFO"] = "(redis-cli not installed)";
|
|
276
|
-
}
|
|
277
|
-
const appDirs = dbScanDirs();
|
|
278
|
-
if (appDirs.length > 0) {
|
|
279
|
-
results["SQLITE_APP_FILES"] = findFiles(appDirs, ["*.sqlite", "*.sqlite3", "*.db"], 4, 80) || "(none found)";
|
|
280
|
-
}
|
|
281
|
-
if (deep) {
|
|
282
|
-
if (IS_WIN) {
|
|
283
|
-
results["SQLITE_DEEP_SCAN"] = run(
|
|
284
|
-
`Get-ChildItem -Path '${HOME}' -Recurse -Depth 6 -Include '*.sqlite','*.sqlite3','*.db' -ErrorAction SilentlyContinue | Where-Object { $_.FullName -notmatch 'node_modules|\\.git' } | Select-Object -First 100 -ExpandProperty FullName`,
|
|
285
|
-
{ timeout: 3e4 }
|
|
286
|
-
) || "(none found)";
|
|
287
|
-
} else {
|
|
288
|
-
results["SQLITE_DEEP_SCAN"] = run(`find "${HOME}" -maxdepth 6 \\( -name "*.sqlite" -o -name "*.sqlite3" -o -name "*.db" \\) -not -path "*/node_modules/*" -not -path "*/.git/*" 2>/dev/null | head -100`) || "(none found)";
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
if (IS_WIN) {
|
|
292
|
-
results["DB_CONFIG_FILES"] = run(
|
|
293
|
-
`Get-ChildItem -Path '${HOME}' -Recurse -Depth 4 -Include '.env','.env.local','database.yml','database.json','docker-compose.yml' -ErrorAction SilentlyContinue | Select-Object -First 20 -ExpandProperty FullName`,
|
|
294
|
-
{ timeout: 15e3 }
|
|
295
|
-
) || "(none found)";
|
|
296
|
-
} else {
|
|
297
|
-
results["DB_CONFIG_FILES"] = run(`find "${HOME}" -maxdepth 4 \\( -name ".env" -o -name ".env.local" -o -name "database.yml" -o -name "database.json" -o -name "docker-compose.yml" \\) 2>/dev/null | head -20`) || "(none found)";
|
|
298
|
-
}
|
|
299
|
-
const out = Object.entries(results).map(([k, v]) => `=== ${k} ===
|
|
300
|
-
${v}`).join("\n\n");
|
|
301
|
-
return { content: [{ type: "text", text: out }] };
|
|
302
|
-
}),
|
|
303
|
-
tool("scan_k8s_resources", "Scan Kubernetes cluster via kubectl \u2014 100% readonly (get, describe)", {
|
|
304
|
-
namespace: z.string().optional().describe("Filter by namespace \u2014 empty = all namespaces")
|
|
305
|
-
}, async (args) => {
|
|
306
|
-
const ns = args["namespace"];
|
|
307
|
-
const nsFlag = ns ? `-n ${ns}` : "--all-namespaces";
|
|
308
|
-
const runK = createScanRunner(run, { timeout: 15e3, threshold: 3 });
|
|
309
|
-
const sections = IS_WIN ? [
|
|
310
|
-
["CONTEXT", "kubectl config current-context"],
|
|
311
|
-
["NODES", "kubectl get nodes -o wide"],
|
|
312
|
-
["NAMESPACES", "kubectl get namespaces"],
|
|
313
|
-
["SERVICES", `kubectl get services ${nsFlag}`],
|
|
314
|
-
["DEPLOYMENTS", `kubectl get deployments ${nsFlag}`],
|
|
315
|
-
["STATEFULSETS", `kubectl get statefulsets ${nsFlag}`],
|
|
316
|
-
["INGRESSES", `kubectl get ingress ${nsFlag}`],
|
|
317
|
-
["PODS_RUNNING", `kubectl get pods ${nsFlag} --field-selector=status.phase=Running`],
|
|
318
|
-
["CONFIGMAPS_SYSTEM", "kubectl get configmaps -n kube-system"]
|
|
319
|
-
] : [
|
|
320
|
-
["CONTEXT", 'kubectl config current-context 2>/dev/null || echo "(no context set)"'],
|
|
321
|
-
["NODES", "kubectl get nodes -o wide"],
|
|
322
|
-
["NAMESPACES", "kubectl get namespaces"],
|
|
323
|
-
["SERVICES", `kubectl get services ${nsFlag}`],
|
|
324
|
-
["DEPLOYMENTS", `kubectl get deployments ${nsFlag}`],
|
|
325
|
-
["STATEFULSETS", `kubectl get statefulsets ${nsFlag}`],
|
|
326
|
-
["INGRESSES", `kubectl get ingress ${nsFlag} 2>/dev/null || echo "(none)"`],
|
|
327
|
-
["PODS_RUNNING", `kubectl get pods ${nsFlag} --field-selector=status.phase=Running 2>/dev/null | head -60`],
|
|
328
|
-
["CONFIGMAPS_SYSTEM", "kubectl get configmaps -n kube-system 2>/dev/null | head -30"]
|
|
329
|
-
];
|
|
330
|
-
const out = sections.map(([l, c]) => `=== ${l} ===
|
|
331
|
-
${runK(c)}`).join("\n\n");
|
|
332
|
-
return { content: [{ type: "text", text: out }] };
|
|
333
|
-
}),
|
|
334
|
-
tool("scan_aws_resources", "Scan AWS infrastructure via AWS CLI \u2014 100% readonly (describe, list)", {
|
|
335
|
-
region: z.string().optional().describe("AWS Region \u2014 default: AWS_DEFAULT_REGION or profile"),
|
|
336
|
-
profile: z.string().optional().describe("AWS CLI profile")
|
|
337
|
-
}, async (args) => {
|
|
338
|
-
const region = args["region"];
|
|
339
|
-
const profile = args["profile"];
|
|
340
|
-
const env = { ...process.env };
|
|
341
|
-
if (region) env["AWS_DEFAULT_REGION"] = region;
|
|
342
|
-
const pf = profile ? `--profile ${profile}` : "";
|
|
343
|
-
const runAws = createScanRunner(run, { timeout: 2e4, env, threshold: 3 });
|
|
344
|
-
const sections = [
|
|
345
|
-
["IDENTITY", `aws sts get-caller-identity ${pf} --output json`],
|
|
346
|
-
["EC2", `aws ec2 describe-instances ${pf} --query "Reservations[*].Instances[*].[InstanceId,InstanceType,State.Name,PublicIpAddress,PrivateIpAddress]" --output table`],
|
|
347
|
-
["RDS", `aws rds describe-db-instances ${pf} --query "DBInstances[*].[DBInstanceIdentifier,Engine,DBInstanceStatus,Endpoint.Address,Endpoint.Port]" --output table`],
|
|
348
|
-
["ELB_V2", `aws elbv2 describe-load-balancers ${pf} --query "LoadBalancers[*].[LoadBalancerName,DNSName,Type,State.Code]" --output table`],
|
|
349
|
-
["EKS", `aws eks list-clusters ${pf} --output json`],
|
|
350
|
-
["ELASTICACHE", `aws elasticache describe-cache-clusters ${pf} --query "CacheClusters[*].[CacheClusterId,Engine,CacheClusterStatus]" --output table`],
|
|
351
|
-
["S3", `aws s3 ls ${pf}`],
|
|
352
|
-
["VPC", `aws ec2 describe-vpcs ${pf} --query "Vpcs[*].[VpcId,CidrBlock,IsDefault]" --output table`]
|
|
353
|
-
];
|
|
354
|
-
const out = sections.map(([l, c]) => `=== ${l} ===
|
|
355
|
-
${runAws(c)}`).join("\n\n");
|
|
356
|
-
return { content: [{ type: "text", text: out }] };
|
|
357
|
-
}),
|
|
358
|
-
tool("scan_gcp_resources", "Scan Google Cloud Platform via gcloud CLI \u2014 100% readonly (list, describe)", {
|
|
359
|
-
project: z.string().optional().describe("GCP Project ID \u2014 default: current gcloud project")
|
|
360
|
-
}, async (args) => {
|
|
361
|
-
const project = args["project"];
|
|
362
|
-
const pf = project ? `--project ${project}` : "";
|
|
363
|
-
const runGcp = createScanRunner(run, { timeout: 2e4, threshold: 3 });
|
|
364
|
-
const sections = [
|
|
365
|
-
["IDENTITY", `gcloud config list account --format="value(core.account)"`],
|
|
366
|
-
["COMPUTE_INSTANCES", `gcloud compute instances list ${pf}`],
|
|
367
|
-
["SQL_INSTANCES", `gcloud sql instances list ${pf}`],
|
|
368
|
-
["GKE_CLUSTERS", `gcloud container clusters list ${pf}`],
|
|
369
|
-
["CLOUD_RUN", `gcloud run services list ${pf} --platform managed`],
|
|
370
|
-
["CLOUD_FUNCTIONS", `gcloud functions list ${pf}`],
|
|
371
|
-
["REDIS", `gcloud redis instances list ${pf} --regions=-`],
|
|
372
|
-
["PUBSUB", `gcloud pubsub topics list ${pf}`],
|
|
373
|
-
["SPANNER", `gcloud spanner instances list ${pf}`]
|
|
374
|
-
];
|
|
375
|
-
const out = sections.map(([l, c]) => `=== ${l} ===
|
|
376
|
-
${runGcp(c)}`).join("\n\n");
|
|
377
|
-
return { content: [{ type: "text", text: out }] };
|
|
378
|
-
}),
|
|
379
|
-
tool("scan_azure_resources", "Scan Azure infrastructure via az CLI \u2014 100% readonly (list, show)", {
|
|
380
|
-
subscription: z.string().optional().describe("Azure Subscription ID"),
|
|
381
|
-
resourceGroup: z.string().optional().describe("Filter by resource group")
|
|
382
|
-
}, async (args) => {
|
|
383
|
-
const sub = args["subscription"];
|
|
384
|
-
const rg = args["resourceGroup"];
|
|
385
|
-
const sf = sub ? `--subscription ${sub}` : "";
|
|
386
|
-
const rf = rg ? `--resource-group ${rg}` : "";
|
|
387
|
-
const runAz = createScanRunner(run, { timeout: 2e4, threshold: 3 });
|
|
388
|
-
const sections = [
|
|
389
|
-
["IDENTITY", `az account show --output json ${sf}`],
|
|
390
|
-
["VMS", `az vm list ${sf} ${rf} --output table`],
|
|
391
|
-
["AKS", `az aks list ${sf} ${rf} --output table`],
|
|
392
|
-
["SQL_SERVERS", `az sql server list ${sf} ${rf} --output table`],
|
|
393
|
-
["POSTGRES", `az postgres server list ${sf} ${rf} --output table`],
|
|
394
|
-
["REDIS", `az redis list ${sf} ${rf} --output table`],
|
|
395
|
-
["WEBAPPS", `az webapp list ${sf} ${rf} --output table`],
|
|
396
|
-
["CONTAINER_APPS", `az containerapp list ${sf} ${rf} --output table`],
|
|
397
|
-
["FUNCTIONS", `az functionapp list ${sf} ${rf} --output table`]
|
|
398
|
-
];
|
|
399
|
-
const out = sections.map(([l, c]) => `=== ${l} ===
|
|
400
|
-
${runAz(c)}`).join("\n\n");
|
|
401
|
-
return { content: [{ type: "text", text: out }] };
|
|
402
|
-
}),
|
|
403
|
-
tool("scan_installed_apps", "Scan all installed apps and tools \u2014 IDEs, office, dev tools, business apps, databases", {
|
|
404
|
-
searchHint: z.string().optional().describe('Optional search term to find specific tools (e.g. "hubspot windsurf cursor")')
|
|
405
|
-
}, async (args) => {
|
|
406
|
-
const hint = args["searchHint"];
|
|
407
|
-
const results = {};
|
|
408
|
-
results["PLATFORM"] = `${PLATFORM} (${IS_WIN ? "Windows" : IS_MAC ? "macOS" : "Linux"})`;
|
|
409
|
-
if (IS_MAC) {
|
|
410
|
-
results["APPLICATIONS"] = run("ls /Applications/ 2>/dev/null | head -200") || "(empty)";
|
|
411
|
-
results["USER_APPLICATIONS"] = run("ls ~/Applications/ 2>/dev/null | head -100") || "(empty)";
|
|
412
|
-
results["BREW_CASKS"] = run("brew list --cask 2>/dev/null | head -100") || "(brew not installed)";
|
|
413
|
-
results["BREW_FORMULAE"] = run("brew list --formula 2>/dev/null | head -150") || "(brew not installed)";
|
|
414
|
-
results["SPOTLIGHT_APPS"] = run(`mdfind "kMDItemKind == 'Application'" 2>/dev/null | grep -v "^/System" | grep -v "^/Library/Apple" | head -100`) || "(Spotlight not available)";
|
|
415
|
-
} else if (IS_LINUX) {
|
|
416
|
-
results["DPKG"] = run("dpkg --list 2>/dev/null | awk '{print $2}' | head -200") || "(dpkg not available)";
|
|
417
|
-
results["SNAP"] = run("snap list 2>/dev/null | head -50") || "(snap not available)";
|
|
418
|
-
results["FLATPAK"] = run("flatpak list 2>/dev/null | head -50") || "(flatpak not available)";
|
|
419
|
-
results["DESKTOP_FILES"] = run("ls /usr/share/applications/*.desktop ~/.local/share/applications/*.desktop 2>/dev/null | xargs -I{} basename {} .desktop 2>/dev/null | head -100") || "(no .desktop files)";
|
|
420
|
-
results["RPM"] = run("rpm -qa 2>/dev/null | head -200") || "(rpm not available)";
|
|
421
|
-
} else if (IS_WIN) {
|
|
422
|
-
results["WINGET"] = run("winget list --accept-source-agreements", { timeout: 2e4 }) || "(winget not available)";
|
|
423
|
-
results["INSTALLED_PROGRAMS"] = scanWindowsPrograms() || "(registry scan failed)";
|
|
424
|
-
results["CHOCO"] = run("choco list --local-only", { timeout: 15e3 }) || "(chocolatey not installed)";
|
|
425
|
-
results["SCOOP"] = run("scoop list", { timeout: 15e3 }) || "(scoop not installed)";
|
|
426
|
-
}
|
|
427
|
-
const knownTools = [
|
|
428
|
-
// IDEs & Editors
|
|
429
|
-
"code",
|
|
430
|
-
"code-insiders",
|
|
431
|
-
"cursor",
|
|
432
|
-
"windsurf",
|
|
433
|
-
"zed",
|
|
434
|
-
"vim",
|
|
435
|
-
"nvim",
|
|
436
|
-
"emacs",
|
|
437
|
-
"nano",
|
|
438
|
-
"sublime_text",
|
|
439
|
-
"atom",
|
|
440
|
-
"idea",
|
|
441
|
-
"webstorm",
|
|
442
|
-
"pycharm",
|
|
443
|
-
"goland",
|
|
444
|
-
"datagrip",
|
|
445
|
-
"clion",
|
|
446
|
-
"rider",
|
|
447
|
-
"phpstorm",
|
|
448
|
-
"rubymine",
|
|
449
|
-
"appcode",
|
|
450
|
-
// Dev Tools
|
|
451
|
-
"git",
|
|
452
|
-
"gh",
|
|
453
|
-
"docker",
|
|
454
|
-
"docker-compose",
|
|
455
|
-
"podman",
|
|
456
|
-
"kubectl",
|
|
457
|
-
"helm",
|
|
458
|
-
"terraform",
|
|
459
|
-
"ansible",
|
|
460
|
-
"node",
|
|
461
|
-
"npm",
|
|
462
|
-
"npx",
|
|
463
|
-
"yarn",
|
|
464
|
-
"pnpm",
|
|
465
|
-
"bun",
|
|
466
|
-
"deno",
|
|
467
|
-
"python",
|
|
468
|
-
"python3",
|
|
469
|
-
"pip",
|
|
470
|
-
"pip3",
|
|
471
|
-
"pipenv",
|
|
472
|
-
"poetry",
|
|
473
|
-
"conda",
|
|
474
|
-
"ruby",
|
|
475
|
-
"gem",
|
|
476
|
-
"bundler",
|
|
477
|
-
"rails",
|
|
478
|
-
"java",
|
|
479
|
-
"mvn",
|
|
480
|
-
"gradle",
|
|
481
|
-
"kotlin",
|
|
482
|
-
"go",
|
|
483
|
-
"cargo",
|
|
484
|
-
"rustc",
|
|
485
|
-
"php",
|
|
486
|
-
"composer",
|
|
487
|
-
"dotnet",
|
|
488
|
-
// Databases
|
|
489
|
-
"psql",
|
|
490
|
-
"mysql",
|
|
491
|
-
"mysqladmin",
|
|
492
|
-
"mongo",
|
|
493
|
-
"mongosh",
|
|
494
|
-
"redis-cli",
|
|
495
|
-
"sqlite3",
|
|
496
|
-
"clickhouse-client",
|
|
497
|
-
// Cloud CLIs
|
|
498
|
-
"aws",
|
|
499
|
-
"gcloud",
|
|
500
|
-
"az",
|
|
501
|
-
"heroku",
|
|
502
|
-
"fly",
|
|
503
|
-
"vercel",
|
|
504
|
-
"netlify",
|
|
505
|
-
"wrangler",
|
|
506
|
-
// Infra
|
|
507
|
-
"vagrant",
|
|
508
|
-
"packer",
|
|
509
|
-
"consul",
|
|
510
|
-
"vault",
|
|
511
|
-
"nomad",
|
|
512
|
-
// Communication / SaaS
|
|
513
|
-
"slack",
|
|
514
|
-
"discord",
|
|
515
|
-
"zoom",
|
|
516
|
-
"teams",
|
|
517
|
-
"skype",
|
|
518
|
-
"telegram",
|
|
519
|
-
"signal",
|
|
520
|
-
// Browsers
|
|
521
|
-
"google-chrome",
|
|
522
|
-
"chromium",
|
|
523
|
-
"firefox",
|
|
524
|
-
"safari",
|
|
525
|
-
"brave",
|
|
526
|
-
"opera",
|
|
527
|
-
"edge",
|
|
528
|
-
// Windows-specific
|
|
529
|
-
...IS_WIN ? ["pwsh", "powershell", "wsl", "winget", "choco", "scoop", "notepad++"] : [],
|
|
530
|
-
// Monitoring / Analytics
|
|
531
|
-
"datadog-agent",
|
|
532
|
-
"newrelic-agent",
|
|
533
|
-
"prometheus",
|
|
534
|
-
"grafana-cli",
|
|
535
|
-
// Other tools
|
|
536
|
-
"ngrok",
|
|
537
|
-
"stripe",
|
|
538
|
-
"supabase",
|
|
539
|
-
"neon"
|
|
540
|
-
];
|
|
541
|
-
const found = [];
|
|
542
|
-
const notFound = [];
|
|
543
|
-
for (const t of knownTools) {
|
|
544
|
-
const r = commandExists(t);
|
|
545
|
-
if (r) found.push(`${t}: ${r}`);
|
|
546
|
-
else notFound.push(t);
|
|
547
|
-
}
|
|
548
|
-
results["TOOLS_FOUND"] = found.join("\n") || "(none found)";
|
|
549
|
-
results["TOOLS_NOT_FOUND"] = notFound.join(", ");
|
|
550
|
-
if (hint) {
|
|
551
|
-
const terms = hint.split(/[\s,]+/).filter(Boolean);
|
|
552
|
-
const hintResults = [];
|
|
553
|
-
for (const term of terms) {
|
|
554
|
-
const safe = term.replace(/[^a-zA-Z0-9._-]/g, "");
|
|
555
|
-
if (!safe) continue;
|
|
556
|
-
const cmdPath = commandExists(safe);
|
|
557
|
-
if (cmdPath) {
|
|
558
|
-
hintResults.push(`${term}: ${cmdPath}`);
|
|
559
|
-
continue;
|
|
560
|
-
}
|
|
561
|
-
let fallback = "";
|
|
562
|
-
if (IS_WIN) {
|
|
563
|
-
fallback = run(
|
|
564
|
-
`Get-ChildItem -Path 'C:\\Program Files','C:\\Program Files (x86)','${HOME}\\AppData\\Local\\Programs' -Recurse -Depth 3 -Filter '*${safe}*' -ErrorAction SilentlyContinue | Select-Object -First 5 -ExpandProperty FullName`,
|
|
565
|
-
{ timeout: 1e4 }
|
|
566
|
-
);
|
|
567
|
-
} else if (IS_MAC) {
|
|
568
|
-
fallback = run(`mdfind -name "${safe}" 2>/dev/null | head -5`);
|
|
569
|
-
} else {
|
|
570
|
-
fallback = run(`find /usr/bin /usr/local/bin /opt/homebrew/bin ~/.local/bin /Applications ~/Applications 2>/dev/null -iname "*${safe}*" -maxdepth 3 2>/dev/null | head -5`);
|
|
571
|
-
}
|
|
572
|
-
hintResults.push(fallback ? `${term}: ${fallback}` : `${term}: (not found)`);
|
|
573
|
-
}
|
|
574
|
-
results["HINT_SEARCH"] = hintResults.join("\n");
|
|
575
|
-
}
|
|
576
|
-
const out = Object.entries(results).map(([k, v]) => `=== ${k} ===
|
|
577
|
-
${v}`).join("\n\n");
|
|
578
|
-
return { content: [{ type: "text", text: out }] };
|
|
579
|
-
})
|
|
580
|
-
];
|
|
581
|
-
return createSdkMcpServer({
|
|
582
|
-
name: "cartography",
|
|
583
|
-
version: "0.1.0",
|
|
584
|
-
tools
|
|
585
|
-
});
|
|
586
|
-
}
|
|
117
|
+
// src/providers/types.ts
|
|
118
|
+
var ProviderRegistry = class {
|
|
119
|
+
factories = /* @__PURE__ */ new Map();
|
|
120
|
+
register(name, factory) {
|
|
121
|
+
this.factories.set(name, factory);
|
|
122
|
+
}
|
|
123
|
+
has(name) {
|
|
124
|
+
return this.factories.has(name);
|
|
125
|
+
}
|
|
126
|
+
resolve(name) {
|
|
127
|
+
const f = this.factories.get(name);
|
|
128
|
+
if (!f) throw new Error(`Unknown provider "${name}". Available: ${this.names().join(", ")}`);
|
|
129
|
+
return f();
|
|
130
|
+
}
|
|
131
|
+
names() {
|
|
132
|
+
return [...this.factories.keys()];
|
|
133
|
+
}
|
|
134
|
+
};
|
|
587
135
|
|
|
588
136
|
// src/safety.ts
|
|
589
137
|
var safetyHook = async (input, _toolUseID, _options) => {
|
|
@@ -611,10 +159,451 @@ var safetyHook = async (input, _toolUseID, _options) => {
|
|
|
611
159
|
};
|
|
612
160
|
};
|
|
613
161
|
|
|
162
|
+
// src/audit.ts
|
|
163
|
+
function createAuditHook(db, sessionId) {
|
|
164
|
+
return async (input) => {
|
|
165
|
+
try {
|
|
166
|
+
if (!("tool_name" in input)) return {};
|
|
167
|
+
const i = input;
|
|
168
|
+
const command = i.tool_input?.command ?? JSON.stringify(i.tool_input ?? {}).slice(0, 2e3);
|
|
169
|
+
const response = typeof i.tool_response === "string" ? i.tool_response : JSON.stringify(i.tool_response ?? "");
|
|
170
|
+
db.insertEvent(sessionId, {
|
|
171
|
+
eventType: "tool_executed",
|
|
172
|
+
process: i.tool_name,
|
|
173
|
+
pid: process.pid,
|
|
174
|
+
command,
|
|
175
|
+
resultBytes: Buffer.byteLength(response)
|
|
176
|
+
});
|
|
177
|
+
} catch (err) {
|
|
178
|
+
logDebug(`audit hook failed to record event: ${String(err)}`);
|
|
179
|
+
}
|
|
180
|
+
return {};
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// src/providers/claude.ts
|
|
185
|
+
function createClaudeProvider() {
|
|
186
|
+
return {
|
|
187
|
+
name: "claude",
|
|
188
|
+
async ensureAvailable(_config) {
|
|
189
|
+
try {
|
|
190
|
+
await import("@anthropic-ai/claude-agent-sdk");
|
|
191
|
+
} catch {
|
|
192
|
+
throw new Error(
|
|
193
|
+
"Claude provider unavailable: the @anthropic-ai/claude-agent-sdk package is not installed.\n Install: npm install @anthropic-ai/claude-agent-sdk"
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
async *run(ctx) {
|
|
198
|
+
const { config, db, sessionId, systemPrompt, initialPrompt, onAskUser, deadlineMs } = ctx;
|
|
199
|
+
const { query } = await import("@anthropic-ai/claude-agent-sdk");
|
|
200
|
+
const tools = await createCartographyTools(db, sessionId, {
|
|
201
|
+
onAskUser,
|
|
202
|
+
maxResponseBytes: config.maxToolResponseBytes
|
|
203
|
+
});
|
|
204
|
+
let turnCount = 0;
|
|
205
|
+
for await (const msg of query({
|
|
206
|
+
prompt: initialPrompt,
|
|
207
|
+
options: {
|
|
208
|
+
model: config.models.lead,
|
|
209
|
+
maxTurns: config.maxTurns,
|
|
210
|
+
systemPrompt,
|
|
211
|
+
mcpServers: { cartography: tools },
|
|
212
|
+
allowedTools: [
|
|
213
|
+
"Bash",
|
|
214
|
+
"mcp__cartography__save_node",
|
|
215
|
+
"mcp__cartography__save_edge",
|
|
216
|
+
"mcp__cartography__get_catalog",
|
|
217
|
+
"mcp__cartography__scan_bookmarks",
|
|
218
|
+
"mcp__cartography__scan_browser_history",
|
|
219
|
+
"mcp__cartography__scan_installed_apps",
|
|
220
|
+
"mcp__cartography__scan_local_databases",
|
|
221
|
+
"mcp__cartography__scan_k8s_resources",
|
|
222
|
+
"mcp__cartography__scan_aws_resources",
|
|
223
|
+
"mcp__cartography__scan_gcp_resources",
|
|
224
|
+
"mcp__cartography__scan_azure_resources",
|
|
225
|
+
"mcp__cartography__ask_user"
|
|
226
|
+
],
|
|
227
|
+
hooks: {
|
|
228
|
+
PreToolUse: [{ matcher: "Bash", hooks: [safetyHook] }],
|
|
229
|
+
PostToolUse: [{ hooks: [createAuditHook(db, sessionId)] }]
|
|
230
|
+
},
|
|
231
|
+
permissionMode: "bypassPermissions"
|
|
232
|
+
}
|
|
233
|
+
})) {
|
|
234
|
+
if (Date.now() > deadlineMs) {
|
|
235
|
+
yield { kind: "error", text: "Discovery timeout \u2014 wall-clock limit reached" };
|
|
236
|
+
yield { kind: "done" };
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
if (msg.type === "assistant") {
|
|
240
|
+
turnCount++;
|
|
241
|
+
yield { kind: "turn", turn: turnCount };
|
|
242
|
+
for (const block of msg.message.content) {
|
|
243
|
+
if (block.type === "text") {
|
|
244
|
+
yield { kind: "thinking", text: block.text };
|
|
245
|
+
}
|
|
246
|
+
if (block.type === "tool_use") {
|
|
247
|
+
yield {
|
|
248
|
+
kind: "tool_call",
|
|
249
|
+
tool: block.name,
|
|
250
|
+
input: block.input
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
if (msg.type === "user") {
|
|
256
|
+
const content = msg.message?.content;
|
|
257
|
+
if (Array.isArray(content)) {
|
|
258
|
+
for (const block of content) {
|
|
259
|
+
if (typeof block === "object" && block !== null && "type" in block && block.type === "tool_result") {
|
|
260
|
+
const tb = block;
|
|
261
|
+
const text = typeof tb.content === "string" ? tb.content : "";
|
|
262
|
+
yield { kind: "tool_result", tool: tb.tool_use_id ?? "", output: text };
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (msg.type === "result") {
|
|
268
|
+
yield { kind: "done" };
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// src/providers/shell.ts
|
|
277
|
+
import { z } from "zod";
|
|
278
|
+
function createBashTool() {
|
|
279
|
+
const shell = IS_WIN ? "powershell" : "posix";
|
|
280
|
+
return {
|
|
281
|
+
name: "Bash",
|
|
282
|
+
description: "Run a read-only shell command (inspect ports, processes, config). Mutating or destructive commands are blocked by the read-only allowlist.",
|
|
283
|
+
inputShape: { command: z.string().describe("The read-only shell command to run") },
|
|
284
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
285
|
+
handler: async (args) => {
|
|
286
|
+
const command = String(args["command"] ?? "").trim();
|
|
287
|
+
if (!command) return { content: [{ type: "text", text: "" }] };
|
|
288
|
+
const decision = checkReadOnly(command, { shell });
|
|
289
|
+
if (!decision.allowed) {
|
|
290
|
+
return {
|
|
291
|
+
content: [
|
|
292
|
+
{ type: "text", text: `BLOCKED: ${decision.reason ?? "not read-only"} \u2014 read-only allowlist policy` }
|
|
293
|
+
]
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
const output = run(command) || "(no output)";
|
|
297
|
+
return { content: [{ type: "text", text: output }] };
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// src/providers/zod-schema.ts
|
|
303
|
+
function unwrap(schema) {
|
|
304
|
+
let current = schema;
|
|
305
|
+
let required = true;
|
|
306
|
+
let description = current.description;
|
|
307
|
+
for (; ; ) {
|
|
308
|
+
const def = current.def;
|
|
309
|
+
const typeName = def?.type;
|
|
310
|
+
if (typeName === "optional" || typeName === "default") {
|
|
311
|
+
required = false;
|
|
312
|
+
const inner = def?.innerType;
|
|
313
|
+
if (!inner) break;
|
|
314
|
+
current = inner;
|
|
315
|
+
description = description ?? current.description;
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
if (typeName === "nullable") {
|
|
319
|
+
const inner = def?.innerType;
|
|
320
|
+
if (!inner) break;
|
|
321
|
+
current = inner;
|
|
322
|
+
description = description ?? current.description;
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
break;
|
|
326
|
+
}
|
|
327
|
+
return { schema: current, required, description };
|
|
328
|
+
}
|
|
329
|
+
function convert(schema, field) {
|
|
330
|
+
const def = schema.def;
|
|
331
|
+
const typeName = def?.["type"];
|
|
332
|
+
switch (typeName) {
|
|
333
|
+
case "string":
|
|
334
|
+
return { type: "string" };
|
|
335
|
+
case "number": {
|
|
336
|
+
const out = { type: "number" };
|
|
337
|
+
const checks = def?.["checks"] ?? [];
|
|
338
|
+
for (const c of checks) {
|
|
339
|
+
const cd = c?._zod?.def;
|
|
340
|
+
if (cd?.check === "greater_than") out["minimum"] = cd.value;
|
|
341
|
+
if (cd?.check === "less_than") out["maximum"] = cd.value;
|
|
342
|
+
}
|
|
343
|
+
return out;
|
|
344
|
+
}
|
|
345
|
+
case "boolean":
|
|
346
|
+
return { type: "boolean" };
|
|
347
|
+
case "enum": {
|
|
348
|
+
const entries = def?.["entries"];
|
|
349
|
+
const values = entries ? Object.values(entries) : [];
|
|
350
|
+
return { type: "string", enum: values };
|
|
351
|
+
}
|
|
352
|
+
case "array": {
|
|
353
|
+
const element = def?.["element"];
|
|
354
|
+
return { type: "array", items: element ? convert(unwrap(element).schema, field) : {} };
|
|
355
|
+
}
|
|
356
|
+
case "record":
|
|
357
|
+
return { type: "object", additionalProperties: true };
|
|
358
|
+
default:
|
|
359
|
+
throw new Error(
|
|
360
|
+
`zod-schema: unsupported zod construct "${typeName ?? "unknown"}" on field "${field}". Extend src/providers/zod-schema.ts to support it.`
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
function shapeToJsonSchema(shape) {
|
|
365
|
+
const properties = {};
|
|
366
|
+
const required = [];
|
|
367
|
+
for (const [key, raw] of Object.entries(shape)) {
|
|
368
|
+
const { schema, required: isRequired, description } = unwrap(raw);
|
|
369
|
+
const prop = convert(schema, key);
|
|
370
|
+
if (description) prop["description"] = description;
|
|
371
|
+
properties[key] = prop;
|
|
372
|
+
if (isRequired) required.push(key);
|
|
373
|
+
}
|
|
374
|
+
return { type: "object", properties, required, additionalProperties: false };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// src/providers/audit.ts
|
|
378
|
+
function recordToolEvent(db, sessionId, evt) {
|
|
379
|
+
try {
|
|
380
|
+
db.insertEvent(sessionId, {
|
|
381
|
+
eventType: "tool_executed",
|
|
382
|
+
process: evt.tool,
|
|
383
|
+
pid: process.pid,
|
|
384
|
+
command: evt.command,
|
|
385
|
+
resultBytes: Buffer.byteLength(evt.response)
|
|
386
|
+
});
|
|
387
|
+
} catch (err) {
|
|
388
|
+
logDebug(`audit writer failed to record event: ${String(err)}`);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// src/providers/loop.ts
|
|
393
|
+
async function dispatchTool(call, tools, db, sessionId) {
|
|
394
|
+
const tool = tools.find((t) => t.name === call.name);
|
|
395
|
+
if (!tool) {
|
|
396
|
+
const text = `ERROR: unknown tool "${call.name}"`;
|
|
397
|
+
recordToolEvent(db, sessionId, { tool: call.name, command: JSON.stringify(call.args).slice(0, 2e3), response: text });
|
|
398
|
+
return text;
|
|
399
|
+
}
|
|
400
|
+
let output;
|
|
401
|
+
try {
|
|
402
|
+
const result = await tool.handler(call.args);
|
|
403
|
+
output = result.content.map((c) => c.text).join("\n");
|
|
404
|
+
} catch (err) {
|
|
405
|
+
output = `ERROR: ${err instanceof Error ? err.message : String(err)}`;
|
|
406
|
+
}
|
|
407
|
+
const command = call.name === "Bash" ? String(call.args["command"] ?? "") : JSON.stringify(call.args).slice(0, 2e3);
|
|
408
|
+
recordToolEvent(db, sessionId, { tool: call.name, command, response: output });
|
|
409
|
+
return output;
|
|
410
|
+
}
|
|
411
|
+
async function* runToolLoop(opts, chat) {
|
|
412
|
+
const { db, sessionId, tools, maxTurns, deadlineMs } = opts;
|
|
413
|
+
let outcomes = [];
|
|
414
|
+
let turn = 0;
|
|
415
|
+
try {
|
|
416
|
+
while (turn < maxTurns) {
|
|
417
|
+
if (Date.now() > deadlineMs) {
|
|
418
|
+
yield { kind: "error", text: "Discovery timeout \u2014 wall-clock limit reached" };
|
|
419
|
+
yield { kind: "done" };
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
const result = await chat(outcomes);
|
|
423
|
+
turn++;
|
|
424
|
+
yield { kind: "turn", turn };
|
|
425
|
+
if (result.text) yield { kind: "thinking", text: result.text };
|
|
426
|
+
if (result.toolCalls.length === 0) {
|
|
427
|
+
yield { kind: "done" };
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
const nextOutcomes = [];
|
|
431
|
+
for (const call of result.toolCalls) {
|
|
432
|
+
yield { kind: "tool_call", tool: call.name, input: call.args };
|
|
433
|
+
const output = await dispatchTool(call, tools, db, sessionId);
|
|
434
|
+
yield { kind: "tool_result", tool: call.name, output };
|
|
435
|
+
nextOutcomes.push({ id: call.id, name: call.name, output });
|
|
436
|
+
}
|
|
437
|
+
outcomes = nextOutcomes;
|
|
438
|
+
}
|
|
439
|
+
yield { kind: "done" };
|
|
440
|
+
} catch (err) {
|
|
441
|
+
yield { kind: "error", text: `Discovery error: ${err instanceof Error ? err.message : String(err)}` };
|
|
442
|
+
yield { kind: "done" };
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// src/providers/openai.ts
|
|
447
|
+
function toOpenAITools(tools) {
|
|
448
|
+
return tools.map((t) => ({
|
|
449
|
+
type: "function",
|
|
450
|
+
function: { name: t.name, description: t.description, parameters: shapeToJsonSchema(t.inputShape) }
|
|
451
|
+
}));
|
|
452
|
+
}
|
|
453
|
+
function createOpenAIProvider() {
|
|
454
|
+
return {
|
|
455
|
+
name: "openai",
|
|
456
|
+
async ensureAvailable(_config) {
|
|
457
|
+
try {
|
|
458
|
+
await import("openai");
|
|
459
|
+
} catch {
|
|
460
|
+
throw new Error(
|
|
461
|
+
"OpenAI provider unavailable: the `openai` package is not installed.\n Install: npm install openai"
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
if (!process.env["OPENAI_API_KEY"]) {
|
|
465
|
+
throw new Error(
|
|
466
|
+
"OpenAI provider unavailable: OPENAI_API_KEY is not set.\n Set it: export OPENAI_API_KEY=sk-..."
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
},
|
|
470
|
+
async *run(ctx) {
|
|
471
|
+
const { config, db, sessionId, systemPrompt, initialPrompt, onAskUser, deadlineMs } = ctx;
|
|
472
|
+
const mod = await import("openai");
|
|
473
|
+
const apiKey = process.env["OPENAI_API_KEY"] ?? "";
|
|
474
|
+
const baseURL = process.env["OPENAI_BASE_URL"];
|
|
475
|
+
const client = new mod.default({ apiKey, ...baseURL ? { baseURL } : {} });
|
|
476
|
+
const handlers = await buildCartographyToolHandlers(db, sessionId, {
|
|
477
|
+
onAskUser,
|
|
478
|
+
maxResponseBytes: config.maxToolResponseBytes
|
|
479
|
+
});
|
|
480
|
+
const tools = [...handlers, createBashTool()];
|
|
481
|
+
const openaiTools = toOpenAITools(tools);
|
|
482
|
+
const messages = [
|
|
483
|
+
{ role: "system", content: systemPrompt },
|
|
484
|
+
{ role: "user", content: initialPrompt }
|
|
485
|
+
];
|
|
486
|
+
const chat = async (outcomes) => {
|
|
487
|
+
for (const oc of outcomes) {
|
|
488
|
+
messages.push({ role: "tool", tool_call_id: oc.id, content: oc.output });
|
|
489
|
+
}
|
|
490
|
+
const completion = await client.chat.completions.create({
|
|
491
|
+
model: config.models.lead,
|
|
492
|
+
messages,
|
|
493
|
+
tools: openaiTools,
|
|
494
|
+
tool_choice: "auto"
|
|
495
|
+
});
|
|
496
|
+
const choice = completion.choices[0]?.message;
|
|
497
|
+
const text = choice?.content ?? "";
|
|
498
|
+
const toolCalls = choice?.tool_calls ?? [];
|
|
499
|
+
messages.push({ role: "assistant", content: text || null, ...toolCalls.length ? { tool_calls: toolCalls } : {} });
|
|
500
|
+
return {
|
|
501
|
+
text,
|
|
502
|
+
toolCalls: toolCalls.map((tc) => ({
|
|
503
|
+
id: tc.id,
|
|
504
|
+
name: tc.function.name,
|
|
505
|
+
args: parseArgs(tc.function.arguments)
|
|
506
|
+
}))
|
|
507
|
+
};
|
|
508
|
+
};
|
|
509
|
+
yield* runToolLoop({ db, sessionId, tools, maxTurns: config.maxTurns, deadlineMs }, chat);
|
|
510
|
+
}
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
function parseArgs(raw) {
|
|
514
|
+
try {
|
|
515
|
+
const parsed = JSON.parse(raw || "{}");
|
|
516
|
+
return typeof parsed === "object" && parsed !== null ? parsed : {};
|
|
517
|
+
} catch {
|
|
518
|
+
return {};
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// src/providers/ollama.ts
|
|
523
|
+
var DEFAULT_HOST = "http://127.0.0.1:11434";
|
|
524
|
+
function host() {
|
|
525
|
+
return (process.env["OLLAMA_HOST"] || DEFAULT_HOST).replace(/\/+$/, "");
|
|
526
|
+
}
|
|
527
|
+
function toOllamaTools(tools) {
|
|
528
|
+
return tools.map((t) => ({
|
|
529
|
+
type: "function",
|
|
530
|
+
function: { name: t.name, description: t.description, parameters: shapeToJsonSchema(t.inputShape) }
|
|
531
|
+
}));
|
|
532
|
+
}
|
|
533
|
+
function createOllamaProvider() {
|
|
534
|
+
return {
|
|
535
|
+
name: "ollama",
|
|
536
|
+
async ensureAvailable(_config) {
|
|
537
|
+
const base = host();
|
|
538
|
+
try {
|
|
539
|
+
const res = await fetch(`${base}/api/tags`, { method: "GET" });
|
|
540
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
541
|
+
} catch {
|
|
542
|
+
throw new Error(
|
|
543
|
+
`Ollama provider unavailable: not reachable at ${base}.
|
|
544
|
+
Start it: ollama serve (or set OLLAMA_HOST=<url>)`
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
},
|
|
548
|
+
async *run(ctx) {
|
|
549
|
+
const { config, db, sessionId, systemPrompt, initialPrompt, onAskUser, deadlineMs } = ctx;
|
|
550
|
+
const base = host();
|
|
551
|
+
const handlers = await buildCartographyToolHandlers(db, sessionId, {
|
|
552
|
+
onAskUser,
|
|
553
|
+
maxResponseBytes: config.maxToolResponseBytes
|
|
554
|
+
});
|
|
555
|
+
const tools = [...handlers, createBashTool()];
|
|
556
|
+
const ollamaTools = toOllamaTools(tools);
|
|
557
|
+
const messages = [
|
|
558
|
+
{ role: "system", content: systemPrompt },
|
|
559
|
+
{ role: "user", content: initialPrompt }
|
|
560
|
+
];
|
|
561
|
+
const chat = async (outcomes) => {
|
|
562
|
+
for (const oc of outcomes) {
|
|
563
|
+
messages.push({ role: "tool", content: oc.output });
|
|
564
|
+
}
|
|
565
|
+
const res = await fetch(`${base}/api/chat`, {
|
|
566
|
+
method: "POST",
|
|
567
|
+
headers: { "content-type": "application/json" },
|
|
568
|
+
body: JSON.stringify({ model: config.models.lead, messages, tools: ollamaTools, stream: false })
|
|
569
|
+
});
|
|
570
|
+
if (!res.ok) {
|
|
571
|
+
throw new Error(`Ollama /api/chat returned HTTP ${res.status}`);
|
|
572
|
+
}
|
|
573
|
+
const data = await res.json();
|
|
574
|
+
const text = data.message?.content ?? "";
|
|
575
|
+
const toolCalls = data.message?.tool_calls ?? [];
|
|
576
|
+
messages.push({
|
|
577
|
+
role: "assistant",
|
|
578
|
+
content: text,
|
|
579
|
+
...toolCalls.length ? { tool_calls: toolCalls } : {}
|
|
580
|
+
});
|
|
581
|
+
return {
|
|
582
|
+
text,
|
|
583
|
+
toolCalls: toolCalls.map((tc, i) => ({
|
|
584
|
+
id: `${tc.function.name}:${i}`,
|
|
585
|
+
name: tc.function.name,
|
|
586
|
+
args: tc.function.arguments ?? {}
|
|
587
|
+
}))
|
|
588
|
+
};
|
|
589
|
+
};
|
|
590
|
+
yield* runToolLoop({ db, sessionId, tools, maxTurns: config.maxTurns, deadlineMs }, chat);
|
|
591
|
+
}
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// src/providers/registry.ts
|
|
596
|
+
function createDefaultRegistry() {
|
|
597
|
+
const r = new ProviderRegistry();
|
|
598
|
+
r.register("claude", createClaudeProvider);
|
|
599
|
+
r.register("openai", createOpenAIProvider);
|
|
600
|
+
r.register("ollama", createOllamaProvider);
|
|
601
|
+
return r;
|
|
602
|
+
}
|
|
603
|
+
var defaultProviderRegistry = createDefaultRegistry();
|
|
604
|
+
|
|
614
605
|
// src/agent.ts
|
|
615
606
|
async function runDiscovery(config, db, sessionId, onEvent, onAskUser, hint) {
|
|
616
|
-
const { query } = await import("./sdk-A6NLO3DJ.js");
|
|
617
|
-
const tools = await createCartographyTools(db, sessionId, { onAskUser });
|
|
618
607
|
const hintSection = hint ? `
|
|
619
608
|
\u26A1 USER HINT (HIGH PRIORITY): The user wants to find these specific tools: "${hint}"
|
|
620
609
|
\u2192 Run scan_installed_apps(searchHint: "${hint}") IMMEDIATELY and save found tools as saas_tool nodes!
|
|
@@ -714,6 +703,7 @@ RULES:
|
|
|
714
703
|
\u2022 metadata allowed: { description, category, port, version, path } \u2014 no passwords
|
|
715
704
|
\u2022 Call get_catalog before save_node \u2192 avoid duplicates
|
|
716
705
|
\u2022 Save edges whenever connections are clearly identifiable
|
|
706
|
+
\u2022 Max crawl depth: ${config.maxDepth} hops from an entry point \u2014 do not chase leads deeper than this
|
|
717
707
|
|
|
718
708
|
Entry points: ${config.entryPoints.join(", ")}`;
|
|
719
709
|
const initialPrompt = hint ? `Start discovery with USER HINT: "${hint}".
|
|
@@ -728,75 +718,28 @@ Then systematically scan local services, then config files.
|
|
|
728
718
|
Finally, map all edges (Step 8 \u2014 critical!) before finishing.
|
|
729
719
|
Use ask_user when you need context from the user.`;
|
|
730
720
|
const MAX_DISCOVERY_MS = 30 * 60 * 1e3;
|
|
731
|
-
|
|
721
|
+
const startTime = Date.now();
|
|
722
|
+
const deadlineMs = startTime + MAX_DISCOVERY_MS;
|
|
723
|
+
const provider = defaultProviderRegistry.resolve(config.provider ?? "claude");
|
|
724
|
+
await provider.ensureAvailable(config);
|
|
725
|
+
const ctx = {
|
|
726
|
+
config,
|
|
727
|
+
db,
|
|
728
|
+
sessionId,
|
|
729
|
+
systemPrompt,
|
|
730
|
+
initialPrompt,
|
|
731
|
+
onAskUser,
|
|
732
|
+
deadlineMs
|
|
733
|
+
};
|
|
732
734
|
try {
|
|
733
|
-
const
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
model: config.agentModel,
|
|
738
|
-
maxTurns: config.maxTurns,
|
|
739
|
-
systemPrompt,
|
|
740
|
-
mcpServers: { cartography: tools },
|
|
741
|
-
allowedTools: [
|
|
742
|
-
"Bash",
|
|
743
|
-
"mcp__cartograph__save_node",
|
|
744
|
-
"mcp__cartograph__save_edge",
|
|
745
|
-
"mcp__cartograph__get_catalog",
|
|
746
|
-
"mcp__cartograph__scan_bookmarks",
|
|
747
|
-
"mcp__cartograph__scan_browser_history",
|
|
748
|
-
"mcp__cartograph__scan_installed_apps",
|
|
749
|
-
"mcp__cartograph__scan_local_databases",
|
|
750
|
-
"mcp__cartograph__scan_k8s_resources",
|
|
751
|
-
"mcp__cartograph__scan_aws_resources",
|
|
752
|
-
"mcp__cartograph__scan_gcp_resources",
|
|
753
|
-
"mcp__cartograph__scan_azure_resources",
|
|
754
|
-
"mcp__cartograph__ask_user"
|
|
755
|
-
],
|
|
756
|
-
hooks: {
|
|
757
|
-
PreToolUse: [{ matcher: "Bash", hooks: [safetyHook] }]
|
|
758
|
-
},
|
|
759
|
-
permissionMode: "bypassPermissions"
|
|
760
|
-
}
|
|
761
|
-
})) {
|
|
762
|
-
if (Date.now() - startTime > MAX_DISCOVERY_MS) {
|
|
735
|
+
for await (const event of provider.run(ctx)) {
|
|
736
|
+
onEvent?.(event);
|
|
737
|
+
if (event.kind === "done") return;
|
|
738
|
+
if (Date.now() > deadlineMs) {
|
|
763
739
|
onEvent?.({ kind: "error", text: `Discovery timeout after ${MAX_DISCOVERY_MS / 6e4} minutes` });
|
|
764
740
|
onEvent?.({ kind: "done" });
|
|
765
741
|
return;
|
|
766
742
|
}
|
|
767
|
-
if (!onEvent) continue;
|
|
768
|
-
if (msg.type === "assistant") {
|
|
769
|
-
turnCount++;
|
|
770
|
-
onEvent({ kind: "turn", turn: turnCount });
|
|
771
|
-
for (const block of msg.message.content) {
|
|
772
|
-
if (block.type === "text") {
|
|
773
|
-
onEvent({ kind: "thinking", text: block.text });
|
|
774
|
-
}
|
|
775
|
-
if (block.type === "tool_use") {
|
|
776
|
-
onEvent({
|
|
777
|
-
kind: "tool_call",
|
|
778
|
-
tool: block.name,
|
|
779
|
-
input: block.input
|
|
780
|
-
});
|
|
781
|
-
}
|
|
782
|
-
}
|
|
783
|
-
}
|
|
784
|
-
if (msg.type === "user") {
|
|
785
|
-
const content = msg.message?.content;
|
|
786
|
-
if (Array.isArray(content)) {
|
|
787
|
-
for (const block of content) {
|
|
788
|
-
if (typeof block === "object" && block !== null && "type" in block && block.type === "tool_result") {
|
|
789
|
-
const tb = block;
|
|
790
|
-
const text = typeof tb.content === "string" ? tb.content : "";
|
|
791
|
-
onEvent({ kind: "tool_result", tool: tb.tool_use_id ?? "", output: text });
|
|
792
|
-
}
|
|
793
|
-
}
|
|
794
|
-
}
|
|
795
|
-
}
|
|
796
|
-
if (msg.type === "result") {
|
|
797
|
-
onEvent({ kind: "done" });
|
|
798
|
-
return;
|
|
799
|
-
}
|
|
800
743
|
}
|
|
801
744
|
} catch (err) {
|
|
802
745
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -805,6 +748,182 @@ Use ask_user when you need context from the user.`;
|
|
|
805
748
|
}
|
|
806
749
|
}
|
|
807
750
|
|
|
751
|
+
// src/config.ts
|
|
752
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
753
|
+
var ConfigError = class extends Error {
|
|
754
|
+
constructor(message) {
|
|
755
|
+
super(message);
|
|
756
|
+
this.name = "ConfigError";
|
|
757
|
+
}
|
|
758
|
+
};
|
|
759
|
+
function loadConfig(path) {
|
|
760
|
+
const file = readConfigFile(path);
|
|
761
|
+
const overrides = {};
|
|
762
|
+
if (file.organization) overrides.organization = file.organization;
|
|
763
|
+
const entryPoints = file.schedule?.entryPoints ?? file.entryPoints;
|
|
764
|
+
if (entryPoints) overrides.entryPoints = [...entryPoints];
|
|
765
|
+
const dbPath = file.schedule?.dbPath ?? file.dbPath;
|
|
766
|
+
if (dbPath) overrides.dbPath = dbPath;
|
|
767
|
+
if (file.schedule) overrides.schedule = file.schedule;
|
|
768
|
+
if (file.centralDb) {
|
|
769
|
+
const merged = { ...file.centralDb, ...centralDbFromEnv() };
|
|
770
|
+
overrides.centralDb = merged;
|
|
771
|
+
}
|
|
772
|
+
return defaultConfig(overrides);
|
|
773
|
+
}
|
|
774
|
+
function readConfigFile(path) {
|
|
775
|
+
let raw;
|
|
776
|
+
try {
|
|
777
|
+
raw = readFileSync2(path, "utf-8");
|
|
778
|
+
} catch (err) {
|
|
779
|
+
throw new ConfigError(
|
|
780
|
+
`Cannot read config file ${path}: ${err instanceof Error ? err.message : String(err)}`
|
|
781
|
+
);
|
|
782
|
+
}
|
|
783
|
+
let json;
|
|
784
|
+
try {
|
|
785
|
+
json = JSON.parse(raw);
|
|
786
|
+
} catch (err) {
|
|
787
|
+
throw new ConfigError(
|
|
788
|
+
`Invalid JSON in ${path}: ${err instanceof Error ? err.message : String(err)}`
|
|
789
|
+
);
|
|
790
|
+
}
|
|
791
|
+
const parsed = ConfigFileSchema.safeParse(json);
|
|
792
|
+
if (!parsed.success) {
|
|
793
|
+
const detail = parsed.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ");
|
|
794
|
+
throw new ConfigError(`Invalid config in ${path}: ${detail}`);
|
|
795
|
+
}
|
|
796
|
+
return parsed.data;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// src/schedule.ts
|
|
800
|
+
var FIELD_SPECS = [
|
|
801
|
+
{ name: "minute", min: 0, max: 59 },
|
|
802
|
+
{ name: "hour", min: 0, max: 23 },
|
|
803
|
+
{ name: "dom", min: 1, max: 31 },
|
|
804
|
+
{ name: "month", min: 1, max: 12 },
|
|
805
|
+
{ name: "dow", min: 0, max: 7 }
|
|
806
|
+
// 7 and 0 both mean Sunday; normalized to 0 below
|
|
807
|
+
];
|
|
808
|
+
function parseField(raw, spec) {
|
|
809
|
+
const out = /* @__PURE__ */ new Set();
|
|
810
|
+
const add = (n) => {
|
|
811
|
+
if (!Number.isInteger(n) || n < spec.min || n > spec.max) {
|
|
812
|
+
throw new RangeError(`Invalid value "${n}" in cron field "${spec.name}" (allowed ${spec.min}-${spec.max})`);
|
|
813
|
+
}
|
|
814
|
+
out.add(spec.name === "dow" && n === 7 ? 0 : n);
|
|
815
|
+
};
|
|
816
|
+
for (const part of raw.split(",")) {
|
|
817
|
+
if (part === "") {
|
|
818
|
+
throw new RangeError(`Empty term in cron field "${spec.name}"`);
|
|
819
|
+
}
|
|
820
|
+
const [rangePart, stepPart, ...rest] = part.split("/");
|
|
821
|
+
if (rest.length > 0) {
|
|
822
|
+
throw new RangeError(`Malformed step in cron field "${spec.name}": "${part}"`);
|
|
823
|
+
}
|
|
824
|
+
let step = 1;
|
|
825
|
+
if (stepPart !== void 0) {
|
|
826
|
+
step = Number(stepPart);
|
|
827
|
+
if (!Number.isInteger(step) || step < 1) {
|
|
828
|
+
throw new RangeError(`Invalid step "${stepPart}" in cron field "${spec.name}"`);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
let lo;
|
|
832
|
+
let hi;
|
|
833
|
+
if (rangePart === "*") {
|
|
834
|
+
lo = spec.min;
|
|
835
|
+
hi = spec.max;
|
|
836
|
+
} else if (rangePart.includes("-")) {
|
|
837
|
+
const [a, b, ...extra] = rangePart.split("-");
|
|
838
|
+
if (extra.length > 0) {
|
|
839
|
+
throw new RangeError(`Malformed range in cron field "${spec.name}": "${rangePart}"`);
|
|
840
|
+
}
|
|
841
|
+
lo = Number(a);
|
|
842
|
+
hi = Number(b);
|
|
843
|
+
if (!Number.isInteger(lo) || !Number.isInteger(hi)) {
|
|
844
|
+
throw new RangeError(`Non-numeric range in cron field "${spec.name}": "${rangePart}"`);
|
|
845
|
+
}
|
|
846
|
+
if (lo > hi) {
|
|
847
|
+
throw new RangeError(`Descending range in cron field "${spec.name}": "${rangePart}"`);
|
|
848
|
+
}
|
|
849
|
+
} else {
|
|
850
|
+
const n = Number(rangePart);
|
|
851
|
+
if (!Number.isInteger(n)) {
|
|
852
|
+
throw new RangeError(`Non-numeric value in cron field "${spec.name}": "${rangePart}"`);
|
|
853
|
+
}
|
|
854
|
+
lo = n;
|
|
855
|
+
hi = stepPart !== void 0 ? spec.max : n;
|
|
856
|
+
}
|
|
857
|
+
for (let v = lo; v <= hi; v += step) add(v);
|
|
858
|
+
}
|
|
859
|
+
if (out.size === 0) {
|
|
860
|
+
throw new RangeError(`Cron field "${spec.name}" matched no values`);
|
|
861
|
+
}
|
|
862
|
+
return out;
|
|
863
|
+
}
|
|
864
|
+
function parseCron(expr) {
|
|
865
|
+
const fields = expr.trim().split(/\s+/);
|
|
866
|
+
if (fields.length !== 5) {
|
|
867
|
+
throw new RangeError(`Cron expression must have 5 fields (got ${fields.length}): "${expr}"`);
|
|
868
|
+
}
|
|
869
|
+
const [minute, hour, dom, month, dow] = FIELD_SPECS.map((spec, i) => parseField(fields[i], spec));
|
|
870
|
+
return { minute, hour, dom, month, dow };
|
|
871
|
+
}
|
|
872
|
+
function matches(fields, date) {
|
|
873
|
+
if (!fields.minute.has(date.getUTCMinutes())) return false;
|
|
874
|
+
if (!fields.hour.has(date.getUTCHours())) return false;
|
|
875
|
+
if (!fields.month.has(date.getUTCMonth() + 1)) return false;
|
|
876
|
+
const domRestricted = fields.dom.size !== 31;
|
|
877
|
+
const dowRestricted = fields.dow.size !== 7;
|
|
878
|
+
const domOk = fields.dom.has(date.getUTCDate());
|
|
879
|
+
const dowOk = fields.dow.has(date.getUTCDay());
|
|
880
|
+
if (domRestricted && dowRestricted) return domOk || dowOk;
|
|
881
|
+
if (domRestricted) return domOk;
|
|
882
|
+
if (dowRestricted) return dowOk;
|
|
883
|
+
return true;
|
|
884
|
+
}
|
|
885
|
+
var MAX_SEARCH_MINUTES = 4 * 366 * 24 * 60;
|
|
886
|
+
function nextRun(expr, after) {
|
|
887
|
+
const fields = parseCron(expr);
|
|
888
|
+
const cursor2 = new Date(after.getTime());
|
|
889
|
+
cursor2.setUTCSeconds(0, 0);
|
|
890
|
+
cursor2.setUTCMinutes(cursor2.getUTCMinutes() + 1);
|
|
891
|
+
for (let i = 0; i < MAX_SEARCH_MINUTES; i++) {
|
|
892
|
+
if (matches(fields, cursor2)) return new Date(cursor2.getTime());
|
|
893
|
+
cursor2.setUTCMinutes(cursor2.getUTCMinutes() + 1);
|
|
894
|
+
}
|
|
895
|
+
throw new RangeError(`No cron match for "${expr}" within ~4 years after ${after.toISOString()}`);
|
|
896
|
+
}
|
|
897
|
+
async function runOnce(cfg, db) {
|
|
898
|
+
const prior = db.getLatestSession("discover");
|
|
899
|
+
if (prior) {
|
|
900
|
+
const r = await runLocalDiscovery(db, prior.id, {
|
|
901
|
+
hint: cfg.entryPoints.join(","),
|
|
902
|
+
plugins: cfg.plugins,
|
|
903
|
+
mode: "update",
|
|
904
|
+
onProgress: (line) => logInfo(`scan: ${line}`)
|
|
905
|
+
});
|
|
906
|
+
const delta = r.delta ?? diffTopology({ nodes: [], edges: [] }, { nodes: [], edges: [] });
|
|
907
|
+
logInfo("scheduled run complete", { sessionId: prior.id, base: prior.id, ...delta.summary });
|
|
908
|
+
return { sessionId: prior.id, baseSessionId: prior.id, delta, nodes: r.nodes, edges: r.edges, scanners: r.scanners };
|
|
909
|
+
}
|
|
910
|
+
const sessionId = db.createSession("discover", cfg);
|
|
911
|
+
try {
|
|
912
|
+
const r = await runLocalDiscovery(db, sessionId, {
|
|
913
|
+
hint: cfg.entryPoints.join(","),
|
|
914
|
+
plugins: cfg.plugins,
|
|
915
|
+
mode: "replace",
|
|
916
|
+
onProgress: (line) => logInfo(`scan: ${line}`)
|
|
917
|
+
});
|
|
918
|
+
const current = { nodes: db.getNodes(sessionId), edges: db.getEdges(sessionId) };
|
|
919
|
+
const delta = diffTopology({ nodes: [], edges: [] }, current);
|
|
920
|
+
logInfo("scheduled run complete", { sessionId, base: null, ...delta.summary });
|
|
921
|
+
return { sessionId, baseSessionId: void 0, delta, nodes: r.nodes, edges: r.edges, scanners: r.scanners };
|
|
922
|
+
} finally {
|
|
923
|
+
db.endSession(sessionId);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
|
|
808
927
|
// src/exporter.ts
|
|
809
928
|
import { mkdirSync, writeFileSync } from "fs";
|
|
810
929
|
import { join as join2 } from "path";
|
|
@@ -1171,6 +1290,66 @@ function generateDependencyMermaid(nodes, edges) {
|
|
|
1171
1290
|
}
|
|
1172
1291
|
return lines.join("\n");
|
|
1173
1292
|
}
|
|
1293
|
+
var DIFF_CLASSES = {
|
|
1294
|
+
added: "fill:#0d3d0d,stroke:#22c55e,color:#86efac",
|
|
1295
|
+
removed: "fill:#3d0d0d,stroke:#ef4444,color:#fca5a5",
|
|
1296
|
+
changed: "fill:#3d2f0d,stroke:#f59e0b,color:#fcd34d",
|
|
1297
|
+
context: "fill:#1e1e1e,stroke:#555555,color:#999999"
|
|
1298
|
+
};
|
|
1299
|
+
function diffNodeLabel(node, suffix) {
|
|
1300
|
+
const icon = MERMAID_ICONS[node.type] ?? "?";
|
|
1301
|
+
const extra = suffix ? `<br/><small>\u0394 ${suffix}</small>` : "";
|
|
1302
|
+
return `["${icon} <b>${node.name}</b><br/><small>${node.type}</small>${extra}"]`;
|
|
1303
|
+
}
|
|
1304
|
+
function generateDiffMermaid(diff) {
|
|
1305
|
+
const total = diff.summary.nodesAdded + diff.summary.nodesRemoved + diff.summary.nodesChanged + diff.summary.edgesAdded + diff.summary.edgesRemoved;
|
|
1306
|
+
if (total === 0) return 'graph TB\n nodrift["\u2713 No drift between the two sessions"]';
|
|
1307
|
+
const lines = ["graph TB"];
|
|
1308
|
+
for (const [k, style] of Object.entries(DIFF_CLASSES)) lines.push(` classDef ${k} ${style}`);
|
|
1309
|
+
lines.push("");
|
|
1310
|
+
const rank = { added: 3, removed: 3, changed: 3, context: 0 };
|
|
1311
|
+
const entries = /* @__PURE__ */ new Map();
|
|
1312
|
+
const place = (node, cls, suffix) => {
|
|
1313
|
+
const prev = entries.get(node.id);
|
|
1314
|
+
if (prev && rank[prev.cls] >= rank[cls]) return;
|
|
1315
|
+
entries.set(node.id, { node, cls, suffix });
|
|
1316
|
+
};
|
|
1317
|
+
for (const n of diff.nodes.added) place(n, "added");
|
|
1318
|
+
for (const n of diff.nodes.removed) place(n, "removed");
|
|
1319
|
+
for (const c of diff.nodes.changed) place(c.after, "changed", c.changedFields.join(", "));
|
|
1320
|
+
const contextNode = (id) => ({
|
|
1321
|
+
id,
|
|
1322
|
+
type: "unknown",
|
|
1323
|
+
name: id,
|
|
1324
|
+
discoveredVia: "diff",
|
|
1325
|
+
confidence: 1,
|
|
1326
|
+
metadata: {},
|
|
1327
|
+
tags: [],
|
|
1328
|
+
sessionId: "",
|
|
1329
|
+
discoveredAt: "",
|
|
1330
|
+
depth: 0
|
|
1331
|
+
});
|
|
1332
|
+
const ensureEndpoint = (id) => {
|
|
1333
|
+
if (!entries.has(id)) place(contextNode(id), "context");
|
|
1334
|
+
};
|
|
1335
|
+
for (const e of [...diff.edges.added, ...diff.edges.removed]) {
|
|
1336
|
+
ensureEndpoint(e.sourceId);
|
|
1337
|
+
ensureEndpoint(e.targetId);
|
|
1338
|
+
}
|
|
1339
|
+
for (const { node, cls, suffix } of entries.values()) {
|
|
1340
|
+
lines.push(` ${sanitize(node.id)}${diffNodeLabel(node, suffix)}:::${cls}`);
|
|
1341
|
+
}
|
|
1342
|
+
lines.push("");
|
|
1343
|
+
for (const e of diff.edges.added) {
|
|
1344
|
+
const label = EDGE_LABELS[e.relationship] ?? e.relationship;
|
|
1345
|
+
lines.push(` ${sanitize(e.sourceId)} ==>|"+ ${label}"| ${sanitize(e.targetId)}`);
|
|
1346
|
+
}
|
|
1347
|
+
for (const e of diff.edges.removed) {
|
|
1348
|
+
const label = EDGE_LABELS[e.relationship] ?? e.relationship;
|
|
1349
|
+
lines.push(` ${sanitize(e.sourceId)} -.->|"- ${label}"| ${sanitize(e.targetId)}`);
|
|
1350
|
+
}
|
|
1351
|
+
return lines.join("\n");
|
|
1352
|
+
}
|
|
1174
1353
|
function exportBackstageYAML(nodes, edges, org) {
|
|
1175
1354
|
const owner = org ?? "unknown";
|
|
1176
1355
|
const docs = [];
|
|
@@ -1190,7 +1369,7 @@ function exportBackstageYAML(nodes, edges, org) {
|
|
|
1190
1369
|
`spec:`,
|
|
1191
1370
|
` type: ${node.type}`,
|
|
1192
1371
|
` lifecycle: production`,
|
|
1193
|
-
` owner: ${owner}`,
|
|
1372
|
+
` owner: ${node.owner ?? owner}`,
|
|
1194
1373
|
...deps.length > 0 ? [" dependsOn:", ...deps] : []
|
|
1195
1374
|
].join("\n");
|
|
1196
1375
|
docs.push(doc);
|
|
@@ -2311,11 +2490,85 @@ function exportJGF(nodes, edges) {
|
|
|
2311
2490
|
};
|
|
2312
2491
|
return JSON.stringify(jgf, null, 2);
|
|
2313
2492
|
}
|
|
2314
|
-
function
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2493
|
+
function csvField(v) {
|
|
2494
|
+
let s = String(v);
|
|
2495
|
+
if (/^[=+\-@]/.test(s)) s = `'${s}`;
|
|
2496
|
+
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
|
2497
|
+
}
|
|
2498
|
+
function exportCostCSV(summary) {
|
|
2499
|
+
const rows = ["scope,key,currency,period,total,nodes"];
|
|
2500
|
+
for (const c of summary.costByDomain) {
|
|
2501
|
+
rows.push(["domain", c.domain, c.currency, c.period, c.total, c.nodes].map(csvField).join(","));
|
|
2502
|
+
}
|
|
2503
|
+
for (const c of summary.costByOwner) {
|
|
2504
|
+
rows.push(["owner", c.owner, c.currency, c.period, c.total, c.nodes].map(csvField).join(","));
|
|
2505
|
+
}
|
|
2506
|
+
return rows.join("\n") + "\n";
|
|
2507
|
+
}
|
|
2508
|
+
function exportCostSummary(summary) {
|
|
2509
|
+
return JSON.stringify({
|
|
2510
|
+
costByDomain: summary.costByDomain,
|
|
2511
|
+
costByOwner: summary.costByOwner,
|
|
2512
|
+
costCoverage: summary.costCoverage
|
|
2513
|
+
}, null, 2);
|
|
2514
|
+
}
|
|
2515
|
+
var SEVERITY_RANK = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
2516
|
+
function exportComplianceReport(report, format) {
|
|
2517
|
+
if (format === "json") return JSON.stringify(report, null, 2);
|
|
2518
|
+
if (format === "markdown") {
|
|
2519
|
+
const scoreStr = report.score === null ? "n/a" : `${report.score}/100`;
|
|
2520
|
+
const out = [
|
|
2521
|
+
`# Compliance \u2014 ${report.rulesetName} v${report.rulesetVersion}`,
|
|
2522
|
+
``,
|
|
2523
|
+
`**Status:** ${report.status.toUpperCase()} \xB7 **Score:** ${scoreStr}`,
|
|
2524
|
+
``,
|
|
2525
|
+
`| Controls | Count |`,
|
|
2526
|
+
`|----------|-------|`,
|
|
2527
|
+
`| Passed | ${report.totals.passed} |`,
|
|
2528
|
+
`| Failed | ${report.totals.failed} |`,
|
|
2529
|
+
`| Not applicable | ${report.totals.notApplicable} |`,
|
|
2530
|
+
`| Total | ${report.totals.rules} |`,
|
|
2531
|
+
``,
|
|
2532
|
+
`| Severity | Failed | Passed |`,
|
|
2533
|
+
`|----------|--------|--------|`,
|
|
2534
|
+
...["critical", "high", "medium", "low"].map(
|
|
2535
|
+
(s) => `| ${s} | ${report.bySeverity[s].failed} | ${report.bySeverity[s].passed} |`
|
|
2536
|
+
)
|
|
2537
|
+
];
|
|
2538
|
+
if (report.gaps.length === 0) {
|
|
2539
|
+
out.push(``, `\u2713 No compliance gaps.`);
|
|
2540
|
+
} else {
|
|
2541
|
+
out.push(``, `## Gaps`);
|
|
2542
|
+
for (const g of [...report.gaps].sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity])) {
|
|
2543
|
+
out.push(``, `### [${g.severity}] ${g.control} \u2014 ${g.title}`, ...g.nodeIds.map((id) => `- \`${id}\``));
|
|
2544
|
+
}
|
|
2545
|
+
}
|
|
2546
|
+
return out.join("\n");
|
|
2547
|
+
}
|
|
2548
|
+
const lines = [
|
|
2549
|
+
"graph TB",
|
|
2550
|
+
" classDef critical fill:#7f1d1d,stroke:#ef4444,color:#fff;",
|
|
2551
|
+
" classDef high fill:#7c2d12,stroke:#f97316,color:#fff;",
|
|
2552
|
+
" classDef medium fill:#713f12,stroke:#eab308,color:#fff;",
|
|
2553
|
+
" classDef low fill:#1e3a5f,stroke:#3b82f6,color:#fff;"
|
|
2554
|
+
];
|
|
2555
|
+
if (report.gaps.length === 0) {
|
|
2556
|
+
lines.push(' ok["\u2713 No compliance gaps"]');
|
|
2557
|
+
return lines.join("\n");
|
|
2558
|
+
}
|
|
2559
|
+
const mmSafe = (s) => s.replace(/["\]\r\n]/g, "'");
|
|
2560
|
+
let i = 0;
|
|
2561
|
+
for (const g of [...report.gaps].sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity])) {
|
|
2562
|
+
const gid = `g${i++}`;
|
|
2563
|
+
lines.push(` ${gid}["${mmSafe(g.control)}: ${mmSafe(g.title)} (${g.nodeIds.length})"]:::${g.severity}`);
|
|
2564
|
+
}
|
|
2565
|
+
return lines.join("\n");
|
|
2566
|
+
}
|
|
2567
|
+
function exportAll(db, sessionId, outputDir, formats = ["mermaid", "json", "yaml", "html", "map", "discovery"]) {
|
|
2568
|
+
mkdirSync(outputDir, { recursive: true });
|
|
2569
|
+
const nodes = db.getNodes(sessionId);
|
|
2570
|
+
const edges = db.getEdges(sessionId);
|
|
2571
|
+
const jgfPath = join2(outputDir, "cartography-graph.jgf.json");
|
|
2319
2572
|
writeFileSync(jgfPath, exportJGF(nodes, edges));
|
|
2320
2573
|
if (formats.includes("mermaid")) {
|
|
2321
2574
|
writeFileSync(join2(outputDir, "topology.mermaid"), generateTopologyMermaid(nodes, edges));
|
|
@@ -2330,13 +2583,777 @@ function exportAll(db, sessionId, outputDir, formats = ["mermaid", "json", "yaml
|
|
|
2330
2583
|
if (formats.includes("html") || formats.includes("map") || formats.includes("discovery")) {
|
|
2331
2584
|
writeFileSync(join2(outputDir, "discovery.html"), exportDiscoveryApp(nodes, edges));
|
|
2332
2585
|
}
|
|
2586
|
+
if (formats.includes("cost")) {
|
|
2587
|
+
const summary = db.getGraphSummary(sessionId);
|
|
2588
|
+
writeFileSync(join2(outputDir, "cost-by-domain.csv"), exportCostCSV(summary));
|
|
2589
|
+
writeFileSync(join2(outputDir, "cost-summary.json"), exportCostSummary(summary));
|
|
2590
|
+
}
|
|
2591
|
+
}
|
|
2592
|
+
|
|
2593
|
+
// src/compliance/report.ts
|
|
2594
|
+
var NODE_CAP = 50;
|
|
2595
|
+
function formatComplianceText(report) {
|
|
2596
|
+
const scoreStr = report.score === null ? "n/a" : `${report.score}/100`;
|
|
2597
|
+
const lines = [
|
|
2598
|
+
`Compliance: ${report.rulesetName} v${report.rulesetVersion} \u2014 ${report.status.toUpperCase()} (score ${scoreStr})`,
|
|
2599
|
+
`Controls: ${report.totals.passed} passed, ${report.totals.failed} failed, ${report.totals.notApplicable} n/a (of ${report.totals.rules})`,
|
|
2600
|
+
"",
|
|
2601
|
+
"By severity (failed/passed):",
|
|
2602
|
+
...["critical", "high", "medium", "low"].map(
|
|
2603
|
+
(s) => ` - ${s}: ${report.bySeverity[s].failed} failed / ${report.bySeverity[s].passed} passed`
|
|
2604
|
+
)
|
|
2605
|
+
];
|
|
2606
|
+
if (report.gaps.length === 0) {
|
|
2607
|
+
lines.push("", "\u2713 No compliance gaps.");
|
|
2608
|
+
return lines.join("\n");
|
|
2609
|
+
}
|
|
2610
|
+
lines.push("", `Gaps (${report.gaps.length}):`);
|
|
2611
|
+
for (const g of report.gaps) {
|
|
2612
|
+
lines.push(` \u2717 [${g.severity}] ${g.control} \u2014 ${g.title}`);
|
|
2613
|
+
const shown = g.nodeIds.slice(0, NODE_CAP);
|
|
2614
|
+
for (const id of shown) lines.push(` ${id}`);
|
|
2615
|
+
if (g.nodeIds.length > NODE_CAP) lines.push(` \u2026 +${g.nodeIds.length - NODE_CAP} more`);
|
|
2616
|
+
}
|
|
2617
|
+
return lines.join("\n");
|
|
2618
|
+
}
|
|
2619
|
+
|
|
2620
|
+
// src/cost.ts
|
|
2621
|
+
import { readFileSync as readFileSync3 } from "fs";
|
|
2622
|
+
import { resolve } from "path";
|
|
2623
|
+
function splitCsvLine(line) {
|
|
2624
|
+
const out = [];
|
|
2625
|
+
let cur = "";
|
|
2626
|
+
let inQuotes = false;
|
|
2627
|
+
for (let i = 0; i < line.length; i++) {
|
|
2628
|
+
const ch = line[i];
|
|
2629
|
+
if (inQuotes) {
|
|
2630
|
+
if (ch === '"') {
|
|
2631
|
+
if (line[i + 1] === '"') {
|
|
2632
|
+
cur += '"';
|
|
2633
|
+
i++;
|
|
2634
|
+
} else {
|
|
2635
|
+
inQuotes = false;
|
|
2636
|
+
}
|
|
2637
|
+
} else cur += ch;
|
|
2638
|
+
} else if (ch === '"') {
|
|
2639
|
+
inQuotes = true;
|
|
2640
|
+
} else if (ch === ",") {
|
|
2641
|
+
out.push(cur);
|
|
2642
|
+
cur = "";
|
|
2643
|
+
} else cur += ch;
|
|
2644
|
+
}
|
|
2645
|
+
out.push(cur);
|
|
2646
|
+
return out.map((s) => s.trim());
|
|
2647
|
+
}
|
|
2648
|
+
function parseCostCsv(text) {
|
|
2649
|
+
const lines = text.split(/\r?\n/).filter((l) => l.trim().length > 0);
|
|
2650
|
+
if (lines.length === 0) return [];
|
|
2651
|
+
const header = splitCsvLine(lines[0]).map((h) => h.toLowerCase());
|
|
2652
|
+
const col = (name) => header.indexOf(name);
|
|
2653
|
+
const iNode = col("nodeid");
|
|
2654
|
+
const iOwner = col("owner");
|
|
2655
|
+
const iAmount = col("amount");
|
|
2656
|
+
const iCurrency = col("currency");
|
|
2657
|
+
const iPeriod = col("period");
|
|
2658
|
+
const iSource = col("source");
|
|
2659
|
+
if (iNode < 0) {
|
|
2660
|
+
logWarn('cost csv: missing required "nodeId" header column');
|
|
2661
|
+
return [];
|
|
2662
|
+
}
|
|
2663
|
+
const records = [];
|
|
2664
|
+
for (let r = 1; r < lines.length; r++) {
|
|
2665
|
+
const f = splitCsvLine(lines[r]);
|
|
2666
|
+
const nodeId = f[iNode];
|
|
2667
|
+
if (!nodeId) {
|
|
2668
|
+
logWarn(`cost csv: row ${r + 1} skipped (empty nodeId)`);
|
|
2669
|
+
continue;
|
|
2670
|
+
}
|
|
2671
|
+
const rec = { nodeId };
|
|
2672
|
+
if (iOwner >= 0 && f[iOwner]) rec.owner = f[iOwner];
|
|
2673
|
+
const amountRaw = iAmount >= 0 ? f[iAmount] : "";
|
|
2674
|
+
if (amountRaw) {
|
|
2675
|
+
const parsed = CostEntrySchema.safeParse({
|
|
2676
|
+
amount: Number(amountRaw),
|
|
2677
|
+
currency: iCurrency >= 0 ? f[iCurrency] : void 0,
|
|
2678
|
+
period: iPeriod >= 0 ? f[iPeriod] : void 0,
|
|
2679
|
+
...iSource >= 0 && f[iSource] ? { source: f[iSource] } : {}
|
|
2680
|
+
});
|
|
2681
|
+
if (!parsed.success) {
|
|
2682
|
+
logWarn(`cost csv: row ${r + 1} skipped (invalid cost fields)`);
|
|
2683
|
+
if (!rec.owner) continue;
|
|
2684
|
+
} else {
|
|
2685
|
+
rec.cost = parsed.data;
|
|
2686
|
+
}
|
|
2687
|
+
}
|
|
2688
|
+
if (rec.owner || rec.cost) records.push(rec);
|
|
2689
|
+
}
|
|
2690
|
+
return records;
|
|
2691
|
+
}
|
|
2692
|
+
var CsvCostSource = class {
|
|
2693
|
+
constructor(opts) {
|
|
2694
|
+
this.opts = opts;
|
|
2695
|
+
const base = opts.filePath.split(/[\\/]/).pop() ?? opts.filePath;
|
|
2696
|
+
this.id = `csv:${base}`;
|
|
2697
|
+
}
|
|
2698
|
+
id;
|
|
2699
|
+
async fetch() {
|
|
2700
|
+
const text = readFileSync3(resolve(this.opts.filePath), "utf-8");
|
|
2701
|
+
const records = parseCostCsv(text);
|
|
2702
|
+
const match = this.opts.match ?? "nodeId";
|
|
2703
|
+
const out = /* @__PURE__ */ new Map();
|
|
2704
|
+
if (match === "nodeId") {
|
|
2705
|
+
for (const rec of records) out.set(rec.nodeId, rec);
|
|
2706
|
+
return out;
|
|
2707
|
+
}
|
|
2708
|
+
if (!this.opts.db || !this.opts.sessionId) {
|
|
2709
|
+
logWarn(`cost csv: match '${match}' requires db + sessionId; falling back to nodeId`);
|
|
2710
|
+
for (const rec of records) out.set(rec.nodeId, rec);
|
|
2711
|
+
return out;
|
|
2712
|
+
}
|
|
2713
|
+
const nodes = this.opts.db.getNodes(this.opts.sessionId);
|
|
2714
|
+
const index = /* @__PURE__ */ new Map();
|
|
2715
|
+
for (const n of nodes) {
|
|
2716
|
+
if (match === "name") index.set(n.name, n.id);
|
|
2717
|
+
else for (const t of n.tags) index.set(t, n.id);
|
|
2718
|
+
}
|
|
2719
|
+
for (const rec of records) {
|
|
2720
|
+
const resolved = index.get(rec.nodeId);
|
|
2721
|
+
out.set(resolved ?? rec.nodeId, { ...rec, nodeId: resolved ?? rec.nodeId });
|
|
2722
|
+
}
|
|
2723
|
+
return out;
|
|
2724
|
+
}
|
|
2725
|
+
};
|
|
2726
|
+
async function enrichCosts(db, sessionId, source) {
|
|
2727
|
+
const records = await source.fetch();
|
|
2728
|
+
let matched = 0;
|
|
2729
|
+
const unmatchedIds = [];
|
|
2730
|
+
for (const [nodeId, rec] of records) {
|
|
2731
|
+
const ok = db.enrichNodeAttribution(sessionId, nodeId, {
|
|
2732
|
+
owner: rec.owner ?? void 0,
|
|
2733
|
+
cost: rec.cost ?? void 0
|
|
2734
|
+
});
|
|
2735
|
+
if (ok) matched++;
|
|
2736
|
+
else unmatchedIds.push(nodeId);
|
|
2737
|
+
}
|
|
2738
|
+
return { source: source.id, total: records.size, matched, unmatched: unmatchedIds.length, unmatchedIds };
|
|
2333
2739
|
}
|
|
2334
2740
|
|
|
2335
2741
|
// src/cli.ts
|
|
2336
|
-
import { readFileSync as
|
|
2337
|
-
import { resolve, dirname } from "path";
|
|
2742
|
+
import { readFileSync as readFileSync5, existsSync as existsSync3, writeFileSync as writeFileSync3 } from "fs";
|
|
2743
|
+
import { resolve as resolve2, dirname as dirname2 } from "path";
|
|
2338
2744
|
import { fileURLToPath } from "url";
|
|
2339
2745
|
import { createInterface } from "readline";
|
|
2746
|
+
|
|
2747
|
+
// src/sharing.ts
|
|
2748
|
+
function wildcardCount(pattern) {
|
|
2749
|
+
return (pattern.match(/\*/g) ?? []).length;
|
|
2750
|
+
}
|
|
2751
|
+
function globMatch(pattern, id) {
|
|
2752
|
+
let re = "^";
|
|
2753
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
2754
|
+
const c = pattern[i];
|
|
2755
|
+
if (c === "*") {
|
|
2756
|
+
if (pattern[i + 1] === "*") {
|
|
2757
|
+
re += ".*";
|
|
2758
|
+
i++;
|
|
2759
|
+
} else re += "[^:]*";
|
|
2760
|
+
} else {
|
|
2761
|
+
re += c.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
|
|
2762
|
+
}
|
|
2763
|
+
}
|
|
2764
|
+
re += "$";
|
|
2765
|
+
return new RegExp(re).test(id);
|
|
2766
|
+
}
|
|
2767
|
+
function resolveSharingLevel(nodeId, policy) {
|
|
2768
|
+
const matches2 = policy.overrides.filter((o) => o.pattern !== "*" && o.pattern !== "**" && globMatch(o.pattern, nodeId)).sort(
|
|
2769
|
+
(a, b) => wildcardCount(a.pattern) - wildcardCount(b.pattern) || b.pattern.length - a.pattern.length
|
|
2770
|
+
);
|
|
2771
|
+
return matches2.length ? matches2[0].level : policy.defaultLevel;
|
|
2772
|
+
}
|
|
2773
|
+
function nodeHosts(node) {
|
|
2774
|
+
const out = [node.id, node.name];
|
|
2775
|
+
const meta = node.metadata ?? {};
|
|
2776
|
+
for (const k of ["host", "url", "domain"]) {
|
|
2777
|
+
const v = meta[k];
|
|
2778
|
+
if (typeof v === "string") out.push(v);
|
|
2779
|
+
}
|
|
2780
|
+
if (node.domain) out.push(node.domain);
|
|
2781
|
+
return out;
|
|
2782
|
+
}
|
|
2783
|
+
function resolveEffectiveLevel(node, policy) {
|
|
2784
|
+
if (nodeHosts(node).some((h) => isPersonalHost(h))) return "none";
|
|
2785
|
+
return resolveSharingLevel(node.id, policy);
|
|
2786
|
+
}
|
|
2787
|
+
function applySharingLevel(node, level, orgKey, db) {
|
|
2788
|
+
if (level === "none") return null;
|
|
2789
|
+
if (level === "full") return { ...node, metadata: { ...node.metadata ?? {} }, tags: [...node.tags ?? []] };
|
|
2790
|
+
return {
|
|
2791
|
+
...node,
|
|
2792
|
+
id: pseudonymizeString(node.id, orgKey, db),
|
|
2793
|
+
name: pseudonymizeString(node.name, orgKey, db),
|
|
2794
|
+
metadata: pseudonymize(node.metadata ?? {}, orgKey, db),
|
|
2795
|
+
tags: (node.tags ?? []).map((t) => pseudonymizeString(t, orgKey, db))
|
|
2796
|
+
};
|
|
2797
|
+
}
|
|
2798
|
+
function previewShare(db, sessionId, orgKey, policy, opts = {}) {
|
|
2799
|
+
const persist = opts.persistReversal ? db : void 0;
|
|
2800
|
+
const nodes = db.getNodes(sessionId);
|
|
2801
|
+
const edges = db.getEdges(sessionId);
|
|
2802
|
+
const entries = [];
|
|
2803
|
+
const idMap = /* @__PURE__ */ new Map();
|
|
2804
|
+
const droppedNodeIds = [];
|
|
2805
|
+
for (const node of nodes) {
|
|
2806
|
+
const level = resolveEffectiveLevel(node, policy);
|
|
2807
|
+
const payload = applySharingLevel(node, level, orgKey, persist);
|
|
2808
|
+
entries.push({ node, level, payload });
|
|
2809
|
+
if (payload === null) {
|
|
2810
|
+
idMap.set(node.id, null);
|
|
2811
|
+
droppedNodeIds.push(node.id);
|
|
2812
|
+
} else {
|
|
2813
|
+
idMap.set(node.id, payload.id);
|
|
2814
|
+
}
|
|
2815
|
+
}
|
|
2816
|
+
const outEdges = [];
|
|
2817
|
+
for (const e of edges) {
|
|
2818
|
+
const src = idMap.get(e.sourceId);
|
|
2819
|
+
const tgt = idMap.get(e.targetId);
|
|
2820
|
+
if (src == null || tgt == null) continue;
|
|
2821
|
+
outEdges.push({ sourceId: src, targetId: tgt, relationship: e.relationship });
|
|
2822
|
+
}
|
|
2823
|
+
return { nodes: entries, edges: outEdges, droppedNodeIds };
|
|
2824
|
+
}
|
|
2825
|
+
function isRemembered(policy, nodeId) {
|
|
2826
|
+
const matched = policy.overrides.some((o) => o.pattern !== "*" && o.pattern !== "**" && globMatch(o.pattern, nodeId));
|
|
2827
|
+
return matched || policy.defaultLevel !== "none";
|
|
2828
|
+
}
|
|
2829
|
+
|
|
2830
|
+
// src/sync/hash.ts
|
|
2831
|
+
import { createHash } from "crypto";
|
|
2832
|
+
function shareHash(kind, payload) {
|
|
2833
|
+
return createHash("sha256").update(stableStringify({ kind, payload })).digest("hex");
|
|
2834
|
+
}
|
|
2835
|
+
|
|
2836
|
+
// src/sync/classify.ts
|
|
2837
|
+
function classify(input) {
|
|
2838
|
+
const { preview, policy, sharedHashes } = input;
|
|
2839
|
+
const result = { share: [], withhold: [], pending: [] };
|
|
2840
|
+
const sharedNodeIds = /* @__PURE__ */ new Set();
|
|
2841
|
+
for (const entry of preview.nodes) {
|
|
2842
|
+
if (entry.payload === null) {
|
|
2843
|
+
result.withhold.push({ contentHash: "", kind: "node", nodeId: entry.node.id, payload: null });
|
|
2844
|
+
continue;
|
|
2845
|
+
}
|
|
2846
|
+
const contentHash = shareHash("node", entry.payload);
|
|
2847
|
+
if (sharedHashes.has(contentHash)) continue;
|
|
2848
|
+
const item = { contentHash, kind: "node", nodeId: entry.node.id, payload: entry.payload };
|
|
2849
|
+
if (isRemembered(policy, entry.node.id)) {
|
|
2850
|
+
result.share.push(item);
|
|
2851
|
+
sharedNodeIds.add(entry.node.id);
|
|
2852
|
+
} else {
|
|
2853
|
+
result.pending.push(item);
|
|
2854
|
+
}
|
|
2855
|
+
}
|
|
2856
|
+
const sharedRemappedIds = /* @__PURE__ */ new Set();
|
|
2857
|
+
for (const entry of preview.nodes) {
|
|
2858
|
+
if (entry.payload !== null && sharedNodeIds.has(entry.node.id)) {
|
|
2859
|
+
sharedRemappedIds.add(entry.payload.id);
|
|
2860
|
+
}
|
|
2861
|
+
}
|
|
2862
|
+
for (const e of preview.edges) {
|
|
2863
|
+
const payload = { sourceId: e.sourceId, targetId: e.targetId, relationship: e.relationship };
|
|
2864
|
+
const contentHash = shareHash("edge", payload);
|
|
2865
|
+
const bothShared = sharedRemappedIds.has(e.sourceId) && sharedRemappedIds.has(e.targetId);
|
|
2866
|
+
if (!bothShared) {
|
|
2867
|
+
result.withhold.push({ contentHash: "", kind: "edge", payload });
|
|
2868
|
+
continue;
|
|
2869
|
+
}
|
|
2870
|
+
if (sharedHashes.has(contentHash)) continue;
|
|
2871
|
+
result.share.push({ contentHash, kind: "edge", payload });
|
|
2872
|
+
}
|
|
2873
|
+
return result;
|
|
2874
|
+
}
|
|
2875
|
+
|
|
2876
|
+
// src/sync/push.ts
|
|
2877
|
+
import { createHash as createHash2 } from "crypto";
|
|
2878
|
+
var PUSH_SCHEMA_VERSION = 1;
|
|
2879
|
+
var DEFAULT_BATCH = 100;
|
|
2880
|
+
var DEFAULT_RETRIES = 4;
|
|
2881
|
+
var DEFAULT_TIMEOUT_MS = 15e3;
|
|
2882
|
+
function defaultLog(line) {
|
|
2883
|
+
process.stderr.write(`[cartography-sync] ${line}
|
|
2884
|
+
`);
|
|
2885
|
+
}
|
|
2886
|
+
function defaultSleep(ms) {
|
|
2887
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
2888
|
+
}
|
|
2889
|
+
function batchKey(items) {
|
|
2890
|
+
const hashes = items.map((i) => i.contentHash).sort();
|
|
2891
|
+
return createHash2("sha256").update(stableStringify(hashes)).digest("hex");
|
|
2892
|
+
}
|
|
2893
|
+
async function pushDeltas(config, items, opts = {}) {
|
|
2894
|
+
const central = config.centralDb;
|
|
2895
|
+
if (!central?.url || !central.token) {
|
|
2896
|
+
throw new Error("sync push: centralDb not configured (set centralDb.url + token)");
|
|
2897
|
+
}
|
|
2898
|
+
let parsed;
|
|
2899
|
+
try {
|
|
2900
|
+
parsed = new URL(central.url);
|
|
2901
|
+
} catch {
|
|
2902
|
+
throw new Error("sync push: centralDb.url is not a valid URL");
|
|
2903
|
+
}
|
|
2904
|
+
const insecureAllowed = process.env.CARTOGRAPHY_ALLOW_INSECURE_SYNC === "1";
|
|
2905
|
+
if (parsed.protocol !== "https:" && !insecureAllowed) {
|
|
2906
|
+
throw new Error(
|
|
2907
|
+
`sync push: refusing to send over insecure ${parsed.protocol}// \u2014 use https:// (or set CARTOGRAPHY_ALLOW_INSECURE_SYNC=1 for local testing only)`
|
|
2908
|
+
);
|
|
2909
|
+
}
|
|
2910
|
+
const log = opts.log ?? defaultLog;
|
|
2911
|
+
const sleep = opts.sleep ?? defaultSleep;
|
|
2912
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
2913
|
+
const batchSize = Math.max(1, opts.batchSize ?? central.batchSize ?? DEFAULT_BATCH);
|
|
2914
|
+
const maxRetries = Math.max(0, opts.maxRetries ?? DEFAULT_RETRIES);
|
|
2915
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
2916
|
+
const safeUrl = stripSensitive(central.url);
|
|
2917
|
+
if (items.length === 0) {
|
|
2918
|
+
log("nothing to push (0 approved items)");
|
|
2919
|
+
return { sent: 0, batches: 0, failed: 0, sentHashes: [] };
|
|
2920
|
+
}
|
|
2921
|
+
const batches = [];
|
|
2922
|
+
for (let i = 0; i < items.length; i += batchSize) {
|
|
2923
|
+
batches.push(items.slice(i, i + batchSize).map((it) => ({ ...it, payload: redactValue(it.payload) })));
|
|
2924
|
+
}
|
|
2925
|
+
let sent = 0;
|
|
2926
|
+
let failed = 0;
|
|
2927
|
+
const sentHashes = [];
|
|
2928
|
+
for (const batch of batches) {
|
|
2929
|
+
const key = batchKey(batch);
|
|
2930
|
+
const body = JSON.stringify({
|
|
2931
|
+
schemaVersion: PUSH_SCHEMA_VERSION,
|
|
2932
|
+
...central.org ? { org: central.org } : {},
|
|
2933
|
+
items: batch.map((b) => ({ contentHash: b.contentHash, kind: b.kind, payload: b.payload }))
|
|
2934
|
+
});
|
|
2935
|
+
if (opts.dryRun) {
|
|
2936
|
+
log(`dry-run: would POST ${batch.length} item(s) to ${safeUrl} (idempotency ${key.slice(0, 12)}\u2026)`);
|
|
2937
|
+
sent += batch.length;
|
|
2938
|
+
sentHashes.push(...batch.map((b) => b.contentHash));
|
|
2939
|
+
continue;
|
|
2940
|
+
}
|
|
2941
|
+
let ok = false;
|
|
2942
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
2943
|
+
const controller = new AbortController();
|
|
2944
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
2945
|
+
const startedAt = Date.now();
|
|
2946
|
+
try {
|
|
2947
|
+
const res = await fetchImpl(central.url, {
|
|
2948
|
+
method: "POST",
|
|
2949
|
+
headers: {
|
|
2950
|
+
"authorization": `Bearer ${central.token}`,
|
|
2951
|
+
"content-type": "application/json",
|
|
2952
|
+
"x-idempotency-key": key
|
|
2953
|
+
},
|
|
2954
|
+
body,
|
|
2955
|
+
signal: controller.signal
|
|
2956
|
+
});
|
|
2957
|
+
const elapsed = Date.now() - startedAt;
|
|
2958
|
+
if (res.ok) {
|
|
2959
|
+
log(`pushed ${batch.length} item(s) \u2192 ${safeUrl} [${res.status}] ${elapsed}ms (attempt ${attempt + 1})`);
|
|
2960
|
+
ok = true;
|
|
2961
|
+
break;
|
|
2962
|
+
}
|
|
2963
|
+
if (res.status >= 400 && res.status < 500) {
|
|
2964
|
+
log(`batch rejected \u2192 ${safeUrl} [${res.status}] (no retry)`);
|
|
2965
|
+
break;
|
|
2966
|
+
}
|
|
2967
|
+
log(`batch failed \u2192 ${safeUrl} [${res.status}] (attempt ${attempt + 1}/${maxRetries + 1})`);
|
|
2968
|
+
} catch (err) {
|
|
2969
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2970
|
+
log(`batch error \u2192 ${safeUrl}: ${msg.replace(/https?:\/\/[^\s]+/g, (u) => stripSensitive(u))} (attempt ${attempt + 1}/${maxRetries + 1})`);
|
|
2971
|
+
} finally {
|
|
2972
|
+
clearTimeout(timer);
|
|
2973
|
+
}
|
|
2974
|
+
if (attempt < maxRetries) {
|
|
2975
|
+
const base = Math.min(2 ** attempt * 250, 4e3);
|
|
2976
|
+
await sleep(base + Math.floor(Math.random() * 100));
|
|
2977
|
+
}
|
|
2978
|
+
}
|
|
2979
|
+
if (ok) {
|
|
2980
|
+
sent += batch.length;
|
|
2981
|
+
sentHashes.push(...batch.map((b) => b.contentHash));
|
|
2982
|
+
} else {
|
|
2983
|
+
failed += batch.length;
|
|
2984
|
+
}
|
|
2985
|
+
}
|
|
2986
|
+
return { sent, batches: batches.length, failed, sentHashes };
|
|
2987
|
+
}
|
|
2988
|
+
|
|
2989
|
+
// src/sync/index.ts
|
|
2990
|
+
function runSyncClassify(db, sessionId, config, opts = {}) {
|
|
2991
|
+
if (!config.centralDb?.url) return { enqueued: 0, autoShared: 0, withheld: 0 };
|
|
2992
|
+
const orgKey = opts.orgKey ?? loadOrgKey({ organization: config.organization });
|
|
2993
|
+
const policy = db.getSharingPolicy();
|
|
2994
|
+
const preview = previewShare(db, sessionId, orgKey, policy, { persistReversal: true });
|
|
2995
|
+
const sharedHashes = db.getSharedHashes();
|
|
2996
|
+
const { share, pending, withhold } = classify({ preview, policy, sharedHashes });
|
|
2997
|
+
const writeAll = db.rawConnection().transaction(() => {
|
|
2998
|
+
for (const item of share) {
|
|
2999
|
+
db.enqueuePending({
|
|
3000
|
+
contentHash: item.contentHash,
|
|
3001
|
+
sessionId,
|
|
3002
|
+
nodeId: item.nodeId,
|
|
3003
|
+
kind: item.kind,
|
|
3004
|
+
payload: item.payload,
|
|
3005
|
+
status: "approved",
|
|
3006
|
+
decidedBy: "rule"
|
|
3007
|
+
});
|
|
3008
|
+
}
|
|
3009
|
+
for (const item of pending) {
|
|
3010
|
+
db.enqueuePending({
|
|
3011
|
+
contentHash: item.contentHash,
|
|
3012
|
+
sessionId,
|
|
3013
|
+
nodeId: item.nodeId,
|
|
3014
|
+
kind: item.kind,
|
|
3015
|
+
payload: item.payload,
|
|
3016
|
+
status: "pending"
|
|
3017
|
+
});
|
|
3018
|
+
}
|
|
3019
|
+
for (const item of withhold) {
|
|
3020
|
+
if (!item.contentHash) continue;
|
|
3021
|
+
db.enqueuePending({
|
|
3022
|
+
contentHash: item.contentHash,
|
|
3023
|
+
sessionId,
|
|
3024
|
+
nodeId: item.nodeId,
|
|
3025
|
+
kind: item.kind,
|
|
3026
|
+
payload: item.payload,
|
|
3027
|
+
status: "withheld",
|
|
3028
|
+
decidedBy: "rule"
|
|
3029
|
+
});
|
|
3030
|
+
}
|
|
3031
|
+
});
|
|
3032
|
+
writeAll();
|
|
3033
|
+
return { enqueued: pending.length, autoShared: share.length, withheld: withhold.length };
|
|
3034
|
+
}
|
|
3035
|
+
|
|
3036
|
+
// src/installer/format.ts
|
|
3037
|
+
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
|
|
3038
|
+
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
|
3039
|
+
function parseConfig(text, format) {
|
|
3040
|
+
if (!text.trim()) return {};
|
|
3041
|
+
try {
|
|
3042
|
+
switch (format) {
|
|
3043
|
+
case "json":
|
|
3044
|
+
return JSON.parse(text);
|
|
3045
|
+
case "toml":
|
|
3046
|
+
return parseToml(text);
|
|
3047
|
+
case "yaml":
|
|
3048
|
+
return parseYaml(text) ?? {};
|
|
3049
|
+
}
|
|
3050
|
+
} catch (err) {
|
|
3051
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
3052
|
+
throw new Error(`Failed to parse existing ${format.toUpperCase()} config: ${detail}`);
|
|
3053
|
+
}
|
|
3054
|
+
}
|
|
3055
|
+
function serializeConfig(obj, format) {
|
|
3056
|
+
switch (format) {
|
|
3057
|
+
case "json":
|
|
3058
|
+
return JSON.stringify(obj, null, 2) + "\n";
|
|
3059
|
+
case "toml":
|
|
3060
|
+
return stringifyToml(obj) + "\n";
|
|
3061
|
+
case "yaml":
|
|
3062
|
+
return stringifyYaml(obj);
|
|
3063
|
+
}
|
|
3064
|
+
}
|
|
3065
|
+
|
|
3066
|
+
// src/installer/merge.ts
|
|
3067
|
+
function isPlainObject(v) {
|
|
3068
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
3069
|
+
}
|
|
3070
|
+
function deepMerge(target, source) {
|
|
3071
|
+
const out = { ...target };
|
|
3072
|
+
for (const [key, value] of Object.entries(source)) {
|
|
3073
|
+
const existing = out[key];
|
|
3074
|
+
if (isPlainObject(existing) && isPlainObject(value)) {
|
|
3075
|
+
out[key] = deepMerge(existing, value);
|
|
3076
|
+
} else {
|
|
3077
|
+
out[key] = value;
|
|
3078
|
+
}
|
|
3079
|
+
}
|
|
3080
|
+
return out;
|
|
3081
|
+
}
|
|
3082
|
+
|
|
3083
|
+
// src/installer/shapes.ts
|
|
3084
|
+
function mcpServerObject(entry) {
|
|
3085
|
+
if (entry.url) {
|
|
3086
|
+
return { type: "http", url: entry.url, ...entry.env ? { env: entry.env } : {} };
|
|
3087
|
+
}
|
|
3088
|
+
return {
|
|
3089
|
+
command: entry.command,
|
|
3090
|
+
args: entry.args ?? [],
|
|
3091
|
+
...entry.env ? { env: entry.env } : {}
|
|
3092
|
+
};
|
|
3093
|
+
}
|
|
3094
|
+
|
|
3095
|
+
// src/installer/entry.ts
|
|
3096
|
+
var PACKAGE_NAME = "@datasynx/agentic-ai-cartography";
|
|
3097
|
+
var MCP_BIN = "cartography-mcp";
|
|
3098
|
+
var DEFAULT_SERVER_NAME = "cartography";
|
|
3099
|
+
function defaultServerEntry(opts = {}) {
|
|
3100
|
+
if (opts.transport === "http") {
|
|
3101
|
+
return { url: opts.url ?? "http://127.0.0.1:3737/mcp", ...opts.env ? { env: opts.env } : {} };
|
|
3102
|
+
}
|
|
3103
|
+
const args = ["-y", "--package", PACKAGE_NAME, MCP_BIN, ...opts.packageArgs ?? []];
|
|
3104
|
+
return { command: "npx", args, ...opts.env ? { env: opts.env } : {} };
|
|
3105
|
+
}
|
|
3106
|
+
|
|
3107
|
+
// src/installer/install.ts
|
|
3108
|
+
import { mkdirSync as mkdirSync2, readFileSync as readFileSync4, writeFileSync as writeFileSync2, existsSync as existsSync2 } from "fs";
|
|
3109
|
+
import { dirname } from "path";
|
|
3110
|
+
import { homedir } from "os";
|
|
3111
|
+
function currentOs() {
|
|
3112
|
+
if (process.platform === "win32") return "win";
|
|
3113
|
+
if (process.platform === "darwin") return "mac";
|
|
3114
|
+
return "linux";
|
|
3115
|
+
}
|
|
3116
|
+
function defaultContext(scope) {
|
|
3117
|
+
return { scope, os: currentOs(), home: homedir(), cwd: process.cwd(), env: process.env };
|
|
3118
|
+
}
|
|
3119
|
+
function planInstall(spec, ctx, opts) {
|
|
3120
|
+
const path = spec.path(ctx);
|
|
3121
|
+
if (!path) {
|
|
3122
|
+
throw new Error(`${spec.label} does not support the "${ctx.scope}" scope.`);
|
|
3123
|
+
}
|
|
3124
|
+
const fileExists = existsSync2(path);
|
|
3125
|
+
const before = fileExists ? readFileSync4(path, "utf8") : "";
|
|
3126
|
+
const existing = parseConfig(before, spec.format);
|
|
3127
|
+
const merged = spec.apply(existing, opts.serverName ?? DEFAULT_SERVER_NAME, opts.entry);
|
|
3128
|
+
const after = serializeConfig(merged, spec.format);
|
|
3129
|
+
return {
|
|
3130
|
+
client: spec.id,
|
|
3131
|
+
label: spec.label,
|
|
3132
|
+
path,
|
|
3133
|
+
format: spec.format,
|
|
3134
|
+
before,
|
|
3135
|
+
after,
|
|
3136
|
+
fileExists,
|
|
3137
|
+
changed: after !== before,
|
|
3138
|
+
...spec.note ? { note: spec.note } : {}
|
|
3139
|
+
};
|
|
3140
|
+
}
|
|
3141
|
+
function applyInstall(plan) {
|
|
3142
|
+
mkdirSync2(dirname(plan.path), { recursive: true });
|
|
3143
|
+
writeFileSync2(plan.path, plan.after, "utf8");
|
|
3144
|
+
}
|
|
3145
|
+
function renderDiff(before, after) {
|
|
3146
|
+
if (before === after) return " (no changes)";
|
|
3147
|
+
const b = before.length ? before.split("\n") : [];
|
|
3148
|
+
const a = after.split("\n");
|
|
3149
|
+
const out = [];
|
|
3150
|
+
const max = Math.max(b.length, a.length);
|
|
3151
|
+
for (let i = 0; i < max; i++) {
|
|
3152
|
+
if (b[i] === a[i]) {
|
|
3153
|
+
if (a[i] !== void 0) out.push(` ${a[i]}`);
|
|
3154
|
+
} else {
|
|
3155
|
+
if (b[i] !== void 0) out.push(`- ${b[i]}`);
|
|
3156
|
+
if (a[i] !== void 0) out.push(`+ ${a[i]}`);
|
|
3157
|
+
}
|
|
3158
|
+
}
|
|
3159
|
+
return out.join("\n");
|
|
3160
|
+
}
|
|
3161
|
+
|
|
3162
|
+
// src/installer/registry.ts
|
|
3163
|
+
import { join as join3 } from "path";
|
|
3164
|
+
function jsonKeyedClient(args) {
|
|
3165
|
+
return {
|
|
3166
|
+
id: args.id,
|
|
3167
|
+
label: args.label,
|
|
3168
|
+
format: "json",
|
|
3169
|
+
note: args.note,
|
|
3170
|
+
path: (ctx) => ctx.scope === "project" ? args.projectPath?.(ctx) : args.globalPath(ctx),
|
|
3171
|
+
apply: (existing, name, entry) => deepMerge(existing, { [args.key]: { [name]: mcpServerObject(entry) } })
|
|
3172
|
+
};
|
|
3173
|
+
}
|
|
3174
|
+
var claudeCode = jsonKeyedClient({
|
|
3175
|
+
id: "claude-code",
|
|
3176
|
+
label: "Claude Code",
|
|
3177
|
+
key: "mcpServers",
|
|
3178
|
+
globalPath: (ctx) => join3(ctx.home, ".claude.json"),
|
|
3179
|
+
projectPath: (ctx) => join3(ctx.cwd, ".mcp.json")
|
|
3180
|
+
});
|
|
3181
|
+
var cursor = jsonKeyedClient({
|
|
3182
|
+
id: "cursor",
|
|
3183
|
+
label: "Cursor",
|
|
3184
|
+
key: "mcpServers",
|
|
3185
|
+
globalPath: (ctx) => join3(ctx.home, ".cursor", "mcp.json"),
|
|
3186
|
+
projectPath: (ctx) => join3(ctx.cwd, ".cursor", "mcp.json")
|
|
3187
|
+
});
|
|
3188
|
+
function vscodeServerObject(entry) {
|
|
3189
|
+
if (entry.url) return { type: "http", url: entry.url, ...entry.env ? { env: entry.env } : {} };
|
|
3190
|
+
return { type: "stdio", command: entry.command, args: entry.args ?? [], ...entry.env ? { env: entry.env } : {} };
|
|
3191
|
+
}
|
|
3192
|
+
function vscodeUserDir(ctx) {
|
|
3193
|
+
if (ctx.os === "win") return join3(ctx.env.APPDATA ?? join3(ctx.home, "AppData", "Roaming"), "Code", "User");
|
|
3194
|
+
if (ctx.os === "mac") return join3(ctx.home, "Library", "Application Support", "Code", "User");
|
|
3195
|
+
return join3(ctx.home, ".config", "Code", "User");
|
|
3196
|
+
}
|
|
3197
|
+
var vscode = {
|
|
3198
|
+
id: "vscode",
|
|
3199
|
+
label: "VS Code (Copilot)",
|
|
3200
|
+
format: "json",
|
|
3201
|
+
note: "Uses the `servers` key (not `mcpServers`) \u2014 the most common copy-paste mistake.",
|
|
3202
|
+
path: (ctx) => ctx.scope === "project" ? join3(ctx.cwd, ".vscode", "mcp.json") : join3(vscodeUserDir(ctx), "mcp.json"),
|
|
3203
|
+
apply: (existing, name, entry) => deepMerge(existing, { servers: { [name]: vscodeServerObject(entry) } })
|
|
3204
|
+
};
|
|
3205
|
+
var codex = {
|
|
3206
|
+
id: "codex",
|
|
3207
|
+
label: "Codex CLI",
|
|
3208
|
+
format: "toml",
|
|
3209
|
+
note: 'Project scope only loads in "trusted" projects.',
|
|
3210
|
+
path: (ctx) => ctx.scope === "project" ? join3(ctx.cwd, ".codex", "config.toml") : join3(ctx.home, ".codex", "config.toml"),
|
|
3211
|
+
apply: (existing, name, entry) => deepMerge(existing, { mcp_servers: { [name]: mcpServerObject(entry) } })
|
|
3212
|
+
};
|
|
3213
|
+
var windsurf = jsonKeyedClient({
|
|
3214
|
+
id: "windsurf",
|
|
3215
|
+
label: "Windsurf",
|
|
3216
|
+
key: "mcpServers",
|
|
3217
|
+
globalPath: (ctx) => join3(ctx.home, ".codeium", "windsurf", "mcp_config.json")
|
|
3218
|
+
});
|
|
3219
|
+
function codeGlobalStorage(ctx, extensionId) {
|
|
3220
|
+
return join3(vscodeUserDir(ctx), "globalStorage", extensionId, "settings", "cline_mcp_settings.json");
|
|
3221
|
+
}
|
|
3222
|
+
var cline = {
|
|
3223
|
+
id: "cline",
|
|
3224
|
+
label: "Cline",
|
|
3225
|
+
format: "json",
|
|
3226
|
+
path: (ctx) => ctx.scope === "project" ? void 0 : codeGlobalStorage(ctx, "saoudrizwan.claude-dev"),
|
|
3227
|
+
// Cline augments the standard object with its own auto-approve/disable flags.
|
|
3228
|
+
apply: (existing, name, entry) => deepMerge(existing, { mcpServers: { [name]: { ...mcpServerObject(entry), alwaysAllow: [], disabled: false } } })
|
|
3229
|
+
};
|
|
3230
|
+
var roo = {
|
|
3231
|
+
id: "roo",
|
|
3232
|
+
label: "Roo Code",
|
|
3233
|
+
format: "json",
|
|
3234
|
+
note: "Project .roo/mcp.json takes precedence over the global settings.",
|
|
3235
|
+
path: (ctx) => ctx.scope === "project" ? join3(ctx.cwd, ".roo", "mcp.json") : codeGlobalStorage(ctx, "rooveterinaryinc.roo-cline"),
|
|
3236
|
+
apply: (existing, name, entry) => deepMerge(existing, { mcpServers: { [name]: mcpServerObject(entry) } })
|
|
3237
|
+
};
|
|
3238
|
+
var zed = {
|
|
3239
|
+
id: "zed",
|
|
3240
|
+
label: "Zed",
|
|
3241
|
+
format: "json",
|
|
3242
|
+
note: 'Manual servers need "source": "custom"; remote uses an mcp-remote bridge.',
|
|
3243
|
+
path: (ctx) => {
|
|
3244
|
+
if (ctx.scope === "project") return join3(ctx.cwd, ".zed", "settings.json");
|
|
3245
|
+
if (ctx.os === "win") return join3(ctx.env.APPDATA ?? join3(ctx.home, "AppData", "Roaming"), "Zed", "settings.json");
|
|
3246
|
+
return join3(ctx.home, ".config", "zed", "settings.json");
|
|
3247
|
+
},
|
|
3248
|
+
apply: (existing, name, entry) => {
|
|
3249
|
+
const inner = entry.url ? { source: "custom", url: entry.url } : { source: "custom", command: entry.command, args: entry.args ?? [], ...entry.env ? { env: entry.env } : {} };
|
|
3250
|
+
return deepMerge(existing, { context_servers: { [name]: inner } });
|
|
3251
|
+
}
|
|
3252
|
+
};
|
|
3253
|
+
var junie = {
|
|
3254
|
+
id: "junie",
|
|
3255
|
+
label: "JetBrains / Junie",
|
|
3256
|
+
format: "json",
|
|
3257
|
+
path: (ctx) => ctx.scope === "project" ? join3(ctx.cwd, ".junie", "mcp", "mcp.json") : join3(ctx.home, ".junie", "mcp", "mcp.json"),
|
|
3258
|
+
apply: (existing, name, entry) => deepMerge(existing, { mcpServers: { [name]: mcpServerObject(entry) } })
|
|
3259
|
+
};
|
|
3260
|
+
var gemini = {
|
|
3261
|
+
id: "gemini",
|
|
3262
|
+
label: "Gemini CLI",
|
|
3263
|
+
format: "json",
|
|
3264
|
+
path: (ctx) => ctx.scope === "project" ? join3(ctx.cwd, ".gemini", "settings.json") : join3(ctx.home, ".gemini", "settings.json"),
|
|
3265
|
+
apply: (existing, name, entry) => {
|
|
3266
|
+
const inner = entry.url ? { httpUrl: entry.url, ...entry.env ? { env: entry.env } : {} } : mcpServerObject(entry);
|
|
3267
|
+
return deepMerge(existing, { mcpServers: { [name]: inner } });
|
|
3268
|
+
}
|
|
3269
|
+
};
|
|
3270
|
+
var goose = {
|
|
3271
|
+
id: "goose",
|
|
3272
|
+
label: "Goose",
|
|
3273
|
+
format: "yaml",
|
|
3274
|
+
note: "Verify the extension shape against current Goose docs; built-ins are left untouched.",
|
|
3275
|
+
path: (ctx) => {
|
|
3276
|
+
if (ctx.os === "win") return join3(ctx.env.APPDATA ?? join3(ctx.home, "AppData", "Roaming"), "Block", "goose", "config", "config.yaml");
|
|
3277
|
+
return join3(ctx.home, ".config", "goose", "config.yaml");
|
|
3278
|
+
},
|
|
3279
|
+
apply: (existing, name, entry) => {
|
|
3280
|
+
const inner = entry.url ? { name, type: "streamable_http", enabled: true, uri: entry.url, ...entry.env ? { env: entry.env } : {} } : { name, type: "stdio", enabled: true, command: entry.command, args: entry.args ?? [], ...entry.env ? { env: entry.env } : {} };
|
|
3281
|
+
return deepMerge(existing, { extensions: { [name]: inner } });
|
|
3282
|
+
}
|
|
3283
|
+
};
|
|
3284
|
+
function isObj(v) {
|
|
3285
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
3286
|
+
}
|
|
3287
|
+
var openhands = {
|
|
3288
|
+
id: "openhands",
|
|
3289
|
+
label: "OpenHands",
|
|
3290
|
+
format: "toml",
|
|
3291
|
+
note: "SHTTP is preferred; SSE is legacy. Only api_key is supported (no arbitrary headers).",
|
|
3292
|
+
path: (ctx) => ctx.scope === "project" ? join3(ctx.cwd, "config.toml") : join3(ctx.home, ".openhands", "config.toml"),
|
|
3293
|
+
apply: (existing, name, entry) => {
|
|
3294
|
+
const mcp = isObj(existing.mcp) ? { ...existing.mcp } : {};
|
|
3295
|
+
const key = entry.url ? "shttp_servers" : "stdio_servers";
|
|
3296
|
+
const arr = Array.isArray(mcp[key]) ? [...mcp[key]] : [];
|
|
3297
|
+
const item = entry.url ? { url: entry.url, ...entry.env ? { env: entry.env } : {} } : { name, command: entry.command, args: entry.args ?? [], ...entry.env ? { env: entry.env } : {} };
|
|
3298
|
+
const matches2 = (s) => entry.url ? s.url === entry.url : s.name === name;
|
|
3299
|
+
const idx = arr.findIndex(matches2);
|
|
3300
|
+
if (idx >= 0) arr[idx] = item;
|
|
3301
|
+
else arr.push(item);
|
|
3302
|
+
mcp[key] = arr;
|
|
3303
|
+
return { ...existing, mcp };
|
|
3304
|
+
}
|
|
3305
|
+
};
|
|
3306
|
+
var claudeDesktop = {
|
|
3307
|
+
id: "claude-desktop",
|
|
3308
|
+
label: "Claude Desktop",
|
|
3309
|
+
format: "json",
|
|
3310
|
+
note: "One-click install is also available via the .mcpb bundle (npm run build:mcpb).",
|
|
3311
|
+
path: (ctx) => {
|
|
3312
|
+
if (ctx.scope === "project") return void 0;
|
|
3313
|
+
if (ctx.os === "win") return join3(ctx.env.APPDATA ?? join3(ctx.home, "AppData", "Roaming"), "Claude", "claude_desktop_config.json");
|
|
3314
|
+
if (ctx.os === "mac") return join3(ctx.home, "Library", "Application Support", "Claude", "claude_desktop_config.json");
|
|
3315
|
+
return join3(ctx.home, ".config", "Claude", "claude_desktop_config.json");
|
|
3316
|
+
},
|
|
3317
|
+
apply: (existing, name, entry) => deepMerge(existing, { mcpServers: { [name]: mcpServerObject(entry) } })
|
|
3318
|
+
};
|
|
3319
|
+
var CLIENTS = [
|
|
3320
|
+
claudeCode,
|
|
3321
|
+
cursor,
|
|
3322
|
+
vscode,
|
|
3323
|
+
codex,
|
|
3324
|
+
windsurf,
|
|
3325
|
+
cline,
|
|
3326
|
+
roo,
|
|
3327
|
+
zed,
|
|
3328
|
+
junie,
|
|
3329
|
+
gemini,
|
|
3330
|
+
goose,
|
|
3331
|
+
openhands,
|
|
3332
|
+
claudeDesktop
|
|
3333
|
+
];
|
|
3334
|
+
function getClient(id) {
|
|
3335
|
+
return CLIENTS.find((c) => c.id === id);
|
|
3336
|
+
}
|
|
3337
|
+
function listClients() {
|
|
3338
|
+
return CLIENTS.map(({ id, label, format, note }) => ({ id, label, format, note }));
|
|
3339
|
+
}
|
|
3340
|
+
|
|
3341
|
+
// src/installer/deeplinks.ts
|
|
3342
|
+
function cursorDeeplink(name, entry) {
|
|
3343
|
+
const config = Buffer.from(JSON.stringify(mcpServerObject(entry))).toString("base64");
|
|
3344
|
+
const params = new URLSearchParams({ name, config });
|
|
3345
|
+
return `cursor://anysphere.cursor-deeplink/mcp/install?${params.toString()}`;
|
|
3346
|
+
}
|
|
3347
|
+
function vscodeDeeplink(name, entry, opts = {}) {
|
|
3348
|
+
const scheme = opts.insiders ? "vscode-insiders" : "vscode";
|
|
3349
|
+
const payload = encodeURIComponent(JSON.stringify({ name, ...mcpServerObject(entry) }));
|
|
3350
|
+
return `${scheme}://mcp/install?${payload}`;
|
|
3351
|
+
}
|
|
3352
|
+
function codeAddMcpCommand(name, entry) {
|
|
3353
|
+
return `code --add-mcp '${JSON.stringify({ name, ...mcpServerObject(entry) })}'`;
|
|
3354
|
+
}
|
|
3355
|
+
|
|
3356
|
+
// src/cli.ts
|
|
2340
3357
|
var bold = (s) => `\x1B[1m${s}\x1B[0m`;
|
|
2341
3358
|
var dim = (s) => `\x1B[2m${s}\x1B[0m`;
|
|
2342
3359
|
var cyan = (s) => `\x1B[36m${s}\x1B[0m`;
|
|
@@ -2344,6 +3361,46 @@ var green = (s) => `\x1B[32m${s}\x1B[0m`;
|
|
|
2344
3361
|
var yellow = (s) => `\x1B[33m${s}\x1B[0m`;
|
|
2345
3362
|
var magenta = (s) => `\x1B[35m${s}\x1B[0m`;
|
|
2346
3363
|
var red = (s) => `\x1B[31m${s}\x1B[0m`;
|
|
3364
|
+
function renderDiffText(d) {
|
|
3365
|
+
const out = [];
|
|
3366
|
+
out.push(`${bold("Topology diff")} ${dim(d.base.sessionId.slice(0, 8))} \u2192 ${dim(d.current.sessionId.slice(0, 8))}`);
|
|
3367
|
+
out.push(` base: ${d.base.nodeCount} nodes, ${d.base.edgeCount} edges ${dim(d.base.startedAt)}`);
|
|
3368
|
+
out.push(` current: ${d.current.nodeCount} nodes, ${d.current.edgeCount} edges ${dim(d.current.startedAt)}`);
|
|
3369
|
+
out.push("");
|
|
3370
|
+
out.push(` nodes: ${green("+" + d.summary.nodesAdded)} ${red("-" + d.summary.nodesRemoved)} ${yellow("~" + d.summary.nodesChanged)} edges: ${green("+" + d.summary.edgesAdded)} ${red("-" + d.summary.edgesRemoved)}`);
|
|
3371
|
+
if (d.summary.nodesAdded + d.summary.nodesRemoved + d.summary.nodesChanged + d.summary.edgesAdded + d.summary.edgesRemoved === 0) {
|
|
3372
|
+
out.push("");
|
|
3373
|
+
out.push(` ${green("\u2713")} No drift between the two sessions.`);
|
|
3374
|
+
return out.join("\n");
|
|
3375
|
+
}
|
|
3376
|
+
out.push("");
|
|
3377
|
+
for (const n of d.nodes.added) out.push(` ${green("+")} ${n.id} ${dim("(" + n.type + ")")}`);
|
|
3378
|
+
for (const n of d.nodes.removed) out.push(` ${red("-")} ${n.id} ${dim("(" + n.type + ")")}`);
|
|
3379
|
+
for (const c of d.nodes.changed) out.push(` ${yellow("~")} ${c.id} ${dim("[" + c.changedFields.join(", ") + "]")}`);
|
|
3380
|
+
for (const e of d.edges.added) out.push(` ${green("+")} edge ${e.sourceId} ${dim("\u2500" + e.relationship + "\u2192")} ${e.targetId}`);
|
|
3381
|
+
for (const e of d.edges.removed) out.push(` ${red("-")} edge ${e.sourceId} ${dim("\u2500" + e.relationship + "\u2192")} ${e.targetId}`);
|
|
3382
|
+
return out.join("\n");
|
|
3383
|
+
}
|
|
3384
|
+
function renderDriftSummaryText(r) {
|
|
3385
|
+
const s = r.delta.summary;
|
|
3386
|
+
const base = r.baseSessionId ? r.baseSessionId.slice(0, 8) : "\u2205";
|
|
3387
|
+
return `${green("+" + s.nodesAdded)}/${red("-" + s.nodesRemoved)}/${yellow("~" + s.nodesChanged)} nodes, ${green("+" + s.edgesAdded)}/${red("-" + s.edgesRemoved)} edges ${dim("(session " + r.sessionId.slice(0, 8) + ", base " + base + ")")}`;
|
|
3388
|
+
}
|
|
3389
|
+
function maybeQueueForSync(db, sessionId, config, w) {
|
|
3390
|
+
if (!config.centralDb?.url) return;
|
|
3391
|
+
try {
|
|
3392
|
+
const r = runSyncClassify(db, sessionId, config);
|
|
3393
|
+
if (r.enqueued > 0) {
|
|
3394
|
+
w(` ${cyan("\u21EA")} ${bold(String(r.enqueued))} item(s) queued for review \u2014 run ${bold("'datasynx-cartography sync review'")}
|
|
3395
|
+
`);
|
|
3396
|
+
} else if (r.autoShared > 0) {
|
|
3397
|
+
w(` ${cyan("\u21EA")} ${bold(String(r.autoShared))} item(s) auto-approved by policy \u2014 run ${bold("'datasynx-cartography sync push'")}
|
|
3398
|
+
`);
|
|
3399
|
+
}
|
|
3400
|
+
} catch (err) {
|
|
3401
|
+
logWarn(`central-DB sync classify skipped: ${err instanceof Error ? err.message : String(err)}`);
|
|
3402
|
+
}
|
|
3403
|
+
}
|
|
2347
3404
|
main();
|
|
2348
3405
|
function main() {
|
|
2349
3406
|
let activeDb = null;
|
|
@@ -2356,18 +3413,35 @@ function main() {
|
|
|
2356
3413
|
}
|
|
2357
3414
|
activeDb = null;
|
|
2358
3415
|
}
|
|
2359
|
-
process.
|
|
3416
|
+
process.removeListener("SIGTERM", shutdown);
|
|
3417
|
+
process.removeListener("SIGINT", shutdown);
|
|
3418
|
+
process.kill(process.pid, signal);
|
|
2360
3419
|
};
|
|
2361
|
-
process.on("SIGTERM",
|
|
2362
|
-
process.on("SIGINT",
|
|
3420
|
+
process.on("SIGTERM", shutdown);
|
|
3421
|
+
process.on("SIGINT", shutdown);
|
|
2363
3422
|
cleanupTempFiles();
|
|
2364
3423
|
const program = new Command();
|
|
2365
3424
|
const CMD = "datasynx-cartography";
|
|
2366
|
-
const __dirname = import.meta.dirname ??
|
|
2367
|
-
|
|
3425
|
+
const __dirname = import.meta.dirname ?? dirname2(fileURLToPath(import.meta.url));
|
|
3426
|
+
let VERSION = "0.0.0";
|
|
3427
|
+
try {
|
|
3428
|
+
VERSION = JSON.parse(readFileSync5(resolve2(__dirname, "..", "package.json"), "utf-8")).version ?? VERSION;
|
|
3429
|
+
} catch {
|
|
3430
|
+
logWarn("Could not read package.json version; falling back to 0.0.0");
|
|
3431
|
+
}
|
|
2368
3432
|
program.name(CMD).description("AI-powered Infrastructure Discovery & Agentic AI Cartography").version(VERSION);
|
|
2369
|
-
program.command("discover").description("Scan and map your infrastructure").option("--entry <hosts...>", "Entry points", ["localhost"]).option("--depth <n>", "Max crawl depth", "8").option("--max-turns <n>", "Max agent turns", "50").option("--model <m>", "Agent model", "claude-sonnet-4-5-20250929").option("--org <name>", "Organization name (for Backstage)").option("-o, --output <dir>", "Output directory", "./datasynx-output").option("--db <path>", "DB path").option("-v, --verbose", "Show agent reasoning", false).action(async (opts) => {
|
|
2370
|
-
|
|
3433
|
+
program.command("discover").description("Scan and map your infrastructure").option("--entry <hosts...>", "Entry points", ["localhost"]).option("--depth <n>", "Max crawl depth", "8").option("--max-turns <n>", "Max agent turns", "50").option("--provider <name>", "Agent provider: claude, openai, ollama (or CARTOGRAPHY_PROVIDER)").option("--model <m>", "Agent model", "claude-sonnet-4-5-20250929").option("--org <name>", "Organization name (for Backstage)").option("-o, --output <dir>", "Output directory", "./datasynx-output").option("--db <path>", "DB path").option("--name <name>", "Custom session name (default: auto-derived from the topology)").option("--update [sessionId]", "Re-scan an existing session in place (deterministic local scan; default: latest discover session)").option("--output-format <fmt>", "Progress/result format: text, json, stream-json", "text").option("-v, --verbose", "Show agent reasoning", false).action(async (opts) => {
|
|
3434
|
+
const providerName = opts.provider ?? process.env.CARTOGRAPHY_PROVIDER ?? "claude";
|
|
3435
|
+
if (!defaultProviderRegistry.has(providerName)) {
|
|
3436
|
+
process.stderr.write(
|
|
3437
|
+
`\u274C Unknown provider "${providerName}" (valid: ${defaultProviderRegistry.names().join(", ")})
|
|
3438
|
+
`
|
|
3439
|
+
);
|
|
3440
|
+
process.exitCode = 2;
|
|
3441
|
+
return;
|
|
3442
|
+
}
|
|
3443
|
+
const provider = providerName;
|
|
3444
|
+
checkPrerequisites(provider);
|
|
2371
3445
|
const parsedDepth = parseInt(opts.depth, 10);
|
|
2372
3446
|
const parsedMaxTurns = parseInt(opts.maxTurns, 10);
|
|
2373
3447
|
if (Number.isNaN(parsedDepth) || parsedDepth < 1 || parsedDepth > 50) {
|
|
@@ -2382,11 +3456,20 @@ function main() {
|
|
|
2382
3456
|
process.exitCode = 2;
|
|
2383
3457
|
return;
|
|
2384
3458
|
}
|
|
3459
|
+
const fmt = opts.outputFormat ?? "text";
|
|
3460
|
+
if (!["text", "json", "stream-json"].includes(fmt)) {
|
|
3461
|
+
process.stderr.write(`\u274C Invalid --output-format: "${opts.outputFormat}" (must be text, json, or stream-json)
|
|
3462
|
+
`);
|
|
3463
|
+
process.exitCode = 2;
|
|
3464
|
+
return;
|
|
3465
|
+
}
|
|
3466
|
+
const isText = fmt === "text";
|
|
2385
3467
|
setVerbose(opts.verbose);
|
|
2386
3468
|
const config = defaultConfig({
|
|
2387
3469
|
entryPoints: opts.entry,
|
|
2388
3470
|
maxDepth: parsedDepth,
|
|
2389
3471
|
maxTurns: parsedMaxTurns,
|
|
3472
|
+
provider,
|
|
2390
3473
|
agentModel: opts.model,
|
|
2391
3474
|
organization: opts.org,
|
|
2392
3475
|
outputDir: opts.output,
|
|
@@ -2395,14 +3478,63 @@ function main() {
|
|
|
2395
3478
|
});
|
|
2396
3479
|
logInfo("Discovery started", {
|
|
2397
3480
|
entryPoints: config.entryPoints,
|
|
3481
|
+
provider: config.provider,
|
|
2398
3482
|
model: config.agentModel,
|
|
2399
3483
|
maxTurns: config.maxTurns,
|
|
2400
3484
|
maxDepth: config.maxDepth
|
|
2401
3485
|
});
|
|
2402
3486
|
const db = new CartographyDB(config.dbPath);
|
|
2403
3487
|
activeDb = db;
|
|
2404
|
-
const sessionId = db.createSession("discover", config);
|
|
2405
3488
|
const w = process.stderr.write.bind(process.stderr);
|
|
3489
|
+
if (opts.update) {
|
|
3490
|
+
const tenantId = normalizeTenant(opts.org);
|
|
3491
|
+
const targetId = typeof opts.update === "string" ? opts.update : db.getLatestSession("discover", tenantId)?.id;
|
|
3492
|
+
const targetSession = targetId ? db.getSession(targetId) : void 0;
|
|
3493
|
+
if (!targetId || !targetSession) {
|
|
3494
|
+
process.stderr.write(
|
|
3495
|
+
`\u274C No discover session to update${typeof opts.update === "string" ? ` (id "${opts.update}")` : ""}; run \`discover\` first.
|
|
3496
|
+
`
|
|
3497
|
+
);
|
|
3498
|
+
process.exitCode = 2;
|
|
3499
|
+
db.close();
|
|
3500
|
+
activeDb = null;
|
|
3501
|
+
return;
|
|
3502
|
+
}
|
|
3503
|
+
const baseNodeCount = db.getNodes(targetId).length;
|
|
3504
|
+
const baseEdgeCount = db.getEdges(targetId).length;
|
|
3505
|
+
if (isText) {
|
|
3506
|
+
w("\n");
|
|
3507
|
+
w(` ${bold("CARTOGRAPHY")} ${dim("incremental rescan \xB7 " + targetId.slice(0, 8))}
|
|
3508
|
+
`);
|
|
3509
|
+
w(dim(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n"));
|
|
3510
|
+
}
|
|
3511
|
+
try {
|
|
3512
|
+
const r = await runLocalDiscovery(db, targetId, { mode: "update" });
|
|
3513
|
+
const updated = db.getSession(targetId);
|
|
3514
|
+
const diff = {
|
|
3515
|
+
base: { sessionId: targetId, startedAt: targetSession.startedAt, nodeCount: baseNodeCount, edgeCount: baseEdgeCount },
|
|
3516
|
+
current: { sessionId: targetId, startedAt: updated?.lastScannedAt ?? (/* @__PURE__ */ new Date()).toISOString(), nodeCount: r.nodes, edgeCount: r.edges },
|
|
3517
|
+
nodes: r.delta?.nodes ?? { added: [], removed: [], changed: [], unchanged: 0 },
|
|
3518
|
+
edges: r.delta?.edges ?? { added: [], removed: [], unchanged: 0 },
|
|
3519
|
+
summary: r.delta?.summary ?? { nodesAdded: 0, nodesRemoved: 0, nodesChanged: 0, edgesAdded: 0, edgesRemoved: 0 },
|
|
3520
|
+
anomalies: { base: [], current: [], added: [] }
|
|
3521
|
+
};
|
|
3522
|
+
if (fmt === "text") w(renderDiffText(diff) + "\n\n");
|
|
3523
|
+
else process.stdout.write(JSON.stringify(diff, null, 2) + "\n");
|
|
3524
|
+
logInfo("Incremental rescan complete", { sessionId: targetId, ...diff.summary });
|
|
3525
|
+
} catch (err) {
|
|
3526
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
3527
|
+
logError("Incremental rescan failed", { sessionId: targetId, error: errMsg });
|
|
3528
|
+
w(`
|
|
3529
|
+
${bold(red("\u2717"))} Rescan failed: ${errMsg}
|
|
3530
|
+
`);
|
|
3531
|
+
process.exitCode = 1;
|
|
3532
|
+
}
|
|
3533
|
+
db.close();
|
|
3534
|
+
activeDb = null;
|
|
3535
|
+
return;
|
|
3536
|
+
}
|
|
3537
|
+
const sessionId = db.createSession("discover", config, opts.org);
|
|
2406
3538
|
const SPINNER = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
|
|
2407
3539
|
let spinIdx = 0;
|
|
2408
3540
|
let spinnerTimer = null;
|
|
@@ -2427,13 +3559,15 @@ function main() {
|
|
|
2427
3559
|
let turnNum = 0;
|
|
2428
3560
|
let nodeCount = 0;
|
|
2429
3561
|
let edgeCount = 0;
|
|
2430
|
-
|
|
2431
|
-
|
|
3562
|
+
if (isText) {
|
|
3563
|
+
w("\n");
|
|
3564
|
+
w(` ${bold("CARTOGRAPHY")} ${dim(config.entryPoints.join(", "))}
|
|
2432
3565
|
`);
|
|
2433
|
-
|
|
3566
|
+
w(` ${dim("Model: " + config.agentModel + " | MaxTurns: " + config.maxTurns)}
|
|
2434
3567
|
`);
|
|
2435
|
-
|
|
2436
|
-
|
|
3568
|
+
w(dim(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n"));
|
|
3569
|
+
w("\n");
|
|
3570
|
+
}
|
|
2437
3571
|
const logLine = (icon, msg) => {
|
|
2438
3572
|
stopSpinner();
|
|
2439
3573
|
const elapsed = ((Date.now() - startTime) / 1e3).toFixed(1);
|
|
@@ -2441,6 +3575,10 @@ function main() {
|
|
|
2441
3575
|
`);
|
|
2442
3576
|
};
|
|
2443
3577
|
const handleEvent = (event) => {
|
|
3578
|
+
if (!isText) {
|
|
3579
|
+
if (fmt === "stream-json") process.stdout.write(JSON.stringify(event) + "\n");
|
|
3580
|
+
return;
|
|
3581
|
+
}
|
|
2444
3582
|
switch (event.kind) {
|
|
2445
3583
|
case "turn":
|
|
2446
3584
|
turnNum = event.turn;
|
|
@@ -2457,7 +3595,7 @@ function main() {
|
|
|
2457
3595
|
}
|
|
2458
3596
|
break;
|
|
2459
3597
|
case "tool_call": {
|
|
2460
|
-
const toolName = event.tool.replace("
|
|
3598
|
+
const toolName = event.tool.replace("mcp__cartography__", "");
|
|
2461
3599
|
if (toolName === "Bash") {
|
|
2462
3600
|
const cmd = (event.input["command"] ?? "").substring(0, 70);
|
|
2463
3601
|
startSpinner(`${yellow("$")} ${cmd}`);
|
|
@@ -2505,6 +3643,7 @@ function main() {
|
|
|
2505
3643
|
}
|
|
2506
3644
|
};
|
|
2507
3645
|
const onAskUser = async (question, context) => {
|
|
3646
|
+
if (!isText) return "(Non-interactive mode \u2014 please continue without this information)";
|
|
2508
3647
|
stopSpinner();
|
|
2509
3648
|
w("\n");
|
|
2510
3649
|
w(dim(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n"));
|
|
@@ -2519,7 +3658,7 @@ function main() {
|
|
|
2519
3658
|
return "(Non-interactive mode \u2014 please continue without this information)";
|
|
2520
3659
|
}
|
|
2521
3660
|
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
2522
|
-
const answer = await new Promise((
|
|
3661
|
+
const answer = await new Promise((resolve3) => rl.question(` ${cyan("\u2192")} `, resolve3));
|
|
2523
3662
|
rl.close();
|
|
2524
3663
|
w("\n");
|
|
2525
3664
|
return answer || "(No answer \u2014 please continue)";
|
|
@@ -2541,6 +3680,9 @@ function main() {
|
|
|
2541
3680
|
}
|
|
2542
3681
|
stopSpinner();
|
|
2543
3682
|
db.endSession(sessionId);
|
|
3683
|
+
maybeQueueForSync(db, sessionId, config, w);
|
|
3684
|
+
const sessionName = opts.name?.trim() || deriveSessionName(db.getGraphSummary(sessionId), db.getSession(sessionId)?.startedAt ?? (/* @__PURE__ */ new Date()).toISOString());
|
|
3685
|
+
db.setSessionName(sessionId, sessionName);
|
|
2544
3686
|
const stats = db.getStats(sessionId);
|
|
2545
3687
|
const totalSec = ((Date.now() - startTime) / 1e3).toFixed(1);
|
|
2546
3688
|
logInfo("Discovery completed", {
|
|
@@ -2549,6 +3691,22 @@ function main() {
|
|
|
2549
3691
|
edges: stats.edges,
|
|
2550
3692
|
durationSec: parseFloat(totalSec)
|
|
2551
3693
|
});
|
|
3694
|
+
if (!isText) {
|
|
3695
|
+
const durationMs = Date.now() - startTime;
|
|
3696
|
+
if (fmt === "stream-json") {
|
|
3697
|
+
process.stdout.write(JSON.stringify({ kind: "result", sessionId, nodes: stats.nodes, edges: stats.edges, durationMs }) + "\n");
|
|
3698
|
+
} else {
|
|
3699
|
+
process.stdout.write(JSON.stringify(
|
|
3700
|
+
{ sessionId, stats, nodes: db.getNodes(sessionId), edges: db.getEdges(sessionId), durationMs },
|
|
3701
|
+
null,
|
|
3702
|
+
2
|
|
3703
|
+
) + "\n");
|
|
3704
|
+
}
|
|
3705
|
+
exportAll(db, sessionId, config.outputDir, ["discovery"]);
|
|
3706
|
+
db.close();
|
|
3707
|
+
activeDb = null;
|
|
3708
|
+
return;
|
|
3709
|
+
}
|
|
2552
3710
|
w("\n");
|
|
2553
3711
|
w(dim(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n"));
|
|
2554
3712
|
w(` ${green(bold("DONE"))} ${bold(String(stats.nodes))} nodes, ${bold(String(stats.edges))} edges ${dim("in " + totalSec + "s")}
|
|
@@ -2588,7 +3746,7 @@ function main() {
|
|
|
2588
3746
|
w("\n");
|
|
2589
3747
|
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
2590
3748
|
const answer = await new Promise(
|
|
2591
|
-
(
|
|
3749
|
+
(resolve3) => rl.question(` ${yellow("?")} Remove nodes (numbers, empty = keep all): `, resolve3)
|
|
2592
3750
|
);
|
|
2593
3751
|
rl.close();
|
|
2594
3752
|
const toRemove = answer.trim().split(/[\s,]+/).map(Number).filter((n) => n >= 1 && n <= allNodes.length);
|
|
@@ -2607,9 +3765,9 @@ function main() {
|
|
|
2607
3765
|
}
|
|
2608
3766
|
}
|
|
2609
3767
|
exportAll(db, sessionId, config.outputDir, ["discovery"]);
|
|
2610
|
-
const discoveryPath =
|
|
3768
|
+
const discoveryPath = resolve2(config.outputDir, "discovery.html");
|
|
2611
3769
|
w("\n");
|
|
2612
|
-
if (
|
|
3770
|
+
if (existsSync3(discoveryPath)) {
|
|
2613
3771
|
w(` ${green("\u2713")} ${bold("discovery.html")} ${dim("\u2190 Enterprise Map")}
|
|
2614
3772
|
`);
|
|
2615
3773
|
}
|
|
@@ -2626,7 +3784,7 @@ function main() {
|
|
|
2626
3784
|
while (continueDiscovery) {
|
|
2627
3785
|
const rlFollowup = createInterface({ input: process.stdin, output: process.stderr });
|
|
2628
3786
|
const followupHint = await new Promise(
|
|
2629
|
-
(
|
|
3787
|
+
(resolve3) => rlFollowup.question(` ${yellow("\u2192")} Search for (Enter = finish): `, resolve3)
|
|
2630
3788
|
);
|
|
2631
3789
|
rlFollowup.close();
|
|
2632
3790
|
if (!followupHint.trim()) {
|
|
@@ -2654,7 +3812,7 @@ function main() {
|
|
|
2654
3812
|
`);
|
|
2655
3813
|
w("\n");
|
|
2656
3814
|
exportAll(db, sessionId, config.outputDir, ["discovery"]);
|
|
2657
|
-
if (
|
|
3815
|
+
if (existsSync3(discoveryPath)) {
|
|
2658
3816
|
w(` ${green("\u2713")} ${bold("discovery.html updated")}
|
|
2659
3817
|
`);
|
|
2660
3818
|
}
|
|
@@ -2663,7 +3821,7 @@ function main() {
|
|
|
2663
3821
|
}
|
|
2664
3822
|
db.close();
|
|
2665
3823
|
});
|
|
2666
|
-
program.command("export [session-id]").description("Generate all output files").option("-o, --output <dir>", "Output directory", "./datasynx-output").option("--format <fmt...>", "Formats: mermaid,json,yaml,html,map").action((sessionId, opts) => {
|
|
3824
|
+
program.command("export [session-id]").description("Generate all output files").option("-o, --output <dir>", "Output directory", "./datasynx-output").option("--format <fmt...>", "Formats: mermaid,json,yaml,html,map,cost").action((sessionId, opts) => {
|
|
2667
3825
|
const config = defaultConfig({ outputDir: opts.output });
|
|
2668
3826
|
const db = new CartographyDB(config.dbPath);
|
|
2669
3827
|
const session = sessionId ? db.getSession(sessionId) : db.getLatestSession();
|
|
@@ -2679,6 +3837,216 @@ function main() {
|
|
|
2679
3837
|
`);
|
|
2680
3838
|
db.close();
|
|
2681
3839
|
});
|
|
3840
|
+
program.command("diff [base] [current]").description("Compare two discovery sessions (drift detection). Defaults to the two most recent.").option("--format <fmt>", "Output format: text, json, mermaid", "text").option("-o, --output <file>", "Write to a file instead of stdout").option("--db <path>", "DB path").action((base, current, opts) => {
|
|
3841
|
+
const config = defaultConfig(opts.db ? { dbPath: opts.db } : {});
|
|
3842
|
+
const db = new CartographyDB(config.dbPath);
|
|
3843
|
+
activeDb = db;
|
|
3844
|
+
try {
|
|
3845
|
+
const sessions = db.getSessions();
|
|
3846
|
+
const currentId = current ?? sessions[0]?.id;
|
|
3847
|
+
const baseId = base ?? sessions[1]?.id;
|
|
3848
|
+
if (!baseId || !currentId) {
|
|
3849
|
+
process.stderr.write("\u274C Need at least two discovery sessions to diff\n");
|
|
3850
|
+
process.exitCode = 1;
|
|
3851
|
+
return;
|
|
3852
|
+
}
|
|
3853
|
+
if (baseId === currentId) {
|
|
3854
|
+
process.stderr.write("\u274C Base and current session are the same\n");
|
|
3855
|
+
process.exitCode = 1;
|
|
3856
|
+
return;
|
|
3857
|
+
}
|
|
3858
|
+
const d = db.diffSessions(baseId, currentId);
|
|
3859
|
+
const out = opts.format === "json" ? JSON.stringify(d, null, 2) : opts.format === "mermaid" ? generateDiffMermaid(d) : renderDiffText(d);
|
|
3860
|
+
if (opts.output) {
|
|
3861
|
+
writeFileSync3(opts.output, out + "\n");
|
|
3862
|
+
process.stderr.write(`\u2713 Wrote diff to: ${opts.output}
|
|
3863
|
+
`);
|
|
3864
|
+
} else {
|
|
3865
|
+
process.stdout.write(out + "\n");
|
|
3866
|
+
}
|
|
3867
|
+
} catch (err) {
|
|
3868
|
+
process.stderr.write(`\u274C ${err instanceof Error ? err.message : String(err)}
|
|
3869
|
+
`);
|
|
3870
|
+
process.exitCode = 1;
|
|
3871
|
+
} finally {
|
|
3872
|
+
db.close();
|
|
3873
|
+
activeDb = null;
|
|
3874
|
+
}
|
|
3875
|
+
});
|
|
3876
|
+
program.command("compliance [session-id]").description("Score a session against a compliance ruleset (CIS/SOC2/ISO 27001 starter sets)").option("--ruleset <name>", "Ruleset: baseline, cis, soc2, iso27001", "baseline").option("--format <fmt>", "Output format: text, json, markdown, mermaid", "text").option("-o, --output <file>", "Write to a file instead of stdout").option("--db <path>", "DB path").action((sessionId, opts) => {
|
|
3877
|
+
const config = defaultConfig(opts.db ? { dbPath: opts.db } : {});
|
|
3878
|
+
const db = new CartographyDB(config.dbPath);
|
|
3879
|
+
activeDb = db;
|
|
3880
|
+
try {
|
|
3881
|
+
const ruleset = getRuleset(opts.ruleset);
|
|
3882
|
+
if (!ruleset) {
|
|
3883
|
+
process.stderr.write(`\u274C Unknown ruleset: "${opts.ruleset}" (available: ${listRulesets().map((r) => r.name).join(", ")})
|
|
3884
|
+
`);
|
|
3885
|
+
process.exitCode = 1;
|
|
3886
|
+
return;
|
|
3887
|
+
}
|
|
3888
|
+
const sid = sessionId ?? db.getLatestSession()?.id;
|
|
3889
|
+
if (!sid) {
|
|
3890
|
+
process.stderr.write("\u274C No session to score (run discovery first or pass a session id)\n");
|
|
3891
|
+
process.exitCode = 1;
|
|
3892
|
+
return;
|
|
3893
|
+
}
|
|
3894
|
+
const report = db.scoreSession(sid, ruleset);
|
|
3895
|
+
const out = opts.format === "json" || opts.format === "markdown" || opts.format === "mermaid" ? exportComplianceReport(report, opts.format) : formatComplianceText(report);
|
|
3896
|
+
if (opts.output) {
|
|
3897
|
+
writeFileSync3(opts.output, out + "\n");
|
|
3898
|
+
process.stderr.write(`\u2713 Wrote compliance report to: ${opts.output}
|
|
3899
|
+
`);
|
|
3900
|
+
} else {
|
|
3901
|
+
process.stdout.write(out + "\n");
|
|
3902
|
+
}
|
|
3903
|
+
} catch (err) {
|
|
3904
|
+
process.stderr.write(`\u274C ${err instanceof Error ? err.message : String(err)}
|
|
3905
|
+
`);
|
|
3906
|
+
process.exitCode = 1;
|
|
3907
|
+
} finally {
|
|
3908
|
+
db.close();
|
|
3909
|
+
activeDb = null;
|
|
3910
|
+
}
|
|
3911
|
+
});
|
|
3912
|
+
program.command("drift [base] [current]").description("Classify drift between two sessions and emit to configured sinks (default: stdout). Defaults to the two most recent.").option("--min-severity <s>", "Minimum severity to emit: info|warning|critical", "info").option("--webhook <url>", "Outbound webhook URL (overrides config; token via CARTOGRAPHY_DRIFT_TOKEN)").option("--db <path>", "DB path").action(async (base, current, opts) => {
|
|
3913
|
+
let drift;
|
|
3914
|
+
try {
|
|
3915
|
+
drift = DriftConfigSchema.parse({
|
|
3916
|
+
minSeverity: opts.minSeverity,
|
|
3917
|
+
sinks: opts.webhook ? [{ type: "webhook", url: opts.webhook }] : [{ type: "stdout" }]
|
|
3918
|
+
});
|
|
3919
|
+
} catch (err) {
|
|
3920
|
+
process.stderr.write(`\u274C ${err instanceof Error ? err.message : String(err)}
|
|
3921
|
+
`);
|
|
3922
|
+
process.exitCode = 1;
|
|
3923
|
+
return;
|
|
3924
|
+
}
|
|
3925
|
+
const config = defaultConfig({ ...opts.db ? { dbPath: opts.db } : {}, drift });
|
|
3926
|
+
const db = new CartographyDB(config.dbPath);
|
|
3927
|
+
activeDb = db;
|
|
3928
|
+
try {
|
|
3929
|
+
const alert = await runDrift(db, config, { base, current, minSeverity: drift.minSeverity });
|
|
3930
|
+
if (!alert) {
|
|
3931
|
+
process.stderr.write("\u2139 Need at least two discovery sessions for drift; nothing to do.\n");
|
|
3932
|
+
return;
|
|
3933
|
+
}
|
|
3934
|
+
process.stderr.write(`\u2713 drift severity=${alert.severity} items=${alert.items.length}
|
|
3935
|
+
`);
|
|
3936
|
+
} catch (err) {
|
|
3937
|
+
process.stderr.write(`\u274C ${err instanceof Error ? err.message : String(err)}
|
|
3938
|
+
`);
|
|
3939
|
+
process.exitCode = 1;
|
|
3940
|
+
} finally {
|
|
3941
|
+
db.close();
|
|
3942
|
+
activeDb = null;
|
|
3943
|
+
}
|
|
3944
|
+
});
|
|
3945
|
+
program.command("schedule").description("Run discovery recurringly and record per-run topology drift").requiredOption("--config <file>", "Path to a JSON config file with a schedule block").option("--once", "Run a single pass and exit (cron-driver friendly; default)", false).option("--watch", "Run continuously on the configured cron schedule", false).option("--output-format <fmt>", "Result format: text, json, stream-json (overrides config)").option("--db <path>", "DB path (overrides config)").action(async (opts) => {
|
|
3946
|
+
let cfg;
|
|
3947
|
+
try {
|
|
3948
|
+
cfg = loadConfig(opts.config);
|
|
3949
|
+
} catch (err) {
|
|
3950
|
+
process.stderr.write(`\u274C ${err instanceof ConfigError ? err.message : String(err)}
|
|
3951
|
+
`);
|
|
3952
|
+
process.exitCode = 2;
|
|
3953
|
+
return;
|
|
3954
|
+
}
|
|
3955
|
+
if (opts.db) cfg = defaultConfig({ ...cfg, dbPath: opts.db });
|
|
3956
|
+
const fmt = opts.outputFormat ?? cfg.schedule?.outputFormat ?? "json";
|
|
3957
|
+
if (!["text", "json", "stream-json"].includes(fmt)) {
|
|
3958
|
+
process.stderr.write(`\u274C Invalid --output-format: "${fmt}" (must be text, json, or stream-json)
|
|
3959
|
+
`);
|
|
3960
|
+
process.exitCode = 2;
|
|
3961
|
+
return;
|
|
3962
|
+
}
|
|
3963
|
+
if (opts.once && opts.watch) {
|
|
3964
|
+
process.stderr.write("\u274C --once and --watch are mutually exclusive\n");
|
|
3965
|
+
process.exitCode = 2;
|
|
3966
|
+
return;
|
|
3967
|
+
}
|
|
3968
|
+
const cron = cfg.schedule?.cron;
|
|
3969
|
+
if (opts.watch && !cron) {
|
|
3970
|
+
process.stderr.write("\u274C --watch requires a `schedule.cron` in the config file\n");
|
|
3971
|
+
process.exitCode = 2;
|
|
3972
|
+
return;
|
|
3973
|
+
}
|
|
3974
|
+
if (cron) {
|
|
3975
|
+
try {
|
|
3976
|
+
nextRun(cron, /* @__PURE__ */ new Date());
|
|
3977
|
+
} catch (err) {
|
|
3978
|
+
process.stderr.write(`\u274C Invalid cron "${cron}": ${err instanceof Error ? err.message : String(err)}
|
|
3979
|
+
`);
|
|
3980
|
+
process.exitCode = 2;
|
|
3981
|
+
return;
|
|
3982
|
+
}
|
|
3983
|
+
}
|
|
3984
|
+
const db = new CartographyDB(cfg.dbPath);
|
|
3985
|
+
activeDb = db;
|
|
3986
|
+
const emit = (r) => {
|
|
3987
|
+
if (fmt === "text") {
|
|
3988
|
+
process.stdout.write(renderDriftSummaryText(r) + "\n");
|
|
3989
|
+
} else {
|
|
3990
|
+
const payload = { sessionId: r.sessionId, baseSessionId: r.baseSessionId ?? null, summary: r.delta.summary };
|
|
3991
|
+
process.stdout.write(JSON.stringify(payload) + "\n");
|
|
3992
|
+
}
|
|
3993
|
+
};
|
|
3994
|
+
const doRun = async () => {
|
|
3995
|
+
const r = await runOnce(cfg, db);
|
|
3996
|
+
db.recordDriftRun(r.sessionId, r.baseSessionId, r.delta);
|
|
3997
|
+
maybeQueueForSync(db, r.sessionId, cfg, (s) => process.stderr.write(s));
|
|
3998
|
+
emit(r);
|
|
3999
|
+
};
|
|
4000
|
+
if (opts.watch) {
|
|
4001
|
+
let stopped = false;
|
|
4002
|
+
let timer = null;
|
|
4003
|
+
const MAX_DELAY_MS = 24 * 60 * 60 * 1e3;
|
|
4004
|
+
let nextAnnounced = null;
|
|
4005
|
+
const schedule = () => {
|
|
4006
|
+
if (stopped) return;
|
|
4007
|
+
const next = nextRun(cron, /* @__PURE__ */ new Date());
|
|
4008
|
+
const targetMs = next.getTime();
|
|
4009
|
+
if (next.toISOString() !== nextAnnounced) {
|
|
4010
|
+
logInfo(`next scheduled run at ${next.toISOString()}`);
|
|
4011
|
+
nextAnnounced = next.toISOString();
|
|
4012
|
+
}
|
|
4013
|
+
const remaining = targetMs - Date.now();
|
|
4014
|
+
if (remaining > MAX_DELAY_MS) {
|
|
4015
|
+
timer = setTimeout(schedule, MAX_DELAY_MS);
|
|
4016
|
+
return;
|
|
4017
|
+
}
|
|
4018
|
+
timer = setTimeout(() => {
|
|
4019
|
+
void (async () => {
|
|
4020
|
+
try {
|
|
4021
|
+
await doRun();
|
|
4022
|
+
} catch (err) {
|
|
4023
|
+
logError(`scheduled run failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
4024
|
+
}
|
|
4025
|
+
nextAnnounced = null;
|
|
4026
|
+
schedule();
|
|
4027
|
+
})();
|
|
4028
|
+
}, Math.max(0, remaining));
|
|
4029
|
+
};
|
|
4030
|
+
const stop = () => {
|
|
4031
|
+
stopped = true;
|
|
4032
|
+
if (timer) clearTimeout(timer);
|
|
4033
|
+
};
|
|
4034
|
+
process.once("SIGINT", stop);
|
|
4035
|
+
process.once("SIGTERM", stop);
|
|
4036
|
+
schedule();
|
|
4037
|
+
return;
|
|
4038
|
+
}
|
|
4039
|
+
try {
|
|
4040
|
+
await doRun();
|
|
4041
|
+
} catch (err) {
|
|
4042
|
+
process.stderr.write(`\u274C ${err instanceof Error ? err.message : String(err)}
|
|
4043
|
+
`);
|
|
4044
|
+
process.exitCode = 1;
|
|
4045
|
+
} finally {
|
|
4046
|
+
db.close();
|
|
4047
|
+
activeDb = null;
|
|
4048
|
+
}
|
|
4049
|
+
});
|
|
2682
4050
|
program.command("show [session-id]").description("Show session details").action((sessionId) => {
|
|
2683
4051
|
const config = defaultConfig();
|
|
2684
4052
|
const db = new CartographyDB(config.dbPath);
|
|
@@ -2693,6 +4061,8 @@ function main() {
|
|
|
2693
4061
|
const nodes = db.getNodes(session.id);
|
|
2694
4062
|
process.stdout.write(`
|
|
2695
4063
|
Session: ${session.id}
|
|
4064
|
+
`);
|
|
4065
|
+
if (session.name) process.stdout.write(` Name: ${session.name}
|
|
2696
4066
|
`);
|
|
2697
4067
|
process.stdout.write(` Mode: ${session.mode}
|
|
2698
4068
|
`);
|
|
@@ -2708,6 +4078,15 @@ Session: ${session.id}
|
|
|
2708
4078
|
`);
|
|
2709
4079
|
process.stdout.write(` Tasks: ${stats.tasks}
|
|
2710
4080
|
`);
|
|
4081
|
+
const events = db.getEvents(session.id);
|
|
4082
|
+
if (events.length > 0) {
|
|
4083
|
+
process.stdout.write("\n Recent activity:\n");
|
|
4084
|
+
for (const e of events.slice(-15)) {
|
|
4085
|
+
const kb = e.resultBytes != null ? ` (${(e.resultBytes / 1024).toFixed(1)} KB)` : "";
|
|
4086
|
+
process.stdout.write(` ${e.timestamp} ${e.process} ${(e.command ?? "").slice(0, 60)}${kb}
|
|
4087
|
+
`);
|
|
4088
|
+
}
|
|
4089
|
+
}
|
|
2711
4090
|
if (nodes.length > 0) {
|
|
2712
4091
|
process.stdout.write("\n Discovered nodes:\n");
|
|
2713
4092
|
for (const node of nodes.slice(0, 20)) {
|
|
@@ -2735,7 +4114,7 @@ Session: ${session.id}
|
|
|
2735
4114
|
const stats = db.getStats(session.id);
|
|
2736
4115
|
const status = session.completedAt ? "\u2713" : "\u25CF";
|
|
2737
4116
|
process.stdout.write(
|
|
2738
|
-
`${status} ${session.id.substring(0, 8)} [${session.mode}] ${session.startedAt.substring(0, 19)} nodes:${stats.nodes} edges:${stats.edges}
|
|
4117
|
+
`${status} ${session.id.substring(0, 8)} [${session.mode}] ${session.startedAt.substring(0, 19)} nodes:${stats.nodes} edges:${stats.edges}${session.name ? ` ${session.name}` : ""}
|
|
2739
4118
|
`
|
|
2740
4119
|
);
|
|
2741
4120
|
}
|
|
@@ -2774,7 +4153,7 @@ Session: ${session.id}
|
|
|
2774
4153
|
const status = session.completedAt ? green("\u2713") : yellow("\u25CF");
|
|
2775
4154
|
const age = session.startedAt.substring(0, 16).replace("T", " ");
|
|
2776
4155
|
const sid = cyan(session.id.substring(0, 8));
|
|
2777
|
-
w(` ${status} ${sid} ${b("[" + session.mode + "]")} ${d(age)}
|
|
4156
|
+
w(` ${status} ${sid} ${b("[" + session.mode + "]")} ${d(age)}${session.name ? ` ${d(session.name)}` : ""}
|
|
2778
4157
|
`);
|
|
2779
4158
|
w(` ${d("Nodes: " + stats.nodes + " Edges: " + stats.edges)}
|
|
2780
4159
|
`);
|
|
@@ -2792,8 +4171,9 @@ Session: ${session.id}
|
|
|
2792
4171
|
}
|
|
2793
4172
|
db.close();
|
|
2794
4173
|
});
|
|
2795
|
-
program.command("chat [session-id]").description("Interactive chat about your mapped infrastructure").option("--db <path>", "DB path").option("--model <m>", "Model
|
|
4174
|
+
program.command("chat [session-id]").description("Interactive chat about your mapped infrastructure").option("--db <path>", "DB path").option("--model <m>", "Model (defaults to the fast helper model)").action(async (sessionIdArg, opts) => {
|
|
2796
4175
|
const config = defaultConfig();
|
|
4176
|
+
const model = opts.model ?? config.models.fast;
|
|
2797
4177
|
const db = new CartographyDB(opts.db ?? config.dbPath);
|
|
2798
4178
|
const sessions = db.getSessions();
|
|
2799
4179
|
const session = sessionIdArg ? sessions.find((s) => s.id.startsWith(sessionIdArg)) : sessions.filter((s) => s.completedAt).at(-1) ?? sessions.at(-1);
|
|
@@ -2815,7 +4195,7 @@ Session: ${session.id}
|
|
|
2815
4195
|
w(dim(` \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
2816
4196
|
`));
|
|
2817
4197
|
w(` ${dim("Ask anything about your infrastructure. exit = quit.\n\n")}`);
|
|
2818
|
-
const Anthropic = (await import("
|
|
4198
|
+
const Anthropic = (await import("@anthropic-ai/sdk")).default;
|
|
2819
4199
|
const client = new Anthropic();
|
|
2820
4200
|
const infraSummary = JSON.stringify({
|
|
2821
4201
|
nodes: nodes.map((n) => ({
|
|
@@ -2837,7 +4217,7 @@ INFRASTRUCTURE SNAPSHOT (${nodes.length} nodes, ${edges.length} edges):
|
|
|
2837
4217
|
${infraSummary.substring(0, 12e3)}`;
|
|
2838
4218
|
const history = [];
|
|
2839
4219
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
2840
|
-
const ask = () => new Promise((
|
|
4220
|
+
const ask = () => new Promise((resolve3) => rl.question(` ${cyan(">")} `, resolve3));
|
|
2841
4221
|
while (true) {
|
|
2842
4222
|
let userInput;
|
|
2843
4223
|
try {
|
|
@@ -2850,7 +4230,7 @@ ${infraSummary.substring(0, 12e3)}`;
|
|
|
2850
4230
|
history.push({ role: "user", content: userInput });
|
|
2851
4231
|
try {
|
|
2852
4232
|
const resp = await client.messages.create({
|
|
2853
|
-
model
|
|
4233
|
+
model,
|
|
2854
4234
|
max_tokens: 1024,
|
|
2855
4235
|
system: systemPrompt,
|
|
2856
4236
|
messages: history
|
|
@@ -2919,9 +4299,9 @@ ${infraSummary.substring(0, 12e3)}`;
|
|
|
2919
4299
|
out("\n");
|
|
2920
4300
|
out(` ${green("datasynx-cartography discover")}
|
|
2921
4301
|
`);
|
|
2922
|
-
out(` Scans your local infrastructure (
|
|
4302
|
+
out(` Scans your local infrastructure (provider-agnostic: claude, openai, ollama).
|
|
2923
4303
|
`);
|
|
2924
|
-
out(`
|
|
4304
|
+
out(` The agent autonomously runs ${IS_WIN ? "Get-NetTCPConnection, Get-Process" : IS_MAC ? "lsof, ps" : "ss, ps"}, curl, docker inspect, kubectl get
|
|
2925
4305
|
`);
|
|
2926
4306
|
out(` and stores everything in SQLite.
|
|
2927
4307
|
`);
|
|
@@ -2944,7 +4324,7 @@ ${infraSummary.substring(0, 12e3)}`;
|
|
|
2944
4324
|
out("\n");
|
|
2945
4325
|
out(` ${green("datasynx-cartography export [session-id]")}
|
|
2946
4326
|
`);
|
|
2947
|
-
out(dim(" --format <fmt...> mermaid, json, yaml, html, map (default: all)\n"));
|
|
4327
|
+
out(dim(" --format <fmt...> mermaid, json, yaml, html, map, cost (default: all but cost)\n"));
|
|
2948
4328
|
out(dim(" -o, --output <dir> Output directory\n"));
|
|
2949
4329
|
out("\n");
|
|
2950
4330
|
out(` ${green("datasynx-cartography show [session-id]")} ${dim("Session details + node list")}
|
|
@@ -2968,7 +4348,7 @@ ${infraSummary.substring(0, 12e3)}`;
|
|
|
2968
4348
|
out(dim(" \u2514\u2500\u2500 Platform Detection (platform.ts)\n"));
|
|
2969
4349
|
out(dim(" \u2514\u2500\u2500 Shell: /bin/sh (Unix) | PowerShell (Windows)\n"));
|
|
2970
4350
|
out(dim(" \u2514\u2500\u2500 Agent Orchestrator (agent.ts)\n"));
|
|
2971
|
-
out(dim(" \u2514\u2500\u2500 runDiscovery() \u2192
|
|
4351
|
+
out(dim(" \u2514\u2500\u2500 runDiscovery() \u2192 AgentProvider (claude|openai|ollama) + Bash + MCP Tools\n"));
|
|
2972
4352
|
out(dim(" \u2514\u2500\u2500 Custom MCP Tools (tools.ts)\n"));
|
|
2973
4353
|
out(dim(" save_node, save_edge,\n"));
|
|
2974
4354
|
out(dim(" scan_bookmarks, scan_browser_history,\n"));
|
|
@@ -3005,10 +4385,10 @@ ${infraSummary.substring(0, 12e3)}`;
|
|
|
3005
4385
|
out("\n");
|
|
3006
4386
|
});
|
|
3007
4387
|
program.command("bookmarks").description("View all browser bookmarks (Chrome, Chromium, Edge, Brave, Vivaldi, Opera, Firefox)").action(async () => {
|
|
3008
|
-
const { scanAllBookmarks
|
|
4388
|
+
const { scanAllBookmarks } = await import("./bookmarks-WXHE7GN7.js");
|
|
3009
4389
|
const out = (s) => process.stdout.write(s);
|
|
3010
4390
|
process.stderr.write(" Scanning bookmarks...\n\n");
|
|
3011
|
-
const hosts = await
|
|
4391
|
+
const hosts = await scanAllBookmarks();
|
|
3012
4392
|
if (hosts.length === 0) {
|
|
3013
4393
|
out(" (No bookmarks found \u2014 Chrome, Edge, Brave, Vivaldi, Opera and Firefox are supported)\n\n");
|
|
3014
4394
|
return;
|
|
@@ -3035,16 +4415,52 @@ ${infraSummary.substring(0, 12e3)}`;
|
|
|
3035
4415
|
`));
|
|
3036
4416
|
out(dim(" Tip: ") + "datasynx-cartography discover" + dim(" \u2014 scans + classifies all bookmarks automatically\n\n"));
|
|
3037
4417
|
});
|
|
3038
|
-
program.command("
|
|
3039
|
-
const config = defaultConfig(
|
|
4418
|
+
program.command("cost").description("Import cost/owner attribution from a CSV and enrich a session (FinOps)").requiredOption("--file <path>", "CSV: nodeId,owner,amount,currency,period[,source]").option("--session <id>", "Session to enrich (default: latest)").option("--match <strategy>", "Row\u2192node match: nodeId | name | tag", "nodeId").option("--db <path>", "DB path").action(async (opts) => {
|
|
4419
|
+
const config = defaultConfig(opts.db ? { dbPath: opts.db } : {});
|
|
4420
|
+
const db = new CartographyDB(config.dbPath);
|
|
4421
|
+
activeDb = db;
|
|
4422
|
+
try {
|
|
4423
|
+
const sessionId = opts.session ?? db.getLatestSession("discover")?.id;
|
|
4424
|
+
if (!sessionId) {
|
|
4425
|
+
process.stderr.write("\u274C No session to enrich (run discovery first or pass --session)\n");
|
|
4426
|
+
process.exitCode = 1;
|
|
4427
|
+
return;
|
|
4428
|
+
}
|
|
4429
|
+
const match = opts.match;
|
|
4430
|
+
if (!["nodeId", "name", "tag"].includes(match)) {
|
|
4431
|
+
process.stderr.write(`\u274C Invalid --match: "${match}" (nodeId | name | tag)
|
|
4432
|
+
`);
|
|
4433
|
+
process.exitCode = 1;
|
|
4434
|
+
return;
|
|
4435
|
+
}
|
|
4436
|
+
const source = new CsvCostSource({ filePath: opts.file, match, db, sessionId });
|
|
4437
|
+
const r = await enrichCosts(db, sessionId, source);
|
|
4438
|
+
process.stderr.write(`\u2713 cost: ${r.matched} matched, ${r.unmatched} unmatched (of ${r.total}) from ${r.source}
|
|
4439
|
+
`);
|
|
4440
|
+
if (r.unmatchedIds.length > 0) {
|
|
4441
|
+
process.stderr.write(` unmatched ids: ${r.unmatchedIds.slice(0, 20).join(", ")}${r.unmatchedIds.length > 20 ? " \u2026" : ""}
|
|
4442
|
+
`);
|
|
4443
|
+
}
|
|
4444
|
+
if (r.matched === 0 && r.total > 0) process.exitCode = 1;
|
|
4445
|
+
} catch (err) {
|
|
4446
|
+
process.stderr.write(`\u274C ${err instanceof Error ? err.message : String(err)}
|
|
4447
|
+
`);
|
|
4448
|
+
process.exitCode = 1;
|
|
4449
|
+
} finally {
|
|
4450
|
+
db.close();
|
|
4451
|
+
activeDb = null;
|
|
4452
|
+
}
|
|
4453
|
+
});
|
|
4454
|
+
program.command("seed").description("Manually add known infrastructure (tools, DBs, APIs, etc.)").option("--file <path>", "JSON file with node definitions").option("--session <id>", "Add to existing session (default: new session)").option("--org <name>", "Tenant/organization to scope the session to (default: local)").option("--db <path>", "DB path").action(async (opts) => {
|
|
4455
|
+
const config = defaultConfig({ ...opts.db ? { dbPath: opts.db } : {}, ...opts.org ? { organization: opts.org } : {} });
|
|
3040
4456
|
const db = new CartographyDB(config.dbPath);
|
|
3041
|
-
const sessionId = opts.session ?? db.createSession("discover", config);
|
|
4457
|
+
const sessionId = opts.session ?? db.createSession("discover", config, opts.org);
|
|
3042
4458
|
const out = (s) => process.stdout.write(s);
|
|
3043
4459
|
const w = (s) => process.stderr.write(s);
|
|
3044
4460
|
if (opts.file) {
|
|
3045
4461
|
let raw;
|
|
3046
4462
|
try {
|
|
3047
|
-
raw = JSON.parse(
|
|
4463
|
+
raw = JSON.parse(readFileSync5(resolve2(opts.file), "utf8"));
|
|
3048
4464
|
} catch (e) {
|
|
3049
4465
|
w(red(`
|
|
3050
4466
|
\u2717 Could not read file: ${e}
|
|
@@ -3062,7 +4478,7 @@ ${infraSummary.substring(0, 12e3)}`;
|
|
|
3062
4478
|
for (const entry of raw) {
|
|
3063
4479
|
const type = entry["type"];
|
|
3064
4480
|
const name = entry["name"];
|
|
3065
|
-
const
|
|
4481
|
+
const host2 = entry["host"];
|
|
3066
4482
|
const port = entry["port"];
|
|
3067
4483
|
const tags = entry["tags"] ?? [];
|
|
3068
4484
|
const metadata = entry["metadata"] ?? {};
|
|
@@ -3071,14 +4487,14 @@ ${infraSummary.substring(0, 12e3)}`;
|
|
|
3071
4487
|
`));
|
|
3072
4488
|
continue;
|
|
3073
4489
|
}
|
|
3074
|
-
const id =
|
|
4490
|
+
const id = host2 ? `${type}:${host2}${port ? ":" + port : ""}` : `${type}:${name.toLowerCase().replace(/\s+/g, "-")}`;
|
|
3075
4491
|
db.upsertNode(sessionId, {
|
|
3076
4492
|
id,
|
|
3077
4493
|
type,
|
|
3078
4494
|
name,
|
|
3079
4495
|
discoveredVia: "manual",
|
|
3080
4496
|
confidence: 1,
|
|
3081
|
-
metadata: { ...metadata, ...
|
|
4497
|
+
metadata: { ...metadata, ...host2 ? { host: host2 } : {}, ...port ? { port } : {} },
|
|
3082
4498
|
tags
|
|
3083
4499
|
});
|
|
3084
4500
|
out(` ${green("+")} ${cyan(id)} ${dim("(" + type + ")")}
|
|
@@ -3092,7 +4508,7 @@ ${infraSummary.substring(0, 12e3)}`;
|
|
|
3092
4508
|
`);
|
|
3093
4509
|
return;
|
|
3094
4510
|
}
|
|
3095
|
-
const { NODE_TYPES
|
|
4511
|
+
const { NODE_TYPES } = await import("./types-TJWXAQ2L.js");
|
|
3096
4512
|
if (!process.stdin.isTTY) {
|
|
3097
4513
|
w(red("\n \u2717 Interactive mode requires a terminal (use --file for non-interactive)\n\n"));
|
|
3098
4514
|
process.exitCode = 1;
|
|
@@ -3107,7 +4523,7 @@ ${infraSummary.substring(0, 12e3)}`;
|
|
|
3107
4523
|
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
3108
4524
|
const ask = (q) => new Promise((res) => rl.question(q, res));
|
|
3109
4525
|
let saved = 0;
|
|
3110
|
-
const typeList =
|
|
4526
|
+
const typeList = NODE_TYPES.map((t, i) => `${dim((i + 1).toString().padStart(2))} ${t}`).join("\n ");
|
|
3111
4527
|
while (true) {
|
|
3112
4528
|
w("\n");
|
|
3113
4529
|
w(dim(" Node types:\n"));
|
|
@@ -3118,9 +4534,9 @@ ${infraSummary.substring(0, 12e3)}`;
|
|
|
3118
4534
|
if (!typeInput) break;
|
|
3119
4535
|
let nodeType;
|
|
3120
4536
|
const asNum = parseInt(typeInput, 10);
|
|
3121
|
-
if (!isNaN(asNum) && asNum >= 1 && asNum <=
|
|
3122
|
-
nodeType =
|
|
3123
|
-
} else if (
|
|
4537
|
+
if (!isNaN(asNum) && asNum >= 1 && asNum <= NODE_TYPES.length) {
|
|
4538
|
+
nodeType = NODE_TYPES[asNum - 1];
|
|
4539
|
+
} else if (NODE_TYPES.includes(typeInput)) {
|
|
3124
4540
|
nodeType = typeInput;
|
|
3125
4541
|
} else {
|
|
3126
4542
|
w(yellow(` \u26A0 Unknown type: "${typeInput}"
|
|
@@ -3135,17 +4551,17 @@ ${infraSummary.substring(0, 12e3)}`;
|
|
|
3135
4551
|
const hostRaw = (await ask(` ${cyan("Host / IP")} ${dim("[optional, Enter=skip]")}: `)).trim();
|
|
3136
4552
|
const portRaw = (await ask(` ${cyan("Port")} ${dim("[optional]")}: `)).trim();
|
|
3137
4553
|
const tagsRaw = (await ask(` ${cyan("Tags")} ${dim("[comma-separated, optional]")}: `)).trim();
|
|
3138
|
-
const
|
|
4554
|
+
const host2 = hostRaw || void 0;
|
|
3139
4555
|
const port = portRaw ? parseInt(portRaw, 10) : void 0;
|
|
3140
4556
|
const tags = tagsRaw ? tagsRaw.split(",").map((t) => t.trim()).filter(Boolean) : [];
|
|
3141
|
-
const id =
|
|
4557
|
+
const id = host2 ? `${nodeType}:${host2}${port ? ":" + port : ""}` : `${nodeType}:${name.toLowerCase().replace(/\s+/g, "-")}`;
|
|
3142
4558
|
db.upsertNode(sessionId, {
|
|
3143
4559
|
id,
|
|
3144
4560
|
type: nodeType,
|
|
3145
4561
|
name,
|
|
3146
4562
|
discoveredVia: "manual",
|
|
3147
4563
|
confidence: 1,
|
|
3148
|
-
metadata: { ...
|
|
4564
|
+
metadata: { ...host2 ? { host: host2 } : {}, ...port ? { port } : {} },
|
|
3149
4565
|
tags
|
|
3150
4566
|
});
|
|
3151
4567
|
out(` ${green("+")} ${cyan(id)}
|
|
@@ -3168,8 +4584,8 @@ ${infraSummary.substring(0, 12e3)}`;
|
|
|
3168
4584
|
});
|
|
3169
4585
|
program.command("doctor").description("Check all requirements and cloud CLIs").action(async () => {
|
|
3170
4586
|
const { execSync: execSync2 } = await import("child_process");
|
|
3171
|
-
const { existsSync:
|
|
3172
|
-
const { join:
|
|
4587
|
+
const { existsSync: existsSync4, readFileSync: readFileSync6 } = await import("fs");
|
|
4588
|
+
const { join: join4 } = await import("path");
|
|
3173
4589
|
const out = (s) => process.stdout.write(s);
|
|
3174
4590
|
const ok = (msg) => out(` \x1B[32m\u2713\x1B[0m ${msg}
|
|
3175
4591
|
`);
|
|
@@ -3183,10 +4599,10 @@ ${infraSummary.substring(0, 12e3)}`;
|
|
|
3183
4599
|
out(dim2(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n"));
|
|
3184
4600
|
const nodeVer = process.versions.node;
|
|
3185
4601
|
const [major] = nodeVer.split(".").map(Number);
|
|
3186
|
-
if ((major ?? 0) >=
|
|
4602
|
+
if ((major ?? 0) >= 20) {
|
|
3187
4603
|
ok(`Node.js ${nodeVer}`);
|
|
3188
4604
|
} else {
|
|
3189
|
-
err(`Node.js ${nodeVer} \u2014 requires >=
|
|
4605
|
+
err(`Node.js ${nodeVer} \u2014 requires >=20`);
|
|
3190
4606
|
allGood = false;
|
|
3191
4607
|
}
|
|
3192
4608
|
try {
|
|
@@ -3200,7 +4616,7 @@ ${infraSummary.substring(0, 12e3)}`;
|
|
|
3200
4616
|
const home = process.env.HOME ?? process.env.USERPROFILE ?? "/tmp";
|
|
3201
4617
|
let hasOAuth = false;
|
|
3202
4618
|
try {
|
|
3203
|
-
const creds = JSON.parse(
|
|
4619
|
+
const creds = JSON.parse(readFileSync6(join4(home, ".claude", ".credentials.json"), "utf8"));
|
|
3204
4620
|
const oauth = creds["claudeAiOauth"];
|
|
3205
4621
|
hasOAuth = typeof oauth?.["accessToken"] === "string" && oauth["accessToken"].length > 0;
|
|
3206
4622
|
} catch {
|
|
@@ -3250,8 +4666,8 @@ ${infraSummary.substring(0, 12e3)}`;
|
|
|
3250
4666
|
warn(`${name} not found ${dim2("\u2014 discovery without " + name + " will be limited")}`);
|
|
3251
4667
|
}
|
|
3252
4668
|
}
|
|
3253
|
-
const dbDir =
|
|
3254
|
-
if (
|
|
4669
|
+
const dbDir = join4(home, ".cartography");
|
|
4670
|
+
if (existsSync4(dbDir)) {
|
|
3255
4671
|
ok(`~/.cartography ${dim2("(data directory exists)")}`);
|
|
3256
4672
|
} else {
|
|
3257
4673
|
warn("~/.cartography does not exist yet " + dim2("\u2014 will be created on first run"));
|
|
@@ -3298,15 +4714,303 @@ ${infraSummary.substring(0, 12e3)}`;
|
|
|
3298
4714
|
}
|
|
3299
4715
|
db.close();
|
|
3300
4716
|
});
|
|
3301
|
-
program.command("
|
|
3302
|
-
|
|
4717
|
+
program.command("list-clients").description("List the AI hosts the installer can configure").action(() => {
|
|
4718
|
+
o("\n" + bold(" Supported MCP hosts:") + "\n\n");
|
|
4719
|
+
for (const c of listClients()) {
|
|
4720
|
+
o(` ${green(c.id.padEnd(16))} ${bold(c.label.padEnd(20))} ${dim(c.format)}
|
|
4721
|
+
`);
|
|
4722
|
+
if (c.note) o(` ${" ".repeat(16)} ${dim("\u21B3 " + c.note)}
|
|
4723
|
+
`);
|
|
4724
|
+
}
|
|
4725
|
+
o("\n" + dim(` Install: ${CMD} install --client <id> [--project] [--dry-run]`) + "\n\n");
|
|
4726
|
+
});
|
|
4727
|
+
program.command("install").description("Register the Cartography MCP server into an AI host's config (parse-merge, never clobber)").requiredOption("--client <id>", "Target host id (see `list-clients`)").option("--global", "Write the global/user config (default)", false).option("--project", "Write the project-local config instead", false).option("--dry-run", "Show the merge diff without writing", false).option("--deeplink", "Print a one-click install deeplink instead of writing (Cursor / VS Code)", false).option("--name <name>", "Server name to register", DEFAULT_SERVER_NAME).option("--http", "Register the Streamable HTTP endpoint instead of stdio", false).option("--url <url>", "HTTP endpoint (with --http)").option("--db <path>", "Pass --db <path> to the server").option("--session <id>", "Pass --session <id> to the server").action((opts) => {
|
|
4728
|
+
const spec = getClient(opts.client);
|
|
4729
|
+
if (!spec) {
|
|
4730
|
+
logError(`Unknown client "${opts.client}". Run \`${CMD} list-clients\` to see options.`);
|
|
4731
|
+
process.exitCode = 1;
|
|
4732
|
+
return;
|
|
4733
|
+
}
|
|
4734
|
+
const scope = opts.project ? "project" : "global";
|
|
4735
|
+
const packageArgs = [];
|
|
4736
|
+
if (opts.db) packageArgs.push("--db", opts.db);
|
|
4737
|
+
if (opts.session) packageArgs.push("--session", opts.session);
|
|
4738
|
+
const entry = defaultServerEntry({
|
|
3303
4739
|
transport: opts.http ? "http" : "stdio",
|
|
3304
|
-
|
|
3305
|
-
|
|
3306
|
-
dbPath: opts.db,
|
|
3307
|
-
session: opts.session,
|
|
3308
|
-
semantic: opts.semantic
|
|
4740
|
+
...opts.url ? { url: opts.url } : {},
|
|
4741
|
+
...packageArgs.length ? { packageArgs } : {}
|
|
3309
4742
|
});
|
|
4743
|
+
if (opts.deeplink) {
|
|
4744
|
+
if (opts.client === "cursor") {
|
|
4745
|
+
o("\n" + bold(" Cursor one-click:") + "\n " + cyan(cursorDeeplink(opts.name, entry)) + "\n\n");
|
|
4746
|
+
} else if (opts.client === "vscode") {
|
|
4747
|
+
o("\n" + bold(" VS Code one-click:") + "\n " + cyan(vscodeDeeplink(opts.name, entry)) + "\n");
|
|
4748
|
+
o(" " + dim("or: ") + codeAddMcpCommand(opts.name, entry) + "\n\n");
|
|
4749
|
+
} else {
|
|
4750
|
+
logWarn(`No deeplink available for "${opts.client}". Deeplinks exist for: cursor, vscode.`);
|
|
4751
|
+
}
|
|
4752
|
+
return;
|
|
4753
|
+
}
|
|
4754
|
+
try {
|
|
4755
|
+
const plan = planInstall(spec, defaultContext(scope), { serverName: opts.name, entry });
|
|
4756
|
+
o("\n" + bold(` ${plan.label}`) + dim(` (${plan.format}, ${scope})`) + "\n");
|
|
4757
|
+
o(dim(` ${plan.path}`) + "\n");
|
|
4758
|
+
if (plan.note) o(yellow(` \u26A0 ${plan.note}`) + "\n");
|
|
4759
|
+
o("\n" + renderDiff(plan.before, plan.after) + "\n\n");
|
|
4760
|
+
if (!plan.changed) {
|
|
4761
|
+
o(green(" \u2713 Already up to date \u2014 nothing to write.") + "\n\n");
|
|
4762
|
+
return;
|
|
4763
|
+
}
|
|
4764
|
+
if (opts.dryRun) {
|
|
4765
|
+
o(yellow(" Dry run \u2014 no file written.") + "\n\n");
|
|
4766
|
+
return;
|
|
4767
|
+
}
|
|
4768
|
+
applyInstall(plan);
|
|
4769
|
+
o(green(` \u2713 Wrote ${plan.fileExists ? "updated" : "new"} config.`) + " " + dim("Restart the host to pick it up.") + "\n\n");
|
|
4770
|
+
} catch (err) {
|
|
4771
|
+
logError(err instanceof Error ? err.message : String(err));
|
|
4772
|
+
process.exitCode = 1;
|
|
4773
|
+
}
|
|
4774
|
+
});
|
|
4775
|
+
program;
|
|
4776
|
+
const consent = program.command("consent").description("Manage the per-employee data-sharing policy (none|anonymized|full) + admin anonymization");
|
|
4777
|
+
consent.command("default <level>").description("Set the global default sharing level (none|anonymized|full)").option("--db <path>", "DB path").action((level, opts) => {
|
|
4778
|
+
const parsed = SharingLevelSchema.safeParse(level);
|
|
4779
|
+
if (!parsed.success) {
|
|
4780
|
+
logError(`Invalid level "${level}" \u2014 expected one of: none, anonymized, full`);
|
|
4781
|
+
process.exitCode = 1;
|
|
4782
|
+
return;
|
|
4783
|
+
}
|
|
4784
|
+
const config = defaultConfig(opts.db ? { dbPath: opts.db } : {});
|
|
4785
|
+
const db = new CartographyDB(config.dbPath);
|
|
4786
|
+
try {
|
|
4787
|
+
db.setSharingLevel("*", parsed.data);
|
|
4788
|
+
logInfo(`default sharing level set to "${parsed.data}"`);
|
|
4789
|
+
} finally {
|
|
4790
|
+
db.close();
|
|
4791
|
+
}
|
|
4792
|
+
});
|
|
4793
|
+
consent.command("set <pattern> <level>").description("Set a pattern override (glob over the node id; * = within-segment, ** = any)").option("--db <path>", "DB path").action((pattern, level, opts) => {
|
|
4794
|
+
const parsed = SharingLevelSchema.safeParse(level);
|
|
4795
|
+
if (!parsed.success) {
|
|
4796
|
+
logError(`Invalid level "${level}" \u2014 expected one of: none, anonymized, full`);
|
|
4797
|
+
process.exitCode = 1;
|
|
4798
|
+
return;
|
|
4799
|
+
}
|
|
4800
|
+
if (pattern === "*" || pattern === "**") {
|
|
4801
|
+
logError("Use `consent default <level>` to set the global default; `set` is for narrower overrides");
|
|
4802
|
+
process.exitCode = 1;
|
|
4803
|
+
return;
|
|
4804
|
+
}
|
|
4805
|
+
const config = defaultConfig(opts.db ? { dbPath: opts.db } : {});
|
|
4806
|
+
const db = new CartographyDB(config.dbPath);
|
|
4807
|
+
try {
|
|
4808
|
+
db.setSharingLevel(pattern, parsed.data);
|
|
4809
|
+
logInfo(`override "${pattern}" \u2192 "${parsed.data}"`);
|
|
4810
|
+
} finally {
|
|
4811
|
+
db.close();
|
|
4812
|
+
}
|
|
4813
|
+
});
|
|
4814
|
+
consent.command("clear <pattern>").description("Remove a pattern override (the global default cannot be cleared)").option("--db <path>", "DB path").action((pattern, opts) => {
|
|
4815
|
+
const config = defaultConfig(opts.db ? { dbPath: opts.db } : {});
|
|
4816
|
+
const db = new CartographyDB(config.dbPath);
|
|
4817
|
+
try {
|
|
4818
|
+
db.clearSharingOverride(pattern);
|
|
4819
|
+
logInfo(`override "${pattern}" cleared`);
|
|
4820
|
+
} finally {
|
|
4821
|
+
db.close();
|
|
4822
|
+
}
|
|
4823
|
+
});
|
|
4824
|
+
consent.command("list").description("Show the global default + every pattern override").option("--db <path>", "DB path").action((opts) => {
|
|
4825
|
+
const config = defaultConfig(opts.db ? { dbPath: opts.db } : {});
|
|
4826
|
+
const db = new CartographyDB(config.dbPath);
|
|
4827
|
+
try {
|
|
4828
|
+
const policy = db.getSharingPolicy();
|
|
4829
|
+
process.stdout.write(JSON.stringify(policy, null, 2) + "\n");
|
|
4830
|
+
} finally {
|
|
4831
|
+
db.close();
|
|
4832
|
+
}
|
|
4833
|
+
});
|
|
4834
|
+
consent.command("preview [session]").description("Show exactly what would leave the machine for a session (default: latest)").option("--db <path>", "DB path").option("--org <name>", "Organization namespace for the org key").action((session, opts) => {
|
|
4835
|
+
const config = defaultConfig(opts.db ? { dbPath: opts.db } : {});
|
|
4836
|
+
const db = new CartographyDB(config.dbPath);
|
|
4837
|
+
try {
|
|
4838
|
+
const sid = session && session !== "latest" ? session : db.getLatestSession("discover")?.id;
|
|
4839
|
+
if (!sid) {
|
|
4840
|
+
logError("No session found to preview");
|
|
4841
|
+
process.exitCode = 1;
|
|
4842
|
+
return;
|
|
4843
|
+
}
|
|
4844
|
+
const orgKey = loadOrgKey({ organization: opts.org });
|
|
4845
|
+
const policy = db.getSharingPolicy();
|
|
4846
|
+
const preview = previewShare(db, sid, orgKey, policy);
|
|
4847
|
+
process.stdout.write(JSON.stringify(preview, null, 2) + "\n");
|
|
4848
|
+
} finally {
|
|
4849
|
+
db.close();
|
|
4850
|
+
}
|
|
4851
|
+
});
|
|
4852
|
+
const consentKey = consent.command("key").description("Org-key administration");
|
|
4853
|
+
consentKey.command("rotate").description("Rotate the org key (prior reversal entries become unrecoverable)").option("--org <name>", "Organization namespace for the org key").action((opts) => {
|
|
4854
|
+
rotateOrgKey({ organization: opts.org });
|
|
4855
|
+
logInfo("org key rotated");
|
|
4856
|
+
});
|
|
4857
|
+
consent.command("reverse <token>").description("Admin: recover the original plaintext behind a pseudonym token").option("--db <path>", "DB path").option("--org <name>", "Organization namespace for the org key").action((token, opts) => {
|
|
4858
|
+
const config = defaultConfig(opts.db ? { dbPath: opts.db } : {});
|
|
4859
|
+
const db = new CartographyDB(config.dbPath);
|
|
4860
|
+
try {
|
|
4861
|
+
const orgKey = loadOrgKey({ organization: opts.org });
|
|
4862
|
+
const plaintext = reversePseudonym(token, orgKey, db);
|
|
4863
|
+
if (plaintext === void 0) {
|
|
4864
|
+
logError(`Could not reverse "${token}" (unknown token or wrong/rotated org key)`);
|
|
4865
|
+
process.exitCode = 1;
|
|
4866
|
+
return;
|
|
4867
|
+
}
|
|
4868
|
+
process.stdout.write(plaintext + "\n");
|
|
4869
|
+
} finally {
|
|
4870
|
+
db.close();
|
|
4871
|
+
}
|
|
4872
|
+
});
|
|
4873
|
+
const sync = program.command("sync").description("Central-DB outbound sync: review queued items and push approved deltas (opt-in)");
|
|
4874
|
+
sync.command("status").description("Show the pending-review queue (counts by status + pending items)").option("--db <path>", "DB path").action((opts) => {
|
|
4875
|
+
const config = defaultConfig(opts.db ? { dbPath: opts.db } : {});
|
|
4876
|
+
const db = new CartographyDB(config.dbPath);
|
|
4877
|
+
try {
|
|
4878
|
+
if (!config.centralDb?.url) {
|
|
4879
|
+
logWarn("centralDb is not configured \u2014 sync is inert (set centralDb in ~/.cartography/config.json or CARTOGRAPHY_CENTRAL_URL/TOKEN)");
|
|
4880
|
+
}
|
|
4881
|
+
const counts = db.countPendingByStatus();
|
|
4882
|
+
process.stdout.write(JSON.stringify(counts, null, 2) + "\n");
|
|
4883
|
+
const pending = db.getPendingShares({ status: "pending" });
|
|
4884
|
+
for (const p of pending.slice(0, 50)) {
|
|
4885
|
+
process.stdout.write(` ${p.kind === "node" ? "\u25CF" : "\u2192"} ${p.nodeId ?? p.contentHash.slice(0, 12)} ${dim("(" + p.kind + ")")}
|
|
4886
|
+
`);
|
|
4887
|
+
}
|
|
4888
|
+
if (pending.length > 50) process.stdout.write(` ${dim("\u2026 and " + (pending.length - 50) + " more")}
|
|
4889
|
+
`);
|
|
4890
|
+
} finally {
|
|
4891
|
+
db.close();
|
|
4892
|
+
}
|
|
4893
|
+
});
|
|
4894
|
+
sync.command("review").description("Interactively approve/withhold each pending item (decisions are remembered)").option("--db <path>", "DB path").action(async (opts) => {
|
|
4895
|
+
const config = defaultConfig(opts.db ? { dbPath: opts.db } : {});
|
|
4896
|
+
const db = new CartographyDB(config.dbPath);
|
|
4897
|
+
try {
|
|
4898
|
+
const pending = db.getPendingShares({ status: "pending" });
|
|
4899
|
+
if (pending.length === 0) {
|
|
4900
|
+
logInfo("no pending items to review");
|
|
4901
|
+
return;
|
|
4902
|
+
}
|
|
4903
|
+
if (!process.stdin.isTTY) {
|
|
4904
|
+
logWarn(`${pending.length} pending item(s); run \`sync review\` in an interactive terminal to decide them`);
|
|
4905
|
+
return;
|
|
4906
|
+
}
|
|
4907
|
+
const w = process.stderr.write.bind(process.stderr);
|
|
4908
|
+
const patternFor = (p) => p.nodeId;
|
|
4909
|
+
for (const p of pending) {
|
|
4910
|
+
w("\n");
|
|
4911
|
+
w(` ${yellow(bold("?"))} Share ${p.kind} ${bold(p.nodeId ?? p.contentHash.slice(0, 12))}?
|
|
4912
|
+
`);
|
|
4913
|
+
w(` ${dim(JSON.stringify(p.payload))}
|
|
4914
|
+
`);
|
|
4915
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
4916
|
+
const ans = (await new Promise((res) => rl.question(` ${cyan("\u2192")} [s]hare / [w]ithhold / [a]lways / [n]ever / [q]uit: `, res))).trim().toLowerCase();
|
|
4917
|
+
rl.close();
|
|
4918
|
+
const pat = patternFor(p);
|
|
4919
|
+
if (ans === "q") break;
|
|
4920
|
+
if (ans === "s") {
|
|
4921
|
+
db.setPendingStatus(p.contentHash, "approved", "user");
|
|
4922
|
+
} else if (ans === "w") {
|
|
4923
|
+
db.setPendingStatus(p.contentHash, "withheld", "user");
|
|
4924
|
+
} else if (ans === "a") {
|
|
4925
|
+
if (pat) db.setSharingLevel(pat, "full");
|
|
4926
|
+
db.setPendingStatus(p.contentHash, "approved", "user");
|
|
4927
|
+
} else if (ans === "n") {
|
|
4928
|
+
if (pat) db.setSharingLevel(pat, "none");
|
|
4929
|
+
db.setPendingStatus(p.contentHash, "withheld", "user");
|
|
4930
|
+
} else {
|
|
4931
|
+
w(` ${dim("skipped (left pending)")}
|
|
4932
|
+
`);
|
|
4933
|
+
}
|
|
4934
|
+
}
|
|
4935
|
+
const counts = db.countPendingByStatus();
|
|
4936
|
+
logInfo(`review done \u2014 approved ${counts.approved}, withheld ${counts.withheld}, pending ${counts.pending}`);
|
|
4937
|
+
} finally {
|
|
4938
|
+
db.close();
|
|
4939
|
+
}
|
|
4940
|
+
});
|
|
4941
|
+
sync.command("push").description("Push approved deltas to the central ingest endpoint (bearer-auth HTTPS)").option("--db <path>", "DB path").option("--dry-run", "Preview the batches without sending", false).action(async (opts) => {
|
|
4942
|
+
const config = defaultConfig(opts.db ? { dbPath: opts.db } : {});
|
|
4943
|
+
if (!config.centralDb?.url) {
|
|
4944
|
+
logError("centralDb is not configured \u2014 nothing to push (set centralDb.url + token)");
|
|
4945
|
+
process.exitCode = 1;
|
|
4946
|
+
return;
|
|
4947
|
+
}
|
|
4948
|
+
const db = new CartographyDB(config.dbPath);
|
|
4949
|
+
try {
|
|
4950
|
+
const approved = db.getApprovedShares();
|
|
4951
|
+
const items = approved.map((p) => ({ contentHash: p.contentHash, kind: p.kind, payload: p.payload }));
|
|
4952
|
+
const result = await pushDeltas(config, items, { dryRun: opts.dryRun });
|
|
4953
|
+
if (!opts.dryRun) {
|
|
4954
|
+
for (const hash of result.sentHashes) db.setPendingStatus(hash, "shared");
|
|
4955
|
+
}
|
|
4956
|
+
logInfo(`sync push: sent ${result.sent}, batches ${result.batches}, failed ${result.failed}${opts.dryRun ? " (dry-run)" : ""}`);
|
|
4957
|
+
} catch (err) {
|
|
4958
|
+
logError(`sync push failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
4959
|
+
process.exitCode = 1;
|
|
4960
|
+
} finally {
|
|
4961
|
+
db.close();
|
|
4962
|
+
}
|
|
4963
|
+
});
|
|
4964
|
+
program.command("mcp").description("Run the Model Context Protocol server (stdio by default) \u2014 the primary interface for AI agents").option("--http", "Use Streamable HTTP transport instead of stdio", false).option("--port <n>", "HTTP port", "3737").option("--host <h>", "HTTP host", "127.0.0.1").option("--allowed-hosts <list>", "Comma-separated Host allowlist (required for non-loopback --host)").option("--token <secret>", "Bearer token required on HTTP requests (or CARTOGRAPHY_HTTP_TOKEN); mandatory for non-loopback --host").option("--db <path>", "DB path").option("--session <id>", 'Session to serve (id or "latest")', "latest").option("--tenant <id>", "Tenant/organization whose topology to serve (alias: --org; default: local)").option("--org <id>", "Alias for --tenant").option("--no-semantic", "Disable semantic (vector) search").option("--plugins <list>", "Comma-separated scanner plugin package names to load (opt-in; or CARTOGRAPHY_PLUGINS)").option("--server-mode", "Run as a central collector: enable the authenticated POST /ingest write route + org-wide summary (implies --http; opt-in)", false).option("--anon-mode <mode>", "On ingest, reject|strip un-anonymized identifying fragments (server-mode)", "reject").action(async (opts) => {
|
|
4965
|
+
try {
|
|
4966
|
+
const anonMode = opts.anonMode;
|
|
4967
|
+
if (anonMode !== "reject" && anonMode !== "strip") {
|
|
4968
|
+
process.stderr.write(`
|
|
4969
|
+
error: --anon-mode must be 'reject' or 'strip' (got '${anonMode}')
|
|
4970
|
+
`);
|
|
4971
|
+
process.exitCode = 1;
|
|
4972
|
+
return;
|
|
4973
|
+
}
|
|
4974
|
+
await startMcp({
|
|
4975
|
+
transport: opts.http ? "http" : "stdio",
|
|
4976
|
+
port: parseInt(opts.port, 10),
|
|
4977
|
+
host: opts.host,
|
|
4978
|
+
allowedHosts: opts.allowedHosts ? String(opts.allowedHosts).split(",").map((h) => h.trim()).filter(Boolean) : void 0,
|
|
4979
|
+
token: opts.token,
|
|
4980
|
+
dbPath: opts.db,
|
|
4981
|
+
session: opts.session,
|
|
4982
|
+
tenant: opts.tenant ?? opts.org,
|
|
4983
|
+
semantic: opts.semantic,
|
|
4984
|
+
plugins: opts.plugins ? String(opts.plugins).split(",").map((p) => p.trim()).filter(Boolean) : void 0,
|
|
4985
|
+
serverMode: opts.serverMode === true,
|
|
4986
|
+
anonMode
|
|
4987
|
+
});
|
|
4988
|
+
} catch (err) {
|
|
4989
|
+
process.stderr.write(`
|
|
4990
|
+
error: ${err instanceof Error ? err.message : String(err)}
|
|
4991
|
+
`);
|
|
4992
|
+
process.exitCode = 1;
|
|
4993
|
+
}
|
|
4994
|
+
});
|
|
4995
|
+
program.command("api").description("Run the read-only REST/GraphQL API server over the topology store (4.2)").option("--http", "Use HTTP transport (default; kept for symmetry with mcp)", true).option("--port <n>", "HTTP port", "3737").option("--host <h>", "HTTP host", "127.0.0.1").option("--allowed-hosts <list>", "Comma-separated Host allowlist (required for non-loopback --host)").option("--allowed-origins <list>", "Comma-separated CORS Origin allowlist (default: same-origin only)").option("--token <secret>", "Bearer token required on requests (or CARTOGRAPHY_HTTP_TOKEN); mandatory for non-loopback --host").option("--db <path>", "DB path").option("--session <id>", 'Session to serve (id or "latest")', "latest").option("--tenant <id>", "Default tenant whose topology to serve (alias: --org; default: local)").option("--org <id>", "Alias for --tenant").option("--no-graphql", "Disable the /graphql endpoint (REST only)").action(async (opts) => {
|
|
4996
|
+
try {
|
|
4997
|
+
await startApi({
|
|
4998
|
+
port: parseInt(opts.port, 10),
|
|
4999
|
+
host: opts.host,
|
|
5000
|
+
allowedHosts: opts.allowedHosts ? String(opts.allowedHosts).split(",").map((h) => h.trim()).filter(Boolean) : void 0,
|
|
5001
|
+
allowedOrigins: opts.allowedOrigins ? String(opts.allowedOrigins).split(",").map((o2) => o2.trim()).filter(Boolean) : void 0,
|
|
5002
|
+
token: opts.token,
|
|
5003
|
+
dbPath: opts.db,
|
|
5004
|
+
session: opts.session,
|
|
5005
|
+
tenant: opts.tenant ?? opts.org,
|
|
5006
|
+
graphql: opts.graphql
|
|
5007
|
+
});
|
|
5008
|
+
} catch (err) {
|
|
5009
|
+
process.stderr.write(`
|
|
5010
|
+
error: ${err instanceof Error ? err.message : String(err)}
|
|
5011
|
+
`);
|
|
5012
|
+
process.exitCode = 1;
|
|
5013
|
+
}
|
|
3310
5014
|
});
|
|
3311
5015
|
const o = (s) => process.stderr.write(s);
|
|
3312
5016
|
o("\n");
|
|
@@ -3326,7 +5030,7 @@ ${infraSummary.substring(0, 12e3)}`;
|
|
|
3326
5030
|
o("\n");
|
|
3327
5031
|
o(bold(" Commands:\n"));
|
|
3328
5032
|
o("\n");
|
|
3329
|
-
o(` ${green("discover")} ${dim("Scan infrastructure (
|
|
5033
|
+
o(` ${green("discover")} ${dim("Scan infrastructure (provider: claude|openai|ollama)")}
|
|
3330
5034
|
`);
|
|
3331
5035
|
o(` ${green("seed")} ${dim("Manually add known tools/DBs/APIs")}
|
|
3332
5036
|
`);
|