@datasynx/agentic-ai-cartography 1.1.1 → 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 +307 -34
- package/dist/bookmarks-WXHE7GN7.js +28 -0
- package/dist/chunk-2SZ5QHGH.js +847 -0
- 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 +2384 -1236
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +10432 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +3432 -0
- package/dist/index.d.ts +3102 -48
- package/dist/index.js +7645 -954
- package/dist/index.js.map +1 -1
- package/dist/mcp-bin.js +23 -0
- package/dist/mcp-bin.js.map +1 -0
- package/dist/types-TJWXAQ2L.js +66 -0
- package/llms-full.txt +758 -0
- package/llms.txt +24 -0
- package/package.json +71 -21
- 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 +28 -0
- package/dist/bookmarks-BWNVQGPG.js +0 -14
- package/dist/chunk-QKNYI3SU.js +0 -459
- package/dist/chunk-QKNYI3SU.js.map +0 -1
- package/dist/chunk-WJR63RWY.js +0 -133
- package/dist/chunk-WJR63RWY.js.map +0 -1
- package/dist/types-54623ALF.js +0 -26
- package/scripts/postinstall.mjs +0 -7
- /package/dist/{bookmarks-BWNVQGPG.js.map → bookmarks-WXHE7GN7.js.map} +0 -0
- /package/dist/{types-54623ALF.js.map → types-TJWXAQ2L.js.map} +0 -0
package/dist/cli.js
CHANGED
|
@@ -1,27 +1,50 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
|
+
CartographyDB,
|
|
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";
|
|
24
|
+
import {
|
|
25
|
+
ConfigFileSchema,
|
|
26
|
+
CostEntrySchema,
|
|
3
27
|
DOMAIN_COLORS,
|
|
4
28
|
DOMAIN_PALETTE,
|
|
5
|
-
|
|
6
|
-
|
|
29
|
+
DriftConfigSchema,
|
|
30
|
+
NODE_TYPE_GROUPS,
|
|
31
|
+
SharingLevelSchema,
|
|
32
|
+
centralDbFromEnv,
|
|
7
33
|
defaultConfig
|
|
8
|
-
} from "./chunk-
|
|
34
|
+
} from "./chunk-WCR47QA2.js";
|
|
9
35
|
import {
|
|
10
|
-
HOME,
|
|
11
|
-
IS_LINUX,
|
|
12
36
|
IS_MAC,
|
|
13
37
|
IS_WIN,
|
|
14
38
|
PLATFORM,
|
|
39
|
+
checkReadOnly,
|
|
15
40
|
cleanupTempFiles,
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
41
|
+
logDebug,
|
|
42
|
+
logError,
|
|
43
|
+
logInfo,
|
|
44
|
+
logWarn,
|
|
19
45
|
run,
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
scanWindowsDbServices,
|
|
23
|
-
scanWindowsPrograms
|
|
24
|
-
} from "./chunk-QKNYI3SU.js";
|
|
46
|
+
setVerbose
|
|
47
|
+
} from "./chunk-2SZ5QHGH.js";
|
|
25
48
|
|
|
26
49
|
// src/cli.ts
|
|
27
50
|
import { Command } from "commander";
|
|
@@ -42,12 +65,35 @@ function isOAuthLoggedIn() {
|
|
|
42
65
|
return false;
|
|
43
66
|
}
|
|
44
67
|
}
|
|
45
|
-
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() {
|
|
46
92
|
try {
|
|
47
93
|
execSync("claude --version", { stdio: "pipe" });
|
|
48
94
|
} catch {
|
|
49
95
|
process.stderr.write(
|
|
50
|
-
"\n\u274C Claude CLI
|
|
96
|
+
"\n\u274C Claude CLI not found.\n Datasynx Cartography requires the Claude CLI as a runtime dependency.\n\n Install:\n npm install -g @anthropic-ai/claude-code\n # or\n curl -fsSL https://claude.ai/install.sh | bash\n\n Then: claude login\n\n"
|
|
51
97
|
);
|
|
52
98
|
process.exitCode = 1;
|
|
53
99
|
throw new Error("Claude CLI not found");
|
|
@@ -56,1078 +102,503 @@ function checkPrerequisites() {
|
|
|
56
102
|
const hasOAuth = isOAuthLoggedIn();
|
|
57
103
|
if (!hasApiKey && !hasOAuth) {
|
|
58
104
|
process.stderr.write(
|
|
59
|
-
"\u26A0
|
|
105
|
+
"\u26A0 No authentication found. Please choose one of the following options:\n\n Option A \u2014 claude.ai Subscription (recommended):\n claude login\n\n Option B \u2014 API Key:\n export ANTHROPIC_API_KEY=sk-ant-...\n\n"
|
|
60
106
|
);
|
|
61
107
|
} else if (hasOAuth && !hasApiKey) {
|
|
62
|
-
process.stderr.write("\u2713
|
|
108
|
+
process.stderr.write("\u2713 Logged in via claude login (Subscription)\n");
|
|
63
109
|
}
|
|
64
110
|
}
|
|
65
111
|
|
|
66
|
-
// src/
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
var SessionRowSchema = z.object({
|
|
72
|
-
id: z.string(),
|
|
73
|
-
mode: z.literal("discover"),
|
|
74
|
-
started_at: z.string(),
|
|
75
|
-
completed_at: z.string().nullable().optional(),
|
|
76
|
-
config: z.string()
|
|
77
|
-
});
|
|
78
|
-
var NodeRowSchema = z.object({
|
|
79
|
-
id: z.string(),
|
|
80
|
-
session_id: z.string(),
|
|
81
|
-
type: z.enum(NODE_TYPES),
|
|
82
|
-
name: z.string(),
|
|
83
|
-
discovered_via: z.string().nullable().optional(),
|
|
84
|
-
discovered_at: z.string(),
|
|
85
|
-
path_id: z.string().nullable().optional(),
|
|
86
|
-
depth: z.number().default(0),
|
|
87
|
-
confidence: z.number().default(0.5),
|
|
88
|
-
metadata: z.string().default("{}"),
|
|
89
|
-
tags: z.string().default("[]"),
|
|
90
|
-
domain: z.string().nullable().optional(),
|
|
91
|
-
sub_domain: z.string().nullable().optional(),
|
|
92
|
-
quality_score: z.number().nullable().optional()
|
|
93
|
-
});
|
|
94
|
-
var EdgeRowSchema = z.object({
|
|
95
|
-
id: z.string(),
|
|
96
|
-
session_id: z.string(),
|
|
97
|
-
source_id: z.string(),
|
|
98
|
-
target_id: z.string(),
|
|
99
|
-
relationship: z.enum(EDGE_RELATIONSHIPS),
|
|
100
|
-
evidence: z.string().nullable().optional(),
|
|
101
|
-
confidence: z.number().default(0.5),
|
|
102
|
-
discovered_at: z.string()
|
|
103
|
-
});
|
|
104
|
-
var EventRowSchema = z.object({
|
|
105
|
-
id: z.string(),
|
|
106
|
-
session_id: z.string(),
|
|
107
|
-
task_id: z.string().nullable().optional(),
|
|
108
|
-
timestamp: z.string(),
|
|
109
|
-
event_type: z.string(),
|
|
110
|
-
process: z.string(),
|
|
111
|
-
pid: z.number(),
|
|
112
|
-
target: z.string().nullable().optional(),
|
|
113
|
-
target_type: z.string().nullable().optional(),
|
|
114
|
-
port: z.number().nullable().optional(),
|
|
115
|
-
duration_ms: z.number().nullable().optional()
|
|
116
|
-
});
|
|
117
|
-
var TaskRowSchema = z.object({
|
|
118
|
-
id: z.string(),
|
|
119
|
-
session_id: z.string(),
|
|
120
|
-
description: z.string().nullable().optional(),
|
|
121
|
-
started_at: z.string(),
|
|
122
|
-
completed_at: z.string().nullable().optional(),
|
|
123
|
-
steps: z.string().default("[]"),
|
|
124
|
-
involved_services: z.string().default("[]"),
|
|
125
|
-
status: z.enum(["active", "completed", "cancelled"])
|
|
126
|
-
});
|
|
127
|
-
var WorkflowRowSchema = z.object({
|
|
128
|
-
id: z.string(),
|
|
129
|
-
session_id: z.string(),
|
|
130
|
-
name: z.string().nullable().optional(),
|
|
131
|
-
pattern: z.string(),
|
|
132
|
-
task_ids: z.string().default("[]"),
|
|
133
|
-
occurrences: z.number().default(1),
|
|
134
|
-
first_seen: z.string(),
|
|
135
|
-
last_seen: z.string(),
|
|
136
|
-
avg_duration_ms: z.number().nullable().optional(),
|
|
137
|
-
involved_services: z.string().default("[]")
|
|
138
|
-
});
|
|
139
|
-
var ConnectionRowSchema = z.object({
|
|
140
|
-
id: z.string(),
|
|
141
|
-
session_id: z.string(),
|
|
142
|
-
source_asset_id: z.string(),
|
|
143
|
-
target_asset_id: z.string(),
|
|
144
|
-
type: z.string().nullable().optional(),
|
|
145
|
-
created_at: z.string()
|
|
146
|
-
});
|
|
147
|
-
var SCHEMA = `
|
|
148
|
-
PRAGMA journal_mode = WAL;
|
|
149
|
-
PRAGMA foreign_keys = ON;
|
|
150
|
-
PRAGMA busy_timeout = 5000;
|
|
151
|
-
|
|
152
|
-
CREATE TABLE IF NOT EXISTS sessions (
|
|
153
|
-
id TEXT PRIMARY KEY,
|
|
154
|
-
mode TEXT NOT NULL CHECK (mode IN ('discover')),
|
|
155
|
-
started_at TEXT NOT NULL,
|
|
156
|
-
completed_at TEXT,
|
|
157
|
-
config TEXT NOT NULL DEFAULT '{}'
|
|
158
|
-
);
|
|
159
|
-
|
|
160
|
-
CREATE TABLE IF NOT EXISTS nodes (
|
|
161
|
-
id TEXT NOT NULL,
|
|
162
|
-
session_id TEXT NOT NULL REFERENCES sessions(id),
|
|
163
|
-
type TEXT NOT NULL,
|
|
164
|
-
name TEXT NOT NULL,
|
|
165
|
-
discovered_via TEXT,
|
|
166
|
-
discovered_at TEXT NOT NULL,
|
|
167
|
-
path_id TEXT,
|
|
168
|
-
depth INTEGER DEFAULT 0,
|
|
169
|
-
confidence REAL DEFAULT 0.5,
|
|
170
|
-
metadata TEXT NOT NULL DEFAULT '{}',
|
|
171
|
-
tags TEXT NOT NULL DEFAULT '[]',
|
|
172
|
-
domain TEXT,
|
|
173
|
-
sub_domain TEXT,
|
|
174
|
-
quality_score REAL,
|
|
175
|
-
PRIMARY KEY (id, session_id)
|
|
176
|
-
);
|
|
177
|
-
|
|
178
|
-
CREATE TABLE IF NOT EXISTS connections (
|
|
179
|
-
id TEXT PRIMARY KEY,
|
|
180
|
-
session_id TEXT NOT NULL REFERENCES sessions(id),
|
|
181
|
-
source_asset_id TEXT NOT NULL,
|
|
182
|
-
target_asset_id TEXT NOT NULL,
|
|
183
|
-
type TEXT,
|
|
184
|
-
created_at TEXT NOT NULL
|
|
185
|
-
);
|
|
186
|
-
|
|
187
|
-
CREATE TABLE IF NOT EXISTS edges (
|
|
188
|
-
id TEXT PRIMARY KEY,
|
|
189
|
-
session_id TEXT NOT NULL REFERENCES sessions(id),
|
|
190
|
-
source_id TEXT NOT NULL,
|
|
191
|
-
target_id TEXT NOT NULL,
|
|
192
|
-
relationship TEXT NOT NULL,
|
|
193
|
-
evidence TEXT,
|
|
194
|
-
confidence REAL DEFAULT 0.5,
|
|
195
|
-
discovered_at TEXT NOT NULL
|
|
196
|
-
);
|
|
197
|
-
|
|
198
|
-
CREATE TABLE IF NOT EXISTS activity_events (
|
|
199
|
-
id TEXT PRIMARY KEY,
|
|
200
|
-
session_id TEXT NOT NULL REFERENCES sessions(id),
|
|
201
|
-
task_id TEXT,
|
|
202
|
-
timestamp TEXT NOT NULL,
|
|
203
|
-
event_type TEXT NOT NULL,
|
|
204
|
-
process TEXT NOT NULL,
|
|
205
|
-
pid INTEGER NOT NULL,
|
|
206
|
-
target TEXT,
|
|
207
|
-
target_type TEXT,
|
|
208
|
-
port INTEGER,
|
|
209
|
-
duration_ms INTEGER
|
|
210
|
-
);
|
|
211
|
-
|
|
212
|
-
CREATE TABLE IF NOT EXISTS tasks (
|
|
213
|
-
id TEXT PRIMARY KEY,
|
|
214
|
-
session_id TEXT NOT NULL REFERENCES sessions(id),
|
|
215
|
-
description TEXT,
|
|
216
|
-
started_at TEXT NOT NULL,
|
|
217
|
-
completed_at TEXT,
|
|
218
|
-
steps TEXT NOT NULL DEFAULT '[]',
|
|
219
|
-
involved_services TEXT NOT NULL DEFAULT '[]',
|
|
220
|
-
status TEXT DEFAULT 'active' CHECK (status IN ('active','completed','cancelled'))
|
|
221
|
-
);
|
|
222
|
-
|
|
223
|
-
CREATE TABLE IF NOT EXISTS workflows (
|
|
224
|
-
id TEXT PRIMARY KEY,
|
|
225
|
-
session_id TEXT NOT NULL REFERENCES sessions(id),
|
|
226
|
-
name TEXT,
|
|
227
|
-
pattern TEXT NOT NULL,
|
|
228
|
-
task_ids TEXT NOT NULL DEFAULT '[]',
|
|
229
|
-
occurrences INTEGER DEFAULT 1,
|
|
230
|
-
first_seen TEXT NOT NULL,
|
|
231
|
-
last_seen TEXT NOT NULL,
|
|
232
|
-
avg_duration_ms INTEGER,
|
|
233
|
-
involved_services TEXT NOT NULL DEFAULT '[]'
|
|
234
|
-
);
|
|
235
|
-
|
|
236
|
-
CREATE TABLE IF NOT EXISTS node_approvals (
|
|
237
|
-
pattern TEXT PRIMARY KEY,
|
|
238
|
-
action TEXT NOT NULL CHECK (action IN ('save','ignore','auto')),
|
|
239
|
-
created_at TEXT NOT NULL
|
|
240
|
-
);
|
|
241
|
-
|
|
242
|
-
CREATE INDEX IF NOT EXISTS idx_nodes_session ON nodes(session_id);
|
|
243
|
-
CREATE INDEX IF NOT EXISTS idx_edges_session ON edges(session_id);
|
|
244
|
-
CREATE INDEX IF NOT EXISTS idx_events_session ON activity_events(session_id);
|
|
245
|
-
CREATE INDEX IF NOT EXISTS idx_events_task ON activity_events(task_id);
|
|
246
|
-
CREATE INDEX IF NOT EXISTS idx_tasks_session ON tasks(session_id);
|
|
247
|
-
CREATE INDEX IF NOT EXISTS idx_connections_session ON connections(session_id);
|
|
248
|
-
`;
|
|
249
|
-
var CartographyDB = class {
|
|
250
|
-
db;
|
|
251
|
-
constructor(dbPath) {
|
|
252
|
-
mkdirSync(dirname(dbPath), { recursive: true });
|
|
253
|
-
this.db = new Database(dbPath);
|
|
254
|
-
this.db.pragma("journal_mode = WAL");
|
|
255
|
-
this.db.pragma("foreign_keys = ON");
|
|
256
|
-
this.db.pragma("busy_timeout = 5000");
|
|
257
|
-
this.migrate();
|
|
258
|
-
}
|
|
259
|
-
migrate() {
|
|
260
|
-
const version = this.db.pragma("user_version", { simple: true });
|
|
261
|
-
if (version === 0) {
|
|
262
|
-
this.db.exec(SCHEMA);
|
|
263
|
-
this.db.pragma("user_version = 2");
|
|
264
|
-
} else if (version === 1) {
|
|
265
|
-
const cols = this.db.prepare("PRAGMA table_info(nodes)").all().map((c) => c.name);
|
|
266
|
-
if (!cols.includes("domain")) this.db.exec("ALTER TABLE nodes ADD COLUMN domain TEXT");
|
|
267
|
-
if (!cols.includes("sub_domain")) this.db.exec("ALTER TABLE nodes ADD COLUMN sub_domain TEXT");
|
|
268
|
-
if (!cols.includes("quality_score")) this.db.exec("ALTER TABLE nodes ADD COLUMN quality_score REAL");
|
|
269
|
-
this.db.exec(`
|
|
270
|
-
CREATE TABLE IF NOT EXISTS connections (
|
|
271
|
-
id TEXT PRIMARY KEY,
|
|
272
|
-
session_id TEXT NOT NULL REFERENCES sessions(id),
|
|
273
|
-
source_asset_id TEXT NOT NULL,
|
|
274
|
-
target_asset_id TEXT NOT NULL,
|
|
275
|
-
type TEXT,
|
|
276
|
-
created_at TEXT NOT NULL
|
|
277
|
-
);
|
|
278
|
-
CREATE INDEX IF NOT EXISTS idx_connections_session ON connections(session_id);
|
|
279
|
-
`);
|
|
280
|
-
this.db.pragma("user_version = 2");
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
close() {
|
|
284
|
-
this.db.pragma("optimize");
|
|
285
|
-
this.db.close();
|
|
286
|
-
}
|
|
287
|
-
// ── Sessions ────────────────────────────
|
|
288
|
-
createSession(mode, config) {
|
|
289
|
-
const id = crypto.randomUUID();
|
|
290
|
-
this.db.prepare(
|
|
291
|
-
"INSERT INTO sessions (id, mode, started_at, config) VALUES (?, ?, ?, ?)"
|
|
292
|
-
).run(id, mode, (/* @__PURE__ */ new Date()).toISOString(), JSON.stringify(config));
|
|
293
|
-
return id;
|
|
294
|
-
}
|
|
295
|
-
endSession(id) {
|
|
296
|
-
this.db.prepare("UPDATE sessions SET completed_at = ? WHERE id = ?").run((/* @__PURE__ */ new Date()).toISOString(), id);
|
|
112
|
+
// src/providers/types.ts
|
|
113
|
+
var ProviderRegistry = class {
|
|
114
|
+
factories = /* @__PURE__ */ new Map();
|
|
115
|
+
register(name, factory) {
|
|
116
|
+
this.factories.set(name, factory);
|
|
297
117
|
}
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
return row ? this.mapSession(row) : void 0;
|
|
118
|
+
has(name) {
|
|
119
|
+
return this.factories.has(name);
|
|
301
120
|
}
|
|
302
|
-
|
|
303
|
-
const
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
getSessions() {
|
|
307
|
-
const rows = this.db.prepare("SELECT * FROM sessions ORDER BY rowid DESC").all();
|
|
308
|
-
return rows.map((r) => this.mapSession(r));
|
|
309
|
-
}
|
|
310
|
-
mapSession(r) {
|
|
311
|
-
const v = SessionRowSchema.parse(r);
|
|
312
|
-
return {
|
|
313
|
-
id: v.id,
|
|
314
|
-
mode: v.mode,
|
|
315
|
-
startedAt: v.started_at,
|
|
316
|
-
completedAt: v.completed_at ?? void 0,
|
|
317
|
-
config: v.config
|
|
318
|
-
};
|
|
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();
|
|
319
125
|
}
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
this.db.prepare(`
|
|
323
|
-
INSERT OR REPLACE INTO nodes
|
|
324
|
-
(id, session_id, type, name, discovered_via, discovered_at, depth, confidence, metadata, tags,
|
|
325
|
-
domain, sub_domain, quality_score)
|
|
326
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
327
|
-
`).run(
|
|
328
|
-
node.id,
|
|
329
|
-
sessionId,
|
|
330
|
-
node.type,
|
|
331
|
-
node.name,
|
|
332
|
-
node.discoveredVia,
|
|
333
|
-
(/* @__PURE__ */ new Date()).toISOString(),
|
|
334
|
-
depth,
|
|
335
|
-
node.confidence,
|
|
336
|
-
JSON.stringify(node.metadata ?? {}),
|
|
337
|
-
JSON.stringify(node.tags ?? []),
|
|
338
|
-
node.domain ?? null,
|
|
339
|
-
node.subDomain ?? null,
|
|
340
|
-
node.qualityScore ?? null
|
|
341
|
-
);
|
|
126
|
+
names() {
|
|
127
|
+
return [...this.factories.keys()];
|
|
342
128
|
}
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
// src/safety.ts
|
|
132
|
+
var safetyHook = async (input, _toolUseID, _options) => {
|
|
133
|
+
if (!("tool_name" in input)) return {};
|
|
134
|
+
if (input.tool_name !== "Bash") return {};
|
|
135
|
+
const cmd = (input.tool_input?.command ?? "").trim();
|
|
136
|
+
if (!cmd) {
|
|
137
|
+
return { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow" } };
|
|
346
138
|
}
|
|
347
|
-
|
|
348
|
-
|
|
139
|
+
const decision = checkReadOnly(cmd);
|
|
140
|
+
if (!decision.allowed) {
|
|
349
141
|
return {
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
discoveredAt: v.discovered_at,
|
|
356
|
-
depth: v.depth,
|
|
357
|
-
confidence: v.confidence,
|
|
358
|
-
metadata: JSON.parse(v.metadata),
|
|
359
|
-
tags: JSON.parse(v.tags),
|
|
360
|
-
pathId: v.path_id ?? void 0,
|
|
361
|
-
domain: v.domain ?? void 0,
|
|
362
|
-
subDomain: v.sub_domain ?? void 0,
|
|
363
|
-
qualityScore: v.quality_score ?? void 0
|
|
142
|
+
hookSpecificOutput: {
|
|
143
|
+
hookEventName: "PreToolUse",
|
|
144
|
+
permissionDecision: "deny",
|
|
145
|
+
permissionDecisionReason: `BLOCKED: ${decision.reason} \u2014 read-only allowlist policy`
|
|
146
|
+
}
|
|
364
147
|
};
|
|
365
148
|
}
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
return
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
(
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
149
|
+
return {
|
|
150
|
+
hookSpecificOutput: {
|
|
151
|
+
hookEventName: "PreToolUse",
|
|
152
|
+
permissionDecision: "allow"
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
};
|
|
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;
|
|
444
321
|
}
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
sessionId: v.session_id,
|
|
481
|
-
description: v.description ?? void 0,
|
|
482
|
-
startedAt: v.started_at,
|
|
483
|
-
completedAt: v.completed_at ?? void 0,
|
|
484
|
-
steps: v.steps,
|
|
485
|
-
involvedServices: v.involved_services,
|
|
486
|
-
status: v.status
|
|
487
|
-
};
|
|
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
|
+
);
|
|
488
357
|
}
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
sessionId,
|
|
500
|
-
data.name ?? null,
|
|
501
|
-
data.pattern,
|
|
502
|
-
data.taskIds,
|
|
503
|
-
data.occurrences,
|
|
504
|
-
data.firstSeen,
|
|
505
|
-
data.lastSeen,
|
|
506
|
-
data.avgDurationMs,
|
|
507
|
-
data.involvedServices
|
|
508
|
-
);
|
|
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);
|
|
509
368
|
}
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
lastSeen: v.last_seen,
|
|
523
|
-
avgDurationMs: v.avg_duration_ms ?? 0,
|
|
524
|
-
involvedServices: v.involved_services
|
|
525
|
-
};
|
|
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)
|
|
526
381
|
});
|
|
382
|
+
} catch (err) {
|
|
383
|
+
logDebug(`audit writer failed to record event: ${String(err)}`);
|
|
527
384
|
}
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
const
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
VALUES (?, ?, ?, ?, ?, ?)
|
|
538
|
-
`).run(id, sessionId, conn.sourceAssetId, conn.targetAssetId, conn.type ?? null, (/* @__PURE__ */ new Date()).toISOString());
|
|
539
|
-
return id;
|
|
540
|
-
}
|
|
541
|
-
getConnections(sessionId) {
|
|
542
|
-
const rows = this.db.prepare("SELECT * FROM connections WHERE session_id = ?").all(sessionId);
|
|
543
|
-
return rows.map((r) => {
|
|
544
|
-
const v = ConnectionRowSchema.parse(r);
|
|
545
|
-
return {
|
|
546
|
-
id: v.id,
|
|
547
|
-
sessionId: v.session_id,
|
|
548
|
-
sourceAssetId: v.source_asset_id,
|
|
549
|
-
targetAssetId: v.target_asset_id,
|
|
550
|
-
type: v.type ?? void 0,
|
|
551
|
-
createdAt: v.created_at
|
|
552
|
-
};
|
|
553
|
-
});
|
|
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;
|
|
554
394
|
}
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
INSERT OR REPLACE INTO node_approvals (pattern, action, created_at) VALUES (?, ?, ?)
|
|
562
|
-
`).run(pattern, action, (/* @__PURE__ */ new Date()).toISOString());
|
|
563
|
-
}
|
|
564
|
-
getApproval(pattern) {
|
|
565
|
-
const row = this.db.prepare("SELECT action FROM node_approvals WHERE pattern = ?").get(pattern);
|
|
566
|
-
return row?.action;
|
|
567
|
-
}
|
|
568
|
-
// ── Pruning ──────────────────────────────
|
|
569
|
-
/**
|
|
570
|
-
* Delete a session and all its associated data (nodes, edges, events, tasks, workflows, connections).
|
|
571
|
-
*/
|
|
572
|
-
deleteSession(sessionId) {
|
|
573
|
-
this.db.prepare("DELETE FROM connections WHERE session_id = ?").run(sessionId);
|
|
574
|
-
this.db.prepare("DELETE FROM workflows WHERE session_id = ?").run(sessionId);
|
|
575
|
-
this.db.prepare("DELETE FROM activity_events WHERE session_id = ?").run(sessionId);
|
|
576
|
-
this.db.prepare("DELETE FROM tasks WHERE session_id = ?").run(sessionId);
|
|
577
|
-
this.db.prepare("DELETE FROM edges WHERE session_id = ?").run(sessionId);
|
|
578
|
-
this.db.prepare("DELETE FROM nodes WHERE session_id = ?").run(sessionId);
|
|
579
|
-
this.db.prepare("DELETE FROM sessions WHERE id = ?").run(sessionId);
|
|
580
|
-
}
|
|
581
|
-
/**
|
|
582
|
-
* Prune sessions older than the given ISO date string. Returns count of deleted sessions.
|
|
583
|
-
*/
|
|
584
|
-
pruneSessions(olderThan) {
|
|
585
|
-
const rows = this.db.prepare(
|
|
586
|
-
"SELECT id FROM sessions WHERE started_at < ?"
|
|
587
|
-
).all(olderThan);
|
|
588
|
-
for (const row of rows) {
|
|
589
|
-
this.deleteSession(row.id);
|
|
590
|
-
}
|
|
591
|
-
return rows.length;
|
|
592
|
-
}
|
|
593
|
-
// ── Stats ───────────────────────────────
|
|
594
|
-
getStats(sessionId) {
|
|
595
|
-
const nodes = this.db.prepare("SELECT COUNT(*) as c FROM nodes WHERE session_id = ?").get(sessionId).c;
|
|
596
|
-
const edges = this.db.prepare("SELECT COUNT(*) as c FROM edges WHERE session_id = ?").get(sessionId).c;
|
|
597
|
-
const events = this.db.prepare("SELECT COUNT(*) as c FROM activity_events WHERE session_id = ?").get(sessionId).c;
|
|
598
|
-
const tasks = this.db.prepare("SELECT COUNT(*) as c FROM tasks WHERE session_id = ?").get(sessionId).c;
|
|
599
|
-
return { nodes, edges, events, tasks };
|
|
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)}`;
|
|
600
401
|
}
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
import { z as z2 } from "zod";
|
|
605
|
-
function createScanRunner(runFn, opts = {}) {
|
|
606
|
-
const threshold = opts.threshold ?? 3;
|
|
607
|
-
let consecutiveFailures = 0;
|
|
608
|
-
let tripped = false;
|
|
609
|
-
return (cmd) => {
|
|
610
|
-
if (tripped) return "(skipped \u2014 circuit breaker: too many consecutive failures)";
|
|
611
|
-
const result = runFn(cmd, { timeout: opts.timeout ?? 2e4, env: opts.env });
|
|
612
|
-
if (!result) {
|
|
613
|
-
consecutiveFailures++;
|
|
614
|
-
if (consecutiveFailures >= threshold) tripped = true;
|
|
615
|
-
return "(error or not available)";
|
|
616
|
-
}
|
|
617
|
-
consecutiveFailures = 0;
|
|
618
|
-
return result;
|
|
619
|
-
};
|
|
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;
|
|
620
405
|
}
|
|
621
|
-
function
|
|
406
|
+
async function* runToolLoop(opts, chat) {
|
|
407
|
+
const { db, sessionId, tools, maxTurns, deadlineMs } = opts;
|
|
408
|
+
let outcomes = [];
|
|
409
|
+
let turn = 0;
|
|
622
410
|
try {
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
}
|
|
629
|
-
async function createCartographyTools(db, sessionId, opts = {}) {
|
|
630
|
-
const { tool, createSdkMcpServer } = await import("@anthropic-ai/claude-agent-sdk");
|
|
631
|
-
const tools = [
|
|
632
|
-
tool("save_node", "Save an infrastructure node to the catalog", {
|
|
633
|
-
id: z2.string(),
|
|
634
|
-
type: z2.enum(NODE_TYPES),
|
|
635
|
-
name: z2.string(),
|
|
636
|
-
discoveredVia: z2.string(),
|
|
637
|
-
confidence: z2.number().min(0).max(1),
|
|
638
|
-
metadata: z2.record(z2.string(), z2.unknown()).optional(),
|
|
639
|
-
tags: z2.array(z2.string()).optional(),
|
|
640
|
-
domain: z2.string().optional().describe('Business domain, e.g. "Marketing", "Finance"'),
|
|
641
|
-
subDomain: z2.string().optional().describe('Sub-domain, e.g. "Forecast client orders"'),
|
|
642
|
-
qualityScore: z2.number().min(0).max(100).optional().describe("Data quality score 0\u2013100")
|
|
643
|
-
}, async (args) => {
|
|
644
|
-
const node = {
|
|
645
|
-
id: stripSensitive(args["id"]),
|
|
646
|
-
type: args["type"],
|
|
647
|
-
name: args["name"],
|
|
648
|
-
discoveredVia: args["discoveredVia"],
|
|
649
|
-
confidence: args["confidence"],
|
|
650
|
-
metadata: args["metadata"] ?? {},
|
|
651
|
-
tags: args["tags"] ?? [],
|
|
652
|
-
domain: args["domain"],
|
|
653
|
-
subDomain: args["subDomain"],
|
|
654
|
-
qualityScore: args["qualityScore"]
|
|
655
|
-
};
|
|
656
|
-
db.upsertNode(sessionId, node);
|
|
657
|
-
return { content: [{ type: "text", text: `\u2713 Node: ${node.id}` }] };
|
|
658
|
-
}),
|
|
659
|
-
tool("save_edge", "Save a relationship (edge) between two nodes \u2014 ALWAYS save edges when connections are clear", {
|
|
660
|
-
sourceId: z2.string(),
|
|
661
|
-
targetId: z2.string(),
|
|
662
|
-
relationship: z2.enum(EDGE_RELATIONSHIPS),
|
|
663
|
-
evidence: z2.string(),
|
|
664
|
-
confidence: z2.number().min(0).max(1)
|
|
665
|
-
}, async (args) => {
|
|
666
|
-
db.insertEdge(sessionId, {
|
|
667
|
-
sourceId: args["sourceId"],
|
|
668
|
-
targetId: args["targetId"],
|
|
669
|
-
relationship: args["relationship"],
|
|
670
|
-
evidence: args["evidence"],
|
|
671
|
-
confidence: args["confidence"]
|
|
672
|
-
});
|
|
673
|
-
return { content: [{ type: "text", text: `\u2713 ${args["sourceId"]}\u2192${args["targetId"]}` }] };
|
|
674
|
-
}),
|
|
675
|
-
tool("get_catalog", "Get the current catalog \u2014 use before save_node to avoid duplicates", {
|
|
676
|
-
includeEdges: z2.boolean().default(true)
|
|
677
|
-
}, async (args) => {
|
|
678
|
-
const nodes = db.getNodes(sessionId);
|
|
679
|
-
const edges = args["includeEdges"] ? db.getEdges(sessionId) : [];
|
|
680
|
-
return {
|
|
681
|
-
content: [{
|
|
682
|
-
type: "text",
|
|
683
|
-
text: JSON.stringify({
|
|
684
|
-
count: { nodes: nodes.length, edges: edges.length },
|
|
685
|
-
nodeIds: nodes.map((n) => n.id)
|
|
686
|
-
})
|
|
687
|
-
}]
|
|
688
|
-
};
|
|
689
|
-
}),
|
|
690
|
-
tool("ask_user", "Ask the user a question \u2014 for clarifications, missing context, or consent (e.g. before scanning browser history)", {
|
|
691
|
-
question: z2.string().describe("The question for the user (clear and specific)"),
|
|
692
|
-
context: z2.string().optional().describe("Optional context explaining why this is relevant")
|
|
693
|
-
}, async (args) => {
|
|
694
|
-
const question = args["question"];
|
|
695
|
-
const context = args["context"];
|
|
696
|
-
if (opts.onAskUser) {
|
|
697
|
-
const answer = await opts.onAskUser(question, context);
|
|
698
|
-
return { content: [{ type: "text", text: answer }] };
|
|
699
|
-
}
|
|
700
|
-
return {
|
|
701
|
-
content: [{ type: "text", text: "(Non-interactive mode \u2014 please continue without this information)" }]
|
|
702
|
-
};
|
|
703
|
-
}),
|
|
704
|
-
tool("scan_bookmarks", "Scan all browser bookmarks \u2014 hostnames only, no personal data (Chrome, Chromium, Edge, Brave, Vivaldi, Opera, Firefox)", {
|
|
705
|
-
minConfidence: z2.number().min(0).max(1).default(0.5).optional()
|
|
706
|
-
}, async () => {
|
|
707
|
-
const hosts = await scanAllBookmarks();
|
|
708
|
-
return {
|
|
709
|
-
content: [{
|
|
710
|
-
type: "text",
|
|
711
|
-
text: JSON.stringify({
|
|
712
|
-
count: hosts.length,
|
|
713
|
-
hosts: hosts.map((h) => ({
|
|
714
|
-
hostname: h.hostname,
|
|
715
|
-
port: h.port,
|
|
716
|
-
protocol: h.protocol,
|
|
717
|
-
source: h.source
|
|
718
|
-
})),
|
|
719
|
-
note: "Hostnames only \u2014 no paths, no personal data. Classify each as a business tool (save_node) or ignore (social media, news, shopping)."
|
|
720
|
-
})
|
|
721
|
-
}]
|
|
722
|
-
};
|
|
723
|
-
}),
|
|
724
|
-
tool("scan_browser_history", "Scan browser history \u2014 anonymized hostnames + visit frequency. ALWAYS call ask_user for consent before using this tool.", {
|
|
725
|
-
minVisits: z2.number().min(1).default(3).optional().describe("Minimum visit count to include a host (filters rarely-visited sites)")
|
|
726
|
-
}, async (args) => {
|
|
727
|
-
const minVisits = args["minVisits"] ?? 3;
|
|
728
|
-
const hosts = await scanAllHistory();
|
|
729
|
-
const filtered = hosts.filter((h) => h.visitCount >= minVisits);
|
|
730
|
-
return {
|
|
731
|
-
content: [{
|
|
732
|
-
type: "text",
|
|
733
|
-
text: JSON.stringify({
|
|
734
|
-
count: filtered.length,
|
|
735
|
-
note: "Anonymized \u2014 hostnames only, no URLs, no paths, no personal data. Classify business tools as saas_tool nodes.",
|
|
736
|
-
hosts: filtered.map((h) => ({
|
|
737
|
-
hostname: h.hostname,
|
|
738
|
-
visitCount: h.visitCount,
|
|
739
|
-
protocol: h.protocol,
|
|
740
|
-
source: h.source
|
|
741
|
-
}))
|
|
742
|
-
})
|
|
743
|
-
}]
|
|
744
|
-
};
|
|
745
|
-
}),
|
|
746
|
-
tool("scan_local_databases", "Scan for local database files and running DB servers \u2014 PostgreSQL databases, MySQL, SQLite files from installed apps", {
|
|
747
|
-
deep: z2.boolean().default(false).optional().describe("Also search home directory recursively for SQLite/DB files (slower)")
|
|
748
|
-
}, async (args) => {
|
|
749
|
-
const deep = args["deep"] ?? false;
|
|
750
|
-
const results = {};
|
|
751
|
-
results["PLATFORM"] = `${PLATFORM} (${IS_WIN ? "Windows" : IS_MAC ? "macOS" : "Linux"})`;
|
|
752
|
-
if (IS_WIN) {
|
|
753
|
-
results["DB_SERVICES"] = scanWindowsDbServices() || "(no database services found)";
|
|
754
|
-
}
|
|
755
|
-
if (commandExists("psql")) {
|
|
756
|
-
if (IS_WIN) {
|
|
757
|
-
results["POSTGRES_DATABASES"] = run("psql -lqt", { timeout: 1e4 }) || "(psql found but not running or requires auth)";
|
|
758
|
-
} else {
|
|
759
|
-
results["POSTGRES_DATABASES"] = run(`psql -lqt 2>/dev/null | grep -v "template0\\|template1" | awk '{print $1}' | grep -v "^$\\|^|"`) || "(psql not running or not available)";
|
|
760
|
-
results["POSTGRES_CLUSTERS"] = run("pg_lsclusters 2>/dev/null") || "(pg_lsclusters not available)";
|
|
761
|
-
}
|
|
762
|
-
} else {
|
|
763
|
-
results["POSTGRES_DATABASES"] = "(psql not installed)";
|
|
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;
|
|
764
416
|
}
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
}
|
|
771
|
-
|
|
772
|
-
results["MYSQL_DATABASES"] = "(mysql not installed)";
|
|
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;
|
|
773
424
|
}
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
}
|
|
780
|
-
} else {
|
|
781
|
-
results["MONGODB_DATABASES"] = "(mongosh not installed)";
|
|
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 });
|
|
782
431
|
}
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
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
|
+
);
|
|
805
458
|
}
|
|
806
|
-
if (
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
}
|
|
814
|
-
const
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
const
|
|
823
|
-
const
|
|
824
|
-
const
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
["NAMESPACES", "kubectl get namespaces"],
|
|
828
|
-
["SERVICES", `kubectl get services ${nsFlag}`],
|
|
829
|
-
["DEPLOYMENTS", `kubectl get deployments ${nsFlag}`],
|
|
830
|
-
["STATEFULSETS", `kubectl get statefulsets ${nsFlag}`],
|
|
831
|
-
["INGRESSES", `kubectl get ingress ${nsFlag}`],
|
|
832
|
-
["PODS_RUNNING", `kubectl get pods ${nsFlag} --field-selector=status.phase=Running`],
|
|
833
|
-
["CONFIGMAPS_SYSTEM", "kubectl get configmaps -n kube-system"]
|
|
834
|
-
] : [
|
|
835
|
-
["CONTEXT", 'kubectl config current-context 2>/dev/null || echo "(no context set)"'],
|
|
836
|
-
["NODES", "kubectl get nodes -o wide"],
|
|
837
|
-
["NAMESPACES", "kubectl get namespaces"],
|
|
838
|
-
["SERVICES", `kubectl get services ${nsFlag}`],
|
|
839
|
-
["DEPLOYMENTS", `kubectl get deployments ${nsFlag}`],
|
|
840
|
-
["STATEFULSETS", `kubectl get statefulsets ${nsFlag}`],
|
|
841
|
-
["INGRESSES", `kubectl get ingress ${nsFlag} 2>/dev/null || echo "(none)"`],
|
|
842
|
-
["PODS_RUNNING", `kubectl get pods ${nsFlag} --field-selector=status.phase=Running 2>/dev/null | head -60`],
|
|
843
|
-
["CONFIGMAPS_SYSTEM", "kubectl get configmaps -n kube-system 2>/dev/null | head -30"]
|
|
844
|
-
];
|
|
845
|
-
const out = sections.map(([l, c]) => `=== ${l} ===
|
|
846
|
-
${runK(c)}`).join("\n\n");
|
|
847
|
-
return { content: [{ type: "text", text: out }] };
|
|
848
|
-
}),
|
|
849
|
-
tool("scan_aws_resources", "Scan AWS infrastructure via AWS CLI \u2014 100% readonly (describe, list)", {
|
|
850
|
-
region: z2.string().optional().describe("AWS Region \u2014 default: AWS_DEFAULT_REGION or profile"),
|
|
851
|
-
profile: z2.string().optional().describe("AWS CLI profile")
|
|
852
|
-
}, async (args) => {
|
|
853
|
-
const region = args["region"];
|
|
854
|
-
const profile = args["profile"];
|
|
855
|
-
const env = { ...process.env };
|
|
856
|
-
if (region) env["AWS_DEFAULT_REGION"] = region;
|
|
857
|
-
const pf = profile ? `--profile ${profile}` : "";
|
|
858
|
-
const runAws = createScanRunner(run, { timeout: 2e4, env, threshold: 3 });
|
|
859
|
-
const sections = [
|
|
860
|
-
["IDENTITY", `aws sts get-caller-identity ${pf} --output json`],
|
|
861
|
-
["EC2", `aws ec2 describe-instances ${pf} --query "Reservations[*].Instances[*].[InstanceId,InstanceType,State.Name,PublicIpAddress,PrivateIpAddress]" --output table`],
|
|
862
|
-
["RDS", `aws rds describe-db-instances ${pf} --query "DBInstances[*].[DBInstanceIdentifier,Engine,DBInstanceStatus,Endpoint.Address,Endpoint.Port]" --output table`],
|
|
863
|
-
["ELB_V2", `aws elbv2 describe-load-balancers ${pf} --query "LoadBalancers[*].[LoadBalancerName,DNSName,Type,State.Code]" --output table`],
|
|
864
|
-
["EKS", `aws eks list-clusters ${pf} --output json`],
|
|
865
|
-
["ELASTICACHE", `aws elasticache describe-cache-clusters ${pf} --query "CacheClusters[*].[CacheClusterId,Engine,CacheClusterStatus]" --output table`],
|
|
866
|
-
["S3", `aws s3 ls ${pf}`],
|
|
867
|
-
["VPC", `aws ec2 describe-vpcs ${pf} --query "Vpcs[*].[VpcId,CidrBlock,IsDefault]" --output table`]
|
|
868
|
-
];
|
|
869
|
-
const out = sections.map(([l, c]) => `=== ${l} ===
|
|
870
|
-
${runAws(c)}`).join("\n\n");
|
|
871
|
-
return { content: [{ type: "text", text: out }] };
|
|
872
|
-
}),
|
|
873
|
-
tool("scan_gcp_resources", "Scan Google Cloud Platform via gcloud CLI \u2014 100% readonly (list, describe)", {
|
|
874
|
-
project: z2.string().optional().describe("GCP Project ID \u2014 default: current gcloud project")
|
|
875
|
-
}, async (args) => {
|
|
876
|
-
const project = args["project"];
|
|
877
|
-
const pf = project ? `--project ${project}` : "";
|
|
878
|
-
const runGcp = createScanRunner(run, { timeout: 2e4, threshold: 3 });
|
|
879
|
-
const sections = [
|
|
880
|
-
["IDENTITY", `gcloud config list account --format="value(core.account)"`],
|
|
881
|
-
["COMPUTE_INSTANCES", `gcloud compute instances list ${pf}`],
|
|
882
|
-
["SQL_INSTANCES", `gcloud sql instances list ${pf}`],
|
|
883
|
-
["GKE_CLUSTERS", `gcloud container clusters list ${pf}`],
|
|
884
|
-
["CLOUD_RUN", `gcloud run services list ${pf} --platform managed`],
|
|
885
|
-
["CLOUD_FUNCTIONS", `gcloud functions list ${pf}`],
|
|
886
|
-
["REDIS", `gcloud redis instances list ${pf} --regions=-`],
|
|
887
|
-
["PUBSUB", `gcloud pubsub topics list ${pf}`],
|
|
888
|
-
["SPANNER", `gcloud spanner instances list ${pf}`]
|
|
889
|
-
];
|
|
890
|
-
const out = sections.map(([l, c]) => `=== ${l} ===
|
|
891
|
-
${runGcp(c)}`).join("\n\n");
|
|
892
|
-
return { content: [{ type: "text", text: out }] };
|
|
893
|
-
}),
|
|
894
|
-
tool("scan_azure_resources", "Scan Azure infrastructure via az CLI \u2014 100% readonly (list, show)", {
|
|
895
|
-
subscription: z2.string().optional().describe("Azure Subscription ID"),
|
|
896
|
-
resourceGroup: z2.string().optional().describe("Filter by resource group")
|
|
897
|
-
}, async (args) => {
|
|
898
|
-
const sub = args["subscription"];
|
|
899
|
-
const rg = args["resourceGroup"];
|
|
900
|
-
const sf = sub ? `--subscription ${sub}` : "";
|
|
901
|
-
const rf = rg ? `--resource-group ${rg}` : "";
|
|
902
|
-
const runAz = createScanRunner(run, { timeout: 2e4, threshold: 3 });
|
|
903
|
-
const sections = [
|
|
904
|
-
["IDENTITY", `az account show --output json ${sf}`],
|
|
905
|
-
["VMS", `az vm list ${sf} ${rf} --output table`],
|
|
906
|
-
["AKS", `az aks list ${sf} ${rf} --output table`],
|
|
907
|
-
["SQL_SERVERS", `az sql server list ${sf} ${rf} --output table`],
|
|
908
|
-
["POSTGRES", `az postgres server list ${sf} ${rf} --output table`],
|
|
909
|
-
["REDIS", `az redis list ${sf} ${rf} --output table`],
|
|
910
|
-
["WEBAPPS", `az webapp list ${sf} ${rf} --output table`],
|
|
911
|
-
["CONTAINER_APPS", `az containerapp list ${sf} ${rf} --output table`],
|
|
912
|
-
["FUNCTIONS", `az functionapp list ${sf} ${rf} --output table`]
|
|
913
|
-
];
|
|
914
|
-
const out = sections.map(([l, c]) => `=== ${l} ===
|
|
915
|
-
${runAz(c)}`).join("\n\n");
|
|
916
|
-
return { content: [{ type: "text", text: out }] };
|
|
917
|
-
}),
|
|
918
|
-
tool("scan_installed_apps", "Scan all installed apps and tools \u2014 IDEs, office, dev tools, business apps, databases", {
|
|
919
|
-
searchHint: z2.string().optional().describe('Optional search term to find specific tools (e.g. "hubspot windsurf cursor")')
|
|
920
|
-
}, async (args) => {
|
|
921
|
-
const hint = args["searchHint"];
|
|
922
|
-
const results = {};
|
|
923
|
-
results["PLATFORM"] = `${PLATFORM} (${IS_WIN ? "Windows" : IS_MAC ? "macOS" : "Linux"})`;
|
|
924
|
-
if (IS_MAC) {
|
|
925
|
-
results["APPLICATIONS"] = run("ls /Applications/ 2>/dev/null | head -200") || "(empty)";
|
|
926
|
-
results["USER_APPLICATIONS"] = run("ls ~/Applications/ 2>/dev/null | head -100") || "(empty)";
|
|
927
|
-
results["BREW_CASKS"] = run("brew list --cask 2>/dev/null | head -100") || "(brew not installed)";
|
|
928
|
-
results["BREW_FORMULAE"] = run("brew list --formula 2>/dev/null | head -150") || "(brew not installed)";
|
|
929
|
-
results["SPOTLIGHT_APPS"] = run(`mdfind "kMDItemKind == 'Application'" 2>/dev/null | grep -v "^/System" | grep -v "^/Library/Apple" | head -100`) || "(Spotlight not available)";
|
|
930
|
-
} else if (IS_LINUX) {
|
|
931
|
-
results["DPKG"] = run("dpkg --list 2>/dev/null | awk '{print $2}' | head -200") || "(dpkg not available)";
|
|
932
|
-
results["SNAP"] = run("snap list 2>/dev/null | head -50") || "(snap not available)";
|
|
933
|
-
results["FLATPAK"] = run("flatpak list 2>/dev/null | head -50") || "(flatpak not available)";
|
|
934
|
-
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)";
|
|
935
|
-
results["RPM"] = run("rpm -qa 2>/dev/null | head -200") || "(rpm not available)";
|
|
936
|
-
} else if (IS_WIN) {
|
|
937
|
-
results["WINGET"] = run("winget list --accept-source-agreements", { timeout: 2e4 }) || "(winget not available)";
|
|
938
|
-
results["INSTALLED_PROGRAMS"] = scanWindowsPrograms() || "(registry scan failed)";
|
|
939
|
-
results["CHOCO"] = run("choco list --local-only", { timeout: 15e3 }) || "(chocolatey not installed)";
|
|
940
|
-
results["SCOOP"] = run("scoop list", { timeout: 15e3 }) || "(scoop not installed)";
|
|
941
|
-
}
|
|
942
|
-
const knownTools = [
|
|
943
|
-
// IDEs & Editors
|
|
944
|
-
"code",
|
|
945
|
-
"code-insiders",
|
|
946
|
-
"cursor",
|
|
947
|
-
"windsurf",
|
|
948
|
-
"zed",
|
|
949
|
-
"vim",
|
|
950
|
-
"nvim",
|
|
951
|
-
"emacs",
|
|
952
|
-
"nano",
|
|
953
|
-
"sublime_text",
|
|
954
|
-
"atom",
|
|
955
|
-
"idea",
|
|
956
|
-
"webstorm",
|
|
957
|
-
"pycharm",
|
|
958
|
-
"goland",
|
|
959
|
-
"datagrip",
|
|
960
|
-
"clion",
|
|
961
|
-
"rider",
|
|
962
|
-
"phpstorm",
|
|
963
|
-
"rubymine",
|
|
964
|
-
"appcode",
|
|
965
|
-
// Dev Tools
|
|
966
|
-
"git",
|
|
967
|
-
"gh",
|
|
968
|
-
"docker",
|
|
969
|
-
"docker-compose",
|
|
970
|
-
"podman",
|
|
971
|
-
"kubectl",
|
|
972
|
-
"helm",
|
|
973
|
-
"terraform",
|
|
974
|
-
"ansible",
|
|
975
|
-
"node",
|
|
976
|
-
"npm",
|
|
977
|
-
"npx",
|
|
978
|
-
"yarn",
|
|
979
|
-
"pnpm",
|
|
980
|
-
"bun",
|
|
981
|
-
"deno",
|
|
982
|
-
"python",
|
|
983
|
-
"python3",
|
|
984
|
-
"pip",
|
|
985
|
-
"pip3",
|
|
986
|
-
"pipenv",
|
|
987
|
-
"poetry",
|
|
988
|
-
"conda",
|
|
989
|
-
"ruby",
|
|
990
|
-
"gem",
|
|
991
|
-
"bundler",
|
|
992
|
-
"rails",
|
|
993
|
-
"java",
|
|
994
|
-
"mvn",
|
|
995
|
-
"gradle",
|
|
996
|
-
"kotlin",
|
|
997
|
-
"go",
|
|
998
|
-
"cargo",
|
|
999
|
-
"rustc",
|
|
1000
|
-
"php",
|
|
1001
|
-
"composer",
|
|
1002
|
-
"dotnet",
|
|
1003
|
-
// Databases
|
|
1004
|
-
"psql",
|
|
1005
|
-
"mysql",
|
|
1006
|
-
"mysqladmin",
|
|
1007
|
-
"mongo",
|
|
1008
|
-
"mongosh",
|
|
1009
|
-
"redis-cli",
|
|
1010
|
-
"sqlite3",
|
|
1011
|
-
"clickhouse-client",
|
|
1012
|
-
// Cloud CLIs
|
|
1013
|
-
"aws",
|
|
1014
|
-
"gcloud",
|
|
1015
|
-
"az",
|
|
1016
|
-
"heroku",
|
|
1017
|
-
"fly",
|
|
1018
|
-
"vercel",
|
|
1019
|
-
"netlify",
|
|
1020
|
-
"wrangler",
|
|
1021
|
-
// Infra
|
|
1022
|
-
"vagrant",
|
|
1023
|
-
"packer",
|
|
1024
|
-
"consul",
|
|
1025
|
-
"vault",
|
|
1026
|
-
"nomad",
|
|
1027
|
-
// Communication / SaaS
|
|
1028
|
-
"slack",
|
|
1029
|
-
"discord",
|
|
1030
|
-
"zoom",
|
|
1031
|
-
"teams",
|
|
1032
|
-
"skype",
|
|
1033
|
-
"telegram",
|
|
1034
|
-
"signal",
|
|
1035
|
-
// Browsers
|
|
1036
|
-
"google-chrome",
|
|
1037
|
-
"chromium",
|
|
1038
|
-
"firefox",
|
|
1039
|
-
"safari",
|
|
1040
|
-
"brave",
|
|
1041
|
-
"opera",
|
|
1042
|
-
"edge",
|
|
1043
|
-
// Windows-specific
|
|
1044
|
-
...IS_WIN ? ["pwsh", "powershell", "wsl", "winget", "choco", "scoop", "notepad++"] : [],
|
|
1045
|
-
// Monitoring / Analytics
|
|
1046
|
-
"datadog-agent",
|
|
1047
|
-
"newrelic-agent",
|
|
1048
|
-
"prometheus",
|
|
1049
|
-
"grafana-cli",
|
|
1050
|
-
// Other tools
|
|
1051
|
-
"ngrok",
|
|
1052
|
-
"stripe",
|
|
1053
|
-
"supabase",
|
|
1054
|
-
"neon"
|
|
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 }
|
|
1055
480
|
];
|
|
1056
|
-
const
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
const r = commandExists(t);
|
|
1060
|
-
if (r) found.push(`${t}: ${r}`);
|
|
1061
|
-
else notFound.push(t);
|
|
1062
|
-
}
|
|
1063
|
-
results["TOOLS_FOUND"] = found.join("\n") || "(none found)";
|
|
1064
|
-
results["TOOLS_NOT_FOUND"] = notFound.join(", ");
|
|
1065
|
-
if (hint) {
|
|
1066
|
-
const terms = hint.split(/[\s,]+/).filter(Boolean);
|
|
1067
|
-
const hintResults = [];
|
|
1068
|
-
for (const term of terms) {
|
|
1069
|
-
const safe = term.replace(/[^a-zA-Z0-9._-]/g, "");
|
|
1070
|
-
if (!safe) continue;
|
|
1071
|
-
const cmdPath = commandExists(safe);
|
|
1072
|
-
if (cmdPath) {
|
|
1073
|
-
hintResults.push(`${term}: ${cmdPath}`);
|
|
1074
|
-
continue;
|
|
1075
|
-
}
|
|
1076
|
-
let fallback = "";
|
|
1077
|
-
if (IS_WIN) {
|
|
1078
|
-
fallback = run(
|
|
1079
|
-
`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`,
|
|
1080
|
-
{ timeout: 1e4 }
|
|
1081
|
-
);
|
|
1082
|
-
} else if (IS_MAC) {
|
|
1083
|
-
fallback = run(`mdfind -name "${safe}" 2>/dev/null | head -5`);
|
|
1084
|
-
} else {
|
|
1085
|
-
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`);
|
|
1086
|
-
}
|
|
1087
|
-
hintResults.push(fallback ? `${term}: ${fallback}` : `${term}: (not found)`);
|
|
481
|
+
const chat = async (outcomes) => {
|
|
482
|
+
for (const oc of outcomes) {
|
|
483
|
+
messages.push({ role: "tool", tool_call_id: oc.id, content: oc.output });
|
|
1088
484
|
}
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
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
|
+
};
|
|
1101
507
|
}
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
if (input.tool_name !== "Bash") return {};
|
|
1109
|
-
const cmd = input.tool_input?.command ?? "";
|
|
1110
|
-
if (BLOCKED_CMDS.test(cmd) || BLOCKED_REDIRECTS.test(cmd)) {
|
|
1111
|
-
return {
|
|
1112
|
-
hookSpecificOutput: {
|
|
1113
|
-
hookEventName: "PreToolUse",
|
|
1114
|
-
permissionDecision: "deny",
|
|
1115
|
-
permissionDecisionReason: `BLOCKED: "${cmd}" \u2014 read-only policy`
|
|
1116
|
-
}
|
|
1117
|
-
};
|
|
508
|
+
function parseArgs(raw) {
|
|
509
|
+
try {
|
|
510
|
+
const parsed = JSON.parse(raw || "{}");
|
|
511
|
+
return typeof parsed === "object" && parsed !== null ? parsed : {};
|
|
512
|
+
} catch {
|
|
513
|
+
return {};
|
|
1118
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() {
|
|
1119
529
|
return {
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
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);
|
|
1123
586
|
}
|
|
1124
587
|
};
|
|
1125
|
-
}
|
|
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();
|
|
1126
599
|
|
|
1127
600
|
// src/agent.ts
|
|
1128
601
|
async function runDiscovery(config, db, sessionId, onEvent, onAskUser, hint) {
|
|
1129
|
-
const { query } = await import("@anthropic-ai/claude-agent-sdk");
|
|
1130
|
-
const tools = await createCartographyTools(db, sessionId, { onAskUser });
|
|
1131
602
|
const hintSection = hint ? `
|
|
1132
603
|
\u26A1 USER HINT (HIGH PRIORITY): The user wants to find these specific tools: "${hint}"
|
|
1133
604
|
\u2192 Run scan_installed_apps(searchHint: "${hint}") IMMEDIATELY and save found tools as saas_tool nodes!
|
|
@@ -1227,6 +698,7 @@ RULES:
|
|
|
1227
698
|
\u2022 metadata allowed: { description, category, port, version, path } \u2014 no passwords
|
|
1228
699
|
\u2022 Call get_catalog before save_node \u2192 avoid duplicates
|
|
1229
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
|
|
1230
702
|
|
|
1231
703
|
Entry points: ${config.entryPoints.join(", ")}`;
|
|
1232
704
|
const initialPrompt = hint ? `Start discovery with USER HINT: "${hint}".
|
|
@@ -1241,75 +713,28 @@ Then systematically scan local services, then config files.
|
|
|
1241
713
|
Finally, map all edges (Step 8 \u2014 critical!) before finishing.
|
|
1242
714
|
Use ask_user when you need context from the user.`;
|
|
1243
715
|
const MAX_DISCOVERY_MS = 30 * 60 * 1e3;
|
|
1244
|
-
|
|
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
|
+
};
|
|
1245
729
|
try {
|
|
1246
|
-
const
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
model: config.agentModel,
|
|
1251
|
-
maxTurns: config.maxTurns,
|
|
1252
|
-
systemPrompt,
|
|
1253
|
-
mcpServers: { cartography: tools },
|
|
1254
|
-
allowedTools: [
|
|
1255
|
-
"Bash",
|
|
1256
|
-
"mcp__cartograph__save_node",
|
|
1257
|
-
"mcp__cartograph__save_edge",
|
|
1258
|
-
"mcp__cartograph__get_catalog",
|
|
1259
|
-
"mcp__cartograph__scan_bookmarks",
|
|
1260
|
-
"mcp__cartograph__scan_browser_history",
|
|
1261
|
-
"mcp__cartograph__scan_installed_apps",
|
|
1262
|
-
"mcp__cartograph__scan_local_databases",
|
|
1263
|
-
"mcp__cartograph__scan_k8s_resources",
|
|
1264
|
-
"mcp__cartograph__scan_aws_resources",
|
|
1265
|
-
"mcp__cartograph__scan_gcp_resources",
|
|
1266
|
-
"mcp__cartograph__scan_azure_resources",
|
|
1267
|
-
"mcp__cartograph__ask_user"
|
|
1268
|
-
],
|
|
1269
|
-
hooks: {
|
|
1270
|
-
PreToolUse: [{ matcher: "Bash", hooks: [safetyHook] }]
|
|
1271
|
-
},
|
|
1272
|
-
permissionMode: "bypassPermissions"
|
|
1273
|
-
}
|
|
1274
|
-
})) {
|
|
1275
|
-
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) {
|
|
1276
734
|
onEvent?.({ kind: "error", text: `Discovery timeout after ${MAX_DISCOVERY_MS / 6e4} minutes` });
|
|
1277
735
|
onEvent?.({ kind: "done" });
|
|
1278
736
|
return;
|
|
1279
737
|
}
|
|
1280
|
-
if (!onEvent) continue;
|
|
1281
|
-
if (msg.type === "assistant") {
|
|
1282
|
-
turnCount++;
|
|
1283
|
-
onEvent({ kind: "turn", turn: turnCount });
|
|
1284
|
-
for (const block of msg.message.content) {
|
|
1285
|
-
if (block.type === "text") {
|
|
1286
|
-
onEvent({ kind: "thinking", text: block.text });
|
|
1287
|
-
}
|
|
1288
|
-
if (block.type === "tool_use") {
|
|
1289
|
-
onEvent({
|
|
1290
|
-
kind: "tool_call",
|
|
1291
|
-
tool: block.name,
|
|
1292
|
-
input: block.input
|
|
1293
|
-
});
|
|
1294
|
-
}
|
|
1295
|
-
}
|
|
1296
|
-
}
|
|
1297
|
-
if (msg.type === "user") {
|
|
1298
|
-
const content = msg.message?.content;
|
|
1299
|
-
if (Array.isArray(content)) {
|
|
1300
|
-
for (const block of content) {
|
|
1301
|
-
if (typeof block === "object" && block !== null && "type" in block && block.type === "tool_result") {
|
|
1302
|
-
const tb = block;
|
|
1303
|
-
const text = typeof tb.content === "string" ? tb.content : "";
|
|
1304
|
-
onEvent({ kind: "tool_result", tool: tb.tool_use_id ?? "", output: text });
|
|
1305
|
-
}
|
|
1306
|
-
}
|
|
1307
|
-
}
|
|
1308
|
-
}
|
|
1309
|
-
if (msg.type === "result") {
|
|
1310
|
-
onEvent({ kind: "done" });
|
|
1311
|
-
return;
|
|
1312
|
-
}
|
|
1313
738
|
}
|
|
1314
739
|
} catch (err) {
|
|
1315
740
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -1318,8 +743,184 @@ Use ask_user when you need context from the user.`;
|
|
|
1318
743
|
}
|
|
1319
744
|
}
|
|
1320
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
|
+
|
|
1321
922
|
// src/exporter.ts
|
|
1322
|
-
import { mkdirSync
|
|
923
|
+
import { mkdirSync, writeFileSync } from "fs";
|
|
1323
924
|
import { join as join2 } from "path";
|
|
1324
925
|
|
|
1325
926
|
// src/hex.ts
|
|
@@ -1427,6 +1028,11 @@ function layoutClusters(groups, hexSize) {
|
|
|
1427
1028
|
}
|
|
1428
1029
|
function findFreeOrigin(occupied, count, gap) {
|
|
1429
1030
|
const key = (q, r) => `${q},${r}`;
|
|
1031
|
+
const parsedOccupied = [];
|
|
1032
|
+
for (const oKey of occupied) {
|
|
1033
|
+
const [oq, or] = oKey.split(",").map(Number);
|
|
1034
|
+
parsedOccupied.push({ q: oq, r: or });
|
|
1035
|
+
}
|
|
1430
1036
|
for (let searchRadius = 1; searchRadius < 100; searchRadius++) {
|
|
1431
1037
|
const candidates = hexSpiral({ q: 0, r: 0 }, 1 + 6 * searchRadius * (searchRadius + 1) / 2);
|
|
1432
1038
|
for (const candidate of candidates) {
|
|
@@ -1437,9 +1043,8 @@ function findFreeOrigin(occupied, count, gap) {
|
|
|
1437
1043
|
fits = false;
|
|
1438
1044
|
break;
|
|
1439
1045
|
}
|
|
1440
|
-
for (const
|
|
1441
|
-
|
|
1442
|
-
if (hexDistance(tp, { q: oq, r: or }) < gap) {
|
|
1046
|
+
for (const oc of parsedOccupied) {
|
|
1047
|
+
if (hexDistance(tp, oc) < gap) {
|
|
1443
1048
|
fits = false;
|
|
1444
1049
|
break;
|
|
1445
1050
|
}
|
|
@@ -1537,12 +1142,9 @@ function buildMapData(nodes, edges, options) {
|
|
|
1537
1142
|
|
|
1538
1143
|
// src/exporter.ts
|
|
1539
1144
|
function nodeLayer(type) {
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
if (["message_broker", "queue", "topic"].includes(type)) return "messaging";
|
|
1544
|
-
if (["host", "container", "pod", "k8s_cluster"].includes(type)) return "infra";
|
|
1545
|
-
if (type === "config_file") return "config";
|
|
1145
|
+
for (const [layer, types] of Object.entries(NODE_TYPE_GROUPS)) {
|
|
1146
|
+
if (types.includes(type)) return layer;
|
|
1147
|
+
}
|
|
1546
1148
|
return "other";
|
|
1547
1149
|
}
|
|
1548
1150
|
var LAYER_LABELS = {
|
|
@@ -1683,6 +1285,66 @@ function generateDependencyMermaid(nodes, edges) {
|
|
|
1683
1285
|
}
|
|
1684
1286
|
return lines.join("\n");
|
|
1685
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
|
+
}
|
|
1686
1348
|
function exportBackstageYAML(nodes, edges, org) {
|
|
1687
1349
|
const owner = org ?? "unknown";
|
|
1688
1350
|
const docs = [];
|
|
@@ -1702,7 +1364,7 @@ function exportBackstageYAML(nodes, edges, org) {
|
|
|
1702
1364
|
`spec:`,
|
|
1703
1365
|
` type: ${node.type}`,
|
|
1704
1366
|
` lifecycle: production`,
|
|
1705
|
-
` owner: ${owner}`,
|
|
1367
|
+
` owner: ${node.owner ?? owner}`,
|
|
1706
1368
|
...deps.length > 0 ? [" dependsOn:", ...deps] : []
|
|
1707
1369
|
].join("\n");
|
|
1708
1370
|
docs.push(doc);
|
|
@@ -2823,8 +2485,82 @@ function exportJGF(nodes, edges) {
|
|
|
2823
2485
|
};
|
|
2824
2486
|
return JSON.stringify(jgf, null, 2);
|
|
2825
2487
|
}
|
|
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
|
+
}
|
|
2826
2562
|
function exportAll(db, sessionId, outputDir, formats = ["mermaid", "json", "yaml", "html", "map", "discovery"]) {
|
|
2827
|
-
|
|
2563
|
+
mkdirSync(outputDir, { recursive: true });
|
|
2828
2564
|
const nodes = db.getNodes(sessionId);
|
|
2829
2565
|
const edges = db.getEdges(sessionId);
|
|
2830
2566
|
const jgfPath = join2(outputDir, "cartography-graph.jgf.json");
|
|
@@ -2842,36 +2578,774 @@ function exportAll(db, sessionId, outputDir, formats = ["mermaid", "json", "yaml
|
|
|
2842
2578
|
if (formats.includes("html") || formats.includes("map") || formats.includes("discovery")) {
|
|
2843
2579
|
writeFileSync(join2(outputDir, "discovery.html"), exportDiscoveryApp(nodes, edges));
|
|
2844
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
|
+
}
|
|
2845
2586
|
}
|
|
2846
2587
|
|
|
2847
|
-
// src/
|
|
2848
|
-
|
|
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";
|
|
2849
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 };
|
|
2734
|
+
}
|
|
2735
|
+
|
|
2736
|
+
// src/cli.ts
|
|
2737
|
+
import { readFileSync as readFileSync5, existsSync as existsSync3, writeFileSync as writeFileSync3 } from "fs";
|
|
2738
|
+
import { resolve as resolve2, dirname as dirname2 } from "path";
|
|
2739
|
+
import { fileURLToPath } from "url";
|
|
2850
2740
|
import { createInterface } from "readline";
|
|
2851
2741
|
|
|
2852
|
-
// src/
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
2856
|
-
|
|
2857
|
-
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
|
|
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) } })
|
|
2864
3167
|
};
|
|
2865
|
-
process.stderr.write(JSON.stringify(entry) + "\n");
|
|
2866
3168
|
}
|
|
2867
|
-
|
|
2868
|
-
|
|
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()}`;
|
|
2869
3341
|
}
|
|
2870
|
-
function
|
|
2871
|
-
|
|
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}`;
|
|
2872
3346
|
}
|
|
2873
|
-
function
|
|
2874
|
-
|
|
3347
|
+
function codeAddMcpCommand(name, entry) {
|
|
3348
|
+
return `code --add-mcp '${JSON.stringify({ name, ...mcpServerObject(entry) })}'`;
|
|
2875
3349
|
}
|
|
2876
3350
|
|
|
2877
3351
|
// src/cli.ts
|
|
@@ -2882,6 +3356,46 @@ var green = (s) => `\x1B[32m${s}\x1B[0m`;
|
|
|
2882
3356
|
var yellow = (s) => `\x1B[33m${s}\x1B[0m`;
|
|
2883
3357
|
var magenta = (s) => `\x1B[35m${s}\x1B[0m`;
|
|
2884
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
|
+
}
|
|
2885
3399
|
main();
|
|
2886
3400
|
function main() {
|
|
2887
3401
|
let activeDb = null;
|
|
@@ -2894,17 +3408,35 @@ function main() {
|
|
|
2894
3408
|
}
|
|
2895
3409
|
activeDb = null;
|
|
2896
3410
|
}
|
|
2897
|
-
process.
|
|
3411
|
+
process.removeListener("SIGTERM", shutdown);
|
|
3412
|
+
process.removeListener("SIGINT", shutdown);
|
|
3413
|
+
process.kill(process.pid, signal);
|
|
2898
3414
|
};
|
|
2899
|
-
process.on("SIGTERM",
|
|
2900
|
-
process.on("SIGINT",
|
|
3415
|
+
process.on("SIGTERM", shutdown);
|
|
3416
|
+
process.on("SIGINT", shutdown);
|
|
2901
3417
|
cleanupTempFiles();
|
|
2902
3418
|
const program = new Command();
|
|
2903
3419
|
const CMD = "datasynx-cartography";
|
|
2904
|
-
const
|
|
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
|
+
}
|
|
2905
3427
|
program.name(CMD).description("AI-powered Infrastructure Discovery & Agentic AI Cartography").version(VERSION);
|
|
2906
|
-
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) => {
|
|
2907
|
-
|
|
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);
|
|
2908
3440
|
const parsedDepth = parseInt(opts.depth, 10);
|
|
2909
3441
|
const parsedMaxTurns = parseInt(opts.maxTurns, 10);
|
|
2910
3442
|
if (Number.isNaN(parsedDepth) || parsedDepth < 1 || parsedDepth > 50) {
|
|
@@ -2919,11 +3451,20 @@ function main() {
|
|
|
2919
3451
|
process.exitCode = 2;
|
|
2920
3452
|
return;
|
|
2921
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";
|
|
2922
3462
|
setVerbose(opts.verbose);
|
|
2923
3463
|
const config = defaultConfig({
|
|
2924
3464
|
entryPoints: opts.entry,
|
|
2925
3465
|
maxDepth: parsedDepth,
|
|
2926
3466
|
maxTurns: parsedMaxTurns,
|
|
3467
|
+
provider,
|
|
2927
3468
|
agentModel: opts.model,
|
|
2928
3469
|
organization: opts.org,
|
|
2929
3470
|
outputDir: opts.output,
|
|
@@ -2932,14 +3473,63 @@ function main() {
|
|
|
2932
3473
|
});
|
|
2933
3474
|
logInfo("Discovery started", {
|
|
2934
3475
|
entryPoints: config.entryPoints,
|
|
3476
|
+
provider: config.provider,
|
|
2935
3477
|
model: config.agentModel,
|
|
2936
3478
|
maxTurns: config.maxTurns,
|
|
2937
3479
|
maxDepth: config.maxDepth
|
|
2938
3480
|
});
|
|
2939
3481
|
const db = new CartographyDB(config.dbPath);
|
|
2940
3482
|
activeDb = db;
|
|
2941
|
-
const sessionId = db.createSession("discover", config);
|
|
2942
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);
|
|
2943
3533
|
const SPINNER = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
|
|
2944
3534
|
let spinIdx = 0;
|
|
2945
3535
|
let spinnerTimer = null;
|
|
@@ -2964,13 +3554,15 @@ function main() {
|
|
|
2964
3554
|
let turnNum = 0;
|
|
2965
3555
|
let nodeCount = 0;
|
|
2966
3556
|
let edgeCount = 0;
|
|
2967
|
-
|
|
2968
|
-
|
|
3557
|
+
if (isText) {
|
|
3558
|
+
w("\n");
|
|
3559
|
+
w(` ${bold("CARTOGRAPHY")} ${dim(config.entryPoints.join(", "))}
|
|
2969
3560
|
`);
|
|
2970
|
-
|
|
3561
|
+
w(` ${dim("Model: " + config.agentModel + " | MaxTurns: " + config.maxTurns)}
|
|
2971
3562
|
`);
|
|
2972
|
-
|
|
2973
|
-
|
|
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
|
+
}
|
|
2974
3566
|
const logLine = (icon, msg) => {
|
|
2975
3567
|
stopSpinner();
|
|
2976
3568
|
const elapsed = ((Date.now() - startTime) / 1e3).toFixed(1);
|
|
@@ -2978,6 +3570,10 @@ function main() {
|
|
|
2978
3570
|
`);
|
|
2979
3571
|
};
|
|
2980
3572
|
const handleEvent = (event) => {
|
|
3573
|
+
if (!isText) {
|
|
3574
|
+
if (fmt === "stream-json") process.stdout.write(JSON.stringify(event) + "\n");
|
|
3575
|
+
return;
|
|
3576
|
+
}
|
|
2981
3577
|
switch (event.kind) {
|
|
2982
3578
|
case "turn":
|
|
2983
3579
|
turnNum = event.turn;
|
|
@@ -2994,7 +3590,7 @@ function main() {
|
|
|
2994
3590
|
}
|
|
2995
3591
|
break;
|
|
2996
3592
|
case "tool_call": {
|
|
2997
|
-
const toolName = event.tool.replace("
|
|
3593
|
+
const toolName = event.tool.replace("mcp__cartography__", "");
|
|
2998
3594
|
if (toolName === "Bash") {
|
|
2999
3595
|
const cmd = (event.input["command"] ?? "").substring(0, 70);
|
|
3000
3596
|
startSpinner(`${yellow("$")} ${cmd}`);
|
|
@@ -3042,6 +3638,7 @@ function main() {
|
|
|
3042
3638
|
}
|
|
3043
3639
|
};
|
|
3044
3640
|
const onAskUser = async (question, context) => {
|
|
3641
|
+
if (!isText) return "(Non-interactive mode \u2014 please continue without this information)";
|
|
3045
3642
|
stopSpinner();
|
|
3046
3643
|
w("\n");
|
|
3047
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"));
|
|
@@ -3056,7 +3653,7 @@ function main() {
|
|
|
3056
3653
|
return "(Non-interactive mode \u2014 please continue without this information)";
|
|
3057
3654
|
}
|
|
3058
3655
|
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
3059
|
-
const answer = await new Promise((
|
|
3656
|
+
const answer = await new Promise((resolve3) => rl.question(` ${cyan("\u2192")} `, resolve3));
|
|
3060
3657
|
rl.close();
|
|
3061
3658
|
w("\n");
|
|
3062
3659
|
return answer || "(No answer \u2014 please continue)";
|
|
@@ -3065,7 +3662,8 @@ function main() {
|
|
|
3065
3662
|
await runDiscovery(config, db, sessionId, handleEvent, onAskUser, void 0);
|
|
3066
3663
|
} catch (err) {
|
|
3067
3664
|
stopSpinner();
|
|
3068
|
-
const
|
|
3665
|
+
const rawMsg = err instanceof Error ? err.message : String(err);
|
|
3666
|
+
const errMsg = rawMsg.replace(/https?:\/\/[^\s]+/g, (u) => stripSensitive(u));
|
|
3069
3667
|
logError("Discovery failed", { sessionId, error: errMsg });
|
|
3070
3668
|
w(`
|
|
3071
3669
|
${bold("\x1B[31m\u2717\x1B[0m")} Discovery failed: ${errMsg}
|
|
@@ -3077,6 +3675,9 @@ function main() {
|
|
|
3077
3675
|
}
|
|
3078
3676
|
stopSpinner();
|
|
3079
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);
|
|
3080
3681
|
const stats = db.getStats(sessionId);
|
|
3081
3682
|
const totalSec = ((Date.now() - startTime) / 1e3).toFixed(1);
|
|
3082
3683
|
logInfo("Discovery completed", {
|
|
@@ -3085,6 +3686,22 @@ function main() {
|
|
|
3085
3686
|
edges: stats.edges,
|
|
3086
3687
|
durationSec: parseFloat(totalSec)
|
|
3087
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
|
+
}
|
|
3088
3705
|
w("\n");
|
|
3089
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"));
|
|
3090
3707
|
w(` ${green(bold("DONE"))} ${bold(String(stats.nodes))} nodes, ${bold(String(stats.edges))} edges ${dim("in " + totalSec + "s")}
|
|
@@ -3124,7 +3741,7 @@ function main() {
|
|
|
3124
3741
|
w("\n");
|
|
3125
3742
|
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
3126
3743
|
const answer = await new Promise(
|
|
3127
|
-
(
|
|
3744
|
+
(resolve3) => rl.question(` ${yellow("?")} Remove nodes (numbers, empty = keep all): `, resolve3)
|
|
3128
3745
|
);
|
|
3129
3746
|
rl.close();
|
|
3130
3747
|
const toRemove = answer.trim().split(/[\s,]+/).map(Number).filter((n) => n >= 1 && n <= allNodes.length);
|
|
@@ -3143,9 +3760,9 @@ function main() {
|
|
|
3143
3760
|
}
|
|
3144
3761
|
}
|
|
3145
3762
|
exportAll(db, sessionId, config.outputDir, ["discovery"]);
|
|
3146
|
-
const discoveryPath =
|
|
3763
|
+
const discoveryPath = resolve2(config.outputDir, "discovery.html");
|
|
3147
3764
|
w("\n");
|
|
3148
|
-
if (
|
|
3765
|
+
if (existsSync3(discoveryPath)) {
|
|
3149
3766
|
w(` ${green("\u2713")} ${bold("discovery.html")} ${dim("\u2190 Enterprise Map")}
|
|
3150
3767
|
`);
|
|
3151
3768
|
}
|
|
@@ -3162,7 +3779,7 @@ function main() {
|
|
|
3162
3779
|
while (continueDiscovery) {
|
|
3163
3780
|
const rlFollowup = createInterface({ input: process.stdin, output: process.stderr });
|
|
3164
3781
|
const followupHint = await new Promise(
|
|
3165
|
-
(
|
|
3782
|
+
(resolve3) => rlFollowup.question(` ${yellow("\u2192")} Search for (Enter = finish): `, resolve3)
|
|
3166
3783
|
);
|
|
3167
3784
|
rlFollowup.close();
|
|
3168
3785
|
if (!followupHint.trim()) {
|
|
@@ -3190,7 +3807,7 @@ function main() {
|
|
|
3190
3807
|
`);
|
|
3191
3808
|
w("\n");
|
|
3192
3809
|
exportAll(db, sessionId, config.outputDir, ["discovery"]);
|
|
3193
|
-
if (
|
|
3810
|
+
if (existsSync3(discoveryPath)) {
|
|
3194
3811
|
w(` ${green("\u2713")} ${bold("discovery.html updated")}
|
|
3195
3812
|
`);
|
|
3196
3813
|
}
|
|
@@ -3199,7 +3816,7 @@ function main() {
|
|
|
3199
3816
|
}
|
|
3200
3817
|
db.close();
|
|
3201
3818
|
});
|
|
3202
|
-
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) => {
|
|
3203
3820
|
const config = defaultConfig({ outputDir: opts.output });
|
|
3204
3821
|
const db = new CartographyDB(config.dbPath);
|
|
3205
3822
|
const session = sessionId ? db.getSession(sessionId) : db.getLatestSession();
|
|
@@ -3215,6 +3832,216 @@ function main() {
|
|
|
3215
3832
|
`);
|
|
3216
3833
|
db.close();
|
|
3217
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
|
+
});
|
|
3218
4045
|
program.command("show [session-id]").description("Show session details").action((sessionId) => {
|
|
3219
4046
|
const config = defaultConfig();
|
|
3220
4047
|
const db = new CartographyDB(config.dbPath);
|
|
@@ -3229,6 +4056,8 @@ function main() {
|
|
|
3229
4056
|
const nodes = db.getNodes(session.id);
|
|
3230
4057
|
process.stdout.write(`
|
|
3231
4058
|
Session: ${session.id}
|
|
4059
|
+
`);
|
|
4060
|
+
if (session.name) process.stdout.write(` Name: ${session.name}
|
|
3232
4061
|
`);
|
|
3233
4062
|
process.stdout.write(` Mode: ${session.mode}
|
|
3234
4063
|
`);
|
|
@@ -3244,6 +4073,15 @@ Session: ${session.id}
|
|
|
3244
4073
|
`);
|
|
3245
4074
|
process.stdout.write(` Tasks: ${stats.tasks}
|
|
3246
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
|
+
}
|
|
3247
4085
|
if (nodes.length > 0) {
|
|
3248
4086
|
process.stdout.write("\n Discovered nodes:\n");
|
|
3249
4087
|
for (const node of nodes.slice(0, 20)) {
|
|
@@ -3271,13 +4109,13 @@ Session: ${session.id}
|
|
|
3271
4109
|
const stats = db.getStats(session.id);
|
|
3272
4110
|
const status = session.completedAt ? "\u2713" : "\u25CF";
|
|
3273
4111
|
process.stdout.write(
|
|
3274
|
-
`${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}` : ""}
|
|
3275
4113
|
`
|
|
3276
4114
|
);
|
|
3277
4115
|
}
|
|
3278
4116
|
db.close();
|
|
3279
4117
|
});
|
|
3280
|
-
program.command("overview").description("Overview of all cartography sessions").option("--db <path>", "DB
|
|
4118
|
+
program.command("overview").description("Overview of all cartography sessions").option("--db <path>", "DB path").action((opts) => {
|
|
3281
4119
|
const config = defaultConfig();
|
|
3282
4120
|
const db = new CartographyDB(opts.db ?? config.dbPath);
|
|
3283
4121
|
const sessions = db.getSessions();
|
|
@@ -3310,7 +4148,7 @@ Session: ${session.id}
|
|
|
3310
4148
|
const status = session.completedAt ? green("\u2713") : yellow("\u25CF");
|
|
3311
4149
|
const age = session.startedAt.substring(0, 16).replace("T", " ");
|
|
3312
4150
|
const sid = cyan(session.id.substring(0, 8));
|
|
3313
|
-
w(` ${status} ${sid} ${b("[" + session.mode + "]")} ${d(age)}
|
|
4151
|
+
w(` ${status} ${sid} ${b("[" + session.mode + "]")} ${d(age)}${session.name ? ` ${d(session.name)}` : ""}
|
|
3314
4152
|
`);
|
|
3315
4153
|
w(` ${d("Nodes: " + stats.nodes + " Edges: " + stats.edges)}
|
|
3316
4154
|
`);
|
|
@@ -3328,8 +4166,9 @@ Session: ${session.id}
|
|
|
3328
4166
|
}
|
|
3329
4167
|
db.close();
|
|
3330
4168
|
});
|
|
3331
|
-
program.command("chat [session-id]").description("Interactive chat about your mapped infrastructure").option("--db <path>", "DB
|
|
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) => {
|
|
3332
4170
|
const config = defaultConfig();
|
|
4171
|
+
const model = opts.model ?? config.models.fast;
|
|
3333
4172
|
const db = new CartographyDB(opts.db ?? config.dbPath);
|
|
3334
4173
|
const sessions = db.getSessions();
|
|
3335
4174
|
const session = sessionIdArg ? sessions.find((s) => s.id.startsWith(sessionIdArg)) : sessions.filter((s) => s.completedAt).at(-1) ?? sessions.at(-1);
|
|
@@ -3373,7 +4212,7 @@ INFRASTRUCTURE SNAPSHOT (${nodes.length} nodes, ${edges.length} edges):
|
|
|
3373
4212
|
${infraSummary.substring(0, 12e3)}`;
|
|
3374
4213
|
const history = [];
|
|
3375
4214
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
3376
|
-
const ask = () => new Promise((
|
|
4215
|
+
const ask = () => new Promise((resolve3) => rl.question(` ${cyan(">")} `, resolve3));
|
|
3377
4216
|
while (true) {
|
|
3378
4217
|
let userInput;
|
|
3379
4218
|
try {
|
|
@@ -3386,7 +4225,7 @@ ${infraSummary.substring(0, 12e3)}`;
|
|
|
3386
4225
|
history.push({ role: "user", content: userInput });
|
|
3387
4226
|
try {
|
|
3388
4227
|
const resp = await client.messages.create({
|
|
3389
|
-
model
|
|
4228
|
+
model,
|
|
3390
4229
|
max_tokens: 1024,
|
|
3391
4230
|
system: systemPrompt,
|
|
3392
4231
|
messages: history
|
|
@@ -3455,9 +4294,9 @@ ${infraSummary.substring(0, 12e3)}`;
|
|
|
3455
4294
|
out("\n");
|
|
3456
4295
|
out(` ${green("datasynx-cartography discover")}
|
|
3457
4296
|
`);
|
|
3458
|
-
out(` Scans your local infrastructure (
|
|
4297
|
+
out(` Scans your local infrastructure (provider-agnostic: claude, openai, ollama).
|
|
3459
4298
|
`);
|
|
3460
|
-
out(`
|
|
4299
|
+
out(` The agent autonomously runs ${IS_WIN ? "Get-NetTCPConnection, Get-Process" : IS_MAC ? "lsof, ps" : "ss, ps"}, curl, docker inspect, kubectl get
|
|
3461
4300
|
`);
|
|
3462
4301
|
out(` and stores everything in SQLite.
|
|
3463
4302
|
`);
|
|
@@ -3480,7 +4319,7 @@ ${infraSummary.substring(0, 12e3)}`;
|
|
|
3480
4319
|
out("\n");
|
|
3481
4320
|
out(` ${green("datasynx-cartography export [session-id]")}
|
|
3482
4321
|
`);
|
|
3483
|
-
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"));
|
|
3484
4323
|
out(dim(" -o, --output <dir> Output directory\n"));
|
|
3485
4324
|
out("\n");
|
|
3486
4325
|
out(` ${green("datasynx-cartography show [session-id]")} ${dim("Session details + node list")}
|
|
@@ -3504,7 +4343,7 @@ ${infraSummary.substring(0, 12e3)}`;
|
|
|
3504
4343
|
out(dim(" \u2514\u2500\u2500 Platform Detection (platform.ts)\n"));
|
|
3505
4344
|
out(dim(" \u2514\u2500\u2500 Shell: /bin/sh (Unix) | PowerShell (Windows)\n"));
|
|
3506
4345
|
out(dim(" \u2514\u2500\u2500 Agent Orchestrator (agent.ts)\n"));
|
|
3507
|
-
out(dim(" \u2514\u2500\u2500 runDiscovery() \u2192
|
|
4346
|
+
out(dim(" \u2514\u2500\u2500 runDiscovery() \u2192 AgentProvider (claude|openai|ollama) + Bash + MCP Tools\n"));
|
|
3508
4347
|
out(dim(" \u2514\u2500\u2500 Custom MCP Tools (tools.ts)\n"));
|
|
3509
4348
|
out(dim(" save_node, save_edge,\n"));
|
|
3510
4349
|
out(dim(" scan_bookmarks, scan_browser_history,\n"));
|
|
@@ -3541,10 +4380,10 @@ ${infraSummary.substring(0, 12e3)}`;
|
|
|
3541
4380
|
out("\n");
|
|
3542
4381
|
});
|
|
3543
4382
|
program.command("bookmarks").description("View all browser bookmarks (Chrome, Chromium, Edge, Brave, Vivaldi, Opera, Firefox)").action(async () => {
|
|
3544
|
-
const { scanAllBookmarks
|
|
4383
|
+
const { scanAllBookmarks } = await import("./bookmarks-WXHE7GN7.js");
|
|
3545
4384
|
const out = (s) => process.stdout.write(s);
|
|
3546
4385
|
process.stderr.write(" Scanning bookmarks...\n\n");
|
|
3547
|
-
const hosts = await
|
|
4386
|
+
const hosts = await scanAllBookmarks();
|
|
3548
4387
|
if (hosts.length === 0) {
|
|
3549
4388
|
out(" (No bookmarks found \u2014 Chrome, Edge, Brave, Vivaldi, Opera and Firefox are supported)\n\n");
|
|
3550
4389
|
return;
|
|
@@ -3571,16 +4410,52 @@ ${infraSummary.substring(0, 12e3)}`;
|
|
|
3571
4410
|
`));
|
|
3572
4411
|
out(dim(" Tip: ") + "datasynx-cartography discover" + dim(" \u2014 scans + classifies all bookmarks automatically\n\n"));
|
|
3573
4412
|
});
|
|
3574
|
-
program.command("
|
|
3575
|
-
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 } : {} });
|
|
3576
4451
|
const db = new CartographyDB(config.dbPath);
|
|
3577
|
-
const sessionId = opts.session ?? db.createSession("discover", config);
|
|
4452
|
+
const sessionId = opts.session ?? db.createSession("discover", config, opts.org);
|
|
3578
4453
|
const out = (s) => process.stdout.write(s);
|
|
3579
4454
|
const w = (s) => process.stderr.write(s);
|
|
3580
4455
|
if (opts.file) {
|
|
3581
4456
|
let raw;
|
|
3582
4457
|
try {
|
|
3583
|
-
raw = JSON.parse(
|
|
4458
|
+
raw = JSON.parse(readFileSync5(resolve2(opts.file), "utf8"));
|
|
3584
4459
|
} catch (e) {
|
|
3585
4460
|
w(red(`
|
|
3586
4461
|
\u2717 Could not read file: ${e}
|
|
@@ -3598,7 +4473,7 @@ ${infraSummary.substring(0, 12e3)}`;
|
|
|
3598
4473
|
for (const entry of raw) {
|
|
3599
4474
|
const type = entry["type"];
|
|
3600
4475
|
const name = entry["name"];
|
|
3601
|
-
const
|
|
4476
|
+
const host2 = entry["host"];
|
|
3602
4477
|
const port = entry["port"];
|
|
3603
4478
|
const tags = entry["tags"] ?? [];
|
|
3604
4479
|
const metadata = entry["metadata"] ?? {};
|
|
@@ -3607,14 +4482,14 @@ ${infraSummary.substring(0, 12e3)}`;
|
|
|
3607
4482
|
`));
|
|
3608
4483
|
continue;
|
|
3609
4484
|
}
|
|
3610
|
-
const id =
|
|
4485
|
+
const id = host2 ? `${type}:${host2}${port ? ":" + port : ""}` : `${type}:${name.toLowerCase().replace(/\s+/g, "-")}`;
|
|
3611
4486
|
db.upsertNode(sessionId, {
|
|
3612
4487
|
id,
|
|
3613
4488
|
type,
|
|
3614
4489
|
name,
|
|
3615
4490
|
discoveredVia: "manual",
|
|
3616
4491
|
confidence: 1,
|
|
3617
|
-
metadata: { ...metadata, ...
|
|
4492
|
+
metadata: { ...metadata, ...host2 ? { host: host2 } : {}, ...port ? { port } : {} },
|
|
3618
4493
|
tags
|
|
3619
4494
|
});
|
|
3620
4495
|
out(` ${green("+")} ${cyan(id)} ${dim("(" + type + ")")}
|
|
@@ -3628,7 +4503,7 @@ ${infraSummary.substring(0, 12e3)}`;
|
|
|
3628
4503
|
`);
|
|
3629
4504
|
return;
|
|
3630
4505
|
}
|
|
3631
|
-
const { NODE_TYPES
|
|
4506
|
+
const { NODE_TYPES } = await import("./types-TJWXAQ2L.js");
|
|
3632
4507
|
if (!process.stdin.isTTY) {
|
|
3633
4508
|
w(red("\n \u2717 Interactive mode requires a terminal (use --file for non-interactive)\n\n"));
|
|
3634
4509
|
process.exitCode = 1;
|
|
@@ -3643,7 +4518,7 @@ ${infraSummary.substring(0, 12e3)}`;
|
|
|
3643
4518
|
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
3644
4519
|
const ask = (q) => new Promise((res) => rl.question(q, res));
|
|
3645
4520
|
let saved = 0;
|
|
3646
|
-
const typeList =
|
|
4521
|
+
const typeList = NODE_TYPES.map((t, i) => `${dim((i + 1).toString().padStart(2))} ${t}`).join("\n ");
|
|
3647
4522
|
while (true) {
|
|
3648
4523
|
w("\n");
|
|
3649
4524
|
w(dim(" Node types:\n"));
|
|
@@ -3654,9 +4529,9 @@ ${infraSummary.substring(0, 12e3)}`;
|
|
|
3654
4529
|
if (!typeInput) break;
|
|
3655
4530
|
let nodeType;
|
|
3656
4531
|
const asNum = parseInt(typeInput, 10);
|
|
3657
|
-
if (!isNaN(asNum) && asNum >= 1 && asNum <=
|
|
3658
|
-
nodeType =
|
|
3659
|
-
} 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)) {
|
|
3660
4535
|
nodeType = typeInput;
|
|
3661
4536
|
} else {
|
|
3662
4537
|
w(yellow(` \u26A0 Unknown type: "${typeInput}"
|
|
@@ -3671,17 +4546,17 @@ ${infraSummary.substring(0, 12e3)}`;
|
|
|
3671
4546
|
const hostRaw = (await ask(` ${cyan("Host / IP")} ${dim("[optional, Enter=skip]")}: `)).trim();
|
|
3672
4547
|
const portRaw = (await ask(` ${cyan("Port")} ${dim("[optional]")}: `)).trim();
|
|
3673
4548
|
const tagsRaw = (await ask(` ${cyan("Tags")} ${dim("[comma-separated, optional]")}: `)).trim();
|
|
3674
|
-
const
|
|
4549
|
+
const host2 = hostRaw || void 0;
|
|
3675
4550
|
const port = portRaw ? parseInt(portRaw, 10) : void 0;
|
|
3676
4551
|
const tags = tagsRaw ? tagsRaw.split(",").map((t) => t.trim()).filter(Boolean) : [];
|
|
3677
|
-
const id =
|
|
4552
|
+
const id = host2 ? `${nodeType}:${host2}${port ? ":" + port : ""}` : `${nodeType}:${name.toLowerCase().replace(/\s+/g, "-")}`;
|
|
3678
4553
|
db.upsertNode(sessionId, {
|
|
3679
4554
|
id,
|
|
3680
4555
|
type: nodeType,
|
|
3681
4556
|
name,
|
|
3682
4557
|
discoveredVia: "manual",
|
|
3683
4558
|
confidence: 1,
|
|
3684
|
-
metadata: { ...
|
|
4559
|
+
metadata: { ...host2 ? { host: host2 } : {}, ...port ? { port } : {} },
|
|
3685
4560
|
tags
|
|
3686
4561
|
});
|
|
3687
4562
|
out(` ${green("+")} ${cyan(id)}
|
|
@@ -3704,8 +4579,8 @@ ${infraSummary.substring(0, 12e3)}`;
|
|
|
3704
4579
|
});
|
|
3705
4580
|
program.command("doctor").description("Check all requirements and cloud CLIs").action(async () => {
|
|
3706
4581
|
const { execSync: execSync2 } = await import("child_process");
|
|
3707
|
-
const { existsSync:
|
|
3708
|
-
const { join:
|
|
4582
|
+
const { existsSync: existsSync4, readFileSync: readFileSync6 } = await import("fs");
|
|
4583
|
+
const { join: join4 } = await import("path");
|
|
3709
4584
|
const out = (s) => process.stdout.write(s);
|
|
3710
4585
|
const ok = (msg) => out(` \x1B[32m\u2713\x1B[0m ${msg}
|
|
3711
4586
|
`);
|
|
@@ -3719,10 +4594,10 @@ ${infraSummary.substring(0, 12e3)}`;
|
|
|
3719
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"));
|
|
3720
4595
|
const nodeVer = process.versions.node;
|
|
3721
4596
|
const [major] = nodeVer.split(".").map(Number);
|
|
3722
|
-
if ((major ?? 0) >=
|
|
4597
|
+
if ((major ?? 0) >= 20) {
|
|
3723
4598
|
ok(`Node.js ${nodeVer}`);
|
|
3724
4599
|
} else {
|
|
3725
|
-
err(`Node.js ${nodeVer} \u2014
|
|
4600
|
+
err(`Node.js ${nodeVer} \u2014 requires >=20`);
|
|
3726
4601
|
allGood = false;
|
|
3727
4602
|
}
|
|
3728
4603
|
try {
|
|
@@ -3736,7 +4611,7 @@ ${infraSummary.substring(0, 12e3)}`;
|
|
|
3736
4611
|
const home = process.env.HOME ?? process.env.USERPROFILE ?? "/tmp";
|
|
3737
4612
|
let hasOAuth = false;
|
|
3738
4613
|
try {
|
|
3739
|
-
const creds = JSON.parse(
|
|
4614
|
+
const creds = JSON.parse(readFileSync6(join4(home, ".claude", ".credentials.json"), "utf8"));
|
|
3740
4615
|
const oauth = creds["claudeAiOauth"];
|
|
3741
4616
|
hasOAuth = typeof oauth?.["accessToken"] === "string" && oauth["accessToken"].length > 0;
|
|
3742
4617
|
} catch {
|
|
@@ -3786,8 +4661,8 @@ ${infraSummary.substring(0, 12e3)}`;
|
|
|
3786
4661
|
warn(`${name} not found ${dim2("\u2014 discovery without " + name + " will be limited")}`);
|
|
3787
4662
|
}
|
|
3788
4663
|
}
|
|
3789
|
-
const dbDir =
|
|
3790
|
-
if (
|
|
4664
|
+
const dbDir = join4(home, ".cartography");
|
|
4665
|
+
if (existsSync4(dbDir)) {
|
|
3791
4666
|
ok(`~/.cartography ${dim2("(data directory exists)")}`);
|
|
3792
4667
|
} else {
|
|
3793
4668
|
warn("~/.cartography does not exist yet " + dim2("\u2014 will be created on first run"));
|
|
@@ -3834,64 +4709,337 @@ ${infraSummary.substring(0, 12e3)}`;
|
|
|
3834
4709
|
}
|
|
3835
4710
|
db.close();
|
|
3836
4711
|
});
|
|
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({
|
|
4734
|
+
transport: opts.http ? "http" : "stdio",
|
|
4735
|
+
...opts.url ? { url: opts.url } : {},
|
|
4736
|
+
...packageArgs.length ? { packageArgs } : {}
|
|
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
|
+
}
|
|
4989
|
+
});
|
|
3837
4990
|
const o = (s) => process.stderr.write(s);
|
|
3838
|
-
const _b = (s) => `\x1B[1m${s}\x1B[0m`;
|
|
3839
|
-
const _d = (s) => `\x1B[2m${s}\x1B[0m`;
|
|
3840
|
-
const _c = (s) => `\x1B[36m${s}\x1B[0m`;
|
|
3841
|
-
const _g = (s) => `\x1B[32m${s}\x1B[0m`;
|
|
3842
|
-
const _m = (s) => `\x1B[35m${s}\x1B[0m`;
|
|
3843
4991
|
o("\n");
|
|
3844
|
-
o(
|
|
3845
|
-
o(
|
|
3846
|
-
o(
|
|
3847
|
-
o(
|
|
3848
|
-
o(
|
|
3849
|
-
o(
|
|
4992
|
+
o(cyan(" ____ _ ____ ") + "\n");
|
|
4993
|
+
o(cyan(" | _ \\ __ _| |_ __ _/ ___| _ _ _ __ __ __") + "\n");
|
|
4994
|
+
o(cyan(" | | | |/ _` | __/ _` \\___ \\| | | | '_ \\\\ \\/ /") + "\n");
|
|
4995
|
+
o(cyan(" | |_| | (_| | || (_| |___) | |_| | | | |> < ") + "\n");
|
|
4996
|
+
o(cyan(" |____/ \\__,_|\\__\\__,_|____/ \\__, |_| |_/_/\\_\\") + "\n");
|
|
4997
|
+
o(cyan(" |___/ ") + "\n");
|
|
3850
4998
|
o("\n");
|
|
3851
|
-
o(
|
|
3852
|
-
o(
|
|
3853
|
-
o(
|
|
4999
|
+
o(bold(" Cartography") + " " + dim("v" + VERSION) + "\n");
|
|
5000
|
+
o(dim(" AI-powered Infrastructure Discovery & Agentic AI Cartography\n"));
|
|
5001
|
+
o(dim(" Autonomous infrastructure discovery \u2014 zero-config, provider-agnostic\n"));
|
|
3854
5002
|
o("\n");
|
|
3855
5003
|
if (process.argv.length <= 2) {
|
|
3856
|
-
o(
|
|
5004
|
+
o(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"));
|
|
3857
5005
|
o("\n");
|
|
3858
|
-
o(
|
|
5006
|
+
o(bold(" Commands:\n"));
|
|
3859
5007
|
o("\n");
|
|
3860
|
-
o(` ${
|
|
5008
|
+
o(` ${green("discover")} ${dim("Scan infrastructure (provider: claude|openai|ollama)")}
|
|
3861
5009
|
`);
|
|
3862
|
-
o(` ${
|
|
5010
|
+
o(` ${green("seed")} ${dim("Manually add known tools/DBs/APIs")}
|
|
3863
5011
|
`);
|
|
3864
|
-
o(` ${
|
|
5012
|
+
o(` ${green("bookmarks")} ${dim("View browser bookmarks")}
|
|
3865
5013
|
`);
|
|
3866
|
-
o(` ${
|
|
5014
|
+
o(` ${green("export")} ${dim("[session]")} ${dim("Export Mermaid, JSON, YAML, HTML")}
|
|
3867
5015
|
`);
|
|
3868
|
-
o(` ${
|
|
5016
|
+
o(` ${green("show")} ${dim("[session]")} ${dim("Show session details")}
|
|
3869
5017
|
`);
|
|
3870
|
-
o(` ${
|
|
5018
|
+
o(` ${green("sessions")} ${dim("List all sessions")}
|
|
3871
5019
|
`);
|
|
3872
|
-
o(` ${
|
|
5020
|
+
o(` ${green("doctor")} ${dim("Check requirements (kubectl, aws, gcloud, az)")}
|
|
3873
5021
|
`);
|
|
3874
|
-
o(` ${
|
|
5022
|
+
o(` ${green("docs")} ${dim("Full feature reference")}
|
|
3875
5023
|
`);
|
|
3876
5024
|
o("\n");
|
|
3877
|
-
o(
|
|
5025
|
+
o(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"));
|
|
3878
5026
|
o("\n");
|
|
3879
|
-
o(
|
|
5027
|
+
o(bold(" Quick Start:\n"));
|
|
3880
5028
|
o("\n");
|
|
3881
|
-
o(` ${
|
|
5029
|
+
o(` ${magenta("$")} ${bold("datasynx-cartography doctor")} ${dim("Check requirements")}
|
|
3882
5030
|
`);
|
|
3883
|
-
o(` ${
|
|
5031
|
+
o(` ${magenta("$")} ${bold("datasynx-cartography seed")} ${dim("Add known infrastructure")}
|
|
3884
5032
|
`);
|
|
3885
|
-
o(` ${
|
|
5033
|
+
o(` ${magenta("$")} ${bold("datasynx-cartography discover")} ${dim("One-time scan")}
|
|
3886
5034
|
`);
|
|
3887
5035
|
o("\n");
|
|
3888
|
-
o(
|
|
3889
|
-
o(
|
|
3890
|
-
o(
|
|
5036
|
+
o(dim(" Docs: datasynx-cartography docs\n"));
|
|
5037
|
+
o(dim(" Help: datasynx-cartography --help\n"));
|
|
5038
|
+
o(dim(" npm: @datasynx/agentic-ai-cartography\n"));
|
|
3891
5039
|
o("\n");
|
|
3892
5040
|
return;
|
|
3893
5041
|
}
|
|
3894
|
-
o(
|
|
5042
|
+
o(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"));
|
|
3895
5043
|
o("\n");
|
|
3896
5044
|
program.exitOverride((err) => {
|
|
3897
5045
|
if (err.code === "commander.helpDisplayed") {
|