@datasynx/agentic-ai-cartography 2.0.0 → 2.2.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/{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-BNDCY2RI.js +5672 -0
- package/dist/chunk-BNDCY2RI.js.map +1 -0
- package/dist/chunk-WCR47QA2.js +277 -0
- package/dist/chunk-WCR47QA2.js.map +1 -0
- package/dist/cli.js +2346 -667
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +8406 -58089
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2766 -68
- package/dist/index.d.ts +2766 -68
- package/dist/index.js +7977 -2587
- package/dist/index.js.map +1 -1
- package/dist/mcp-bin.js +16 -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 +23 -8
- package/scripts/build-llms.mjs +89 -0
- package/scripts/build-mcpb.mjs +31 -0
- package/scripts/gen-docs.ts +123 -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,50 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
3
|
CartographyDB,
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
buildCartographyToolHandlers,
|
|
5
|
+
createCartographyTools,
|
|
6
|
+
deriveSessionName,
|
|
7
|
+
diffTopology,
|
|
8
|
+
getRuleset,
|
|
9
|
+
isPersonalHost,
|
|
10
|
+
listRulesets,
|
|
11
|
+
loadOrgKey,
|
|
12
|
+
normalizeTenant,
|
|
13
|
+
pseudonymize,
|
|
14
|
+
pseudonymizeString,
|
|
15
|
+
redactValue,
|
|
16
|
+
reversePseudonym,
|
|
17
|
+
rotateOrgKey,
|
|
18
|
+
runDrift,
|
|
19
|
+
runLocalDiscovery,
|
|
20
|
+
stableStringify,
|
|
21
|
+
startMcp,
|
|
22
|
+
stripSensitive
|
|
23
|
+
} from "./chunk-BNDCY2RI.js";
|
|
6
24
|
import {
|
|
25
|
+
ConfigFileSchema,
|
|
26
|
+
CostEntrySchema,
|
|
7
27
|
DOMAIN_COLORS,
|
|
8
28
|
DOMAIN_PALETTE,
|
|
9
|
-
|
|
10
|
-
NODE_TYPES,
|
|
29
|
+
DriftConfigSchema,
|
|
11
30
|
NODE_TYPE_GROUPS,
|
|
31
|
+
SharingLevelSchema,
|
|
32
|
+
centralDbFromEnv,
|
|
12
33
|
defaultConfig
|
|
13
|
-
} from "./chunk-
|
|
34
|
+
} from "./chunk-WCR47QA2.js";
|
|
14
35
|
import {
|
|
15
|
-
HOME,
|
|
16
|
-
IS_LINUX,
|
|
17
36
|
IS_MAC,
|
|
18
37
|
IS_WIN,
|
|
19
38
|
PLATFORM,
|
|
20
39
|
checkReadOnly,
|
|
21
40
|
cleanupTempFiles,
|
|
22
|
-
commandExists,
|
|
23
|
-
dbScanDirs,
|
|
24
|
-
findFiles,
|
|
25
41
|
logDebug,
|
|
26
42
|
logError,
|
|
27
43
|
logInfo,
|
|
28
44
|
logWarn,
|
|
29
45
|
run,
|
|
30
|
-
scanAllBookmarks,
|
|
31
|
-
scanAllHistory,
|
|
32
|
-
scanWindowsDbServices,
|
|
33
|
-
scanWindowsPrograms,
|
|
34
46
|
setVerbose
|
|
35
|
-
} from "./chunk-
|
|
36
|
-
import "./chunk-UGSNG3QJ.js";
|
|
47
|
+
} from "./chunk-2SZ5QHGH.js";
|
|
37
48
|
|
|
38
49
|
// src/cli.ts
|
|
39
50
|
import { Command } from "commander";
|
|
@@ -54,7 +65,30 @@ function isOAuthLoggedIn() {
|
|
|
54
65
|
return false;
|
|
55
66
|
}
|
|
56
67
|
}
|
|
57
|
-
function checkPrerequisites() {
|
|
68
|
+
function checkPrerequisites(provider = "claude") {
|
|
69
|
+
if (provider === "openai") {
|
|
70
|
+
checkOpenAIPrerequisites();
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (provider === "ollama") {
|
|
74
|
+
process.stderr.write(
|
|
75
|
+
`\u2713 Ollama provider selected (host: ${process.env.OLLAMA_HOST ?? "http://127.0.0.1:11434"})
|
|
76
|
+
`
|
|
77
|
+
);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
checkClaudePrerequisites();
|
|
81
|
+
}
|
|
82
|
+
function checkOpenAIPrerequisites() {
|
|
83
|
+
if (!process.env.OPENAI_API_KEY) {
|
|
84
|
+
process.stderr.write(
|
|
85
|
+
"\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"
|
|
86
|
+
);
|
|
87
|
+
process.exitCode = 1;
|
|
88
|
+
throw new Error("OPENAI_API_KEY not set");
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function checkClaudePrerequisites() {
|
|
58
92
|
try {
|
|
59
93
|
execSync("claude --version", { stdio: "pipe" });
|
|
60
94
|
} catch {
|
|
@@ -75,515 +109,24 @@ function checkPrerequisites() {
|
|
|
75
109
|
}
|
|
76
110
|
}
|
|
77
111
|
|
|
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
|
-
}
|
|
112
|
+
// src/providers/types.ts
|
|
113
|
+
var ProviderRegistry = class {
|
|
114
|
+
factories = /* @__PURE__ */ new Map();
|
|
115
|
+
register(name, factory) {
|
|
116
|
+
this.factories.set(name, factory);
|
|
117
|
+
}
|
|
118
|
+
has(name) {
|
|
119
|
+
return this.factories.has(name);
|
|
120
|
+
}
|
|
121
|
+
resolve(name) {
|
|
122
|
+
const f = this.factories.get(name);
|
|
123
|
+
if (!f) throw new Error(`Unknown provider "${name}". Available: ${this.names().join(", ")}`);
|
|
124
|
+
return f();
|
|
125
|
+
}
|
|
126
|
+
names() {
|
|
127
|
+
return [...this.factories.keys()];
|
|
128
|
+
}
|
|
129
|
+
};
|
|
587
130
|
|
|
588
131
|
// src/safety.ts
|
|
589
132
|
var safetyHook = async (input, _toolUseID, _options) => {
|
|
@@ -611,10 +154,451 @@ var safetyHook = async (input, _toolUseID, _options) => {
|
|
|
611
154
|
};
|
|
612
155
|
};
|
|
613
156
|
|
|
157
|
+
// src/audit.ts
|
|
158
|
+
function createAuditHook(db, sessionId) {
|
|
159
|
+
return async (input) => {
|
|
160
|
+
try {
|
|
161
|
+
if (!("tool_name" in input)) return {};
|
|
162
|
+
const i = input;
|
|
163
|
+
const command = i.tool_input?.command ?? JSON.stringify(i.tool_input ?? {}).slice(0, 2e3);
|
|
164
|
+
const response = typeof i.tool_response === "string" ? i.tool_response : JSON.stringify(i.tool_response ?? "");
|
|
165
|
+
db.insertEvent(sessionId, {
|
|
166
|
+
eventType: "tool_executed",
|
|
167
|
+
process: i.tool_name,
|
|
168
|
+
pid: process.pid,
|
|
169
|
+
command,
|
|
170
|
+
resultBytes: Buffer.byteLength(response)
|
|
171
|
+
});
|
|
172
|
+
} catch (err) {
|
|
173
|
+
logDebug(`audit hook failed to record event: ${String(err)}`);
|
|
174
|
+
}
|
|
175
|
+
return {};
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// src/providers/claude.ts
|
|
180
|
+
function createClaudeProvider() {
|
|
181
|
+
return {
|
|
182
|
+
name: "claude",
|
|
183
|
+
async ensureAvailable(_config) {
|
|
184
|
+
try {
|
|
185
|
+
await import("@anthropic-ai/claude-agent-sdk");
|
|
186
|
+
} catch {
|
|
187
|
+
throw new Error(
|
|
188
|
+
"Claude provider unavailable: the @anthropic-ai/claude-agent-sdk package is not installed.\n Install: npm install @anthropic-ai/claude-agent-sdk"
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
},
|
|
192
|
+
async *run(ctx) {
|
|
193
|
+
const { config, db, sessionId, systemPrompt, initialPrompt, onAskUser, deadlineMs } = ctx;
|
|
194
|
+
const { query } = await import("@anthropic-ai/claude-agent-sdk");
|
|
195
|
+
const tools = await createCartographyTools(db, sessionId, {
|
|
196
|
+
onAskUser,
|
|
197
|
+
maxResponseBytes: config.maxToolResponseBytes
|
|
198
|
+
});
|
|
199
|
+
let turnCount = 0;
|
|
200
|
+
for await (const msg of query({
|
|
201
|
+
prompt: initialPrompt,
|
|
202
|
+
options: {
|
|
203
|
+
model: config.models.lead,
|
|
204
|
+
maxTurns: config.maxTurns,
|
|
205
|
+
systemPrompt,
|
|
206
|
+
mcpServers: { cartography: tools },
|
|
207
|
+
allowedTools: [
|
|
208
|
+
"Bash",
|
|
209
|
+
"mcp__cartography__save_node",
|
|
210
|
+
"mcp__cartography__save_edge",
|
|
211
|
+
"mcp__cartography__get_catalog",
|
|
212
|
+
"mcp__cartography__scan_bookmarks",
|
|
213
|
+
"mcp__cartography__scan_browser_history",
|
|
214
|
+
"mcp__cartography__scan_installed_apps",
|
|
215
|
+
"mcp__cartography__scan_local_databases",
|
|
216
|
+
"mcp__cartography__scan_k8s_resources",
|
|
217
|
+
"mcp__cartography__scan_aws_resources",
|
|
218
|
+
"mcp__cartography__scan_gcp_resources",
|
|
219
|
+
"mcp__cartography__scan_azure_resources",
|
|
220
|
+
"mcp__cartography__ask_user"
|
|
221
|
+
],
|
|
222
|
+
hooks: {
|
|
223
|
+
PreToolUse: [{ matcher: "Bash", hooks: [safetyHook] }],
|
|
224
|
+
PostToolUse: [{ hooks: [createAuditHook(db, sessionId)] }]
|
|
225
|
+
},
|
|
226
|
+
permissionMode: "bypassPermissions"
|
|
227
|
+
}
|
|
228
|
+
})) {
|
|
229
|
+
if (Date.now() > deadlineMs) {
|
|
230
|
+
yield { kind: "error", text: "Discovery timeout \u2014 wall-clock limit reached" };
|
|
231
|
+
yield { kind: "done" };
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (msg.type === "assistant") {
|
|
235
|
+
turnCount++;
|
|
236
|
+
yield { kind: "turn", turn: turnCount };
|
|
237
|
+
for (const block of msg.message.content) {
|
|
238
|
+
if (block.type === "text") {
|
|
239
|
+
yield { kind: "thinking", text: block.text };
|
|
240
|
+
}
|
|
241
|
+
if (block.type === "tool_use") {
|
|
242
|
+
yield {
|
|
243
|
+
kind: "tool_call",
|
|
244
|
+
tool: block.name,
|
|
245
|
+
input: block.input
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
if (msg.type === "user") {
|
|
251
|
+
const content = msg.message?.content;
|
|
252
|
+
if (Array.isArray(content)) {
|
|
253
|
+
for (const block of content) {
|
|
254
|
+
if (typeof block === "object" && block !== null && "type" in block && block.type === "tool_result") {
|
|
255
|
+
const tb = block;
|
|
256
|
+
const text = typeof tb.content === "string" ? tb.content : "";
|
|
257
|
+
yield { kind: "tool_result", tool: tb.tool_use_id ?? "", output: text };
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if (msg.type === "result") {
|
|
263
|
+
yield { kind: "done" };
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// src/providers/shell.ts
|
|
272
|
+
import { z } from "zod";
|
|
273
|
+
function createBashTool() {
|
|
274
|
+
const shell = IS_WIN ? "powershell" : "posix";
|
|
275
|
+
return {
|
|
276
|
+
name: "Bash",
|
|
277
|
+
description: "Run a read-only shell command (inspect ports, processes, config). Mutating or destructive commands are blocked by the read-only allowlist.",
|
|
278
|
+
inputShape: { command: z.string().describe("The read-only shell command to run") },
|
|
279
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
280
|
+
handler: async (args) => {
|
|
281
|
+
const command = String(args["command"] ?? "").trim();
|
|
282
|
+
if (!command) return { content: [{ type: "text", text: "" }] };
|
|
283
|
+
const decision = checkReadOnly(command, { shell });
|
|
284
|
+
if (!decision.allowed) {
|
|
285
|
+
return {
|
|
286
|
+
content: [
|
|
287
|
+
{ type: "text", text: `BLOCKED: ${decision.reason ?? "not read-only"} \u2014 read-only allowlist policy` }
|
|
288
|
+
]
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
const output = run(command) || "(no output)";
|
|
292
|
+
return { content: [{ type: "text", text: output }] };
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// src/providers/zod-schema.ts
|
|
298
|
+
function unwrap(schema) {
|
|
299
|
+
let current = schema;
|
|
300
|
+
let required = true;
|
|
301
|
+
let description = current.description;
|
|
302
|
+
for (; ; ) {
|
|
303
|
+
const def = current.def;
|
|
304
|
+
const typeName = def?.type;
|
|
305
|
+
if (typeName === "optional" || typeName === "default") {
|
|
306
|
+
required = false;
|
|
307
|
+
const inner = def?.innerType;
|
|
308
|
+
if (!inner) break;
|
|
309
|
+
current = inner;
|
|
310
|
+
description = description ?? current.description;
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
if (typeName === "nullable") {
|
|
314
|
+
const inner = def?.innerType;
|
|
315
|
+
if (!inner) break;
|
|
316
|
+
current = inner;
|
|
317
|
+
description = description ?? current.description;
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
break;
|
|
321
|
+
}
|
|
322
|
+
return { schema: current, required, description };
|
|
323
|
+
}
|
|
324
|
+
function convert(schema, field) {
|
|
325
|
+
const def = schema.def;
|
|
326
|
+
const typeName = def?.["type"];
|
|
327
|
+
switch (typeName) {
|
|
328
|
+
case "string":
|
|
329
|
+
return { type: "string" };
|
|
330
|
+
case "number": {
|
|
331
|
+
const out = { type: "number" };
|
|
332
|
+
const checks = def?.["checks"] ?? [];
|
|
333
|
+
for (const c of checks) {
|
|
334
|
+
const cd = c?._zod?.def;
|
|
335
|
+
if (cd?.check === "greater_than") out["minimum"] = cd.value;
|
|
336
|
+
if (cd?.check === "less_than") out["maximum"] = cd.value;
|
|
337
|
+
}
|
|
338
|
+
return out;
|
|
339
|
+
}
|
|
340
|
+
case "boolean":
|
|
341
|
+
return { type: "boolean" };
|
|
342
|
+
case "enum": {
|
|
343
|
+
const entries = def?.["entries"];
|
|
344
|
+
const values = entries ? Object.values(entries) : [];
|
|
345
|
+
return { type: "string", enum: values };
|
|
346
|
+
}
|
|
347
|
+
case "array": {
|
|
348
|
+
const element = def?.["element"];
|
|
349
|
+
return { type: "array", items: element ? convert(unwrap(element).schema, field) : {} };
|
|
350
|
+
}
|
|
351
|
+
case "record":
|
|
352
|
+
return { type: "object", additionalProperties: true };
|
|
353
|
+
default:
|
|
354
|
+
throw new Error(
|
|
355
|
+
`zod-schema: unsupported zod construct "${typeName ?? "unknown"}" on field "${field}". Extend src/providers/zod-schema.ts to support it.`
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
function shapeToJsonSchema(shape) {
|
|
360
|
+
const properties = {};
|
|
361
|
+
const required = [];
|
|
362
|
+
for (const [key, raw] of Object.entries(shape)) {
|
|
363
|
+
const { schema, required: isRequired, description } = unwrap(raw);
|
|
364
|
+
const prop = convert(schema, key);
|
|
365
|
+
if (description) prop["description"] = description;
|
|
366
|
+
properties[key] = prop;
|
|
367
|
+
if (isRequired) required.push(key);
|
|
368
|
+
}
|
|
369
|
+
return { type: "object", properties, required, additionalProperties: false };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// src/providers/audit.ts
|
|
373
|
+
function recordToolEvent(db, sessionId, evt) {
|
|
374
|
+
try {
|
|
375
|
+
db.insertEvent(sessionId, {
|
|
376
|
+
eventType: "tool_executed",
|
|
377
|
+
process: evt.tool,
|
|
378
|
+
pid: process.pid,
|
|
379
|
+
command: evt.command,
|
|
380
|
+
resultBytes: Buffer.byteLength(evt.response)
|
|
381
|
+
});
|
|
382
|
+
} catch (err) {
|
|
383
|
+
logDebug(`audit writer failed to record event: ${String(err)}`);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// src/providers/loop.ts
|
|
388
|
+
async function dispatchTool(call, tools, db, sessionId) {
|
|
389
|
+
const tool = tools.find((t) => t.name === call.name);
|
|
390
|
+
if (!tool) {
|
|
391
|
+
const text = `ERROR: unknown tool "${call.name}"`;
|
|
392
|
+
recordToolEvent(db, sessionId, { tool: call.name, command: JSON.stringify(call.args).slice(0, 2e3), response: text });
|
|
393
|
+
return text;
|
|
394
|
+
}
|
|
395
|
+
let output;
|
|
396
|
+
try {
|
|
397
|
+
const result = await tool.handler(call.args);
|
|
398
|
+
output = result.content.map((c) => c.text).join("\n");
|
|
399
|
+
} catch (err) {
|
|
400
|
+
output = `ERROR: ${err instanceof Error ? err.message : String(err)}`;
|
|
401
|
+
}
|
|
402
|
+
const command = call.name === "Bash" ? String(call.args["command"] ?? "") : JSON.stringify(call.args).slice(0, 2e3);
|
|
403
|
+
recordToolEvent(db, sessionId, { tool: call.name, command, response: output });
|
|
404
|
+
return output;
|
|
405
|
+
}
|
|
406
|
+
async function* runToolLoop(opts, chat) {
|
|
407
|
+
const { db, sessionId, tools, maxTurns, deadlineMs } = opts;
|
|
408
|
+
let outcomes = [];
|
|
409
|
+
let turn = 0;
|
|
410
|
+
try {
|
|
411
|
+
while (turn < maxTurns) {
|
|
412
|
+
if (Date.now() > deadlineMs) {
|
|
413
|
+
yield { kind: "error", text: "Discovery timeout \u2014 wall-clock limit reached" };
|
|
414
|
+
yield { kind: "done" };
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
const result = await chat(outcomes);
|
|
418
|
+
turn++;
|
|
419
|
+
yield { kind: "turn", turn };
|
|
420
|
+
if (result.text) yield { kind: "thinking", text: result.text };
|
|
421
|
+
if (result.toolCalls.length === 0) {
|
|
422
|
+
yield { kind: "done" };
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
const nextOutcomes = [];
|
|
426
|
+
for (const call of result.toolCalls) {
|
|
427
|
+
yield { kind: "tool_call", tool: call.name, input: call.args };
|
|
428
|
+
const output = await dispatchTool(call, tools, db, sessionId);
|
|
429
|
+
yield { kind: "tool_result", tool: call.name, output };
|
|
430
|
+
nextOutcomes.push({ id: call.id, name: call.name, output });
|
|
431
|
+
}
|
|
432
|
+
outcomes = nextOutcomes;
|
|
433
|
+
}
|
|
434
|
+
yield { kind: "done" };
|
|
435
|
+
} catch (err) {
|
|
436
|
+
yield { kind: "error", text: `Discovery error: ${err instanceof Error ? err.message : String(err)}` };
|
|
437
|
+
yield { kind: "done" };
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// src/providers/openai.ts
|
|
442
|
+
function toOpenAITools(tools) {
|
|
443
|
+
return tools.map((t) => ({
|
|
444
|
+
type: "function",
|
|
445
|
+
function: { name: t.name, description: t.description, parameters: shapeToJsonSchema(t.inputShape) }
|
|
446
|
+
}));
|
|
447
|
+
}
|
|
448
|
+
function createOpenAIProvider() {
|
|
449
|
+
return {
|
|
450
|
+
name: "openai",
|
|
451
|
+
async ensureAvailable(_config) {
|
|
452
|
+
try {
|
|
453
|
+
await import("openai");
|
|
454
|
+
} catch {
|
|
455
|
+
throw new Error(
|
|
456
|
+
"OpenAI provider unavailable: the `openai` package is not installed.\n Install: npm install openai"
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
if (!process.env["OPENAI_API_KEY"]) {
|
|
460
|
+
throw new Error(
|
|
461
|
+
"OpenAI provider unavailable: OPENAI_API_KEY is not set.\n Set it: export OPENAI_API_KEY=sk-..."
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
},
|
|
465
|
+
async *run(ctx) {
|
|
466
|
+
const { config, db, sessionId, systemPrompt, initialPrompt, onAskUser, deadlineMs } = ctx;
|
|
467
|
+
const mod = await import("openai");
|
|
468
|
+
const apiKey = process.env["OPENAI_API_KEY"] ?? "";
|
|
469
|
+
const baseURL = process.env["OPENAI_BASE_URL"];
|
|
470
|
+
const client = new mod.default({ apiKey, ...baseURL ? { baseURL } : {} });
|
|
471
|
+
const handlers = await buildCartographyToolHandlers(db, sessionId, {
|
|
472
|
+
onAskUser,
|
|
473
|
+
maxResponseBytes: config.maxToolResponseBytes
|
|
474
|
+
});
|
|
475
|
+
const tools = [...handlers, createBashTool()];
|
|
476
|
+
const openaiTools = toOpenAITools(tools);
|
|
477
|
+
const messages = [
|
|
478
|
+
{ role: "system", content: systemPrompt },
|
|
479
|
+
{ role: "user", content: initialPrompt }
|
|
480
|
+
];
|
|
481
|
+
const chat = async (outcomes) => {
|
|
482
|
+
for (const oc of outcomes) {
|
|
483
|
+
messages.push({ role: "tool", tool_call_id: oc.id, content: oc.output });
|
|
484
|
+
}
|
|
485
|
+
const completion = await client.chat.completions.create({
|
|
486
|
+
model: config.models.lead,
|
|
487
|
+
messages,
|
|
488
|
+
tools: openaiTools,
|
|
489
|
+
tool_choice: "auto"
|
|
490
|
+
});
|
|
491
|
+
const choice = completion.choices[0]?.message;
|
|
492
|
+
const text = choice?.content ?? "";
|
|
493
|
+
const toolCalls = choice?.tool_calls ?? [];
|
|
494
|
+
messages.push({ role: "assistant", content: text || null, ...toolCalls.length ? { tool_calls: toolCalls } : {} });
|
|
495
|
+
return {
|
|
496
|
+
text,
|
|
497
|
+
toolCalls: toolCalls.map((tc) => ({
|
|
498
|
+
id: tc.id,
|
|
499
|
+
name: tc.function.name,
|
|
500
|
+
args: parseArgs(tc.function.arguments)
|
|
501
|
+
}))
|
|
502
|
+
};
|
|
503
|
+
};
|
|
504
|
+
yield* runToolLoop({ db, sessionId, tools, maxTurns: config.maxTurns, deadlineMs }, chat);
|
|
505
|
+
}
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
function parseArgs(raw) {
|
|
509
|
+
try {
|
|
510
|
+
const parsed = JSON.parse(raw || "{}");
|
|
511
|
+
return typeof parsed === "object" && parsed !== null ? parsed : {};
|
|
512
|
+
} catch {
|
|
513
|
+
return {};
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// src/providers/ollama.ts
|
|
518
|
+
var DEFAULT_HOST = "http://127.0.0.1:11434";
|
|
519
|
+
function host() {
|
|
520
|
+
return (process.env["OLLAMA_HOST"] || DEFAULT_HOST).replace(/\/+$/, "");
|
|
521
|
+
}
|
|
522
|
+
function toOllamaTools(tools) {
|
|
523
|
+
return tools.map((t) => ({
|
|
524
|
+
type: "function",
|
|
525
|
+
function: { name: t.name, description: t.description, parameters: shapeToJsonSchema(t.inputShape) }
|
|
526
|
+
}));
|
|
527
|
+
}
|
|
528
|
+
function createOllamaProvider() {
|
|
529
|
+
return {
|
|
530
|
+
name: "ollama",
|
|
531
|
+
async ensureAvailable(_config) {
|
|
532
|
+
const base = host();
|
|
533
|
+
try {
|
|
534
|
+
const res = await fetch(`${base}/api/tags`, { method: "GET" });
|
|
535
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
536
|
+
} catch {
|
|
537
|
+
throw new Error(
|
|
538
|
+
`Ollama provider unavailable: not reachable at ${base}.
|
|
539
|
+
Start it: ollama serve (or set OLLAMA_HOST=<url>)`
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
},
|
|
543
|
+
async *run(ctx) {
|
|
544
|
+
const { config, db, sessionId, systemPrompt, initialPrompt, onAskUser, deadlineMs } = ctx;
|
|
545
|
+
const base = host();
|
|
546
|
+
const handlers = await buildCartographyToolHandlers(db, sessionId, {
|
|
547
|
+
onAskUser,
|
|
548
|
+
maxResponseBytes: config.maxToolResponseBytes
|
|
549
|
+
});
|
|
550
|
+
const tools = [...handlers, createBashTool()];
|
|
551
|
+
const ollamaTools = toOllamaTools(tools);
|
|
552
|
+
const messages = [
|
|
553
|
+
{ role: "system", content: systemPrompt },
|
|
554
|
+
{ role: "user", content: initialPrompt }
|
|
555
|
+
];
|
|
556
|
+
const chat = async (outcomes) => {
|
|
557
|
+
for (const oc of outcomes) {
|
|
558
|
+
messages.push({ role: "tool", content: oc.output });
|
|
559
|
+
}
|
|
560
|
+
const res = await fetch(`${base}/api/chat`, {
|
|
561
|
+
method: "POST",
|
|
562
|
+
headers: { "content-type": "application/json" },
|
|
563
|
+
body: JSON.stringify({ model: config.models.lead, messages, tools: ollamaTools, stream: false })
|
|
564
|
+
});
|
|
565
|
+
if (!res.ok) {
|
|
566
|
+
throw new Error(`Ollama /api/chat returned HTTP ${res.status}`);
|
|
567
|
+
}
|
|
568
|
+
const data = await res.json();
|
|
569
|
+
const text = data.message?.content ?? "";
|
|
570
|
+
const toolCalls = data.message?.tool_calls ?? [];
|
|
571
|
+
messages.push({
|
|
572
|
+
role: "assistant",
|
|
573
|
+
content: text,
|
|
574
|
+
...toolCalls.length ? { tool_calls: toolCalls } : {}
|
|
575
|
+
});
|
|
576
|
+
return {
|
|
577
|
+
text,
|
|
578
|
+
toolCalls: toolCalls.map((tc, i) => ({
|
|
579
|
+
id: `${tc.function.name}:${i}`,
|
|
580
|
+
name: tc.function.name,
|
|
581
|
+
args: tc.function.arguments ?? {}
|
|
582
|
+
}))
|
|
583
|
+
};
|
|
584
|
+
};
|
|
585
|
+
yield* runToolLoop({ db, sessionId, tools, maxTurns: config.maxTurns, deadlineMs }, chat);
|
|
586
|
+
}
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// src/providers/registry.ts
|
|
591
|
+
function createDefaultRegistry() {
|
|
592
|
+
const r = new ProviderRegistry();
|
|
593
|
+
r.register("claude", createClaudeProvider);
|
|
594
|
+
r.register("openai", createOpenAIProvider);
|
|
595
|
+
r.register("ollama", createOllamaProvider);
|
|
596
|
+
return r;
|
|
597
|
+
}
|
|
598
|
+
var defaultProviderRegistry = createDefaultRegistry();
|
|
599
|
+
|
|
614
600
|
// src/agent.ts
|
|
615
601
|
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
602
|
const hintSection = hint ? `
|
|
619
603
|
\u26A1 USER HINT (HIGH PRIORITY): The user wants to find these specific tools: "${hint}"
|
|
620
604
|
\u2192 Run scan_installed_apps(searchHint: "${hint}") IMMEDIATELY and save found tools as saas_tool nodes!
|
|
@@ -714,6 +698,7 @@ RULES:
|
|
|
714
698
|
\u2022 metadata allowed: { description, category, port, version, path } \u2014 no passwords
|
|
715
699
|
\u2022 Call get_catalog before save_node \u2192 avoid duplicates
|
|
716
700
|
\u2022 Save edges whenever connections are clearly identifiable
|
|
701
|
+
\u2022 Max crawl depth: ${config.maxDepth} hops from an entry point \u2014 do not chase leads deeper than this
|
|
717
702
|
|
|
718
703
|
Entry points: ${config.entryPoints.join(", ")}`;
|
|
719
704
|
const initialPrompt = hint ? `Start discovery with USER HINT: "${hint}".
|
|
@@ -728,75 +713,28 @@ Then systematically scan local services, then config files.
|
|
|
728
713
|
Finally, map all edges (Step 8 \u2014 critical!) before finishing.
|
|
729
714
|
Use ask_user when you need context from the user.`;
|
|
730
715
|
const MAX_DISCOVERY_MS = 30 * 60 * 1e3;
|
|
731
|
-
|
|
716
|
+
const startTime = Date.now();
|
|
717
|
+
const deadlineMs = startTime + MAX_DISCOVERY_MS;
|
|
718
|
+
const provider = defaultProviderRegistry.resolve(config.provider ?? "claude");
|
|
719
|
+
await provider.ensureAvailable(config);
|
|
720
|
+
const ctx = {
|
|
721
|
+
config,
|
|
722
|
+
db,
|
|
723
|
+
sessionId,
|
|
724
|
+
systemPrompt,
|
|
725
|
+
initialPrompt,
|
|
726
|
+
onAskUser,
|
|
727
|
+
deadlineMs
|
|
728
|
+
};
|
|
732
729
|
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) {
|
|
730
|
+
for await (const event of provider.run(ctx)) {
|
|
731
|
+
onEvent?.(event);
|
|
732
|
+
if (event.kind === "done") return;
|
|
733
|
+
if (Date.now() > deadlineMs) {
|
|
763
734
|
onEvent?.({ kind: "error", text: `Discovery timeout after ${MAX_DISCOVERY_MS / 6e4} minutes` });
|
|
764
735
|
onEvent?.({ kind: "done" });
|
|
765
736
|
return;
|
|
766
737
|
}
|
|
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
738
|
}
|
|
801
739
|
} catch (err) {
|
|
802
740
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -805,6 +743,182 @@ Use ask_user when you need context from the user.`;
|
|
|
805
743
|
}
|
|
806
744
|
}
|
|
807
745
|
|
|
746
|
+
// src/config.ts
|
|
747
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
748
|
+
var ConfigError = class extends Error {
|
|
749
|
+
constructor(message) {
|
|
750
|
+
super(message);
|
|
751
|
+
this.name = "ConfigError";
|
|
752
|
+
}
|
|
753
|
+
};
|
|
754
|
+
function loadConfig(path) {
|
|
755
|
+
const file = readConfigFile(path);
|
|
756
|
+
const overrides = {};
|
|
757
|
+
if (file.organization) overrides.organization = file.organization;
|
|
758
|
+
const entryPoints = file.schedule?.entryPoints ?? file.entryPoints;
|
|
759
|
+
if (entryPoints) overrides.entryPoints = [...entryPoints];
|
|
760
|
+
const dbPath = file.schedule?.dbPath ?? file.dbPath;
|
|
761
|
+
if (dbPath) overrides.dbPath = dbPath;
|
|
762
|
+
if (file.schedule) overrides.schedule = file.schedule;
|
|
763
|
+
if (file.centralDb) {
|
|
764
|
+
const merged = { ...file.centralDb, ...centralDbFromEnv() };
|
|
765
|
+
overrides.centralDb = merged;
|
|
766
|
+
}
|
|
767
|
+
return defaultConfig(overrides);
|
|
768
|
+
}
|
|
769
|
+
function readConfigFile(path) {
|
|
770
|
+
let raw;
|
|
771
|
+
try {
|
|
772
|
+
raw = readFileSync2(path, "utf-8");
|
|
773
|
+
} catch (err) {
|
|
774
|
+
throw new ConfigError(
|
|
775
|
+
`Cannot read config file ${path}: ${err instanceof Error ? err.message : String(err)}`
|
|
776
|
+
);
|
|
777
|
+
}
|
|
778
|
+
let json;
|
|
779
|
+
try {
|
|
780
|
+
json = JSON.parse(raw);
|
|
781
|
+
} catch (err) {
|
|
782
|
+
throw new ConfigError(
|
|
783
|
+
`Invalid JSON in ${path}: ${err instanceof Error ? err.message : String(err)}`
|
|
784
|
+
);
|
|
785
|
+
}
|
|
786
|
+
const parsed = ConfigFileSchema.safeParse(json);
|
|
787
|
+
if (!parsed.success) {
|
|
788
|
+
const detail = parsed.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ");
|
|
789
|
+
throw new ConfigError(`Invalid config in ${path}: ${detail}`);
|
|
790
|
+
}
|
|
791
|
+
return parsed.data;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// src/schedule.ts
|
|
795
|
+
var FIELD_SPECS = [
|
|
796
|
+
{ name: "minute", min: 0, max: 59 },
|
|
797
|
+
{ name: "hour", min: 0, max: 23 },
|
|
798
|
+
{ name: "dom", min: 1, max: 31 },
|
|
799
|
+
{ name: "month", min: 1, max: 12 },
|
|
800
|
+
{ name: "dow", min: 0, max: 7 }
|
|
801
|
+
// 7 and 0 both mean Sunday; normalized to 0 below
|
|
802
|
+
];
|
|
803
|
+
function parseField(raw, spec) {
|
|
804
|
+
const out = /* @__PURE__ */ new Set();
|
|
805
|
+
const add = (n) => {
|
|
806
|
+
if (!Number.isInteger(n) || n < spec.min || n > spec.max) {
|
|
807
|
+
throw new RangeError(`Invalid value "${n}" in cron field "${spec.name}" (allowed ${spec.min}-${spec.max})`);
|
|
808
|
+
}
|
|
809
|
+
out.add(spec.name === "dow" && n === 7 ? 0 : n);
|
|
810
|
+
};
|
|
811
|
+
for (const part of raw.split(",")) {
|
|
812
|
+
if (part === "") {
|
|
813
|
+
throw new RangeError(`Empty term in cron field "${spec.name}"`);
|
|
814
|
+
}
|
|
815
|
+
const [rangePart, stepPart, ...rest] = part.split("/");
|
|
816
|
+
if (rest.length > 0) {
|
|
817
|
+
throw new RangeError(`Malformed step in cron field "${spec.name}": "${part}"`);
|
|
818
|
+
}
|
|
819
|
+
let step = 1;
|
|
820
|
+
if (stepPart !== void 0) {
|
|
821
|
+
step = Number(stepPart);
|
|
822
|
+
if (!Number.isInteger(step) || step < 1) {
|
|
823
|
+
throw new RangeError(`Invalid step "${stepPart}" in cron field "${spec.name}"`);
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
let lo;
|
|
827
|
+
let hi;
|
|
828
|
+
if (rangePart === "*") {
|
|
829
|
+
lo = spec.min;
|
|
830
|
+
hi = spec.max;
|
|
831
|
+
} else if (rangePart.includes("-")) {
|
|
832
|
+
const [a, b, ...extra] = rangePart.split("-");
|
|
833
|
+
if (extra.length > 0) {
|
|
834
|
+
throw new RangeError(`Malformed range in cron field "${spec.name}": "${rangePart}"`);
|
|
835
|
+
}
|
|
836
|
+
lo = Number(a);
|
|
837
|
+
hi = Number(b);
|
|
838
|
+
if (!Number.isInteger(lo) || !Number.isInteger(hi)) {
|
|
839
|
+
throw new RangeError(`Non-numeric range in cron field "${spec.name}": "${rangePart}"`);
|
|
840
|
+
}
|
|
841
|
+
if (lo > hi) {
|
|
842
|
+
throw new RangeError(`Descending range in cron field "${spec.name}": "${rangePart}"`);
|
|
843
|
+
}
|
|
844
|
+
} else {
|
|
845
|
+
const n = Number(rangePart);
|
|
846
|
+
if (!Number.isInteger(n)) {
|
|
847
|
+
throw new RangeError(`Non-numeric value in cron field "${spec.name}": "${rangePart}"`);
|
|
848
|
+
}
|
|
849
|
+
lo = n;
|
|
850
|
+
hi = stepPart !== void 0 ? spec.max : n;
|
|
851
|
+
}
|
|
852
|
+
for (let v = lo; v <= hi; v += step) add(v);
|
|
853
|
+
}
|
|
854
|
+
if (out.size === 0) {
|
|
855
|
+
throw new RangeError(`Cron field "${spec.name}" matched no values`);
|
|
856
|
+
}
|
|
857
|
+
return out;
|
|
858
|
+
}
|
|
859
|
+
function parseCron(expr) {
|
|
860
|
+
const fields = expr.trim().split(/\s+/);
|
|
861
|
+
if (fields.length !== 5) {
|
|
862
|
+
throw new RangeError(`Cron expression must have 5 fields (got ${fields.length}): "${expr}"`);
|
|
863
|
+
}
|
|
864
|
+
const [minute, hour, dom, month, dow] = FIELD_SPECS.map((spec, i) => parseField(fields[i], spec));
|
|
865
|
+
return { minute, hour, dom, month, dow };
|
|
866
|
+
}
|
|
867
|
+
function matches(fields, date) {
|
|
868
|
+
if (!fields.minute.has(date.getUTCMinutes())) return false;
|
|
869
|
+
if (!fields.hour.has(date.getUTCHours())) return false;
|
|
870
|
+
if (!fields.month.has(date.getUTCMonth() + 1)) return false;
|
|
871
|
+
const domRestricted = fields.dom.size !== 31;
|
|
872
|
+
const dowRestricted = fields.dow.size !== 7;
|
|
873
|
+
const domOk = fields.dom.has(date.getUTCDate());
|
|
874
|
+
const dowOk = fields.dow.has(date.getUTCDay());
|
|
875
|
+
if (domRestricted && dowRestricted) return domOk || dowOk;
|
|
876
|
+
if (domRestricted) return domOk;
|
|
877
|
+
if (dowRestricted) return dowOk;
|
|
878
|
+
return true;
|
|
879
|
+
}
|
|
880
|
+
var MAX_SEARCH_MINUTES = 4 * 366 * 24 * 60;
|
|
881
|
+
function nextRun(expr, after) {
|
|
882
|
+
const fields = parseCron(expr);
|
|
883
|
+
const cursor2 = new Date(after.getTime());
|
|
884
|
+
cursor2.setUTCSeconds(0, 0);
|
|
885
|
+
cursor2.setUTCMinutes(cursor2.getUTCMinutes() + 1);
|
|
886
|
+
for (let i = 0; i < MAX_SEARCH_MINUTES; i++) {
|
|
887
|
+
if (matches(fields, cursor2)) return new Date(cursor2.getTime());
|
|
888
|
+
cursor2.setUTCMinutes(cursor2.getUTCMinutes() + 1);
|
|
889
|
+
}
|
|
890
|
+
throw new RangeError(`No cron match for "${expr}" within ~4 years after ${after.toISOString()}`);
|
|
891
|
+
}
|
|
892
|
+
async function runOnce(cfg, db) {
|
|
893
|
+
const prior = db.getLatestSession("discover");
|
|
894
|
+
if (prior) {
|
|
895
|
+
const r = await runLocalDiscovery(db, prior.id, {
|
|
896
|
+
hint: cfg.entryPoints.join(","),
|
|
897
|
+
plugins: cfg.plugins,
|
|
898
|
+
mode: "update",
|
|
899
|
+
onProgress: (line) => logInfo(`scan: ${line}`)
|
|
900
|
+
});
|
|
901
|
+
const delta = r.delta ?? diffTopology({ nodes: [], edges: [] }, { nodes: [], edges: [] });
|
|
902
|
+
logInfo("scheduled run complete", { sessionId: prior.id, base: prior.id, ...delta.summary });
|
|
903
|
+
return { sessionId: prior.id, baseSessionId: prior.id, delta, nodes: r.nodes, edges: r.edges, scanners: r.scanners };
|
|
904
|
+
}
|
|
905
|
+
const sessionId = db.createSession("discover", cfg);
|
|
906
|
+
try {
|
|
907
|
+
const r = await runLocalDiscovery(db, sessionId, {
|
|
908
|
+
hint: cfg.entryPoints.join(","),
|
|
909
|
+
plugins: cfg.plugins,
|
|
910
|
+
mode: "replace",
|
|
911
|
+
onProgress: (line) => logInfo(`scan: ${line}`)
|
|
912
|
+
});
|
|
913
|
+
const current = { nodes: db.getNodes(sessionId), edges: db.getEdges(sessionId) };
|
|
914
|
+
const delta = diffTopology({ nodes: [], edges: [] }, current);
|
|
915
|
+
logInfo("scheduled run complete", { sessionId, base: null, ...delta.summary });
|
|
916
|
+
return { sessionId, baseSessionId: void 0, delta, nodes: r.nodes, edges: r.edges, scanners: r.scanners };
|
|
917
|
+
} finally {
|
|
918
|
+
db.endSession(sessionId);
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
|
|
808
922
|
// src/exporter.ts
|
|
809
923
|
import { mkdirSync, writeFileSync } from "fs";
|
|
810
924
|
import { join as join2 } from "path";
|
|
@@ -1171,6 +1285,66 @@ function generateDependencyMermaid(nodes, edges) {
|
|
|
1171
1285
|
}
|
|
1172
1286
|
return lines.join("\n");
|
|
1173
1287
|
}
|
|
1288
|
+
var DIFF_CLASSES = {
|
|
1289
|
+
added: "fill:#0d3d0d,stroke:#22c55e,color:#86efac",
|
|
1290
|
+
removed: "fill:#3d0d0d,stroke:#ef4444,color:#fca5a5",
|
|
1291
|
+
changed: "fill:#3d2f0d,stroke:#f59e0b,color:#fcd34d",
|
|
1292
|
+
context: "fill:#1e1e1e,stroke:#555555,color:#999999"
|
|
1293
|
+
};
|
|
1294
|
+
function diffNodeLabel(node, suffix) {
|
|
1295
|
+
const icon = MERMAID_ICONS[node.type] ?? "?";
|
|
1296
|
+
const extra = suffix ? `<br/><small>\u0394 ${suffix}</small>` : "";
|
|
1297
|
+
return `["${icon} <b>${node.name}</b><br/><small>${node.type}</small>${extra}"]`;
|
|
1298
|
+
}
|
|
1299
|
+
function generateDiffMermaid(diff) {
|
|
1300
|
+
const total = diff.summary.nodesAdded + diff.summary.nodesRemoved + diff.summary.nodesChanged + diff.summary.edgesAdded + diff.summary.edgesRemoved;
|
|
1301
|
+
if (total === 0) return 'graph TB\n nodrift["\u2713 No drift between the two sessions"]';
|
|
1302
|
+
const lines = ["graph TB"];
|
|
1303
|
+
for (const [k, style] of Object.entries(DIFF_CLASSES)) lines.push(` classDef ${k} ${style}`);
|
|
1304
|
+
lines.push("");
|
|
1305
|
+
const rank = { added: 3, removed: 3, changed: 3, context: 0 };
|
|
1306
|
+
const entries = /* @__PURE__ */ new Map();
|
|
1307
|
+
const place = (node, cls, suffix) => {
|
|
1308
|
+
const prev = entries.get(node.id);
|
|
1309
|
+
if (prev && rank[prev.cls] >= rank[cls]) return;
|
|
1310
|
+
entries.set(node.id, { node, cls, suffix });
|
|
1311
|
+
};
|
|
1312
|
+
for (const n of diff.nodes.added) place(n, "added");
|
|
1313
|
+
for (const n of diff.nodes.removed) place(n, "removed");
|
|
1314
|
+
for (const c of diff.nodes.changed) place(c.after, "changed", c.changedFields.join(", "));
|
|
1315
|
+
const contextNode = (id) => ({
|
|
1316
|
+
id,
|
|
1317
|
+
type: "unknown",
|
|
1318
|
+
name: id,
|
|
1319
|
+
discoveredVia: "diff",
|
|
1320
|
+
confidence: 1,
|
|
1321
|
+
metadata: {},
|
|
1322
|
+
tags: [],
|
|
1323
|
+
sessionId: "",
|
|
1324
|
+
discoveredAt: "",
|
|
1325
|
+
depth: 0
|
|
1326
|
+
});
|
|
1327
|
+
const ensureEndpoint = (id) => {
|
|
1328
|
+
if (!entries.has(id)) place(contextNode(id), "context");
|
|
1329
|
+
};
|
|
1330
|
+
for (const e of [...diff.edges.added, ...diff.edges.removed]) {
|
|
1331
|
+
ensureEndpoint(e.sourceId);
|
|
1332
|
+
ensureEndpoint(e.targetId);
|
|
1333
|
+
}
|
|
1334
|
+
for (const { node, cls, suffix } of entries.values()) {
|
|
1335
|
+
lines.push(` ${sanitize(node.id)}${diffNodeLabel(node, suffix)}:::${cls}`);
|
|
1336
|
+
}
|
|
1337
|
+
lines.push("");
|
|
1338
|
+
for (const e of diff.edges.added) {
|
|
1339
|
+
const label = EDGE_LABELS[e.relationship] ?? e.relationship;
|
|
1340
|
+
lines.push(` ${sanitize(e.sourceId)} ==>|"+ ${label}"| ${sanitize(e.targetId)}`);
|
|
1341
|
+
}
|
|
1342
|
+
for (const e of diff.edges.removed) {
|
|
1343
|
+
const label = EDGE_LABELS[e.relationship] ?? e.relationship;
|
|
1344
|
+
lines.push(` ${sanitize(e.sourceId)} -.->|"- ${label}"| ${sanitize(e.targetId)}`);
|
|
1345
|
+
}
|
|
1346
|
+
return lines.join("\n");
|
|
1347
|
+
}
|
|
1174
1348
|
function exportBackstageYAML(nodes, edges, org) {
|
|
1175
1349
|
const owner = org ?? "unknown";
|
|
1176
1350
|
const docs = [];
|
|
@@ -1190,7 +1364,7 @@ function exportBackstageYAML(nodes, edges, org) {
|
|
|
1190
1364
|
`spec:`,
|
|
1191
1365
|
` type: ${node.type}`,
|
|
1192
1366
|
` lifecycle: production`,
|
|
1193
|
-
` owner: ${owner}`,
|
|
1367
|
+
` owner: ${node.owner ?? owner}`,
|
|
1194
1368
|
...deps.length > 0 ? [" dependsOn:", ...deps] : []
|
|
1195
1369
|
].join("\n");
|
|
1196
1370
|
docs.push(doc);
|
|
@@ -2311,15 +2485,89 @@ function exportJGF(nodes, edges) {
|
|
|
2311
2485
|
};
|
|
2312
2486
|
return JSON.stringify(jgf, null, 2);
|
|
2313
2487
|
}
|
|
2314
|
-
function
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2488
|
+
function csvField(v) {
|
|
2489
|
+
let s = String(v);
|
|
2490
|
+
if (/^[=+\-@]/.test(s)) s = `'${s}`;
|
|
2491
|
+
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
|
2492
|
+
}
|
|
2493
|
+
function exportCostCSV(summary) {
|
|
2494
|
+
const rows = ["scope,key,currency,period,total,nodes"];
|
|
2495
|
+
for (const c of summary.costByDomain) {
|
|
2496
|
+
rows.push(["domain", c.domain, c.currency, c.period, c.total, c.nodes].map(csvField).join(","));
|
|
2497
|
+
}
|
|
2498
|
+
for (const c of summary.costByOwner) {
|
|
2499
|
+
rows.push(["owner", c.owner, c.currency, c.period, c.total, c.nodes].map(csvField).join(","));
|
|
2500
|
+
}
|
|
2501
|
+
return rows.join("\n") + "\n";
|
|
2502
|
+
}
|
|
2503
|
+
function exportCostSummary(summary) {
|
|
2504
|
+
return JSON.stringify({
|
|
2505
|
+
costByDomain: summary.costByDomain,
|
|
2506
|
+
costByOwner: summary.costByOwner,
|
|
2507
|
+
costCoverage: summary.costCoverage
|
|
2508
|
+
}, null, 2);
|
|
2509
|
+
}
|
|
2510
|
+
var SEVERITY_RANK = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
2511
|
+
function exportComplianceReport(report, format) {
|
|
2512
|
+
if (format === "json") return JSON.stringify(report, null, 2);
|
|
2513
|
+
if (format === "markdown") {
|
|
2514
|
+
const scoreStr = report.score === null ? "n/a" : `${report.score}/100`;
|
|
2515
|
+
const out = [
|
|
2516
|
+
`# Compliance \u2014 ${report.rulesetName} v${report.rulesetVersion}`,
|
|
2517
|
+
``,
|
|
2518
|
+
`**Status:** ${report.status.toUpperCase()} \xB7 **Score:** ${scoreStr}`,
|
|
2519
|
+
``,
|
|
2520
|
+
`| Controls | Count |`,
|
|
2521
|
+
`|----------|-------|`,
|
|
2522
|
+
`| Passed | ${report.totals.passed} |`,
|
|
2523
|
+
`| Failed | ${report.totals.failed} |`,
|
|
2524
|
+
`| Not applicable | ${report.totals.notApplicable} |`,
|
|
2525
|
+
`| Total | ${report.totals.rules} |`,
|
|
2526
|
+
``,
|
|
2527
|
+
`| Severity | Failed | Passed |`,
|
|
2528
|
+
`|----------|--------|--------|`,
|
|
2529
|
+
...["critical", "high", "medium", "low"].map(
|
|
2530
|
+
(s) => `| ${s} | ${report.bySeverity[s].failed} | ${report.bySeverity[s].passed} |`
|
|
2531
|
+
)
|
|
2532
|
+
];
|
|
2533
|
+
if (report.gaps.length === 0) {
|
|
2534
|
+
out.push(``, `\u2713 No compliance gaps.`);
|
|
2535
|
+
} else {
|
|
2536
|
+
out.push(``, `## Gaps`);
|
|
2537
|
+
for (const g of [...report.gaps].sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity])) {
|
|
2538
|
+
out.push(``, `### [${g.severity}] ${g.control} \u2014 ${g.title}`, ...g.nodeIds.map((id) => `- \`${id}\``));
|
|
2539
|
+
}
|
|
2540
|
+
}
|
|
2541
|
+
return out.join("\n");
|
|
2542
|
+
}
|
|
2543
|
+
const lines = [
|
|
2544
|
+
"graph TB",
|
|
2545
|
+
" classDef critical fill:#7f1d1d,stroke:#ef4444,color:#fff;",
|
|
2546
|
+
" classDef high fill:#7c2d12,stroke:#f97316,color:#fff;",
|
|
2547
|
+
" classDef medium fill:#713f12,stroke:#eab308,color:#fff;",
|
|
2548
|
+
" classDef low fill:#1e3a5f,stroke:#3b82f6,color:#fff;"
|
|
2549
|
+
];
|
|
2550
|
+
if (report.gaps.length === 0) {
|
|
2551
|
+
lines.push(' ok["\u2713 No compliance gaps"]');
|
|
2552
|
+
return lines.join("\n");
|
|
2553
|
+
}
|
|
2554
|
+
const mmSafe = (s) => s.replace(/["\]\r\n]/g, "'");
|
|
2555
|
+
let i = 0;
|
|
2556
|
+
for (const g of [...report.gaps].sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity])) {
|
|
2557
|
+
const gid = `g${i++}`;
|
|
2558
|
+
lines.push(` ${gid}["${mmSafe(g.control)}: ${mmSafe(g.title)} (${g.nodeIds.length})"]:::${g.severity}`);
|
|
2559
|
+
}
|
|
2560
|
+
return lines.join("\n");
|
|
2561
|
+
}
|
|
2562
|
+
function exportAll(db, sessionId, outputDir, formats = ["mermaid", "json", "yaml", "html", "map", "discovery"]) {
|
|
2563
|
+
mkdirSync(outputDir, { recursive: true });
|
|
2564
|
+
const nodes = db.getNodes(sessionId);
|
|
2565
|
+
const edges = db.getEdges(sessionId);
|
|
2566
|
+
const jgfPath = join2(outputDir, "cartography-graph.jgf.json");
|
|
2567
|
+
writeFileSync(jgfPath, exportJGF(nodes, edges));
|
|
2568
|
+
if (formats.includes("mermaid")) {
|
|
2569
|
+
writeFileSync(join2(outputDir, "topology.mermaid"), generateTopologyMermaid(nodes, edges));
|
|
2570
|
+
writeFileSync(join2(outputDir, "dependencies.mermaid"), generateDependencyMermaid(nodes, edges));
|
|
2323
2571
|
}
|
|
2324
2572
|
if (formats.includes("json")) {
|
|
2325
2573
|
writeFileSync(join2(outputDir, "catalog.json"), exportJSON(db, sessionId));
|
|
@@ -2330,13 +2578,777 @@ function exportAll(db, sessionId, outputDir, formats = ["mermaid", "json", "yaml
|
|
|
2330
2578
|
if (formats.includes("html") || formats.includes("map") || formats.includes("discovery")) {
|
|
2331
2579
|
writeFileSync(join2(outputDir, "discovery.html"), exportDiscoveryApp(nodes, edges));
|
|
2332
2580
|
}
|
|
2581
|
+
if (formats.includes("cost")) {
|
|
2582
|
+
const summary = db.getGraphSummary(sessionId);
|
|
2583
|
+
writeFileSync(join2(outputDir, "cost-by-domain.csv"), exportCostCSV(summary));
|
|
2584
|
+
writeFileSync(join2(outputDir, "cost-summary.json"), exportCostSummary(summary));
|
|
2585
|
+
}
|
|
2586
|
+
}
|
|
2587
|
+
|
|
2588
|
+
// src/compliance/report.ts
|
|
2589
|
+
var NODE_CAP = 50;
|
|
2590
|
+
function formatComplianceText(report) {
|
|
2591
|
+
const scoreStr = report.score === null ? "n/a" : `${report.score}/100`;
|
|
2592
|
+
const lines = [
|
|
2593
|
+
`Compliance: ${report.rulesetName} v${report.rulesetVersion} \u2014 ${report.status.toUpperCase()} (score ${scoreStr})`,
|
|
2594
|
+
`Controls: ${report.totals.passed} passed, ${report.totals.failed} failed, ${report.totals.notApplicable} n/a (of ${report.totals.rules})`,
|
|
2595
|
+
"",
|
|
2596
|
+
"By severity (failed/passed):",
|
|
2597
|
+
...["critical", "high", "medium", "low"].map(
|
|
2598
|
+
(s) => ` - ${s}: ${report.bySeverity[s].failed} failed / ${report.bySeverity[s].passed} passed`
|
|
2599
|
+
)
|
|
2600
|
+
];
|
|
2601
|
+
if (report.gaps.length === 0) {
|
|
2602
|
+
lines.push("", "\u2713 No compliance gaps.");
|
|
2603
|
+
return lines.join("\n");
|
|
2604
|
+
}
|
|
2605
|
+
lines.push("", `Gaps (${report.gaps.length}):`);
|
|
2606
|
+
for (const g of report.gaps) {
|
|
2607
|
+
lines.push(` \u2717 [${g.severity}] ${g.control} \u2014 ${g.title}`);
|
|
2608
|
+
const shown = g.nodeIds.slice(0, NODE_CAP);
|
|
2609
|
+
for (const id of shown) lines.push(` ${id}`);
|
|
2610
|
+
if (g.nodeIds.length > NODE_CAP) lines.push(` \u2026 +${g.nodeIds.length - NODE_CAP} more`);
|
|
2611
|
+
}
|
|
2612
|
+
return lines.join("\n");
|
|
2613
|
+
}
|
|
2614
|
+
|
|
2615
|
+
// src/cost.ts
|
|
2616
|
+
import { readFileSync as readFileSync3 } from "fs";
|
|
2617
|
+
import { resolve } from "path";
|
|
2618
|
+
function splitCsvLine(line) {
|
|
2619
|
+
const out = [];
|
|
2620
|
+
let cur = "";
|
|
2621
|
+
let inQuotes = false;
|
|
2622
|
+
for (let i = 0; i < line.length; i++) {
|
|
2623
|
+
const ch = line[i];
|
|
2624
|
+
if (inQuotes) {
|
|
2625
|
+
if (ch === '"') {
|
|
2626
|
+
if (line[i + 1] === '"') {
|
|
2627
|
+
cur += '"';
|
|
2628
|
+
i++;
|
|
2629
|
+
} else {
|
|
2630
|
+
inQuotes = false;
|
|
2631
|
+
}
|
|
2632
|
+
} else cur += ch;
|
|
2633
|
+
} else if (ch === '"') {
|
|
2634
|
+
inQuotes = true;
|
|
2635
|
+
} else if (ch === ",") {
|
|
2636
|
+
out.push(cur);
|
|
2637
|
+
cur = "";
|
|
2638
|
+
} else cur += ch;
|
|
2639
|
+
}
|
|
2640
|
+
out.push(cur);
|
|
2641
|
+
return out.map((s) => s.trim());
|
|
2642
|
+
}
|
|
2643
|
+
function parseCostCsv(text) {
|
|
2644
|
+
const lines = text.split(/\r?\n/).filter((l) => l.trim().length > 0);
|
|
2645
|
+
if (lines.length === 0) return [];
|
|
2646
|
+
const header = splitCsvLine(lines[0]).map((h) => h.toLowerCase());
|
|
2647
|
+
const col = (name) => header.indexOf(name);
|
|
2648
|
+
const iNode = col("nodeid");
|
|
2649
|
+
const iOwner = col("owner");
|
|
2650
|
+
const iAmount = col("amount");
|
|
2651
|
+
const iCurrency = col("currency");
|
|
2652
|
+
const iPeriod = col("period");
|
|
2653
|
+
const iSource = col("source");
|
|
2654
|
+
if (iNode < 0) {
|
|
2655
|
+
logWarn('cost csv: missing required "nodeId" header column');
|
|
2656
|
+
return [];
|
|
2657
|
+
}
|
|
2658
|
+
const records = [];
|
|
2659
|
+
for (let r = 1; r < lines.length; r++) {
|
|
2660
|
+
const f = splitCsvLine(lines[r]);
|
|
2661
|
+
const nodeId = f[iNode];
|
|
2662
|
+
if (!nodeId) {
|
|
2663
|
+
logWarn(`cost csv: row ${r + 1} skipped (empty nodeId)`);
|
|
2664
|
+
continue;
|
|
2665
|
+
}
|
|
2666
|
+
const rec = { nodeId };
|
|
2667
|
+
if (iOwner >= 0 && f[iOwner]) rec.owner = f[iOwner];
|
|
2668
|
+
const amountRaw = iAmount >= 0 ? f[iAmount] : "";
|
|
2669
|
+
if (amountRaw) {
|
|
2670
|
+
const parsed = CostEntrySchema.safeParse({
|
|
2671
|
+
amount: Number(amountRaw),
|
|
2672
|
+
currency: iCurrency >= 0 ? f[iCurrency] : void 0,
|
|
2673
|
+
period: iPeriod >= 0 ? f[iPeriod] : void 0,
|
|
2674
|
+
...iSource >= 0 && f[iSource] ? { source: f[iSource] } : {}
|
|
2675
|
+
});
|
|
2676
|
+
if (!parsed.success) {
|
|
2677
|
+
logWarn(`cost csv: row ${r + 1} skipped (invalid cost fields)`);
|
|
2678
|
+
if (!rec.owner) continue;
|
|
2679
|
+
} else {
|
|
2680
|
+
rec.cost = parsed.data;
|
|
2681
|
+
}
|
|
2682
|
+
}
|
|
2683
|
+
if (rec.owner || rec.cost) records.push(rec);
|
|
2684
|
+
}
|
|
2685
|
+
return records;
|
|
2686
|
+
}
|
|
2687
|
+
var CsvCostSource = class {
|
|
2688
|
+
constructor(opts) {
|
|
2689
|
+
this.opts = opts;
|
|
2690
|
+
const base = opts.filePath.split(/[\\/]/).pop() ?? opts.filePath;
|
|
2691
|
+
this.id = `csv:${base}`;
|
|
2692
|
+
}
|
|
2693
|
+
id;
|
|
2694
|
+
async fetch() {
|
|
2695
|
+
const text = readFileSync3(resolve(this.opts.filePath), "utf-8");
|
|
2696
|
+
const records = parseCostCsv(text);
|
|
2697
|
+
const match = this.opts.match ?? "nodeId";
|
|
2698
|
+
const out = /* @__PURE__ */ new Map();
|
|
2699
|
+
if (match === "nodeId") {
|
|
2700
|
+
for (const rec of records) out.set(rec.nodeId, rec);
|
|
2701
|
+
return out;
|
|
2702
|
+
}
|
|
2703
|
+
if (!this.opts.db || !this.opts.sessionId) {
|
|
2704
|
+
logWarn(`cost csv: match '${match}' requires db + sessionId; falling back to nodeId`);
|
|
2705
|
+
for (const rec of records) out.set(rec.nodeId, rec);
|
|
2706
|
+
return out;
|
|
2707
|
+
}
|
|
2708
|
+
const nodes = this.opts.db.getNodes(this.opts.sessionId);
|
|
2709
|
+
const index = /* @__PURE__ */ new Map();
|
|
2710
|
+
for (const n of nodes) {
|
|
2711
|
+
if (match === "name") index.set(n.name, n.id);
|
|
2712
|
+
else for (const t of n.tags) index.set(t, n.id);
|
|
2713
|
+
}
|
|
2714
|
+
for (const rec of records) {
|
|
2715
|
+
const resolved = index.get(rec.nodeId);
|
|
2716
|
+
out.set(resolved ?? rec.nodeId, { ...rec, nodeId: resolved ?? rec.nodeId });
|
|
2717
|
+
}
|
|
2718
|
+
return out;
|
|
2719
|
+
}
|
|
2720
|
+
};
|
|
2721
|
+
async function enrichCosts(db, sessionId, source) {
|
|
2722
|
+
const records = await source.fetch();
|
|
2723
|
+
let matched = 0;
|
|
2724
|
+
const unmatchedIds = [];
|
|
2725
|
+
for (const [nodeId, rec] of records) {
|
|
2726
|
+
const ok = db.enrichNodeAttribution(sessionId, nodeId, {
|
|
2727
|
+
owner: rec.owner ?? void 0,
|
|
2728
|
+
cost: rec.cost ?? void 0
|
|
2729
|
+
});
|
|
2730
|
+
if (ok) matched++;
|
|
2731
|
+
else unmatchedIds.push(nodeId);
|
|
2732
|
+
}
|
|
2733
|
+
return { source: source.id, total: records.size, matched, unmatched: unmatchedIds.length, unmatchedIds };
|
|
2333
2734
|
}
|
|
2334
2735
|
|
|
2335
2736
|
// src/cli.ts
|
|
2336
|
-
import { readFileSync as
|
|
2337
|
-
import { resolve, dirname } from "path";
|
|
2737
|
+
import { readFileSync as readFileSync5, existsSync as existsSync3, writeFileSync as writeFileSync3 } from "fs";
|
|
2738
|
+
import { resolve as resolve2, dirname as dirname2 } from "path";
|
|
2338
2739
|
import { fileURLToPath } from "url";
|
|
2339
2740
|
import { createInterface } from "readline";
|
|
2741
|
+
|
|
2742
|
+
// src/sharing.ts
|
|
2743
|
+
function wildcardCount(pattern) {
|
|
2744
|
+
return (pattern.match(/\*/g) ?? []).length;
|
|
2745
|
+
}
|
|
2746
|
+
function globMatch(pattern, id) {
|
|
2747
|
+
let re = "^";
|
|
2748
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
2749
|
+
const c = pattern[i];
|
|
2750
|
+
if (c === "*") {
|
|
2751
|
+
if (pattern[i + 1] === "*") {
|
|
2752
|
+
re += ".*";
|
|
2753
|
+
i++;
|
|
2754
|
+
} else re += "[^:]*";
|
|
2755
|
+
} else {
|
|
2756
|
+
re += c.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
|
|
2757
|
+
}
|
|
2758
|
+
}
|
|
2759
|
+
re += "$";
|
|
2760
|
+
return new RegExp(re).test(id);
|
|
2761
|
+
}
|
|
2762
|
+
function resolveSharingLevel(nodeId, policy) {
|
|
2763
|
+
const matches2 = policy.overrides.filter((o) => o.pattern !== "*" && o.pattern !== "**" && globMatch(o.pattern, nodeId)).sort(
|
|
2764
|
+
(a, b) => wildcardCount(a.pattern) - wildcardCount(b.pattern) || b.pattern.length - a.pattern.length
|
|
2765
|
+
);
|
|
2766
|
+
return matches2.length ? matches2[0].level : policy.defaultLevel;
|
|
2767
|
+
}
|
|
2768
|
+
function nodeHosts(node) {
|
|
2769
|
+
const out = [node.id, node.name];
|
|
2770
|
+
const meta = node.metadata ?? {};
|
|
2771
|
+
for (const k of ["host", "url", "domain"]) {
|
|
2772
|
+
const v = meta[k];
|
|
2773
|
+
if (typeof v === "string") out.push(v);
|
|
2774
|
+
}
|
|
2775
|
+
if (node.domain) out.push(node.domain);
|
|
2776
|
+
return out;
|
|
2777
|
+
}
|
|
2778
|
+
function resolveEffectiveLevel(node, policy) {
|
|
2779
|
+
if (nodeHosts(node).some((h) => isPersonalHost(h))) return "none";
|
|
2780
|
+
return resolveSharingLevel(node.id, policy);
|
|
2781
|
+
}
|
|
2782
|
+
function applySharingLevel(node, level, orgKey, db) {
|
|
2783
|
+
if (level === "none") return null;
|
|
2784
|
+
if (level === "full") return { ...node, metadata: { ...node.metadata ?? {} }, tags: [...node.tags ?? []] };
|
|
2785
|
+
return {
|
|
2786
|
+
...node,
|
|
2787
|
+
id: pseudonymizeString(node.id, orgKey, db),
|
|
2788
|
+
name: pseudonymizeString(node.name, orgKey, db),
|
|
2789
|
+
metadata: pseudonymize(node.metadata ?? {}, orgKey, db),
|
|
2790
|
+
tags: (node.tags ?? []).map((t) => pseudonymizeString(t, orgKey, db))
|
|
2791
|
+
};
|
|
2792
|
+
}
|
|
2793
|
+
function previewShare(db, sessionId, orgKey, policy, opts = {}) {
|
|
2794
|
+
const persist = opts.persistReversal ? db : void 0;
|
|
2795
|
+
const nodes = db.getNodes(sessionId);
|
|
2796
|
+
const edges = db.getEdges(sessionId);
|
|
2797
|
+
const entries = [];
|
|
2798
|
+
const idMap = /* @__PURE__ */ new Map();
|
|
2799
|
+
const droppedNodeIds = [];
|
|
2800
|
+
for (const node of nodes) {
|
|
2801
|
+
const level = resolveEffectiveLevel(node, policy);
|
|
2802
|
+
const payload = applySharingLevel(node, level, orgKey, persist);
|
|
2803
|
+
entries.push({ node, level, payload });
|
|
2804
|
+
if (payload === null) {
|
|
2805
|
+
idMap.set(node.id, null);
|
|
2806
|
+
droppedNodeIds.push(node.id);
|
|
2807
|
+
} else {
|
|
2808
|
+
idMap.set(node.id, payload.id);
|
|
2809
|
+
}
|
|
2810
|
+
}
|
|
2811
|
+
const outEdges = [];
|
|
2812
|
+
for (const e of edges) {
|
|
2813
|
+
const src = idMap.get(e.sourceId);
|
|
2814
|
+
const tgt = idMap.get(e.targetId);
|
|
2815
|
+
if (src == null || tgt == null) continue;
|
|
2816
|
+
outEdges.push({ sourceId: src, targetId: tgt, relationship: e.relationship });
|
|
2817
|
+
}
|
|
2818
|
+
return { nodes: entries, edges: outEdges, droppedNodeIds };
|
|
2819
|
+
}
|
|
2820
|
+
function isRemembered(policy, nodeId) {
|
|
2821
|
+
const matched = policy.overrides.some((o) => o.pattern !== "*" && o.pattern !== "**" && globMatch(o.pattern, nodeId));
|
|
2822
|
+
return matched || policy.defaultLevel !== "none";
|
|
2823
|
+
}
|
|
2824
|
+
|
|
2825
|
+
// src/sync/hash.ts
|
|
2826
|
+
import { createHash } from "crypto";
|
|
2827
|
+
function shareHash(kind, payload) {
|
|
2828
|
+
return createHash("sha256").update(stableStringify({ kind, payload })).digest("hex");
|
|
2829
|
+
}
|
|
2830
|
+
|
|
2831
|
+
// src/sync/classify.ts
|
|
2832
|
+
function classify(input) {
|
|
2833
|
+
const { preview, policy, sharedHashes } = input;
|
|
2834
|
+
const result = { share: [], withhold: [], pending: [] };
|
|
2835
|
+
const sharedNodeIds = /* @__PURE__ */ new Set();
|
|
2836
|
+
for (const entry of preview.nodes) {
|
|
2837
|
+
if (entry.payload === null) {
|
|
2838
|
+
result.withhold.push({ contentHash: "", kind: "node", nodeId: entry.node.id, payload: null });
|
|
2839
|
+
continue;
|
|
2840
|
+
}
|
|
2841
|
+
const contentHash = shareHash("node", entry.payload);
|
|
2842
|
+
if (sharedHashes.has(contentHash)) continue;
|
|
2843
|
+
const item = { contentHash, kind: "node", nodeId: entry.node.id, payload: entry.payload };
|
|
2844
|
+
if (isRemembered(policy, entry.node.id)) {
|
|
2845
|
+
result.share.push(item);
|
|
2846
|
+
sharedNodeIds.add(entry.node.id);
|
|
2847
|
+
} else {
|
|
2848
|
+
result.pending.push(item);
|
|
2849
|
+
}
|
|
2850
|
+
}
|
|
2851
|
+
const sharedRemappedIds = /* @__PURE__ */ new Set();
|
|
2852
|
+
for (const entry of preview.nodes) {
|
|
2853
|
+
if (entry.payload !== null && sharedNodeIds.has(entry.node.id)) {
|
|
2854
|
+
sharedRemappedIds.add(entry.payload.id);
|
|
2855
|
+
}
|
|
2856
|
+
}
|
|
2857
|
+
for (const e of preview.edges) {
|
|
2858
|
+
const payload = { sourceId: e.sourceId, targetId: e.targetId, relationship: e.relationship };
|
|
2859
|
+
const contentHash = shareHash("edge", payload);
|
|
2860
|
+
const bothShared = sharedRemappedIds.has(e.sourceId) && sharedRemappedIds.has(e.targetId);
|
|
2861
|
+
if (!bothShared) {
|
|
2862
|
+
result.withhold.push({ contentHash: "", kind: "edge", payload });
|
|
2863
|
+
continue;
|
|
2864
|
+
}
|
|
2865
|
+
if (sharedHashes.has(contentHash)) continue;
|
|
2866
|
+
result.share.push({ contentHash, kind: "edge", payload });
|
|
2867
|
+
}
|
|
2868
|
+
return result;
|
|
2869
|
+
}
|
|
2870
|
+
|
|
2871
|
+
// src/sync/push.ts
|
|
2872
|
+
import { createHash as createHash2 } from "crypto";
|
|
2873
|
+
var PUSH_SCHEMA_VERSION = 1;
|
|
2874
|
+
var DEFAULT_BATCH = 100;
|
|
2875
|
+
var DEFAULT_RETRIES = 4;
|
|
2876
|
+
var DEFAULT_TIMEOUT_MS = 15e3;
|
|
2877
|
+
function defaultLog(line) {
|
|
2878
|
+
process.stderr.write(`[cartography-sync] ${line}
|
|
2879
|
+
`);
|
|
2880
|
+
}
|
|
2881
|
+
function defaultSleep(ms) {
|
|
2882
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
2883
|
+
}
|
|
2884
|
+
function batchKey(items) {
|
|
2885
|
+
const hashes = items.map((i) => i.contentHash).sort();
|
|
2886
|
+
return createHash2("sha256").update(stableStringify(hashes)).digest("hex");
|
|
2887
|
+
}
|
|
2888
|
+
async function pushDeltas(config, items, opts = {}) {
|
|
2889
|
+
const central = config.centralDb;
|
|
2890
|
+
if (!central?.url || !central.token) {
|
|
2891
|
+
throw new Error("sync push: centralDb not configured (set centralDb.url + token)");
|
|
2892
|
+
}
|
|
2893
|
+
let parsed;
|
|
2894
|
+
try {
|
|
2895
|
+
parsed = new URL(central.url);
|
|
2896
|
+
} catch {
|
|
2897
|
+
throw new Error("sync push: centralDb.url is not a valid URL");
|
|
2898
|
+
}
|
|
2899
|
+
const insecureAllowed = process.env.CARTOGRAPHY_ALLOW_INSECURE_SYNC === "1";
|
|
2900
|
+
if (parsed.protocol !== "https:" && !insecureAllowed) {
|
|
2901
|
+
throw new Error(
|
|
2902
|
+
`sync push: refusing to send over insecure ${parsed.protocol}// \u2014 use https:// (or set CARTOGRAPHY_ALLOW_INSECURE_SYNC=1 for local testing only)`
|
|
2903
|
+
);
|
|
2904
|
+
}
|
|
2905
|
+
const log = opts.log ?? defaultLog;
|
|
2906
|
+
const sleep = opts.sleep ?? defaultSleep;
|
|
2907
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
2908
|
+
const batchSize = Math.max(1, opts.batchSize ?? central.batchSize ?? DEFAULT_BATCH);
|
|
2909
|
+
const maxRetries = Math.max(0, opts.maxRetries ?? DEFAULT_RETRIES);
|
|
2910
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
2911
|
+
const safeUrl = stripSensitive(central.url);
|
|
2912
|
+
if (items.length === 0) {
|
|
2913
|
+
log("nothing to push (0 approved items)");
|
|
2914
|
+
return { sent: 0, batches: 0, failed: 0, sentHashes: [] };
|
|
2915
|
+
}
|
|
2916
|
+
const batches = [];
|
|
2917
|
+
for (let i = 0; i < items.length; i += batchSize) {
|
|
2918
|
+
batches.push(items.slice(i, i + batchSize).map((it) => ({ ...it, payload: redactValue(it.payload) })));
|
|
2919
|
+
}
|
|
2920
|
+
let sent = 0;
|
|
2921
|
+
let failed = 0;
|
|
2922
|
+
const sentHashes = [];
|
|
2923
|
+
for (const batch of batches) {
|
|
2924
|
+
const key = batchKey(batch);
|
|
2925
|
+
const body = JSON.stringify({
|
|
2926
|
+
schemaVersion: PUSH_SCHEMA_VERSION,
|
|
2927
|
+
...central.org ? { org: central.org } : {},
|
|
2928
|
+
items: batch.map((b) => ({ contentHash: b.contentHash, kind: b.kind, payload: b.payload }))
|
|
2929
|
+
});
|
|
2930
|
+
if (opts.dryRun) {
|
|
2931
|
+
log(`dry-run: would POST ${batch.length} item(s) to ${safeUrl} (idempotency ${key.slice(0, 12)}\u2026)`);
|
|
2932
|
+
sent += batch.length;
|
|
2933
|
+
sentHashes.push(...batch.map((b) => b.contentHash));
|
|
2934
|
+
continue;
|
|
2935
|
+
}
|
|
2936
|
+
let ok = false;
|
|
2937
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
2938
|
+
const controller = new AbortController();
|
|
2939
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
2940
|
+
const startedAt = Date.now();
|
|
2941
|
+
try {
|
|
2942
|
+
const res = await fetchImpl(central.url, {
|
|
2943
|
+
method: "POST",
|
|
2944
|
+
headers: {
|
|
2945
|
+
"authorization": `Bearer ${central.token}`,
|
|
2946
|
+
"content-type": "application/json",
|
|
2947
|
+
"x-idempotency-key": key
|
|
2948
|
+
},
|
|
2949
|
+
body,
|
|
2950
|
+
signal: controller.signal
|
|
2951
|
+
});
|
|
2952
|
+
const elapsed = Date.now() - startedAt;
|
|
2953
|
+
if (res.ok) {
|
|
2954
|
+
log(`pushed ${batch.length} item(s) \u2192 ${safeUrl} [${res.status}] ${elapsed}ms (attempt ${attempt + 1})`);
|
|
2955
|
+
ok = true;
|
|
2956
|
+
break;
|
|
2957
|
+
}
|
|
2958
|
+
if (res.status >= 400 && res.status < 500) {
|
|
2959
|
+
log(`batch rejected \u2192 ${safeUrl} [${res.status}] (no retry)`);
|
|
2960
|
+
break;
|
|
2961
|
+
}
|
|
2962
|
+
log(`batch failed \u2192 ${safeUrl} [${res.status}] (attempt ${attempt + 1}/${maxRetries + 1})`);
|
|
2963
|
+
} catch (err) {
|
|
2964
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2965
|
+
log(`batch error \u2192 ${safeUrl}: ${msg.replace(/https?:\/\/[^\s]+/g, (u) => stripSensitive(u))} (attempt ${attempt + 1}/${maxRetries + 1})`);
|
|
2966
|
+
} finally {
|
|
2967
|
+
clearTimeout(timer);
|
|
2968
|
+
}
|
|
2969
|
+
if (attempt < maxRetries) {
|
|
2970
|
+
const base = Math.min(2 ** attempt * 250, 4e3);
|
|
2971
|
+
await sleep(base + Math.floor(Math.random() * 100));
|
|
2972
|
+
}
|
|
2973
|
+
}
|
|
2974
|
+
if (ok) {
|
|
2975
|
+
sent += batch.length;
|
|
2976
|
+
sentHashes.push(...batch.map((b) => b.contentHash));
|
|
2977
|
+
} else {
|
|
2978
|
+
failed += batch.length;
|
|
2979
|
+
}
|
|
2980
|
+
}
|
|
2981
|
+
return { sent, batches: batches.length, failed, sentHashes };
|
|
2982
|
+
}
|
|
2983
|
+
|
|
2984
|
+
// src/sync/index.ts
|
|
2985
|
+
function runSyncClassify(db, sessionId, config, opts = {}) {
|
|
2986
|
+
if (!config.centralDb?.url) return { enqueued: 0, autoShared: 0, withheld: 0 };
|
|
2987
|
+
const orgKey = opts.orgKey ?? loadOrgKey({ organization: config.organization });
|
|
2988
|
+
const policy = db.getSharingPolicy();
|
|
2989
|
+
const preview = previewShare(db, sessionId, orgKey, policy, { persistReversal: true });
|
|
2990
|
+
const sharedHashes = db.getSharedHashes();
|
|
2991
|
+
const { share, pending, withhold } = classify({ preview, policy, sharedHashes });
|
|
2992
|
+
const writeAll = db.rawConnection().transaction(() => {
|
|
2993
|
+
for (const item of share) {
|
|
2994
|
+
db.enqueuePending({
|
|
2995
|
+
contentHash: item.contentHash,
|
|
2996
|
+
sessionId,
|
|
2997
|
+
nodeId: item.nodeId,
|
|
2998
|
+
kind: item.kind,
|
|
2999
|
+
payload: item.payload,
|
|
3000
|
+
status: "approved",
|
|
3001
|
+
decidedBy: "rule"
|
|
3002
|
+
});
|
|
3003
|
+
}
|
|
3004
|
+
for (const item of pending) {
|
|
3005
|
+
db.enqueuePending({
|
|
3006
|
+
contentHash: item.contentHash,
|
|
3007
|
+
sessionId,
|
|
3008
|
+
nodeId: item.nodeId,
|
|
3009
|
+
kind: item.kind,
|
|
3010
|
+
payload: item.payload,
|
|
3011
|
+
status: "pending"
|
|
3012
|
+
});
|
|
3013
|
+
}
|
|
3014
|
+
for (const item of withhold) {
|
|
3015
|
+
if (!item.contentHash) continue;
|
|
3016
|
+
db.enqueuePending({
|
|
3017
|
+
contentHash: item.contentHash,
|
|
3018
|
+
sessionId,
|
|
3019
|
+
nodeId: item.nodeId,
|
|
3020
|
+
kind: item.kind,
|
|
3021
|
+
payload: item.payload,
|
|
3022
|
+
status: "withheld",
|
|
3023
|
+
decidedBy: "rule"
|
|
3024
|
+
});
|
|
3025
|
+
}
|
|
3026
|
+
});
|
|
3027
|
+
writeAll();
|
|
3028
|
+
return { enqueued: pending.length, autoShared: share.length, withheld: withhold.length };
|
|
3029
|
+
}
|
|
3030
|
+
|
|
3031
|
+
// src/installer/format.ts
|
|
3032
|
+
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
|
|
3033
|
+
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
|
3034
|
+
function parseConfig(text, format) {
|
|
3035
|
+
if (!text.trim()) return {};
|
|
3036
|
+
try {
|
|
3037
|
+
switch (format) {
|
|
3038
|
+
case "json":
|
|
3039
|
+
return JSON.parse(text);
|
|
3040
|
+
case "toml":
|
|
3041
|
+
return parseToml(text);
|
|
3042
|
+
case "yaml":
|
|
3043
|
+
return parseYaml(text) ?? {};
|
|
3044
|
+
}
|
|
3045
|
+
} catch (err) {
|
|
3046
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
3047
|
+
throw new Error(`Failed to parse existing ${format.toUpperCase()} config: ${detail}`);
|
|
3048
|
+
}
|
|
3049
|
+
}
|
|
3050
|
+
function serializeConfig(obj, format) {
|
|
3051
|
+
switch (format) {
|
|
3052
|
+
case "json":
|
|
3053
|
+
return JSON.stringify(obj, null, 2) + "\n";
|
|
3054
|
+
case "toml":
|
|
3055
|
+
return stringifyToml(obj) + "\n";
|
|
3056
|
+
case "yaml":
|
|
3057
|
+
return stringifyYaml(obj);
|
|
3058
|
+
}
|
|
3059
|
+
}
|
|
3060
|
+
|
|
3061
|
+
// src/installer/merge.ts
|
|
3062
|
+
function isPlainObject(v) {
|
|
3063
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
3064
|
+
}
|
|
3065
|
+
function deepMerge(target, source) {
|
|
3066
|
+
const out = { ...target };
|
|
3067
|
+
for (const [key, value] of Object.entries(source)) {
|
|
3068
|
+
const existing = out[key];
|
|
3069
|
+
if (isPlainObject(existing) && isPlainObject(value)) {
|
|
3070
|
+
out[key] = deepMerge(existing, value);
|
|
3071
|
+
} else {
|
|
3072
|
+
out[key] = value;
|
|
3073
|
+
}
|
|
3074
|
+
}
|
|
3075
|
+
return out;
|
|
3076
|
+
}
|
|
3077
|
+
|
|
3078
|
+
// src/installer/shapes.ts
|
|
3079
|
+
function mcpServerObject(entry) {
|
|
3080
|
+
if (entry.url) {
|
|
3081
|
+
return { type: "http", url: entry.url, ...entry.env ? { env: entry.env } : {} };
|
|
3082
|
+
}
|
|
3083
|
+
return {
|
|
3084
|
+
command: entry.command,
|
|
3085
|
+
args: entry.args ?? [],
|
|
3086
|
+
...entry.env ? { env: entry.env } : {}
|
|
3087
|
+
};
|
|
3088
|
+
}
|
|
3089
|
+
|
|
3090
|
+
// src/installer/entry.ts
|
|
3091
|
+
var PACKAGE_NAME = "@datasynx/agentic-ai-cartography";
|
|
3092
|
+
var MCP_BIN = "cartography-mcp";
|
|
3093
|
+
var DEFAULT_SERVER_NAME = "cartography";
|
|
3094
|
+
function defaultServerEntry(opts = {}) {
|
|
3095
|
+
if (opts.transport === "http") {
|
|
3096
|
+
return { url: opts.url ?? "http://127.0.0.1:3737/mcp", ...opts.env ? { env: opts.env } : {} };
|
|
3097
|
+
}
|
|
3098
|
+
const args = ["-y", "--package", PACKAGE_NAME, MCP_BIN, ...opts.packageArgs ?? []];
|
|
3099
|
+
return { command: "npx", args, ...opts.env ? { env: opts.env } : {} };
|
|
3100
|
+
}
|
|
3101
|
+
|
|
3102
|
+
// src/installer/install.ts
|
|
3103
|
+
import { mkdirSync as mkdirSync2, readFileSync as readFileSync4, writeFileSync as writeFileSync2, existsSync as existsSync2 } from "fs";
|
|
3104
|
+
import { dirname } from "path";
|
|
3105
|
+
import { homedir } from "os";
|
|
3106
|
+
function currentOs() {
|
|
3107
|
+
if (process.platform === "win32") return "win";
|
|
3108
|
+
if (process.platform === "darwin") return "mac";
|
|
3109
|
+
return "linux";
|
|
3110
|
+
}
|
|
3111
|
+
function defaultContext(scope) {
|
|
3112
|
+
return { scope, os: currentOs(), home: homedir(), cwd: process.cwd(), env: process.env };
|
|
3113
|
+
}
|
|
3114
|
+
function planInstall(spec, ctx, opts) {
|
|
3115
|
+
const path = spec.path(ctx);
|
|
3116
|
+
if (!path) {
|
|
3117
|
+
throw new Error(`${spec.label} does not support the "${ctx.scope}" scope.`);
|
|
3118
|
+
}
|
|
3119
|
+
const fileExists = existsSync2(path);
|
|
3120
|
+
const before = fileExists ? readFileSync4(path, "utf8") : "";
|
|
3121
|
+
const existing = parseConfig(before, spec.format);
|
|
3122
|
+
const merged = spec.apply(existing, opts.serverName ?? DEFAULT_SERVER_NAME, opts.entry);
|
|
3123
|
+
const after = serializeConfig(merged, spec.format);
|
|
3124
|
+
return {
|
|
3125
|
+
client: spec.id,
|
|
3126
|
+
label: spec.label,
|
|
3127
|
+
path,
|
|
3128
|
+
format: spec.format,
|
|
3129
|
+
before,
|
|
3130
|
+
after,
|
|
3131
|
+
fileExists,
|
|
3132
|
+
changed: after !== before,
|
|
3133
|
+
...spec.note ? { note: spec.note } : {}
|
|
3134
|
+
};
|
|
3135
|
+
}
|
|
3136
|
+
function applyInstall(plan) {
|
|
3137
|
+
mkdirSync2(dirname(plan.path), { recursive: true });
|
|
3138
|
+
writeFileSync2(plan.path, plan.after, "utf8");
|
|
3139
|
+
}
|
|
3140
|
+
function renderDiff(before, after) {
|
|
3141
|
+
if (before === after) return " (no changes)";
|
|
3142
|
+
const b = before.length ? before.split("\n") : [];
|
|
3143
|
+
const a = after.split("\n");
|
|
3144
|
+
const out = [];
|
|
3145
|
+
const max = Math.max(b.length, a.length);
|
|
3146
|
+
for (let i = 0; i < max; i++) {
|
|
3147
|
+
if (b[i] === a[i]) {
|
|
3148
|
+
if (a[i] !== void 0) out.push(` ${a[i]}`);
|
|
3149
|
+
} else {
|
|
3150
|
+
if (b[i] !== void 0) out.push(`- ${b[i]}`);
|
|
3151
|
+
if (a[i] !== void 0) out.push(`+ ${a[i]}`);
|
|
3152
|
+
}
|
|
3153
|
+
}
|
|
3154
|
+
return out.join("\n");
|
|
3155
|
+
}
|
|
3156
|
+
|
|
3157
|
+
// src/installer/registry.ts
|
|
3158
|
+
import { join as join3 } from "path";
|
|
3159
|
+
function jsonKeyedClient(args) {
|
|
3160
|
+
return {
|
|
3161
|
+
id: args.id,
|
|
3162
|
+
label: args.label,
|
|
3163
|
+
format: "json",
|
|
3164
|
+
note: args.note,
|
|
3165
|
+
path: (ctx) => ctx.scope === "project" ? args.projectPath?.(ctx) : args.globalPath(ctx),
|
|
3166
|
+
apply: (existing, name, entry) => deepMerge(existing, { [args.key]: { [name]: mcpServerObject(entry) } })
|
|
3167
|
+
};
|
|
3168
|
+
}
|
|
3169
|
+
var claudeCode = jsonKeyedClient({
|
|
3170
|
+
id: "claude-code",
|
|
3171
|
+
label: "Claude Code",
|
|
3172
|
+
key: "mcpServers",
|
|
3173
|
+
globalPath: (ctx) => join3(ctx.home, ".claude.json"),
|
|
3174
|
+
projectPath: (ctx) => join3(ctx.cwd, ".mcp.json")
|
|
3175
|
+
});
|
|
3176
|
+
var cursor = jsonKeyedClient({
|
|
3177
|
+
id: "cursor",
|
|
3178
|
+
label: "Cursor",
|
|
3179
|
+
key: "mcpServers",
|
|
3180
|
+
globalPath: (ctx) => join3(ctx.home, ".cursor", "mcp.json"),
|
|
3181
|
+
projectPath: (ctx) => join3(ctx.cwd, ".cursor", "mcp.json")
|
|
3182
|
+
});
|
|
3183
|
+
function vscodeServerObject(entry) {
|
|
3184
|
+
if (entry.url) return { type: "http", url: entry.url, ...entry.env ? { env: entry.env } : {} };
|
|
3185
|
+
return { type: "stdio", command: entry.command, args: entry.args ?? [], ...entry.env ? { env: entry.env } : {} };
|
|
3186
|
+
}
|
|
3187
|
+
function vscodeUserDir(ctx) {
|
|
3188
|
+
if (ctx.os === "win") return join3(ctx.env.APPDATA ?? join3(ctx.home, "AppData", "Roaming"), "Code", "User");
|
|
3189
|
+
if (ctx.os === "mac") return join3(ctx.home, "Library", "Application Support", "Code", "User");
|
|
3190
|
+
return join3(ctx.home, ".config", "Code", "User");
|
|
3191
|
+
}
|
|
3192
|
+
var vscode = {
|
|
3193
|
+
id: "vscode",
|
|
3194
|
+
label: "VS Code (Copilot)",
|
|
3195
|
+
format: "json",
|
|
3196
|
+
note: "Uses the `servers` key (not `mcpServers`) \u2014 the most common copy-paste mistake.",
|
|
3197
|
+
path: (ctx) => ctx.scope === "project" ? join3(ctx.cwd, ".vscode", "mcp.json") : join3(vscodeUserDir(ctx), "mcp.json"),
|
|
3198
|
+
apply: (existing, name, entry) => deepMerge(existing, { servers: { [name]: vscodeServerObject(entry) } })
|
|
3199
|
+
};
|
|
3200
|
+
var codex = {
|
|
3201
|
+
id: "codex",
|
|
3202
|
+
label: "Codex CLI",
|
|
3203
|
+
format: "toml",
|
|
3204
|
+
note: 'Project scope only loads in "trusted" projects.',
|
|
3205
|
+
path: (ctx) => ctx.scope === "project" ? join3(ctx.cwd, ".codex", "config.toml") : join3(ctx.home, ".codex", "config.toml"),
|
|
3206
|
+
apply: (existing, name, entry) => deepMerge(existing, { mcp_servers: { [name]: mcpServerObject(entry) } })
|
|
3207
|
+
};
|
|
3208
|
+
var windsurf = jsonKeyedClient({
|
|
3209
|
+
id: "windsurf",
|
|
3210
|
+
label: "Windsurf",
|
|
3211
|
+
key: "mcpServers",
|
|
3212
|
+
globalPath: (ctx) => join3(ctx.home, ".codeium", "windsurf", "mcp_config.json")
|
|
3213
|
+
});
|
|
3214
|
+
function codeGlobalStorage(ctx, extensionId) {
|
|
3215
|
+
return join3(vscodeUserDir(ctx), "globalStorage", extensionId, "settings", "cline_mcp_settings.json");
|
|
3216
|
+
}
|
|
3217
|
+
var cline = {
|
|
3218
|
+
id: "cline",
|
|
3219
|
+
label: "Cline",
|
|
3220
|
+
format: "json",
|
|
3221
|
+
path: (ctx) => ctx.scope === "project" ? void 0 : codeGlobalStorage(ctx, "saoudrizwan.claude-dev"),
|
|
3222
|
+
// Cline augments the standard object with its own auto-approve/disable flags.
|
|
3223
|
+
apply: (existing, name, entry) => deepMerge(existing, { mcpServers: { [name]: { ...mcpServerObject(entry), alwaysAllow: [], disabled: false } } })
|
|
3224
|
+
};
|
|
3225
|
+
var roo = {
|
|
3226
|
+
id: "roo",
|
|
3227
|
+
label: "Roo Code",
|
|
3228
|
+
format: "json",
|
|
3229
|
+
note: "Project .roo/mcp.json takes precedence over the global settings.",
|
|
3230
|
+
path: (ctx) => ctx.scope === "project" ? join3(ctx.cwd, ".roo", "mcp.json") : codeGlobalStorage(ctx, "rooveterinaryinc.roo-cline"),
|
|
3231
|
+
apply: (existing, name, entry) => deepMerge(existing, { mcpServers: { [name]: mcpServerObject(entry) } })
|
|
3232
|
+
};
|
|
3233
|
+
var zed = {
|
|
3234
|
+
id: "zed",
|
|
3235
|
+
label: "Zed",
|
|
3236
|
+
format: "json",
|
|
3237
|
+
note: 'Manual servers need "source": "custom"; remote uses an mcp-remote bridge.',
|
|
3238
|
+
path: (ctx) => {
|
|
3239
|
+
if (ctx.scope === "project") return join3(ctx.cwd, ".zed", "settings.json");
|
|
3240
|
+
if (ctx.os === "win") return join3(ctx.env.APPDATA ?? join3(ctx.home, "AppData", "Roaming"), "Zed", "settings.json");
|
|
3241
|
+
return join3(ctx.home, ".config", "zed", "settings.json");
|
|
3242
|
+
},
|
|
3243
|
+
apply: (existing, name, entry) => {
|
|
3244
|
+
const inner = entry.url ? { source: "custom", url: entry.url } : { source: "custom", command: entry.command, args: entry.args ?? [], ...entry.env ? { env: entry.env } : {} };
|
|
3245
|
+
return deepMerge(existing, { context_servers: { [name]: inner } });
|
|
3246
|
+
}
|
|
3247
|
+
};
|
|
3248
|
+
var junie = {
|
|
3249
|
+
id: "junie",
|
|
3250
|
+
label: "JetBrains / Junie",
|
|
3251
|
+
format: "json",
|
|
3252
|
+
path: (ctx) => ctx.scope === "project" ? join3(ctx.cwd, ".junie", "mcp", "mcp.json") : join3(ctx.home, ".junie", "mcp", "mcp.json"),
|
|
3253
|
+
apply: (existing, name, entry) => deepMerge(existing, { mcpServers: { [name]: mcpServerObject(entry) } })
|
|
3254
|
+
};
|
|
3255
|
+
var gemini = {
|
|
3256
|
+
id: "gemini",
|
|
3257
|
+
label: "Gemini CLI",
|
|
3258
|
+
format: "json",
|
|
3259
|
+
path: (ctx) => ctx.scope === "project" ? join3(ctx.cwd, ".gemini", "settings.json") : join3(ctx.home, ".gemini", "settings.json"),
|
|
3260
|
+
apply: (existing, name, entry) => {
|
|
3261
|
+
const inner = entry.url ? { httpUrl: entry.url, ...entry.env ? { env: entry.env } : {} } : mcpServerObject(entry);
|
|
3262
|
+
return deepMerge(existing, { mcpServers: { [name]: inner } });
|
|
3263
|
+
}
|
|
3264
|
+
};
|
|
3265
|
+
var goose = {
|
|
3266
|
+
id: "goose",
|
|
3267
|
+
label: "Goose",
|
|
3268
|
+
format: "yaml",
|
|
3269
|
+
note: "Verify the extension shape against current Goose docs; built-ins are left untouched.",
|
|
3270
|
+
path: (ctx) => {
|
|
3271
|
+
if (ctx.os === "win") return join3(ctx.env.APPDATA ?? join3(ctx.home, "AppData", "Roaming"), "Block", "goose", "config", "config.yaml");
|
|
3272
|
+
return join3(ctx.home, ".config", "goose", "config.yaml");
|
|
3273
|
+
},
|
|
3274
|
+
apply: (existing, name, entry) => {
|
|
3275
|
+
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 } : {} };
|
|
3276
|
+
return deepMerge(existing, { extensions: { [name]: inner } });
|
|
3277
|
+
}
|
|
3278
|
+
};
|
|
3279
|
+
function isObj(v) {
|
|
3280
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
3281
|
+
}
|
|
3282
|
+
var openhands = {
|
|
3283
|
+
id: "openhands",
|
|
3284
|
+
label: "OpenHands",
|
|
3285
|
+
format: "toml",
|
|
3286
|
+
note: "SHTTP is preferred; SSE is legacy. Only api_key is supported (no arbitrary headers).",
|
|
3287
|
+
path: (ctx) => ctx.scope === "project" ? join3(ctx.cwd, "config.toml") : join3(ctx.home, ".openhands", "config.toml"),
|
|
3288
|
+
apply: (existing, name, entry) => {
|
|
3289
|
+
const mcp = isObj(existing.mcp) ? { ...existing.mcp } : {};
|
|
3290
|
+
const key = entry.url ? "shttp_servers" : "stdio_servers";
|
|
3291
|
+
const arr = Array.isArray(mcp[key]) ? [...mcp[key]] : [];
|
|
3292
|
+
const item = entry.url ? { url: entry.url, ...entry.env ? { env: entry.env } : {} } : { name, command: entry.command, args: entry.args ?? [], ...entry.env ? { env: entry.env } : {} };
|
|
3293
|
+
const matches2 = (s) => entry.url ? s.url === entry.url : s.name === name;
|
|
3294
|
+
const idx = arr.findIndex(matches2);
|
|
3295
|
+
if (idx >= 0) arr[idx] = item;
|
|
3296
|
+
else arr.push(item);
|
|
3297
|
+
mcp[key] = arr;
|
|
3298
|
+
return { ...existing, mcp };
|
|
3299
|
+
}
|
|
3300
|
+
};
|
|
3301
|
+
var claudeDesktop = {
|
|
3302
|
+
id: "claude-desktop",
|
|
3303
|
+
label: "Claude Desktop",
|
|
3304
|
+
format: "json",
|
|
3305
|
+
note: "One-click install is also available via the .mcpb bundle (npm run build:mcpb).",
|
|
3306
|
+
path: (ctx) => {
|
|
3307
|
+
if (ctx.scope === "project") return void 0;
|
|
3308
|
+
if (ctx.os === "win") return join3(ctx.env.APPDATA ?? join3(ctx.home, "AppData", "Roaming"), "Claude", "claude_desktop_config.json");
|
|
3309
|
+
if (ctx.os === "mac") return join3(ctx.home, "Library", "Application Support", "Claude", "claude_desktop_config.json");
|
|
3310
|
+
return join3(ctx.home, ".config", "Claude", "claude_desktop_config.json");
|
|
3311
|
+
},
|
|
3312
|
+
apply: (existing, name, entry) => deepMerge(existing, { mcpServers: { [name]: mcpServerObject(entry) } })
|
|
3313
|
+
};
|
|
3314
|
+
var CLIENTS = [
|
|
3315
|
+
claudeCode,
|
|
3316
|
+
cursor,
|
|
3317
|
+
vscode,
|
|
3318
|
+
codex,
|
|
3319
|
+
windsurf,
|
|
3320
|
+
cline,
|
|
3321
|
+
roo,
|
|
3322
|
+
zed,
|
|
3323
|
+
junie,
|
|
3324
|
+
gemini,
|
|
3325
|
+
goose,
|
|
3326
|
+
openhands,
|
|
3327
|
+
claudeDesktop
|
|
3328
|
+
];
|
|
3329
|
+
function getClient(id) {
|
|
3330
|
+
return CLIENTS.find((c) => c.id === id);
|
|
3331
|
+
}
|
|
3332
|
+
function listClients() {
|
|
3333
|
+
return CLIENTS.map(({ id, label, format, note }) => ({ id, label, format, note }));
|
|
3334
|
+
}
|
|
3335
|
+
|
|
3336
|
+
// src/installer/deeplinks.ts
|
|
3337
|
+
function cursorDeeplink(name, entry) {
|
|
3338
|
+
const config = Buffer.from(JSON.stringify(mcpServerObject(entry))).toString("base64");
|
|
3339
|
+
const params = new URLSearchParams({ name, config });
|
|
3340
|
+
return `cursor://anysphere.cursor-deeplink/mcp/install?${params.toString()}`;
|
|
3341
|
+
}
|
|
3342
|
+
function vscodeDeeplink(name, entry, opts = {}) {
|
|
3343
|
+
const scheme = opts.insiders ? "vscode-insiders" : "vscode";
|
|
3344
|
+
const payload = encodeURIComponent(JSON.stringify({ name, ...mcpServerObject(entry) }));
|
|
3345
|
+
return `${scheme}://mcp/install?${payload}`;
|
|
3346
|
+
}
|
|
3347
|
+
function codeAddMcpCommand(name, entry) {
|
|
3348
|
+
return `code --add-mcp '${JSON.stringify({ name, ...mcpServerObject(entry) })}'`;
|
|
3349
|
+
}
|
|
3350
|
+
|
|
3351
|
+
// src/cli.ts
|
|
2340
3352
|
var bold = (s) => `\x1B[1m${s}\x1B[0m`;
|
|
2341
3353
|
var dim = (s) => `\x1B[2m${s}\x1B[0m`;
|
|
2342
3354
|
var cyan = (s) => `\x1B[36m${s}\x1B[0m`;
|
|
@@ -2344,6 +3356,46 @@ var green = (s) => `\x1B[32m${s}\x1B[0m`;
|
|
|
2344
3356
|
var yellow = (s) => `\x1B[33m${s}\x1B[0m`;
|
|
2345
3357
|
var magenta = (s) => `\x1B[35m${s}\x1B[0m`;
|
|
2346
3358
|
var red = (s) => `\x1B[31m${s}\x1B[0m`;
|
|
3359
|
+
function renderDiffText(d) {
|
|
3360
|
+
const out = [];
|
|
3361
|
+
out.push(`${bold("Topology diff")} ${dim(d.base.sessionId.slice(0, 8))} \u2192 ${dim(d.current.sessionId.slice(0, 8))}`);
|
|
3362
|
+
out.push(` base: ${d.base.nodeCount} nodes, ${d.base.edgeCount} edges ${dim(d.base.startedAt)}`);
|
|
3363
|
+
out.push(` current: ${d.current.nodeCount} nodes, ${d.current.edgeCount} edges ${dim(d.current.startedAt)}`);
|
|
3364
|
+
out.push("");
|
|
3365
|
+
out.push(` nodes: ${green("+" + d.summary.nodesAdded)} ${red("-" + d.summary.nodesRemoved)} ${yellow("~" + d.summary.nodesChanged)} edges: ${green("+" + d.summary.edgesAdded)} ${red("-" + d.summary.edgesRemoved)}`);
|
|
3366
|
+
if (d.summary.nodesAdded + d.summary.nodesRemoved + d.summary.nodesChanged + d.summary.edgesAdded + d.summary.edgesRemoved === 0) {
|
|
3367
|
+
out.push("");
|
|
3368
|
+
out.push(` ${green("\u2713")} No drift between the two sessions.`);
|
|
3369
|
+
return out.join("\n");
|
|
3370
|
+
}
|
|
3371
|
+
out.push("");
|
|
3372
|
+
for (const n of d.nodes.added) out.push(` ${green("+")} ${n.id} ${dim("(" + n.type + ")")}`);
|
|
3373
|
+
for (const n of d.nodes.removed) out.push(` ${red("-")} ${n.id} ${dim("(" + n.type + ")")}`);
|
|
3374
|
+
for (const c of d.nodes.changed) out.push(` ${yellow("~")} ${c.id} ${dim("[" + c.changedFields.join(", ") + "]")}`);
|
|
3375
|
+
for (const e of d.edges.added) out.push(` ${green("+")} edge ${e.sourceId} ${dim("\u2500" + e.relationship + "\u2192")} ${e.targetId}`);
|
|
3376
|
+
for (const e of d.edges.removed) out.push(` ${red("-")} edge ${e.sourceId} ${dim("\u2500" + e.relationship + "\u2192")} ${e.targetId}`);
|
|
3377
|
+
return out.join("\n");
|
|
3378
|
+
}
|
|
3379
|
+
function renderDriftSummaryText(r) {
|
|
3380
|
+
const s = r.delta.summary;
|
|
3381
|
+
const base = r.baseSessionId ? r.baseSessionId.slice(0, 8) : "\u2205";
|
|
3382
|
+
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 + ")")}`;
|
|
3383
|
+
}
|
|
3384
|
+
function maybeQueueForSync(db, sessionId, config, w) {
|
|
3385
|
+
if (!config.centralDb?.url) return;
|
|
3386
|
+
try {
|
|
3387
|
+
const r = runSyncClassify(db, sessionId, config);
|
|
3388
|
+
if (r.enqueued > 0) {
|
|
3389
|
+
w(` ${cyan("\u21EA")} ${bold(String(r.enqueued))} item(s) queued for review \u2014 run ${bold("'datasynx-cartography sync review'")}
|
|
3390
|
+
`);
|
|
3391
|
+
} else if (r.autoShared > 0) {
|
|
3392
|
+
w(` ${cyan("\u21EA")} ${bold(String(r.autoShared))} item(s) auto-approved by policy \u2014 run ${bold("'datasynx-cartography sync push'")}
|
|
3393
|
+
`);
|
|
3394
|
+
}
|
|
3395
|
+
} catch (err) {
|
|
3396
|
+
logWarn(`central-DB sync classify skipped: ${err instanceof Error ? err.message : String(err)}`);
|
|
3397
|
+
}
|
|
3398
|
+
}
|
|
2347
3399
|
main();
|
|
2348
3400
|
function main() {
|
|
2349
3401
|
let activeDb = null;
|
|
@@ -2356,18 +3408,35 @@ function main() {
|
|
|
2356
3408
|
}
|
|
2357
3409
|
activeDb = null;
|
|
2358
3410
|
}
|
|
2359
|
-
process.
|
|
3411
|
+
process.removeListener("SIGTERM", shutdown);
|
|
3412
|
+
process.removeListener("SIGINT", shutdown);
|
|
3413
|
+
process.kill(process.pid, signal);
|
|
2360
3414
|
};
|
|
2361
|
-
process.on("SIGTERM",
|
|
2362
|
-
process.on("SIGINT",
|
|
3415
|
+
process.on("SIGTERM", shutdown);
|
|
3416
|
+
process.on("SIGINT", shutdown);
|
|
2363
3417
|
cleanupTempFiles();
|
|
2364
3418
|
const program = new Command();
|
|
2365
3419
|
const CMD = "datasynx-cartography";
|
|
2366
|
-
const __dirname = import.meta.dirname ??
|
|
2367
|
-
|
|
3420
|
+
const __dirname = import.meta.dirname ?? dirname2(fileURLToPath(import.meta.url));
|
|
3421
|
+
let VERSION = "0.0.0";
|
|
3422
|
+
try {
|
|
3423
|
+
VERSION = JSON.parse(readFileSync5(resolve2(__dirname, "..", "package.json"), "utf-8")).version ?? VERSION;
|
|
3424
|
+
} catch {
|
|
3425
|
+
logWarn("Could not read package.json version; falling back to 0.0.0");
|
|
3426
|
+
}
|
|
2368
3427
|
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
|
-
|
|
3428
|
+
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) => {
|
|
3429
|
+
const providerName = opts.provider ?? process.env.CARTOGRAPHY_PROVIDER ?? "claude";
|
|
3430
|
+
if (!defaultProviderRegistry.has(providerName)) {
|
|
3431
|
+
process.stderr.write(
|
|
3432
|
+
`\u274C Unknown provider "${providerName}" (valid: ${defaultProviderRegistry.names().join(", ")})
|
|
3433
|
+
`
|
|
3434
|
+
);
|
|
3435
|
+
process.exitCode = 2;
|
|
3436
|
+
return;
|
|
3437
|
+
}
|
|
3438
|
+
const provider = providerName;
|
|
3439
|
+
checkPrerequisites(provider);
|
|
2371
3440
|
const parsedDepth = parseInt(opts.depth, 10);
|
|
2372
3441
|
const parsedMaxTurns = parseInt(opts.maxTurns, 10);
|
|
2373
3442
|
if (Number.isNaN(parsedDepth) || parsedDepth < 1 || parsedDepth > 50) {
|
|
@@ -2382,11 +3451,20 @@ function main() {
|
|
|
2382
3451
|
process.exitCode = 2;
|
|
2383
3452
|
return;
|
|
2384
3453
|
}
|
|
3454
|
+
const fmt = opts.outputFormat ?? "text";
|
|
3455
|
+
if (!["text", "json", "stream-json"].includes(fmt)) {
|
|
3456
|
+
process.stderr.write(`\u274C Invalid --output-format: "${opts.outputFormat}" (must be text, json, or stream-json)
|
|
3457
|
+
`);
|
|
3458
|
+
process.exitCode = 2;
|
|
3459
|
+
return;
|
|
3460
|
+
}
|
|
3461
|
+
const isText = fmt === "text";
|
|
2385
3462
|
setVerbose(opts.verbose);
|
|
2386
3463
|
const config = defaultConfig({
|
|
2387
3464
|
entryPoints: opts.entry,
|
|
2388
3465
|
maxDepth: parsedDepth,
|
|
2389
3466
|
maxTurns: parsedMaxTurns,
|
|
3467
|
+
provider,
|
|
2390
3468
|
agentModel: opts.model,
|
|
2391
3469
|
organization: opts.org,
|
|
2392
3470
|
outputDir: opts.output,
|
|
@@ -2395,14 +3473,63 @@ function main() {
|
|
|
2395
3473
|
});
|
|
2396
3474
|
logInfo("Discovery started", {
|
|
2397
3475
|
entryPoints: config.entryPoints,
|
|
3476
|
+
provider: config.provider,
|
|
2398
3477
|
model: config.agentModel,
|
|
2399
3478
|
maxTurns: config.maxTurns,
|
|
2400
3479
|
maxDepth: config.maxDepth
|
|
2401
3480
|
});
|
|
2402
3481
|
const db = new CartographyDB(config.dbPath);
|
|
2403
3482
|
activeDb = db;
|
|
2404
|
-
const sessionId = db.createSession("discover", config);
|
|
2405
3483
|
const w = process.stderr.write.bind(process.stderr);
|
|
3484
|
+
if (opts.update) {
|
|
3485
|
+
const tenantId = normalizeTenant(opts.org);
|
|
3486
|
+
const targetId = typeof opts.update === "string" ? opts.update : db.getLatestSession("discover", tenantId)?.id;
|
|
3487
|
+
const targetSession = targetId ? db.getSession(targetId) : void 0;
|
|
3488
|
+
if (!targetId || !targetSession) {
|
|
3489
|
+
process.stderr.write(
|
|
3490
|
+
`\u274C No discover session to update${typeof opts.update === "string" ? ` (id "${opts.update}")` : ""}; run \`discover\` first.
|
|
3491
|
+
`
|
|
3492
|
+
);
|
|
3493
|
+
process.exitCode = 2;
|
|
3494
|
+
db.close();
|
|
3495
|
+
activeDb = null;
|
|
3496
|
+
return;
|
|
3497
|
+
}
|
|
3498
|
+
const baseNodeCount = db.getNodes(targetId).length;
|
|
3499
|
+
const baseEdgeCount = db.getEdges(targetId).length;
|
|
3500
|
+
if (isText) {
|
|
3501
|
+
w("\n");
|
|
3502
|
+
w(` ${bold("CARTOGRAPHY")} ${dim("incremental rescan \xB7 " + targetId.slice(0, 8))}
|
|
3503
|
+
`);
|
|
3504
|
+
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"));
|
|
3505
|
+
}
|
|
3506
|
+
try {
|
|
3507
|
+
const r = await runLocalDiscovery(db, targetId, { mode: "update" });
|
|
3508
|
+
const updated = db.getSession(targetId);
|
|
3509
|
+
const diff = {
|
|
3510
|
+
base: { sessionId: targetId, startedAt: targetSession.startedAt, nodeCount: baseNodeCount, edgeCount: baseEdgeCount },
|
|
3511
|
+
current: { sessionId: targetId, startedAt: updated?.lastScannedAt ?? (/* @__PURE__ */ new Date()).toISOString(), nodeCount: r.nodes, edgeCount: r.edges },
|
|
3512
|
+
nodes: r.delta?.nodes ?? { added: [], removed: [], changed: [], unchanged: 0 },
|
|
3513
|
+
edges: r.delta?.edges ?? { added: [], removed: [], unchanged: 0 },
|
|
3514
|
+
summary: r.delta?.summary ?? { nodesAdded: 0, nodesRemoved: 0, nodesChanged: 0, edgesAdded: 0, edgesRemoved: 0 },
|
|
3515
|
+
anomalies: { base: [], current: [], added: [] }
|
|
3516
|
+
};
|
|
3517
|
+
if (fmt === "text") w(renderDiffText(diff) + "\n\n");
|
|
3518
|
+
else process.stdout.write(JSON.stringify(diff, null, 2) + "\n");
|
|
3519
|
+
logInfo("Incremental rescan complete", { sessionId: targetId, ...diff.summary });
|
|
3520
|
+
} catch (err) {
|
|
3521
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
3522
|
+
logError("Incremental rescan failed", { sessionId: targetId, error: errMsg });
|
|
3523
|
+
w(`
|
|
3524
|
+
${bold(red("\u2717"))} Rescan failed: ${errMsg}
|
|
3525
|
+
`);
|
|
3526
|
+
process.exitCode = 1;
|
|
3527
|
+
}
|
|
3528
|
+
db.close();
|
|
3529
|
+
activeDb = null;
|
|
3530
|
+
return;
|
|
3531
|
+
}
|
|
3532
|
+
const sessionId = db.createSession("discover", config, opts.org);
|
|
2406
3533
|
const SPINNER = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
|
|
2407
3534
|
let spinIdx = 0;
|
|
2408
3535
|
let spinnerTimer = null;
|
|
@@ -2427,13 +3554,15 @@ function main() {
|
|
|
2427
3554
|
let turnNum = 0;
|
|
2428
3555
|
let nodeCount = 0;
|
|
2429
3556
|
let edgeCount = 0;
|
|
2430
|
-
|
|
2431
|
-
|
|
3557
|
+
if (isText) {
|
|
3558
|
+
w("\n");
|
|
3559
|
+
w(` ${bold("CARTOGRAPHY")} ${dim(config.entryPoints.join(", "))}
|
|
2432
3560
|
`);
|
|
2433
|
-
|
|
3561
|
+
w(` ${dim("Model: " + config.agentModel + " | MaxTurns: " + config.maxTurns)}
|
|
2434
3562
|
`);
|
|
2435
|
-
|
|
2436
|
-
|
|
3563
|
+
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"));
|
|
3564
|
+
w("\n");
|
|
3565
|
+
}
|
|
2437
3566
|
const logLine = (icon, msg) => {
|
|
2438
3567
|
stopSpinner();
|
|
2439
3568
|
const elapsed = ((Date.now() - startTime) / 1e3).toFixed(1);
|
|
@@ -2441,6 +3570,10 @@ function main() {
|
|
|
2441
3570
|
`);
|
|
2442
3571
|
};
|
|
2443
3572
|
const handleEvent = (event) => {
|
|
3573
|
+
if (!isText) {
|
|
3574
|
+
if (fmt === "stream-json") process.stdout.write(JSON.stringify(event) + "\n");
|
|
3575
|
+
return;
|
|
3576
|
+
}
|
|
2444
3577
|
switch (event.kind) {
|
|
2445
3578
|
case "turn":
|
|
2446
3579
|
turnNum = event.turn;
|
|
@@ -2457,7 +3590,7 @@ function main() {
|
|
|
2457
3590
|
}
|
|
2458
3591
|
break;
|
|
2459
3592
|
case "tool_call": {
|
|
2460
|
-
const toolName = event.tool.replace("
|
|
3593
|
+
const toolName = event.tool.replace("mcp__cartography__", "");
|
|
2461
3594
|
if (toolName === "Bash") {
|
|
2462
3595
|
const cmd = (event.input["command"] ?? "").substring(0, 70);
|
|
2463
3596
|
startSpinner(`${yellow("$")} ${cmd}`);
|
|
@@ -2505,6 +3638,7 @@ function main() {
|
|
|
2505
3638
|
}
|
|
2506
3639
|
};
|
|
2507
3640
|
const onAskUser = async (question, context) => {
|
|
3641
|
+
if (!isText) return "(Non-interactive mode \u2014 please continue without this information)";
|
|
2508
3642
|
stopSpinner();
|
|
2509
3643
|
w("\n");
|
|
2510
3644
|
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 +3653,7 @@ function main() {
|
|
|
2519
3653
|
return "(Non-interactive mode \u2014 please continue without this information)";
|
|
2520
3654
|
}
|
|
2521
3655
|
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
2522
|
-
const answer = await new Promise((
|
|
3656
|
+
const answer = await new Promise((resolve3) => rl.question(` ${cyan("\u2192")} `, resolve3));
|
|
2523
3657
|
rl.close();
|
|
2524
3658
|
w("\n");
|
|
2525
3659
|
return answer || "(No answer \u2014 please continue)";
|
|
@@ -2541,6 +3675,9 @@ function main() {
|
|
|
2541
3675
|
}
|
|
2542
3676
|
stopSpinner();
|
|
2543
3677
|
db.endSession(sessionId);
|
|
3678
|
+
maybeQueueForSync(db, sessionId, config, w);
|
|
3679
|
+
const sessionName = opts.name?.trim() || deriveSessionName(db.getGraphSummary(sessionId), db.getSession(sessionId)?.startedAt ?? (/* @__PURE__ */ new Date()).toISOString());
|
|
3680
|
+
db.setSessionName(sessionId, sessionName);
|
|
2544
3681
|
const stats = db.getStats(sessionId);
|
|
2545
3682
|
const totalSec = ((Date.now() - startTime) / 1e3).toFixed(1);
|
|
2546
3683
|
logInfo("Discovery completed", {
|
|
@@ -2549,6 +3686,22 @@ function main() {
|
|
|
2549
3686
|
edges: stats.edges,
|
|
2550
3687
|
durationSec: parseFloat(totalSec)
|
|
2551
3688
|
});
|
|
3689
|
+
if (!isText) {
|
|
3690
|
+
const durationMs = Date.now() - startTime;
|
|
3691
|
+
if (fmt === "stream-json") {
|
|
3692
|
+
process.stdout.write(JSON.stringify({ kind: "result", sessionId, nodes: stats.nodes, edges: stats.edges, durationMs }) + "\n");
|
|
3693
|
+
} else {
|
|
3694
|
+
process.stdout.write(JSON.stringify(
|
|
3695
|
+
{ sessionId, stats, nodes: db.getNodes(sessionId), edges: db.getEdges(sessionId), durationMs },
|
|
3696
|
+
null,
|
|
3697
|
+
2
|
|
3698
|
+
) + "\n");
|
|
3699
|
+
}
|
|
3700
|
+
exportAll(db, sessionId, config.outputDir, ["discovery"]);
|
|
3701
|
+
db.close();
|
|
3702
|
+
activeDb = null;
|
|
3703
|
+
return;
|
|
3704
|
+
}
|
|
2552
3705
|
w("\n");
|
|
2553
3706
|
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
3707
|
w(` ${green(bold("DONE"))} ${bold(String(stats.nodes))} nodes, ${bold(String(stats.edges))} edges ${dim("in " + totalSec + "s")}
|
|
@@ -2588,7 +3741,7 @@ function main() {
|
|
|
2588
3741
|
w("\n");
|
|
2589
3742
|
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
2590
3743
|
const answer = await new Promise(
|
|
2591
|
-
(
|
|
3744
|
+
(resolve3) => rl.question(` ${yellow("?")} Remove nodes (numbers, empty = keep all): `, resolve3)
|
|
2592
3745
|
);
|
|
2593
3746
|
rl.close();
|
|
2594
3747
|
const toRemove = answer.trim().split(/[\s,]+/).map(Number).filter((n) => n >= 1 && n <= allNodes.length);
|
|
@@ -2607,9 +3760,9 @@ function main() {
|
|
|
2607
3760
|
}
|
|
2608
3761
|
}
|
|
2609
3762
|
exportAll(db, sessionId, config.outputDir, ["discovery"]);
|
|
2610
|
-
const discoveryPath =
|
|
3763
|
+
const discoveryPath = resolve2(config.outputDir, "discovery.html");
|
|
2611
3764
|
w("\n");
|
|
2612
|
-
if (
|
|
3765
|
+
if (existsSync3(discoveryPath)) {
|
|
2613
3766
|
w(` ${green("\u2713")} ${bold("discovery.html")} ${dim("\u2190 Enterprise Map")}
|
|
2614
3767
|
`);
|
|
2615
3768
|
}
|
|
@@ -2626,7 +3779,7 @@ function main() {
|
|
|
2626
3779
|
while (continueDiscovery) {
|
|
2627
3780
|
const rlFollowup = createInterface({ input: process.stdin, output: process.stderr });
|
|
2628
3781
|
const followupHint = await new Promise(
|
|
2629
|
-
(
|
|
3782
|
+
(resolve3) => rlFollowup.question(` ${yellow("\u2192")} Search for (Enter = finish): `, resolve3)
|
|
2630
3783
|
);
|
|
2631
3784
|
rlFollowup.close();
|
|
2632
3785
|
if (!followupHint.trim()) {
|
|
@@ -2654,7 +3807,7 @@ function main() {
|
|
|
2654
3807
|
`);
|
|
2655
3808
|
w("\n");
|
|
2656
3809
|
exportAll(db, sessionId, config.outputDir, ["discovery"]);
|
|
2657
|
-
if (
|
|
3810
|
+
if (existsSync3(discoveryPath)) {
|
|
2658
3811
|
w(` ${green("\u2713")} ${bold("discovery.html updated")}
|
|
2659
3812
|
`);
|
|
2660
3813
|
}
|
|
@@ -2663,7 +3816,7 @@ function main() {
|
|
|
2663
3816
|
}
|
|
2664
3817
|
db.close();
|
|
2665
3818
|
});
|
|
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) => {
|
|
3819
|
+
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
3820
|
const config = defaultConfig({ outputDir: opts.output });
|
|
2668
3821
|
const db = new CartographyDB(config.dbPath);
|
|
2669
3822
|
const session = sessionId ? db.getSession(sessionId) : db.getLatestSession();
|
|
@@ -2679,6 +3832,216 @@ function main() {
|
|
|
2679
3832
|
`);
|
|
2680
3833
|
db.close();
|
|
2681
3834
|
});
|
|
3835
|
+
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) => {
|
|
3836
|
+
const config = defaultConfig(opts.db ? { dbPath: opts.db } : {});
|
|
3837
|
+
const db = new CartographyDB(config.dbPath);
|
|
3838
|
+
activeDb = db;
|
|
3839
|
+
try {
|
|
3840
|
+
const sessions = db.getSessions();
|
|
3841
|
+
const currentId = current ?? sessions[0]?.id;
|
|
3842
|
+
const baseId = base ?? sessions[1]?.id;
|
|
3843
|
+
if (!baseId || !currentId) {
|
|
3844
|
+
process.stderr.write("\u274C Need at least two discovery sessions to diff\n");
|
|
3845
|
+
process.exitCode = 1;
|
|
3846
|
+
return;
|
|
3847
|
+
}
|
|
3848
|
+
if (baseId === currentId) {
|
|
3849
|
+
process.stderr.write("\u274C Base and current session are the same\n");
|
|
3850
|
+
process.exitCode = 1;
|
|
3851
|
+
return;
|
|
3852
|
+
}
|
|
3853
|
+
const d = db.diffSessions(baseId, currentId);
|
|
3854
|
+
const out = opts.format === "json" ? JSON.stringify(d, null, 2) : opts.format === "mermaid" ? generateDiffMermaid(d) : renderDiffText(d);
|
|
3855
|
+
if (opts.output) {
|
|
3856
|
+
writeFileSync3(opts.output, out + "\n");
|
|
3857
|
+
process.stderr.write(`\u2713 Wrote diff to: ${opts.output}
|
|
3858
|
+
`);
|
|
3859
|
+
} else {
|
|
3860
|
+
process.stdout.write(out + "\n");
|
|
3861
|
+
}
|
|
3862
|
+
} catch (err) {
|
|
3863
|
+
process.stderr.write(`\u274C ${err instanceof Error ? err.message : String(err)}
|
|
3864
|
+
`);
|
|
3865
|
+
process.exitCode = 1;
|
|
3866
|
+
} finally {
|
|
3867
|
+
db.close();
|
|
3868
|
+
activeDb = null;
|
|
3869
|
+
}
|
|
3870
|
+
});
|
|
3871
|
+
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) => {
|
|
3872
|
+
const config = defaultConfig(opts.db ? { dbPath: opts.db } : {});
|
|
3873
|
+
const db = new CartographyDB(config.dbPath);
|
|
3874
|
+
activeDb = db;
|
|
3875
|
+
try {
|
|
3876
|
+
const ruleset = getRuleset(opts.ruleset);
|
|
3877
|
+
if (!ruleset) {
|
|
3878
|
+
process.stderr.write(`\u274C Unknown ruleset: "${opts.ruleset}" (available: ${listRulesets().map((r) => r.name).join(", ")})
|
|
3879
|
+
`);
|
|
3880
|
+
process.exitCode = 1;
|
|
3881
|
+
return;
|
|
3882
|
+
}
|
|
3883
|
+
const sid = sessionId ?? db.getLatestSession()?.id;
|
|
3884
|
+
if (!sid) {
|
|
3885
|
+
process.stderr.write("\u274C No session to score (run discovery first or pass a session id)\n");
|
|
3886
|
+
process.exitCode = 1;
|
|
3887
|
+
return;
|
|
3888
|
+
}
|
|
3889
|
+
const report = db.scoreSession(sid, ruleset);
|
|
3890
|
+
const out = opts.format === "json" || opts.format === "markdown" || opts.format === "mermaid" ? exportComplianceReport(report, opts.format) : formatComplianceText(report);
|
|
3891
|
+
if (opts.output) {
|
|
3892
|
+
writeFileSync3(opts.output, out + "\n");
|
|
3893
|
+
process.stderr.write(`\u2713 Wrote compliance report to: ${opts.output}
|
|
3894
|
+
`);
|
|
3895
|
+
} else {
|
|
3896
|
+
process.stdout.write(out + "\n");
|
|
3897
|
+
}
|
|
3898
|
+
} catch (err) {
|
|
3899
|
+
process.stderr.write(`\u274C ${err instanceof Error ? err.message : String(err)}
|
|
3900
|
+
`);
|
|
3901
|
+
process.exitCode = 1;
|
|
3902
|
+
} finally {
|
|
3903
|
+
db.close();
|
|
3904
|
+
activeDb = null;
|
|
3905
|
+
}
|
|
3906
|
+
});
|
|
3907
|
+
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) => {
|
|
3908
|
+
let drift;
|
|
3909
|
+
try {
|
|
3910
|
+
drift = DriftConfigSchema.parse({
|
|
3911
|
+
minSeverity: opts.minSeverity,
|
|
3912
|
+
sinks: opts.webhook ? [{ type: "webhook", url: opts.webhook }] : [{ type: "stdout" }]
|
|
3913
|
+
});
|
|
3914
|
+
} catch (err) {
|
|
3915
|
+
process.stderr.write(`\u274C ${err instanceof Error ? err.message : String(err)}
|
|
3916
|
+
`);
|
|
3917
|
+
process.exitCode = 1;
|
|
3918
|
+
return;
|
|
3919
|
+
}
|
|
3920
|
+
const config = defaultConfig({ ...opts.db ? { dbPath: opts.db } : {}, drift });
|
|
3921
|
+
const db = new CartographyDB(config.dbPath);
|
|
3922
|
+
activeDb = db;
|
|
3923
|
+
try {
|
|
3924
|
+
const alert = await runDrift(db, config, { base, current, minSeverity: drift.minSeverity });
|
|
3925
|
+
if (!alert) {
|
|
3926
|
+
process.stderr.write("\u2139 Need at least two discovery sessions for drift; nothing to do.\n");
|
|
3927
|
+
return;
|
|
3928
|
+
}
|
|
3929
|
+
process.stderr.write(`\u2713 drift severity=${alert.severity} items=${alert.items.length}
|
|
3930
|
+
`);
|
|
3931
|
+
} catch (err) {
|
|
3932
|
+
process.stderr.write(`\u274C ${err instanceof Error ? err.message : String(err)}
|
|
3933
|
+
`);
|
|
3934
|
+
process.exitCode = 1;
|
|
3935
|
+
} finally {
|
|
3936
|
+
db.close();
|
|
3937
|
+
activeDb = null;
|
|
3938
|
+
}
|
|
3939
|
+
});
|
|
3940
|
+
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) => {
|
|
3941
|
+
let cfg;
|
|
3942
|
+
try {
|
|
3943
|
+
cfg = loadConfig(opts.config);
|
|
3944
|
+
} catch (err) {
|
|
3945
|
+
process.stderr.write(`\u274C ${err instanceof ConfigError ? err.message : String(err)}
|
|
3946
|
+
`);
|
|
3947
|
+
process.exitCode = 2;
|
|
3948
|
+
return;
|
|
3949
|
+
}
|
|
3950
|
+
if (opts.db) cfg = defaultConfig({ ...cfg, dbPath: opts.db });
|
|
3951
|
+
const fmt = opts.outputFormat ?? cfg.schedule?.outputFormat ?? "json";
|
|
3952
|
+
if (!["text", "json", "stream-json"].includes(fmt)) {
|
|
3953
|
+
process.stderr.write(`\u274C Invalid --output-format: "${fmt}" (must be text, json, or stream-json)
|
|
3954
|
+
`);
|
|
3955
|
+
process.exitCode = 2;
|
|
3956
|
+
return;
|
|
3957
|
+
}
|
|
3958
|
+
if (opts.once && opts.watch) {
|
|
3959
|
+
process.stderr.write("\u274C --once and --watch are mutually exclusive\n");
|
|
3960
|
+
process.exitCode = 2;
|
|
3961
|
+
return;
|
|
3962
|
+
}
|
|
3963
|
+
const cron = cfg.schedule?.cron;
|
|
3964
|
+
if (opts.watch && !cron) {
|
|
3965
|
+
process.stderr.write("\u274C --watch requires a `schedule.cron` in the config file\n");
|
|
3966
|
+
process.exitCode = 2;
|
|
3967
|
+
return;
|
|
3968
|
+
}
|
|
3969
|
+
if (cron) {
|
|
3970
|
+
try {
|
|
3971
|
+
nextRun(cron, /* @__PURE__ */ new Date());
|
|
3972
|
+
} catch (err) {
|
|
3973
|
+
process.stderr.write(`\u274C Invalid cron "${cron}": ${err instanceof Error ? err.message : String(err)}
|
|
3974
|
+
`);
|
|
3975
|
+
process.exitCode = 2;
|
|
3976
|
+
return;
|
|
3977
|
+
}
|
|
3978
|
+
}
|
|
3979
|
+
const db = new CartographyDB(cfg.dbPath);
|
|
3980
|
+
activeDb = db;
|
|
3981
|
+
const emit = (r) => {
|
|
3982
|
+
if (fmt === "text") {
|
|
3983
|
+
process.stdout.write(renderDriftSummaryText(r) + "\n");
|
|
3984
|
+
} else {
|
|
3985
|
+
const payload = { sessionId: r.sessionId, baseSessionId: r.baseSessionId ?? null, summary: r.delta.summary };
|
|
3986
|
+
process.stdout.write(JSON.stringify(payload) + "\n");
|
|
3987
|
+
}
|
|
3988
|
+
};
|
|
3989
|
+
const doRun = async () => {
|
|
3990
|
+
const r = await runOnce(cfg, db);
|
|
3991
|
+
db.recordDriftRun(r.sessionId, r.baseSessionId, r.delta);
|
|
3992
|
+
maybeQueueForSync(db, r.sessionId, cfg, (s) => process.stderr.write(s));
|
|
3993
|
+
emit(r);
|
|
3994
|
+
};
|
|
3995
|
+
if (opts.watch) {
|
|
3996
|
+
let stopped = false;
|
|
3997
|
+
let timer = null;
|
|
3998
|
+
const MAX_DELAY_MS = 24 * 60 * 60 * 1e3;
|
|
3999
|
+
let nextAnnounced = null;
|
|
4000
|
+
const schedule = () => {
|
|
4001
|
+
if (stopped) return;
|
|
4002
|
+
const next = nextRun(cron, /* @__PURE__ */ new Date());
|
|
4003
|
+
const targetMs = next.getTime();
|
|
4004
|
+
if (next.toISOString() !== nextAnnounced) {
|
|
4005
|
+
logInfo(`next scheduled run at ${next.toISOString()}`);
|
|
4006
|
+
nextAnnounced = next.toISOString();
|
|
4007
|
+
}
|
|
4008
|
+
const remaining = targetMs - Date.now();
|
|
4009
|
+
if (remaining > MAX_DELAY_MS) {
|
|
4010
|
+
timer = setTimeout(schedule, MAX_DELAY_MS);
|
|
4011
|
+
return;
|
|
4012
|
+
}
|
|
4013
|
+
timer = setTimeout(() => {
|
|
4014
|
+
void (async () => {
|
|
4015
|
+
try {
|
|
4016
|
+
await doRun();
|
|
4017
|
+
} catch (err) {
|
|
4018
|
+
logError(`scheduled run failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
4019
|
+
}
|
|
4020
|
+
nextAnnounced = null;
|
|
4021
|
+
schedule();
|
|
4022
|
+
})();
|
|
4023
|
+
}, Math.max(0, remaining));
|
|
4024
|
+
};
|
|
4025
|
+
const stop = () => {
|
|
4026
|
+
stopped = true;
|
|
4027
|
+
if (timer) clearTimeout(timer);
|
|
4028
|
+
};
|
|
4029
|
+
process.once("SIGINT", stop);
|
|
4030
|
+
process.once("SIGTERM", stop);
|
|
4031
|
+
schedule();
|
|
4032
|
+
return;
|
|
4033
|
+
}
|
|
4034
|
+
try {
|
|
4035
|
+
await doRun();
|
|
4036
|
+
} catch (err) {
|
|
4037
|
+
process.stderr.write(`\u274C ${err instanceof Error ? err.message : String(err)}
|
|
4038
|
+
`);
|
|
4039
|
+
process.exitCode = 1;
|
|
4040
|
+
} finally {
|
|
4041
|
+
db.close();
|
|
4042
|
+
activeDb = null;
|
|
4043
|
+
}
|
|
4044
|
+
});
|
|
2682
4045
|
program.command("show [session-id]").description("Show session details").action((sessionId) => {
|
|
2683
4046
|
const config = defaultConfig();
|
|
2684
4047
|
const db = new CartographyDB(config.dbPath);
|
|
@@ -2693,6 +4056,8 @@ function main() {
|
|
|
2693
4056
|
const nodes = db.getNodes(session.id);
|
|
2694
4057
|
process.stdout.write(`
|
|
2695
4058
|
Session: ${session.id}
|
|
4059
|
+
`);
|
|
4060
|
+
if (session.name) process.stdout.write(` Name: ${session.name}
|
|
2696
4061
|
`);
|
|
2697
4062
|
process.stdout.write(` Mode: ${session.mode}
|
|
2698
4063
|
`);
|
|
@@ -2708,6 +4073,15 @@ Session: ${session.id}
|
|
|
2708
4073
|
`);
|
|
2709
4074
|
process.stdout.write(` Tasks: ${stats.tasks}
|
|
2710
4075
|
`);
|
|
4076
|
+
const events = db.getEvents(session.id);
|
|
4077
|
+
if (events.length > 0) {
|
|
4078
|
+
process.stdout.write("\n Recent activity:\n");
|
|
4079
|
+
for (const e of events.slice(-15)) {
|
|
4080
|
+
const kb = e.resultBytes != null ? ` (${(e.resultBytes / 1024).toFixed(1)} KB)` : "";
|
|
4081
|
+
process.stdout.write(` ${e.timestamp} ${e.process} ${(e.command ?? "").slice(0, 60)}${kb}
|
|
4082
|
+
`);
|
|
4083
|
+
}
|
|
4084
|
+
}
|
|
2711
4085
|
if (nodes.length > 0) {
|
|
2712
4086
|
process.stdout.write("\n Discovered nodes:\n");
|
|
2713
4087
|
for (const node of nodes.slice(0, 20)) {
|
|
@@ -2735,7 +4109,7 @@ Session: ${session.id}
|
|
|
2735
4109
|
const stats = db.getStats(session.id);
|
|
2736
4110
|
const status = session.completedAt ? "\u2713" : "\u25CF";
|
|
2737
4111
|
process.stdout.write(
|
|
2738
|
-
`${status} ${session.id.substring(0, 8)} [${session.mode}] ${session.startedAt.substring(0, 19)} nodes:${stats.nodes} edges:${stats.edges}
|
|
4112
|
+
`${status} ${session.id.substring(0, 8)} [${session.mode}] ${session.startedAt.substring(0, 19)} nodes:${stats.nodes} edges:${stats.edges}${session.name ? ` ${session.name}` : ""}
|
|
2739
4113
|
`
|
|
2740
4114
|
);
|
|
2741
4115
|
}
|
|
@@ -2774,7 +4148,7 @@ Session: ${session.id}
|
|
|
2774
4148
|
const status = session.completedAt ? green("\u2713") : yellow("\u25CF");
|
|
2775
4149
|
const age = session.startedAt.substring(0, 16).replace("T", " ");
|
|
2776
4150
|
const sid = cyan(session.id.substring(0, 8));
|
|
2777
|
-
w(` ${status} ${sid} ${b("[" + session.mode + "]")} ${d(age)}
|
|
4151
|
+
w(` ${status} ${sid} ${b("[" + session.mode + "]")} ${d(age)}${session.name ? ` ${d(session.name)}` : ""}
|
|
2778
4152
|
`);
|
|
2779
4153
|
w(` ${d("Nodes: " + stats.nodes + " Edges: " + stats.edges)}
|
|
2780
4154
|
`);
|
|
@@ -2792,8 +4166,9 @@ Session: ${session.id}
|
|
|
2792
4166
|
}
|
|
2793
4167
|
db.close();
|
|
2794
4168
|
});
|
|
2795
|
-
program.command("chat [session-id]").description("Interactive chat about your mapped infrastructure").option("--db <path>", "DB path").option("--model <m>", "Model
|
|
4169
|
+
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
4170
|
const config = defaultConfig();
|
|
4171
|
+
const model = opts.model ?? config.models.fast;
|
|
2797
4172
|
const db = new CartographyDB(opts.db ?? config.dbPath);
|
|
2798
4173
|
const sessions = db.getSessions();
|
|
2799
4174
|
const session = sessionIdArg ? sessions.find((s) => s.id.startsWith(sessionIdArg)) : sessions.filter((s) => s.completedAt).at(-1) ?? sessions.at(-1);
|
|
@@ -2815,7 +4190,7 @@ Session: ${session.id}
|
|
|
2815
4190
|
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
4191
|
`));
|
|
2817
4192
|
w(` ${dim("Ask anything about your infrastructure. exit = quit.\n\n")}`);
|
|
2818
|
-
const Anthropic = (await import("
|
|
4193
|
+
const Anthropic = (await import("@anthropic-ai/sdk")).default;
|
|
2819
4194
|
const client = new Anthropic();
|
|
2820
4195
|
const infraSummary = JSON.stringify({
|
|
2821
4196
|
nodes: nodes.map((n) => ({
|
|
@@ -2837,7 +4212,7 @@ INFRASTRUCTURE SNAPSHOT (${nodes.length} nodes, ${edges.length} edges):
|
|
|
2837
4212
|
${infraSummary.substring(0, 12e3)}`;
|
|
2838
4213
|
const history = [];
|
|
2839
4214
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
2840
|
-
const ask = () => new Promise((
|
|
4215
|
+
const ask = () => new Promise((resolve3) => rl.question(` ${cyan(">")} `, resolve3));
|
|
2841
4216
|
while (true) {
|
|
2842
4217
|
let userInput;
|
|
2843
4218
|
try {
|
|
@@ -2850,7 +4225,7 @@ ${infraSummary.substring(0, 12e3)}`;
|
|
|
2850
4225
|
history.push({ role: "user", content: userInput });
|
|
2851
4226
|
try {
|
|
2852
4227
|
const resp = await client.messages.create({
|
|
2853
|
-
model
|
|
4228
|
+
model,
|
|
2854
4229
|
max_tokens: 1024,
|
|
2855
4230
|
system: systemPrompt,
|
|
2856
4231
|
messages: history
|
|
@@ -2919,9 +4294,9 @@ ${infraSummary.substring(0, 12e3)}`;
|
|
|
2919
4294
|
out("\n");
|
|
2920
4295
|
out(` ${green("datasynx-cartography discover")}
|
|
2921
4296
|
`);
|
|
2922
|
-
out(` Scans your local infrastructure (
|
|
4297
|
+
out(` Scans your local infrastructure (provider-agnostic: claude, openai, ollama).
|
|
2923
4298
|
`);
|
|
2924
|
-
out(`
|
|
4299
|
+
out(` The agent autonomously runs ${IS_WIN ? "Get-NetTCPConnection, Get-Process" : IS_MAC ? "lsof, ps" : "ss, ps"}, curl, docker inspect, kubectl get
|
|
2925
4300
|
`);
|
|
2926
4301
|
out(` and stores everything in SQLite.
|
|
2927
4302
|
`);
|
|
@@ -2944,7 +4319,7 @@ ${infraSummary.substring(0, 12e3)}`;
|
|
|
2944
4319
|
out("\n");
|
|
2945
4320
|
out(` ${green("datasynx-cartography export [session-id]")}
|
|
2946
4321
|
`);
|
|
2947
|
-
out(dim(" --format <fmt...> mermaid, json, yaml, html, map (default: all)\n"));
|
|
4322
|
+
out(dim(" --format <fmt...> mermaid, json, yaml, html, map, cost (default: all but cost)\n"));
|
|
2948
4323
|
out(dim(" -o, --output <dir> Output directory\n"));
|
|
2949
4324
|
out("\n");
|
|
2950
4325
|
out(` ${green("datasynx-cartography show [session-id]")} ${dim("Session details + node list")}
|
|
@@ -2968,7 +4343,7 @@ ${infraSummary.substring(0, 12e3)}`;
|
|
|
2968
4343
|
out(dim(" \u2514\u2500\u2500 Platform Detection (platform.ts)\n"));
|
|
2969
4344
|
out(dim(" \u2514\u2500\u2500 Shell: /bin/sh (Unix) | PowerShell (Windows)\n"));
|
|
2970
4345
|
out(dim(" \u2514\u2500\u2500 Agent Orchestrator (agent.ts)\n"));
|
|
2971
|
-
out(dim(" \u2514\u2500\u2500 runDiscovery() \u2192
|
|
4346
|
+
out(dim(" \u2514\u2500\u2500 runDiscovery() \u2192 AgentProvider (claude|openai|ollama) + Bash + MCP Tools\n"));
|
|
2972
4347
|
out(dim(" \u2514\u2500\u2500 Custom MCP Tools (tools.ts)\n"));
|
|
2973
4348
|
out(dim(" save_node, save_edge,\n"));
|
|
2974
4349
|
out(dim(" scan_bookmarks, scan_browser_history,\n"));
|
|
@@ -3005,10 +4380,10 @@ ${infraSummary.substring(0, 12e3)}`;
|
|
|
3005
4380
|
out("\n");
|
|
3006
4381
|
});
|
|
3007
4382
|
program.command("bookmarks").description("View all browser bookmarks (Chrome, Chromium, Edge, Brave, Vivaldi, Opera, Firefox)").action(async () => {
|
|
3008
|
-
const { scanAllBookmarks
|
|
4383
|
+
const { scanAllBookmarks } = await import("./bookmarks-WXHE7GN7.js");
|
|
3009
4384
|
const out = (s) => process.stdout.write(s);
|
|
3010
4385
|
process.stderr.write(" Scanning bookmarks...\n\n");
|
|
3011
|
-
const hosts = await
|
|
4386
|
+
const hosts = await scanAllBookmarks();
|
|
3012
4387
|
if (hosts.length === 0) {
|
|
3013
4388
|
out(" (No bookmarks found \u2014 Chrome, Edge, Brave, Vivaldi, Opera and Firefox are supported)\n\n");
|
|
3014
4389
|
return;
|
|
@@ -3035,16 +4410,52 @@ ${infraSummary.substring(0, 12e3)}`;
|
|
|
3035
4410
|
`));
|
|
3036
4411
|
out(dim(" Tip: ") + "datasynx-cartography discover" + dim(" \u2014 scans + classifies all bookmarks automatically\n\n"));
|
|
3037
4412
|
});
|
|
3038
|
-
program.command("
|
|
3039
|
-
const config = defaultConfig(
|
|
4413
|
+
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) => {
|
|
4414
|
+
const config = defaultConfig(opts.db ? { dbPath: opts.db } : {});
|
|
4415
|
+
const db = new CartographyDB(config.dbPath);
|
|
4416
|
+
activeDb = db;
|
|
4417
|
+
try {
|
|
4418
|
+
const sessionId = opts.session ?? db.getLatestSession("discover")?.id;
|
|
4419
|
+
if (!sessionId) {
|
|
4420
|
+
process.stderr.write("\u274C No session to enrich (run discovery first or pass --session)\n");
|
|
4421
|
+
process.exitCode = 1;
|
|
4422
|
+
return;
|
|
4423
|
+
}
|
|
4424
|
+
const match = opts.match;
|
|
4425
|
+
if (!["nodeId", "name", "tag"].includes(match)) {
|
|
4426
|
+
process.stderr.write(`\u274C Invalid --match: "${match}" (nodeId | name | tag)
|
|
4427
|
+
`);
|
|
4428
|
+
process.exitCode = 1;
|
|
4429
|
+
return;
|
|
4430
|
+
}
|
|
4431
|
+
const source = new CsvCostSource({ filePath: opts.file, match, db, sessionId });
|
|
4432
|
+
const r = await enrichCosts(db, sessionId, source);
|
|
4433
|
+
process.stderr.write(`\u2713 cost: ${r.matched} matched, ${r.unmatched} unmatched (of ${r.total}) from ${r.source}
|
|
4434
|
+
`);
|
|
4435
|
+
if (r.unmatchedIds.length > 0) {
|
|
4436
|
+
process.stderr.write(` unmatched ids: ${r.unmatchedIds.slice(0, 20).join(", ")}${r.unmatchedIds.length > 20 ? " \u2026" : ""}
|
|
4437
|
+
`);
|
|
4438
|
+
}
|
|
4439
|
+
if (r.matched === 0 && r.total > 0) process.exitCode = 1;
|
|
4440
|
+
} catch (err) {
|
|
4441
|
+
process.stderr.write(`\u274C ${err instanceof Error ? err.message : String(err)}
|
|
4442
|
+
`);
|
|
4443
|
+
process.exitCode = 1;
|
|
4444
|
+
} finally {
|
|
4445
|
+
db.close();
|
|
4446
|
+
activeDb = null;
|
|
4447
|
+
}
|
|
4448
|
+
});
|
|
4449
|
+
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) => {
|
|
4450
|
+
const config = defaultConfig({ ...opts.db ? { dbPath: opts.db } : {}, ...opts.org ? { organization: opts.org } : {} });
|
|
3040
4451
|
const db = new CartographyDB(config.dbPath);
|
|
3041
|
-
const sessionId = opts.session ?? db.createSession("discover", config);
|
|
4452
|
+
const sessionId = opts.session ?? db.createSession("discover", config, opts.org);
|
|
3042
4453
|
const out = (s) => process.stdout.write(s);
|
|
3043
4454
|
const w = (s) => process.stderr.write(s);
|
|
3044
4455
|
if (opts.file) {
|
|
3045
4456
|
let raw;
|
|
3046
4457
|
try {
|
|
3047
|
-
raw = JSON.parse(
|
|
4458
|
+
raw = JSON.parse(readFileSync5(resolve2(opts.file), "utf8"));
|
|
3048
4459
|
} catch (e) {
|
|
3049
4460
|
w(red(`
|
|
3050
4461
|
\u2717 Could not read file: ${e}
|
|
@@ -3062,7 +4473,7 @@ ${infraSummary.substring(0, 12e3)}`;
|
|
|
3062
4473
|
for (const entry of raw) {
|
|
3063
4474
|
const type = entry["type"];
|
|
3064
4475
|
const name = entry["name"];
|
|
3065
|
-
const
|
|
4476
|
+
const host2 = entry["host"];
|
|
3066
4477
|
const port = entry["port"];
|
|
3067
4478
|
const tags = entry["tags"] ?? [];
|
|
3068
4479
|
const metadata = entry["metadata"] ?? {};
|
|
@@ -3071,14 +4482,14 @@ ${infraSummary.substring(0, 12e3)}`;
|
|
|
3071
4482
|
`));
|
|
3072
4483
|
continue;
|
|
3073
4484
|
}
|
|
3074
|
-
const id =
|
|
4485
|
+
const id = host2 ? `${type}:${host2}${port ? ":" + port : ""}` : `${type}:${name.toLowerCase().replace(/\s+/g, "-")}`;
|
|
3075
4486
|
db.upsertNode(sessionId, {
|
|
3076
4487
|
id,
|
|
3077
4488
|
type,
|
|
3078
4489
|
name,
|
|
3079
4490
|
discoveredVia: "manual",
|
|
3080
4491
|
confidence: 1,
|
|
3081
|
-
metadata: { ...metadata, ...
|
|
4492
|
+
metadata: { ...metadata, ...host2 ? { host: host2 } : {}, ...port ? { port } : {} },
|
|
3082
4493
|
tags
|
|
3083
4494
|
});
|
|
3084
4495
|
out(` ${green("+")} ${cyan(id)} ${dim("(" + type + ")")}
|
|
@@ -3092,7 +4503,7 @@ ${infraSummary.substring(0, 12e3)}`;
|
|
|
3092
4503
|
`);
|
|
3093
4504
|
return;
|
|
3094
4505
|
}
|
|
3095
|
-
const { NODE_TYPES
|
|
4506
|
+
const { NODE_TYPES } = await import("./types-TJWXAQ2L.js");
|
|
3096
4507
|
if (!process.stdin.isTTY) {
|
|
3097
4508
|
w(red("\n \u2717 Interactive mode requires a terminal (use --file for non-interactive)\n\n"));
|
|
3098
4509
|
process.exitCode = 1;
|
|
@@ -3107,7 +4518,7 @@ ${infraSummary.substring(0, 12e3)}`;
|
|
|
3107
4518
|
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
3108
4519
|
const ask = (q) => new Promise((res) => rl.question(q, res));
|
|
3109
4520
|
let saved = 0;
|
|
3110
|
-
const typeList =
|
|
4521
|
+
const typeList = NODE_TYPES.map((t, i) => `${dim((i + 1).toString().padStart(2))} ${t}`).join("\n ");
|
|
3111
4522
|
while (true) {
|
|
3112
4523
|
w("\n");
|
|
3113
4524
|
w(dim(" Node types:\n"));
|
|
@@ -3118,9 +4529,9 @@ ${infraSummary.substring(0, 12e3)}`;
|
|
|
3118
4529
|
if (!typeInput) break;
|
|
3119
4530
|
let nodeType;
|
|
3120
4531
|
const asNum = parseInt(typeInput, 10);
|
|
3121
|
-
if (!isNaN(asNum) && asNum >= 1 && asNum <=
|
|
3122
|
-
nodeType =
|
|
3123
|
-
} else if (
|
|
4532
|
+
if (!isNaN(asNum) && asNum >= 1 && asNum <= NODE_TYPES.length) {
|
|
4533
|
+
nodeType = NODE_TYPES[asNum - 1];
|
|
4534
|
+
} else if (NODE_TYPES.includes(typeInput)) {
|
|
3124
4535
|
nodeType = typeInput;
|
|
3125
4536
|
} else {
|
|
3126
4537
|
w(yellow(` \u26A0 Unknown type: "${typeInput}"
|
|
@@ -3135,17 +4546,17 @@ ${infraSummary.substring(0, 12e3)}`;
|
|
|
3135
4546
|
const hostRaw = (await ask(` ${cyan("Host / IP")} ${dim("[optional, Enter=skip]")}: `)).trim();
|
|
3136
4547
|
const portRaw = (await ask(` ${cyan("Port")} ${dim("[optional]")}: `)).trim();
|
|
3137
4548
|
const tagsRaw = (await ask(` ${cyan("Tags")} ${dim("[comma-separated, optional]")}: `)).trim();
|
|
3138
|
-
const
|
|
4549
|
+
const host2 = hostRaw || void 0;
|
|
3139
4550
|
const port = portRaw ? parseInt(portRaw, 10) : void 0;
|
|
3140
4551
|
const tags = tagsRaw ? tagsRaw.split(",").map((t) => t.trim()).filter(Boolean) : [];
|
|
3141
|
-
const id =
|
|
4552
|
+
const id = host2 ? `${nodeType}:${host2}${port ? ":" + port : ""}` : `${nodeType}:${name.toLowerCase().replace(/\s+/g, "-")}`;
|
|
3142
4553
|
db.upsertNode(sessionId, {
|
|
3143
4554
|
id,
|
|
3144
4555
|
type: nodeType,
|
|
3145
4556
|
name,
|
|
3146
4557
|
discoveredVia: "manual",
|
|
3147
4558
|
confidence: 1,
|
|
3148
|
-
metadata: { ...
|
|
4559
|
+
metadata: { ...host2 ? { host: host2 } : {}, ...port ? { port } : {} },
|
|
3149
4560
|
tags
|
|
3150
4561
|
});
|
|
3151
4562
|
out(` ${green("+")} ${cyan(id)}
|
|
@@ -3168,8 +4579,8 @@ ${infraSummary.substring(0, 12e3)}`;
|
|
|
3168
4579
|
});
|
|
3169
4580
|
program.command("doctor").description("Check all requirements and cloud CLIs").action(async () => {
|
|
3170
4581
|
const { execSync: execSync2 } = await import("child_process");
|
|
3171
|
-
const { existsSync:
|
|
3172
|
-
const { join:
|
|
4582
|
+
const { existsSync: existsSync4, readFileSync: readFileSync6 } = await import("fs");
|
|
4583
|
+
const { join: join4 } = await import("path");
|
|
3173
4584
|
const out = (s) => process.stdout.write(s);
|
|
3174
4585
|
const ok = (msg) => out(` \x1B[32m\u2713\x1B[0m ${msg}
|
|
3175
4586
|
`);
|
|
@@ -3183,10 +4594,10 @@ ${infraSummary.substring(0, 12e3)}`;
|
|
|
3183
4594
|
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
4595
|
const nodeVer = process.versions.node;
|
|
3185
4596
|
const [major] = nodeVer.split(".").map(Number);
|
|
3186
|
-
if ((major ?? 0) >=
|
|
4597
|
+
if ((major ?? 0) >= 20) {
|
|
3187
4598
|
ok(`Node.js ${nodeVer}`);
|
|
3188
4599
|
} else {
|
|
3189
|
-
err(`Node.js ${nodeVer} \u2014 requires >=
|
|
4600
|
+
err(`Node.js ${nodeVer} \u2014 requires >=20`);
|
|
3190
4601
|
allGood = false;
|
|
3191
4602
|
}
|
|
3192
4603
|
try {
|
|
@@ -3200,7 +4611,7 @@ ${infraSummary.substring(0, 12e3)}`;
|
|
|
3200
4611
|
const home = process.env.HOME ?? process.env.USERPROFILE ?? "/tmp";
|
|
3201
4612
|
let hasOAuth = false;
|
|
3202
4613
|
try {
|
|
3203
|
-
const creds = JSON.parse(
|
|
4614
|
+
const creds = JSON.parse(readFileSync6(join4(home, ".claude", ".credentials.json"), "utf8"));
|
|
3204
4615
|
const oauth = creds["claudeAiOauth"];
|
|
3205
4616
|
hasOAuth = typeof oauth?.["accessToken"] === "string" && oauth["accessToken"].length > 0;
|
|
3206
4617
|
} catch {
|
|
@@ -3250,8 +4661,8 @@ ${infraSummary.substring(0, 12e3)}`;
|
|
|
3250
4661
|
warn(`${name} not found ${dim2("\u2014 discovery without " + name + " will be limited")}`);
|
|
3251
4662
|
}
|
|
3252
4663
|
}
|
|
3253
|
-
const dbDir =
|
|
3254
|
-
if (
|
|
4664
|
+
const dbDir = join4(home, ".cartography");
|
|
4665
|
+
if (existsSync4(dbDir)) {
|
|
3255
4666
|
ok(`~/.cartography ${dim2("(data directory exists)")}`);
|
|
3256
4667
|
} else {
|
|
3257
4668
|
warn("~/.cartography does not exist yet " + dim2("\u2014 will be created on first run"));
|
|
@@ -3298,15 +4709,283 @@ ${infraSummary.substring(0, 12e3)}`;
|
|
|
3298
4709
|
}
|
|
3299
4710
|
db.close();
|
|
3300
4711
|
});
|
|
3301
|
-
program.command("
|
|
3302
|
-
|
|
4712
|
+
program.command("list-clients").description("List the AI hosts the installer can configure").action(() => {
|
|
4713
|
+
o("\n" + bold(" Supported MCP hosts:") + "\n\n");
|
|
4714
|
+
for (const c of listClients()) {
|
|
4715
|
+
o(` ${green(c.id.padEnd(16))} ${bold(c.label.padEnd(20))} ${dim(c.format)}
|
|
4716
|
+
`);
|
|
4717
|
+
if (c.note) o(` ${" ".repeat(16)} ${dim("\u21B3 " + c.note)}
|
|
4718
|
+
`);
|
|
4719
|
+
}
|
|
4720
|
+
o("\n" + dim(` Install: ${CMD} install --client <id> [--project] [--dry-run]`) + "\n\n");
|
|
4721
|
+
});
|
|
4722
|
+
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) => {
|
|
4723
|
+
const spec = getClient(opts.client);
|
|
4724
|
+
if (!spec) {
|
|
4725
|
+
logError(`Unknown client "${opts.client}". Run \`${CMD} list-clients\` to see options.`);
|
|
4726
|
+
process.exitCode = 1;
|
|
4727
|
+
return;
|
|
4728
|
+
}
|
|
4729
|
+
const scope = opts.project ? "project" : "global";
|
|
4730
|
+
const packageArgs = [];
|
|
4731
|
+
if (opts.db) packageArgs.push("--db", opts.db);
|
|
4732
|
+
if (opts.session) packageArgs.push("--session", opts.session);
|
|
4733
|
+
const entry = defaultServerEntry({
|
|
3303
4734
|
transport: opts.http ? "http" : "stdio",
|
|
3304
|
-
|
|
3305
|
-
|
|
3306
|
-
dbPath: opts.db,
|
|
3307
|
-
session: opts.session,
|
|
3308
|
-
semantic: opts.semantic
|
|
4735
|
+
...opts.url ? { url: opts.url } : {},
|
|
4736
|
+
...packageArgs.length ? { packageArgs } : {}
|
|
3309
4737
|
});
|
|
4738
|
+
if (opts.deeplink) {
|
|
4739
|
+
if (opts.client === "cursor") {
|
|
4740
|
+
o("\n" + bold(" Cursor one-click:") + "\n " + cyan(cursorDeeplink(opts.name, entry)) + "\n\n");
|
|
4741
|
+
} else if (opts.client === "vscode") {
|
|
4742
|
+
o("\n" + bold(" VS Code one-click:") + "\n " + cyan(vscodeDeeplink(opts.name, entry)) + "\n");
|
|
4743
|
+
o(" " + dim("or: ") + codeAddMcpCommand(opts.name, entry) + "\n\n");
|
|
4744
|
+
} else {
|
|
4745
|
+
logWarn(`No deeplink available for "${opts.client}". Deeplinks exist for: cursor, vscode.`);
|
|
4746
|
+
}
|
|
4747
|
+
return;
|
|
4748
|
+
}
|
|
4749
|
+
try {
|
|
4750
|
+
const plan = planInstall(spec, defaultContext(scope), { serverName: opts.name, entry });
|
|
4751
|
+
o("\n" + bold(` ${plan.label}`) + dim(` (${plan.format}, ${scope})`) + "\n");
|
|
4752
|
+
o(dim(` ${plan.path}`) + "\n");
|
|
4753
|
+
if (plan.note) o(yellow(` \u26A0 ${plan.note}`) + "\n");
|
|
4754
|
+
o("\n" + renderDiff(plan.before, plan.after) + "\n\n");
|
|
4755
|
+
if (!plan.changed) {
|
|
4756
|
+
o(green(" \u2713 Already up to date \u2014 nothing to write.") + "\n\n");
|
|
4757
|
+
return;
|
|
4758
|
+
}
|
|
4759
|
+
if (opts.dryRun) {
|
|
4760
|
+
o(yellow(" Dry run \u2014 no file written.") + "\n\n");
|
|
4761
|
+
return;
|
|
4762
|
+
}
|
|
4763
|
+
applyInstall(plan);
|
|
4764
|
+
o(green(` \u2713 Wrote ${plan.fileExists ? "updated" : "new"} config.`) + " " + dim("Restart the host to pick it up.") + "\n\n");
|
|
4765
|
+
} catch (err) {
|
|
4766
|
+
logError(err instanceof Error ? err.message : String(err));
|
|
4767
|
+
process.exitCode = 1;
|
|
4768
|
+
}
|
|
4769
|
+
});
|
|
4770
|
+
program;
|
|
4771
|
+
const consent = program.command("consent").description("Manage the per-employee data-sharing policy (none|anonymized|full) + admin anonymization");
|
|
4772
|
+
consent.command("default <level>").description("Set the global default sharing level (none|anonymized|full)").option("--db <path>", "DB path").action((level, opts) => {
|
|
4773
|
+
const parsed = SharingLevelSchema.safeParse(level);
|
|
4774
|
+
if (!parsed.success) {
|
|
4775
|
+
logError(`Invalid level "${level}" \u2014 expected one of: none, anonymized, full`);
|
|
4776
|
+
process.exitCode = 1;
|
|
4777
|
+
return;
|
|
4778
|
+
}
|
|
4779
|
+
const config = defaultConfig(opts.db ? { dbPath: opts.db } : {});
|
|
4780
|
+
const db = new CartographyDB(config.dbPath);
|
|
4781
|
+
try {
|
|
4782
|
+
db.setSharingLevel("*", parsed.data);
|
|
4783
|
+
logInfo(`default sharing level set to "${parsed.data}"`);
|
|
4784
|
+
} finally {
|
|
4785
|
+
db.close();
|
|
4786
|
+
}
|
|
4787
|
+
});
|
|
4788
|
+
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) => {
|
|
4789
|
+
const parsed = SharingLevelSchema.safeParse(level);
|
|
4790
|
+
if (!parsed.success) {
|
|
4791
|
+
logError(`Invalid level "${level}" \u2014 expected one of: none, anonymized, full`);
|
|
4792
|
+
process.exitCode = 1;
|
|
4793
|
+
return;
|
|
4794
|
+
}
|
|
4795
|
+
if (pattern === "*" || pattern === "**") {
|
|
4796
|
+
logError("Use `consent default <level>` to set the global default; `set` is for narrower overrides");
|
|
4797
|
+
process.exitCode = 1;
|
|
4798
|
+
return;
|
|
4799
|
+
}
|
|
4800
|
+
const config = defaultConfig(opts.db ? { dbPath: opts.db } : {});
|
|
4801
|
+
const db = new CartographyDB(config.dbPath);
|
|
4802
|
+
try {
|
|
4803
|
+
db.setSharingLevel(pattern, parsed.data);
|
|
4804
|
+
logInfo(`override "${pattern}" \u2192 "${parsed.data}"`);
|
|
4805
|
+
} finally {
|
|
4806
|
+
db.close();
|
|
4807
|
+
}
|
|
4808
|
+
});
|
|
4809
|
+
consent.command("clear <pattern>").description("Remove a pattern override (the global default cannot be cleared)").option("--db <path>", "DB path").action((pattern, opts) => {
|
|
4810
|
+
const config = defaultConfig(opts.db ? { dbPath: opts.db } : {});
|
|
4811
|
+
const db = new CartographyDB(config.dbPath);
|
|
4812
|
+
try {
|
|
4813
|
+
db.clearSharingOverride(pattern);
|
|
4814
|
+
logInfo(`override "${pattern}" cleared`);
|
|
4815
|
+
} finally {
|
|
4816
|
+
db.close();
|
|
4817
|
+
}
|
|
4818
|
+
});
|
|
4819
|
+
consent.command("list").description("Show the global default + every pattern override").option("--db <path>", "DB path").action((opts) => {
|
|
4820
|
+
const config = defaultConfig(opts.db ? { dbPath: opts.db } : {});
|
|
4821
|
+
const db = new CartographyDB(config.dbPath);
|
|
4822
|
+
try {
|
|
4823
|
+
const policy = db.getSharingPolicy();
|
|
4824
|
+
process.stdout.write(JSON.stringify(policy, null, 2) + "\n");
|
|
4825
|
+
} finally {
|
|
4826
|
+
db.close();
|
|
4827
|
+
}
|
|
4828
|
+
});
|
|
4829
|
+
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) => {
|
|
4830
|
+
const config = defaultConfig(opts.db ? { dbPath: opts.db } : {});
|
|
4831
|
+
const db = new CartographyDB(config.dbPath);
|
|
4832
|
+
try {
|
|
4833
|
+
const sid = session && session !== "latest" ? session : db.getLatestSession("discover")?.id;
|
|
4834
|
+
if (!sid) {
|
|
4835
|
+
logError("No session found to preview");
|
|
4836
|
+
process.exitCode = 1;
|
|
4837
|
+
return;
|
|
4838
|
+
}
|
|
4839
|
+
const orgKey = loadOrgKey({ organization: opts.org });
|
|
4840
|
+
const policy = db.getSharingPolicy();
|
|
4841
|
+
const preview = previewShare(db, sid, orgKey, policy);
|
|
4842
|
+
process.stdout.write(JSON.stringify(preview, null, 2) + "\n");
|
|
4843
|
+
} finally {
|
|
4844
|
+
db.close();
|
|
4845
|
+
}
|
|
4846
|
+
});
|
|
4847
|
+
const consentKey = consent.command("key").description("Org-key administration");
|
|
4848
|
+
consentKey.command("rotate").description("Rotate the org key (prior reversal entries become unrecoverable)").option("--org <name>", "Organization namespace for the org key").action((opts) => {
|
|
4849
|
+
rotateOrgKey({ organization: opts.org });
|
|
4850
|
+
logInfo("org key rotated");
|
|
4851
|
+
});
|
|
4852
|
+
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) => {
|
|
4853
|
+
const config = defaultConfig(opts.db ? { dbPath: opts.db } : {});
|
|
4854
|
+
const db = new CartographyDB(config.dbPath);
|
|
4855
|
+
try {
|
|
4856
|
+
const orgKey = loadOrgKey({ organization: opts.org });
|
|
4857
|
+
const plaintext = reversePseudonym(token, orgKey, db);
|
|
4858
|
+
if (plaintext === void 0) {
|
|
4859
|
+
logError(`Could not reverse "${token}" (unknown token or wrong/rotated org key)`);
|
|
4860
|
+
process.exitCode = 1;
|
|
4861
|
+
return;
|
|
4862
|
+
}
|
|
4863
|
+
process.stdout.write(plaintext + "\n");
|
|
4864
|
+
} finally {
|
|
4865
|
+
db.close();
|
|
4866
|
+
}
|
|
4867
|
+
});
|
|
4868
|
+
const sync = program.command("sync").description("Central-DB outbound sync: review queued items and push approved deltas (opt-in)");
|
|
4869
|
+
sync.command("status").description("Show the pending-review queue (counts by status + pending items)").option("--db <path>", "DB path").action((opts) => {
|
|
4870
|
+
const config = defaultConfig(opts.db ? { dbPath: opts.db } : {});
|
|
4871
|
+
const db = new CartographyDB(config.dbPath);
|
|
4872
|
+
try {
|
|
4873
|
+
if (!config.centralDb?.url) {
|
|
4874
|
+
logWarn("centralDb is not configured \u2014 sync is inert (set centralDb in ~/.cartography/config.json or CARTOGRAPHY_CENTRAL_URL/TOKEN)");
|
|
4875
|
+
}
|
|
4876
|
+
const counts = db.countPendingByStatus();
|
|
4877
|
+
process.stdout.write(JSON.stringify(counts, null, 2) + "\n");
|
|
4878
|
+
const pending = db.getPendingShares({ status: "pending" });
|
|
4879
|
+
for (const p of pending.slice(0, 50)) {
|
|
4880
|
+
process.stdout.write(` ${p.kind === "node" ? "\u25CF" : "\u2192"} ${p.nodeId ?? p.contentHash.slice(0, 12)} ${dim("(" + p.kind + ")")}
|
|
4881
|
+
`);
|
|
4882
|
+
}
|
|
4883
|
+
if (pending.length > 50) process.stdout.write(` ${dim("\u2026 and " + (pending.length - 50) + " more")}
|
|
4884
|
+
`);
|
|
4885
|
+
} finally {
|
|
4886
|
+
db.close();
|
|
4887
|
+
}
|
|
4888
|
+
});
|
|
4889
|
+
sync.command("review").description("Interactively approve/withhold each pending item (decisions are remembered)").option("--db <path>", "DB path").action(async (opts) => {
|
|
4890
|
+
const config = defaultConfig(opts.db ? { dbPath: opts.db } : {});
|
|
4891
|
+
const db = new CartographyDB(config.dbPath);
|
|
4892
|
+
try {
|
|
4893
|
+
const pending = db.getPendingShares({ status: "pending" });
|
|
4894
|
+
if (pending.length === 0) {
|
|
4895
|
+
logInfo("no pending items to review");
|
|
4896
|
+
return;
|
|
4897
|
+
}
|
|
4898
|
+
if (!process.stdin.isTTY) {
|
|
4899
|
+
logWarn(`${pending.length} pending item(s); run \`sync review\` in an interactive terminal to decide them`);
|
|
4900
|
+
return;
|
|
4901
|
+
}
|
|
4902
|
+
const w = process.stderr.write.bind(process.stderr);
|
|
4903
|
+
const patternFor = (p) => p.nodeId;
|
|
4904
|
+
for (const p of pending) {
|
|
4905
|
+
w("\n");
|
|
4906
|
+
w(` ${yellow(bold("?"))} Share ${p.kind} ${bold(p.nodeId ?? p.contentHash.slice(0, 12))}?
|
|
4907
|
+
`);
|
|
4908
|
+
w(` ${dim(JSON.stringify(p.payload))}
|
|
4909
|
+
`);
|
|
4910
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
4911
|
+
const ans = (await new Promise((res) => rl.question(` ${cyan("\u2192")} [s]hare / [w]ithhold / [a]lways / [n]ever / [q]uit: `, res))).trim().toLowerCase();
|
|
4912
|
+
rl.close();
|
|
4913
|
+
const pat = patternFor(p);
|
|
4914
|
+
if (ans === "q") break;
|
|
4915
|
+
if (ans === "s") {
|
|
4916
|
+
db.setPendingStatus(p.contentHash, "approved", "user");
|
|
4917
|
+
} else if (ans === "w") {
|
|
4918
|
+
db.setPendingStatus(p.contentHash, "withheld", "user");
|
|
4919
|
+
} else if (ans === "a") {
|
|
4920
|
+
if (pat) db.setSharingLevel(pat, "full");
|
|
4921
|
+
db.setPendingStatus(p.contentHash, "approved", "user");
|
|
4922
|
+
} else if (ans === "n") {
|
|
4923
|
+
if (pat) db.setSharingLevel(pat, "none");
|
|
4924
|
+
db.setPendingStatus(p.contentHash, "withheld", "user");
|
|
4925
|
+
} else {
|
|
4926
|
+
w(` ${dim("skipped (left pending)")}
|
|
4927
|
+
`);
|
|
4928
|
+
}
|
|
4929
|
+
}
|
|
4930
|
+
const counts = db.countPendingByStatus();
|
|
4931
|
+
logInfo(`review done \u2014 approved ${counts.approved}, withheld ${counts.withheld}, pending ${counts.pending}`);
|
|
4932
|
+
} finally {
|
|
4933
|
+
db.close();
|
|
4934
|
+
}
|
|
4935
|
+
});
|
|
4936
|
+
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) => {
|
|
4937
|
+
const config = defaultConfig(opts.db ? { dbPath: opts.db } : {});
|
|
4938
|
+
if (!config.centralDb?.url) {
|
|
4939
|
+
logError("centralDb is not configured \u2014 nothing to push (set centralDb.url + token)");
|
|
4940
|
+
process.exitCode = 1;
|
|
4941
|
+
return;
|
|
4942
|
+
}
|
|
4943
|
+
const db = new CartographyDB(config.dbPath);
|
|
4944
|
+
try {
|
|
4945
|
+
const approved = db.getApprovedShares();
|
|
4946
|
+
const items = approved.map((p) => ({ contentHash: p.contentHash, kind: p.kind, payload: p.payload }));
|
|
4947
|
+
const result = await pushDeltas(config, items, { dryRun: opts.dryRun });
|
|
4948
|
+
if (!opts.dryRun) {
|
|
4949
|
+
for (const hash of result.sentHashes) db.setPendingStatus(hash, "shared");
|
|
4950
|
+
}
|
|
4951
|
+
logInfo(`sync push: sent ${result.sent}, batches ${result.batches}, failed ${result.failed}${opts.dryRun ? " (dry-run)" : ""}`);
|
|
4952
|
+
} catch (err) {
|
|
4953
|
+
logError(`sync push failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
4954
|
+
process.exitCode = 1;
|
|
4955
|
+
} finally {
|
|
4956
|
+
db.close();
|
|
4957
|
+
}
|
|
4958
|
+
});
|
|
4959
|
+
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) => {
|
|
4960
|
+
try {
|
|
4961
|
+
const anonMode = opts.anonMode;
|
|
4962
|
+
if (anonMode !== "reject" && anonMode !== "strip") {
|
|
4963
|
+
process.stderr.write(`
|
|
4964
|
+
error: --anon-mode must be 'reject' or 'strip' (got '${anonMode}')
|
|
4965
|
+
`);
|
|
4966
|
+
process.exitCode = 1;
|
|
4967
|
+
return;
|
|
4968
|
+
}
|
|
4969
|
+
await startMcp({
|
|
4970
|
+
transport: opts.http ? "http" : "stdio",
|
|
4971
|
+
port: parseInt(opts.port, 10),
|
|
4972
|
+
host: opts.host,
|
|
4973
|
+
allowedHosts: opts.allowedHosts ? String(opts.allowedHosts).split(",").map((h) => h.trim()).filter(Boolean) : void 0,
|
|
4974
|
+
token: opts.token,
|
|
4975
|
+
dbPath: opts.db,
|
|
4976
|
+
session: opts.session,
|
|
4977
|
+
tenant: opts.tenant ?? opts.org,
|
|
4978
|
+
semantic: opts.semantic,
|
|
4979
|
+
plugins: opts.plugins ? String(opts.plugins).split(",").map((p) => p.trim()).filter(Boolean) : void 0,
|
|
4980
|
+
serverMode: opts.serverMode === true,
|
|
4981
|
+
anonMode
|
|
4982
|
+
});
|
|
4983
|
+
} catch (err) {
|
|
4984
|
+
process.stderr.write(`
|
|
4985
|
+
error: ${err instanceof Error ? err.message : String(err)}
|
|
4986
|
+
`);
|
|
4987
|
+
process.exitCode = 1;
|
|
4988
|
+
}
|
|
3310
4989
|
});
|
|
3311
4990
|
const o = (s) => process.stderr.write(s);
|
|
3312
4991
|
o("\n");
|
|
@@ -3326,7 +5005,7 @@ ${infraSummary.substring(0, 12e3)}`;
|
|
|
3326
5005
|
o("\n");
|
|
3327
5006
|
o(bold(" Commands:\n"));
|
|
3328
5007
|
o("\n");
|
|
3329
|
-
o(` ${green("discover")} ${dim("Scan infrastructure (
|
|
5008
|
+
o(` ${green("discover")} ${dim("Scan infrastructure (provider: claude|openai|ollama)")}
|
|
3330
5009
|
`);
|
|
3331
5010
|
o(` ${green("seed")} ${dim("Manually add known tools/DBs/APIs")}
|
|
3332
5011
|
`);
|