browsertrack 0.2.1 → 0.2.3
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 +9 -6
- package/README.md +2 -0
- package/dist/{chunk-464D4U2U.js → chunk-5NR5K3ER.js} +299 -44
- package/dist/chunk-5NR5K3ER.js.map +1 -0
- package/dist/{chunk-3HOXPTM2.js → chunk-G5CIZSQM.js} +808 -53
- package/dist/chunk-G5CIZSQM.js.map +1 -0
- package/dist/{chunk-6VA7GBAO.js → chunk-ONPW7AYL.js} +144 -12
- package/dist/chunk-ONPW7AYL.js.map +1 -0
- package/dist/{chunk-INXDWPJW.js → chunk-PG4JJDCV.js} +353 -119
- package/dist/chunk-PG4JJDCV.js.map +1 -0
- package/dist/cli/index.js +1058 -546
- package/dist/cli/index.js.map +1 -1
- package/dist/client/index.cjs +449 -128
- package/dist/client/index.d.ts +2 -0
- package/dist/client/index.js +2 -2
- package/dist/client.iife.js +121 -15
- package/dist/core/index.d.ts +32 -2
- package/dist/core/index.js +11 -1
- package/dist/daemon/index.d.ts +2 -2
- package/dist/daemon/index.js +6 -8
- package/dist/index.d.ts +2 -2
- package/dist/index.js +16 -7
- package/dist/mcp/index.d.ts +1 -1
- package/dist/mcp/index.js +7 -4
- package/dist/{server-DiVmTrIR.d.ts → server-BRG-RQQP.d.ts} +10 -1
- package/docs/cli.md +4 -1
- package/docs/getting-started.md +60 -6
- package/docs/mcp-reference.md +42 -0
- package/package.json +1 -1
- package/packages/cli/src/index.ts +247 -151
- package/packages/client/src/interceptors/navigation.ts +38 -26
- package/packages/client/src/interceptors/network.ts +22 -17
- package/packages/client/src/notes/inspector.ts +289 -65
- package/packages/client/src/source/resolver.ts +9 -3
- package/packages/client/src/transport/websocket.ts +23 -18
- package/packages/core/src/index.ts +1 -0
- package/packages/core/src/safety.ts +86 -0
- package/packages/core/src/selector.ts +110 -15
- package/packages/daemon/src/server/daemon.ts +7 -1
- package/packages/daemon/src/server/http.ts +125 -5
- package/packages/daemon/src/server/ws.ts +33 -29
- package/packages/daemon/src/storage/db.ts +57 -35
- package/packages/mcp/src/handlers.ts +121 -45
- package/packages/mcp/src/server.ts +221 -3
- package/test/core/safety.test.ts +106 -0
- package/test/core/selector.test.ts +133 -0
- package/test/daemon/storage.test.ts +36 -0
- package/test/e2e/daemon-mcp-e2e.test.ts +10 -0
- package/test/mcp/auto-start.test.ts +87 -0
- package/dist/chunk-3HOXPTM2.js.map +0 -1
- package/dist/chunk-464D4U2U.js.map +0 -1
- package/dist/chunk-6VA7GBAO.js.map +0 -1
- package/dist/chunk-7OCOQGDN.js +0 -635
- package/dist/chunk-7OCOQGDN.js.map +0 -1
- package/dist/chunk-INXDWPJW.js.map +0 -1
package/dist/cli/index.js
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
// packages/cli/src/index.ts
|
|
4
4
|
import { Command } from "commander";
|
|
5
|
-
import
|
|
6
|
-
import
|
|
5
|
+
import fs6 from "fs";
|
|
6
|
+
import path7 from "path";
|
|
7
7
|
|
|
8
8
|
// packages/daemon/src/config.ts
|
|
9
9
|
import os from "os";
|
|
@@ -28,6 +28,218 @@ function getDaemonConfig(overrides = {}) {
|
|
|
28
28
|
import Database from "better-sqlite3";
|
|
29
29
|
import fs from "fs";
|
|
30
30
|
import path2 from "path";
|
|
31
|
+
|
|
32
|
+
// packages/core/src/fingerprint.ts
|
|
33
|
+
function normalizeErrorMessage(message) {
|
|
34
|
+
if (!message) return "unknown_error";
|
|
35
|
+
return message.trim().replace(/0x[0-9a-fA-F]+/g, "0x<HEX>").replace(/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/g, "<UUID>").replace(/\?[tv]=[\w.-]+/g, "").replace(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z?/g, "<TIMESTAMP>").replace(/#\d+/g, "#<ID>").replace(/\s+/g, " ");
|
|
36
|
+
}
|
|
37
|
+
function normalizeSourceFile(filename) {
|
|
38
|
+
if (!filename) return "unknown_source";
|
|
39
|
+
let cleaned = filename.trim();
|
|
40
|
+
try {
|
|
41
|
+
if (cleaned.startsWith("http://") || cleaned.startsWith("https://")) {
|
|
42
|
+
const url = new URL(cleaned);
|
|
43
|
+
cleaned = url.pathname;
|
|
44
|
+
}
|
|
45
|
+
} catch {
|
|
46
|
+
cleaned = cleaned.replace(/^https?:\/\/[^/]+/, "");
|
|
47
|
+
}
|
|
48
|
+
cleaned = cleaned.split("?")[0].split("#")[0];
|
|
49
|
+
cleaned = cleaned.replace(/\\/g, "/");
|
|
50
|
+
return cleaned || "unknown_source";
|
|
51
|
+
}
|
|
52
|
+
function extractSourceFromStack(stack) {
|
|
53
|
+
if (!stack) return null;
|
|
54
|
+
const lines = stack.split("\n");
|
|
55
|
+
for (const line of lines) {
|
|
56
|
+
const match = line.match(/(?:at\s+(?:.*?\s+\()?)?(https?:\/\/[^\s)]+|file:\/\/[^\s)]+|\/[^\s)]+):(\d+):(\d+)\)?/);
|
|
57
|
+
if (match) {
|
|
58
|
+
return {
|
|
59
|
+
file: normalizeSourceFile(match[1]),
|
|
60
|
+
line: parseInt(match[2], 10),
|
|
61
|
+
column: parseInt(match[3], 10)
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
function djb2Hash(str) {
|
|
68
|
+
let hash = 5381;
|
|
69
|
+
for (let i = 0; i < str.length; i++) {
|
|
70
|
+
hash = hash * 33 ^ str.charCodeAt(i);
|
|
71
|
+
}
|
|
72
|
+
return (hash >>> 0).toString(16).padStart(8, "0");
|
|
73
|
+
}
|
|
74
|
+
function computeFingerprint(input) {
|
|
75
|
+
const normType = (input.type || "Error").trim().toLowerCase();
|
|
76
|
+
const normMsg = normalizeErrorMessage(input.message);
|
|
77
|
+
let sourceFile = normalizeSourceFile(input.sourceFile);
|
|
78
|
+
let line = input.line || 0;
|
|
79
|
+
if ((sourceFile === "unknown_source" || line === 0) && input.stack) {
|
|
80
|
+
const extracted = extractSourceFromStack(input.stack);
|
|
81
|
+
if (extracted) {
|
|
82
|
+
sourceFile = extracted.file;
|
|
83
|
+
line = extracted.line;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const rawKey = `${normType}::${normMsg}::${sourceFile}::${line}`;
|
|
87
|
+
const hash = djb2Hash(rawKey);
|
|
88
|
+
return `fp_${hash}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// packages/core/src/redaction.ts
|
|
92
|
+
import { redact as visulimaRedact, standardRules } from "@visulima/redact";
|
|
93
|
+
var REDACTED_PLACEHOLDER = "[REDACTED]";
|
|
94
|
+
var SENSITIVE_KEY_PATTERNS = [
|
|
95
|
+
/^authorization$/i,
|
|
96
|
+
/^cookie$/i,
|
|
97
|
+
/^set-cookie$/i,
|
|
98
|
+
/password/i,
|
|
99
|
+
/token/i,
|
|
100
|
+
/secret/i,
|
|
101
|
+
/api[-_]?key/i,
|
|
102
|
+
/access[-_]?token/i,
|
|
103
|
+
/refresh[-_]?token/i,
|
|
104
|
+
/credentials/i,
|
|
105
|
+
/private[-_]?key/i,
|
|
106
|
+
/ssn/i,
|
|
107
|
+
/credit[-_]?card/i,
|
|
108
|
+
/cvv/i
|
|
109
|
+
];
|
|
110
|
+
var SENSITIVE_QUERY_PARAMS = [
|
|
111
|
+
"token",
|
|
112
|
+
"auth",
|
|
113
|
+
"key",
|
|
114
|
+
"apikey",
|
|
115
|
+
"api_key",
|
|
116
|
+
"secret",
|
|
117
|
+
"password",
|
|
118
|
+
"access_token",
|
|
119
|
+
"refresh_token",
|
|
120
|
+
"code",
|
|
121
|
+
"signature"
|
|
122
|
+
];
|
|
123
|
+
var BROWSER_SECURITY_RULES = [
|
|
124
|
+
{ deep: true, key: "password", replacement: REDACTED_PLACEHOLDER },
|
|
125
|
+
{ deep: true, key: "secret", replacement: REDACTED_PLACEHOLDER },
|
|
126
|
+
{ deep: true, key: "token", replacement: REDACTED_PLACEHOLDER },
|
|
127
|
+
{ deep: true, key: "authorization", replacement: REDACTED_PLACEHOLDER },
|
|
128
|
+
{ deep: true, key: "cookie", replacement: REDACTED_PLACEHOLDER },
|
|
129
|
+
{ deep: true, key: "set-cookie", replacement: REDACTED_PLACEHOLDER },
|
|
130
|
+
{ deep: true, key: "apikey", replacement: REDACTED_PLACEHOLDER },
|
|
131
|
+
{ deep: true, key: "api_key", replacement: REDACTED_PLACEHOLDER },
|
|
132
|
+
{ deep: true, key: "creditcard", pattern: /(?:\d[ -]*?){13,16}/, replacement: REDACTED_PLACEHOLDER },
|
|
133
|
+
{ deep: true, key: "cvv", replacement: REDACTED_PLACEHOLDER },
|
|
134
|
+
{ deep: true, key: "ssn", pattern: /\b\d{3}-\d{2}-\d{4}\b/, replacement: REDACTED_PLACEHOLDER },
|
|
135
|
+
{ deep: true, key: "awsid", pattern: /\bAKIA[0-9A-Z]{16}\b/, replacement: REDACTED_PLACEHOLDER },
|
|
136
|
+
{ deep: true, key: "awskey", pattern: /\b[0-9a-zA-Z/+]{40}\b/, replacement: REDACTED_PLACEHOLDER },
|
|
137
|
+
{ deep: true, key: "jwt", pattern: /\beyJ[0-9a-zA-Z_\-]*\.[0-9a-zA-Z_\-]*\.[0-9a-zA-Z_\-]*\b/, replacement: REDACTED_PLACEHOLDER },
|
|
138
|
+
{ deep: true, key: "slack_token", pattern: /\bxox[baprs]-[0-9a-zA-Z]{10,48}\b/, replacement: REDACTED_PLACEHOLDER }
|
|
139
|
+
];
|
|
140
|
+
function isSensitiveKey(key) {
|
|
141
|
+
if (!key) return false;
|
|
142
|
+
const cleaned = key.replace(/[-_]/g, "");
|
|
143
|
+
return SENSITIVE_KEY_PATTERNS.some((pattern) => pattern.test(key) || pattern.test(cleaned));
|
|
144
|
+
}
|
|
145
|
+
function redactUrl(rawUrl) {
|
|
146
|
+
if (!rawUrl) return rawUrl;
|
|
147
|
+
try {
|
|
148
|
+
const isRelative = !rawUrl.startsWith("http://") && !rawUrl.startsWith("https://") && !rawUrl.startsWith("ws://") && !rawUrl.startsWith("wss://");
|
|
149
|
+
const base = "http://localhost";
|
|
150
|
+
const parsed = new URL(rawUrl, base);
|
|
151
|
+
let changed = false;
|
|
152
|
+
for (const param of SENSITIVE_QUERY_PARAMS) {
|
|
153
|
+
if (parsed.searchParams.has(param)) {
|
|
154
|
+
parsed.searchParams.set(param, REDACTED_PLACEHOLDER);
|
|
155
|
+
changed = true;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
for (const key of Array.from(parsed.searchParams.keys())) {
|
|
159
|
+
if (isSensitiveKey(key)) {
|
|
160
|
+
parsed.searchParams.set(key, REDACTED_PLACEHOLDER);
|
|
161
|
+
changed = true;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (!changed) return rawUrl;
|
|
165
|
+
let result = isRelative ? parsed.pathname + parsed.search + parsed.hash : parsed.toString();
|
|
166
|
+
result = result.replace(/%5BREDACTED%5D/g, REDACTED_PLACEHOLDER);
|
|
167
|
+
return result;
|
|
168
|
+
} catch {
|
|
169
|
+
let safe = rawUrl;
|
|
170
|
+
for (const param of SENSITIVE_QUERY_PARAMS) {
|
|
171
|
+
const reg = new RegExp(`([?&]${param}=)[^&#]+`, "gi");
|
|
172
|
+
safe = safe.replace(reg, `$1${REDACTED_PLACEHOLDER}`);
|
|
173
|
+
}
|
|
174
|
+
return safe;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
function redactSensitiveData(data, maxDepth = 6, currentDepth = 0) {
|
|
178
|
+
if (data === null || data === void 0) return data;
|
|
179
|
+
if (typeof data !== "object") return data;
|
|
180
|
+
if (currentDepth > maxDepth) return "[DEPTH_EXCEEDED]";
|
|
181
|
+
if (Array.isArray(data)) {
|
|
182
|
+
return data.map((item) => redactSensitiveData(item, maxDepth, currentDepth + 1));
|
|
183
|
+
}
|
|
184
|
+
const result = {};
|
|
185
|
+
for (const [key, value] of Object.entries(data)) {
|
|
186
|
+
if (typeof value === "object" && value !== null) {
|
|
187
|
+
result[key] = redactSensitiveData(value, maxDepth, currentDepth + 1);
|
|
188
|
+
} else if (isSensitiveKey(key)) {
|
|
189
|
+
result[key] = REDACTED_PLACEHOLDER;
|
|
190
|
+
} else if (typeof value === "string") {
|
|
191
|
+
if (value.startsWith("http://") || value.startsWith("https://") || value.includes("?")) {
|
|
192
|
+
result[key] = redactUrl(value);
|
|
193
|
+
} else {
|
|
194
|
+
try {
|
|
195
|
+
result[key] = visulimaRedact(value, BROWSER_SECURITY_RULES);
|
|
196
|
+
} catch {
|
|
197
|
+
result[key] = value;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
} else {
|
|
201
|
+
result[key] = value;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return result;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// packages/core/src/safety.ts
|
|
208
|
+
function safeJsonParse(raw, fallback) {
|
|
209
|
+
if (raw === null || raw === void 0) return fallback;
|
|
210
|
+
if (typeof raw !== "string") return typeof raw === "object" ? raw : fallback;
|
|
211
|
+
try {
|
|
212
|
+
return JSON.parse(raw);
|
|
213
|
+
} catch {
|
|
214
|
+
return fallback;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
function safeJsonStringify(val, fallback = "{}") {
|
|
218
|
+
if (val === void 0) return fallback;
|
|
219
|
+
try {
|
|
220
|
+
const seen = /* @__PURE__ */ new WeakSet();
|
|
221
|
+
return JSON.stringify(val, (key, value) => {
|
|
222
|
+
if (typeof value === "object" && value !== null) {
|
|
223
|
+
if (seen.has(value)) {
|
|
224
|
+
return "[Circular]";
|
|
225
|
+
}
|
|
226
|
+
seen.add(value);
|
|
227
|
+
}
|
|
228
|
+
if (typeof value === "bigint") {
|
|
229
|
+
return value.toString();
|
|
230
|
+
}
|
|
231
|
+
return value;
|
|
232
|
+
});
|
|
233
|
+
} catch {
|
|
234
|
+
try {
|
|
235
|
+
return JSON.stringify(String(val));
|
|
236
|
+
} catch {
|
|
237
|
+
return fallback;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// packages/daemon/src/storage/db.ts
|
|
31
243
|
var StorageDB = class {
|
|
32
244
|
db;
|
|
33
245
|
constructor(dbPath) {
|
|
@@ -36,6 +248,7 @@ var StorageDB = class {
|
|
|
36
248
|
this.db = new Database(dbPath);
|
|
37
249
|
this.db.pragma("journal_mode = WAL");
|
|
38
250
|
this.db.pragma("synchronous = NORMAL");
|
|
251
|
+
this.db.pragma("busy_timeout = 5000");
|
|
39
252
|
this.initTables();
|
|
40
253
|
}
|
|
41
254
|
initTables() {
|
|
@@ -167,18 +380,36 @@ var StorageDB = class {
|
|
|
167
380
|
CREATE INDEX IF NOT EXISTS idx_incidents_project ON incidents(project_id, status);
|
|
168
381
|
CREATE INDEX IF NOT EXISTS idx_incidents_fp ON incidents(fingerprint);
|
|
169
382
|
CREATE INDEX IF NOT EXISTS idx_notes_project ON notes(project_id, status);
|
|
170
|
-
CREATE INDEX IF NOT EXISTS idx_notes_scenario ON notes(scenario_id, step_number);
|
|
171
383
|
`);
|
|
172
384
|
try {
|
|
173
|
-
this.db.
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
385
|
+
const columns = this.db.prepare("PRAGMA table_info(notes)").all().map(
|
|
386
|
+
(c) => c.name
|
|
387
|
+
);
|
|
388
|
+
if (!columns.includes("scenario_id")) {
|
|
389
|
+
this.db.exec("ALTER TABLE notes ADD COLUMN scenario_id TEXT;");
|
|
390
|
+
}
|
|
391
|
+
if (!columns.includes("step_number")) {
|
|
392
|
+
this.db.exec("ALTER TABLE notes ADD COLUMN step_number INTEGER;");
|
|
393
|
+
}
|
|
394
|
+
if (!columns.includes("scenario_title")) {
|
|
395
|
+
this.db.exec("ALTER TABLE notes ADD COLUMN scenario_title TEXT;");
|
|
396
|
+
}
|
|
178
397
|
} catch {
|
|
398
|
+
try {
|
|
399
|
+
this.db.exec("ALTER TABLE notes ADD COLUMN scenario_id TEXT;");
|
|
400
|
+
} catch {
|
|
401
|
+
}
|
|
402
|
+
try {
|
|
403
|
+
this.db.exec("ALTER TABLE notes ADD COLUMN step_number INTEGER;");
|
|
404
|
+
} catch {
|
|
405
|
+
}
|
|
406
|
+
try {
|
|
407
|
+
this.db.exec("ALTER TABLE notes ADD COLUMN scenario_title TEXT;");
|
|
408
|
+
} catch {
|
|
409
|
+
}
|
|
179
410
|
}
|
|
180
411
|
try {
|
|
181
|
-
this.db.exec("
|
|
412
|
+
this.db.exec("CREATE INDEX IF NOT EXISTS idx_notes_scenario ON notes(scenario_id, step_number);");
|
|
182
413
|
} catch {
|
|
183
414
|
}
|
|
184
415
|
}
|
|
@@ -343,7 +574,7 @@ var StorageDB = class {
|
|
|
343
574
|
id: r.id,
|
|
344
575
|
sessionId: r.session_id,
|
|
345
576
|
eventType: r.event_type,
|
|
346
|
-
payload:
|
|
577
|
+
payload: safeJsonParse(r.payload, {}),
|
|
347
578
|
timestamp: r.timestamp,
|
|
348
579
|
route: r.route,
|
|
349
580
|
url: r.url
|
|
@@ -404,9 +635,9 @@ var StorageDB = class {
|
|
|
404
635
|
incident.occurrences,
|
|
405
636
|
incident.status,
|
|
406
637
|
incident.stack || null,
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
incident.lastElement ?
|
|
638
|
+
safeJsonStringify(incident.breadcrumbs || []),
|
|
639
|
+
safeJsonStringify(incident.networkFailures || []),
|
|
640
|
+
incident.lastElement ? safeJsonStringify(incident.lastElement) : null,
|
|
410
641
|
incident.screenshots?.error || null
|
|
411
642
|
);
|
|
412
643
|
}
|
|
@@ -420,8 +651,8 @@ var StorageDB = class {
|
|
|
420
651
|
update.lastSeen,
|
|
421
652
|
update.occurrences,
|
|
422
653
|
update.route,
|
|
423
|
-
|
|
424
|
-
update.lastElement ?
|
|
654
|
+
safeJsonStringify(update.breadcrumbs || []),
|
|
655
|
+
update.lastElement ? safeJsonStringify(update.lastElement) : null,
|
|
425
656
|
update.stack || null,
|
|
426
657
|
incidentId
|
|
427
658
|
);
|
|
@@ -441,8 +672,8 @@ var StorageDB = class {
|
|
|
441
672
|
occurrence.route,
|
|
442
673
|
occurrence.url,
|
|
443
674
|
occurrence.stack || null,
|
|
444
|
-
|
|
445
|
-
occurrence.lastElement ?
|
|
675
|
+
safeJsonStringify(occurrence.breadcrumbs || []),
|
|
676
|
+
occurrence.lastElement ? safeJsonStringify(occurrence.lastElement) : null
|
|
446
677
|
);
|
|
447
678
|
}
|
|
448
679
|
// --- VERIFICATIONS ---
|
|
@@ -454,7 +685,7 @@ var StorageDB = class {
|
|
|
454
685
|
v.id,
|
|
455
686
|
v.incidentId,
|
|
456
687
|
v.status,
|
|
457
|
-
|
|
688
|
+
safeJsonStringify(v.checks),
|
|
458
689
|
v.beforeScreenshot || null,
|
|
459
690
|
v.afterScreenshot || null,
|
|
460
691
|
v.message || null,
|
|
@@ -467,7 +698,7 @@ var StorageDB = class {
|
|
|
467
698
|
return {
|
|
468
699
|
incidentId: row.incident_id,
|
|
469
700
|
status: row.status,
|
|
470
|
-
checks:
|
|
701
|
+
checks: safeJsonParse(row.checks, []),
|
|
471
702
|
screenshots: {
|
|
472
703
|
before: row.before_screenshot || void 0,
|
|
473
704
|
after: row.after_screenshot || void 0
|
|
@@ -492,11 +723,11 @@ var StorageDB = class {
|
|
|
492
723
|
note.message,
|
|
493
724
|
note.route,
|
|
494
725
|
note.url,
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
note.target ?
|
|
498
|
-
note.elementContext ?
|
|
499
|
-
note.region ?
|
|
726
|
+
safeJsonStringify(note.viewport),
|
|
727
|
+
safeJsonStringify(note.scroll),
|
|
728
|
+
note.target ? safeJsonStringify(note.target) : null,
|
|
729
|
+
note.elementContext ? safeJsonStringify(note.elementContext) : null,
|
|
730
|
+
note.region ? safeJsonStringify(note.region) : null,
|
|
500
731
|
note.screenshots?.original || null,
|
|
501
732
|
note.incidentId || null,
|
|
502
733
|
note.scenarioId || null,
|
|
@@ -637,8 +868,8 @@ var StorageDB = class {
|
|
|
637
868
|
`nver_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
|
|
638
869
|
v.noteId,
|
|
639
870
|
v.status,
|
|
640
|
-
|
|
641
|
-
v.geometryDiff ?
|
|
871
|
+
safeJsonStringify(v.checks),
|
|
872
|
+
v.geometryDiff ? safeJsonStringify(v.geometryDiff) : null,
|
|
642
873
|
v.screenshots?.before || null,
|
|
643
874
|
v.screenshots?.after || null,
|
|
644
875
|
v.message || null,
|
|
@@ -651,8 +882,8 @@ var StorageDB = class {
|
|
|
651
882
|
return {
|
|
652
883
|
noteId: row.note_id,
|
|
653
884
|
status: row.status,
|
|
654
|
-
checks:
|
|
655
|
-
geometryDiff:
|
|
885
|
+
checks: safeJsonParse(row.checks, []),
|
|
886
|
+
geometryDiff: safeJsonParse(row.geometry_diff, void 0),
|
|
656
887
|
screenshots: {
|
|
657
888
|
before: row.before_screenshot || void 0,
|
|
658
889
|
after: row.after_screenshot || void 0
|
|
@@ -693,9 +924,9 @@ var StorageDB = class {
|
|
|
693
924
|
occurrences: row.occurrences,
|
|
694
925
|
status: row.status,
|
|
695
926
|
stack: row.stack || void 0,
|
|
696
|
-
breadcrumbs:
|
|
697
|
-
networkFailures:
|
|
698
|
-
lastElement:
|
|
927
|
+
breadcrumbs: safeJsonParse(row.breadcrumbs, []),
|
|
928
|
+
networkFailures: safeJsonParse(row.network_failures, []),
|
|
929
|
+
lastElement: safeJsonParse(row.last_element, void 0),
|
|
699
930
|
screenshots: row.screenshot_path ? {
|
|
700
931
|
error: row.screenshot_path
|
|
701
932
|
} : void 0
|
|
@@ -710,11 +941,11 @@ var StorageDB = class {
|
|
|
710
941
|
message: row.message,
|
|
711
942
|
route: row.route,
|
|
712
943
|
url: row.url,
|
|
713
|
-
viewport:
|
|
714
|
-
scroll:
|
|
715
|
-
target:
|
|
716
|
-
elementContext:
|
|
717
|
-
region:
|
|
944
|
+
viewport: safeJsonParse(row.viewport_json, { width: 0, height: 0, devicePixelRatio: 1 }),
|
|
945
|
+
scroll: safeJsonParse(row.scroll_json, { scrollX: 0, scrollY: 0 }),
|
|
946
|
+
target: safeJsonParse(row.target_json, void 0),
|
|
947
|
+
elementContext: safeJsonParse(row.element_context_json, void 0),
|
|
948
|
+
region: safeJsonParse(row.region_json, void 0),
|
|
718
949
|
status: row.status,
|
|
719
950
|
incidentId: row.incident_id || void 0,
|
|
720
951
|
scenarioId: row.scenario_id || void 0,
|
|
@@ -893,183 +1124,6 @@ var SessionManager = class {
|
|
|
893
1124
|
|
|
894
1125
|
// packages/daemon/src/incidents/engine.ts
|
|
895
1126
|
import crypto from "crypto";
|
|
896
|
-
|
|
897
|
-
// packages/core/src/fingerprint.ts
|
|
898
|
-
function normalizeErrorMessage(message) {
|
|
899
|
-
if (!message) return "unknown_error";
|
|
900
|
-
return message.trim().replace(/0x[0-9a-fA-F]+/g, "0x<HEX>").replace(/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/g, "<UUID>").replace(/\?[tv]=[\w.-]+/g, "").replace(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z?/g, "<TIMESTAMP>").replace(/#\d+/g, "#<ID>").replace(/\s+/g, " ");
|
|
901
|
-
}
|
|
902
|
-
function normalizeSourceFile(filename) {
|
|
903
|
-
if (!filename) return "unknown_source";
|
|
904
|
-
let cleaned = filename.trim();
|
|
905
|
-
try {
|
|
906
|
-
if (cleaned.startsWith("http://") || cleaned.startsWith("https://")) {
|
|
907
|
-
const url = new URL(cleaned);
|
|
908
|
-
cleaned = url.pathname;
|
|
909
|
-
}
|
|
910
|
-
} catch {
|
|
911
|
-
cleaned = cleaned.replace(/^https?:\/\/[^/]+/, "");
|
|
912
|
-
}
|
|
913
|
-
cleaned = cleaned.split("?")[0].split("#")[0];
|
|
914
|
-
cleaned = cleaned.replace(/\\/g, "/");
|
|
915
|
-
return cleaned || "unknown_source";
|
|
916
|
-
}
|
|
917
|
-
function extractSourceFromStack(stack) {
|
|
918
|
-
if (!stack) return null;
|
|
919
|
-
const lines = stack.split("\n");
|
|
920
|
-
for (const line of lines) {
|
|
921
|
-
const match = line.match(/(?:at\s+(?:.*?\s+\()?)?(https?:\/\/[^\s)]+|file:\/\/[^\s)]+|\/[^\s)]+):(\d+):(\d+)\)?/);
|
|
922
|
-
if (match) {
|
|
923
|
-
return {
|
|
924
|
-
file: normalizeSourceFile(match[1]),
|
|
925
|
-
line: parseInt(match[2], 10),
|
|
926
|
-
column: parseInt(match[3], 10)
|
|
927
|
-
};
|
|
928
|
-
}
|
|
929
|
-
}
|
|
930
|
-
return null;
|
|
931
|
-
}
|
|
932
|
-
function djb2Hash(str) {
|
|
933
|
-
let hash = 5381;
|
|
934
|
-
for (let i = 0; i < str.length; i++) {
|
|
935
|
-
hash = hash * 33 ^ str.charCodeAt(i);
|
|
936
|
-
}
|
|
937
|
-
return (hash >>> 0).toString(16).padStart(8, "0");
|
|
938
|
-
}
|
|
939
|
-
function computeFingerprint(input) {
|
|
940
|
-
const normType = (input.type || "Error").trim().toLowerCase();
|
|
941
|
-
const normMsg = normalizeErrorMessage(input.message);
|
|
942
|
-
let sourceFile = normalizeSourceFile(input.sourceFile);
|
|
943
|
-
let line = input.line || 0;
|
|
944
|
-
if ((sourceFile === "unknown_source" || line === 0) && input.stack) {
|
|
945
|
-
const extracted = extractSourceFromStack(input.stack);
|
|
946
|
-
if (extracted) {
|
|
947
|
-
sourceFile = extracted.file;
|
|
948
|
-
line = extracted.line;
|
|
949
|
-
}
|
|
950
|
-
}
|
|
951
|
-
const rawKey = `${normType}::${normMsg}::${sourceFile}::${line}`;
|
|
952
|
-
const hash = djb2Hash(rawKey);
|
|
953
|
-
return `fp_${hash}`;
|
|
954
|
-
}
|
|
955
|
-
|
|
956
|
-
// packages/core/src/redaction.ts
|
|
957
|
-
import { redact as visulimaRedact, standardRules } from "@visulima/redact";
|
|
958
|
-
var REDACTED_PLACEHOLDER = "[REDACTED]";
|
|
959
|
-
var SENSITIVE_KEY_PATTERNS = [
|
|
960
|
-
/^authorization$/i,
|
|
961
|
-
/^cookie$/i,
|
|
962
|
-
/^set-cookie$/i,
|
|
963
|
-
/password/i,
|
|
964
|
-
/token/i,
|
|
965
|
-
/secret/i,
|
|
966
|
-
/api[-_]?key/i,
|
|
967
|
-
/access[-_]?token/i,
|
|
968
|
-
/refresh[-_]?token/i,
|
|
969
|
-
/credentials/i,
|
|
970
|
-
/private[-_]?key/i,
|
|
971
|
-
/ssn/i,
|
|
972
|
-
/credit[-_]?card/i,
|
|
973
|
-
/cvv/i
|
|
974
|
-
];
|
|
975
|
-
var SENSITIVE_QUERY_PARAMS = [
|
|
976
|
-
"token",
|
|
977
|
-
"auth",
|
|
978
|
-
"key",
|
|
979
|
-
"apikey",
|
|
980
|
-
"api_key",
|
|
981
|
-
"secret",
|
|
982
|
-
"password",
|
|
983
|
-
"access_token",
|
|
984
|
-
"refresh_token",
|
|
985
|
-
"code",
|
|
986
|
-
"signature"
|
|
987
|
-
];
|
|
988
|
-
var BROWSER_SECURITY_RULES = [
|
|
989
|
-
{ deep: true, key: "password", replacement: REDACTED_PLACEHOLDER },
|
|
990
|
-
{ deep: true, key: "secret", replacement: REDACTED_PLACEHOLDER },
|
|
991
|
-
{ deep: true, key: "token", replacement: REDACTED_PLACEHOLDER },
|
|
992
|
-
{ deep: true, key: "authorization", replacement: REDACTED_PLACEHOLDER },
|
|
993
|
-
{ deep: true, key: "cookie", replacement: REDACTED_PLACEHOLDER },
|
|
994
|
-
{ deep: true, key: "set-cookie", replacement: REDACTED_PLACEHOLDER },
|
|
995
|
-
{ deep: true, key: "apikey", replacement: REDACTED_PLACEHOLDER },
|
|
996
|
-
{ deep: true, key: "api_key", replacement: REDACTED_PLACEHOLDER },
|
|
997
|
-
{ deep: true, key: "creditcard", pattern: /(?:\d[ -]*?){13,16}/, replacement: REDACTED_PLACEHOLDER },
|
|
998
|
-
{ deep: true, key: "cvv", replacement: REDACTED_PLACEHOLDER },
|
|
999
|
-
{ deep: true, key: "ssn", pattern: /\b\d{3}-\d{2}-\d{4}\b/, replacement: REDACTED_PLACEHOLDER },
|
|
1000
|
-
{ deep: true, key: "awsid", pattern: /\bAKIA[0-9A-Z]{16}\b/, replacement: REDACTED_PLACEHOLDER },
|
|
1001
|
-
{ deep: true, key: "awskey", pattern: /\b[0-9a-zA-Z/+]{40}\b/, replacement: REDACTED_PLACEHOLDER },
|
|
1002
|
-
{ deep: true, key: "jwt", pattern: /\beyJ[0-9a-zA-Z_\-]*\.[0-9a-zA-Z_\-]*\.[0-9a-zA-Z_\-]*\b/, replacement: REDACTED_PLACEHOLDER },
|
|
1003
|
-
{ deep: true, key: "slack_token", pattern: /\bxox[baprs]-[0-9a-zA-Z]{10,48}\b/, replacement: REDACTED_PLACEHOLDER }
|
|
1004
|
-
];
|
|
1005
|
-
function isSensitiveKey(key) {
|
|
1006
|
-
if (!key) return false;
|
|
1007
|
-
const cleaned = key.replace(/[-_]/g, "");
|
|
1008
|
-
return SENSITIVE_KEY_PATTERNS.some((pattern) => pattern.test(key) || pattern.test(cleaned));
|
|
1009
|
-
}
|
|
1010
|
-
function redactUrl(rawUrl) {
|
|
1011
|
-
if (!rawUrl) return rawUrl;
|
|
1012
|
-
try {
|
|
1013
|
-
const isRelative = !rawUrl.startsWith("http://") && !rawUrl.startsWith("https://") && !rawUrl.startsWith("ws://") && !rawUrl.startsWith("wss://");
|
|
1014
|
-
const base = "http://localhost";
|
|
1015
|
-
const parsed = new URL(rawUrl, base);
|
|
1016
|
-
let changed = false;
|
|
1017
|
-
for (const param of SENSITIVE_QUERY_PARAMS) {
|
|
1018
|
-
if (parsed.searchParams.has(param)) {
|
|
1019
|
-
parsed.searchParams.set(param, REDACTED_PLACEHOLDER);
|
|
1020
|
-
changed = true;
|
|
1021
|
-
}
|
|
1022
|
-
}
|
|
1023
|
-
for (const key of Array.from(parsed.searchParams.keys())) {
|
|
1024
|
-
if (isSensitiveKey(key)) {
|
|
1025
|
-
parsed.searchParams.set(key, REDACTED_PLACEHOLDER);
|
|
1026
|
-
changed = true;
|
|
1027
|
-
}
|
|
1028
|
-
}
|
|
1029
|
-
if (!changed) return rawUrl;
|
|
1030
|
-
let result = isRelative ? parsed.pathname + parsed.search + parsed.hash : parsed.toString();
|
|
1031
|
-
result = result.replace(/%5BREDACTED%5D/g, REDACTED_PLACEHOLDER);
|
|
1032
|
-
return result;
|
|
1033
|
-
} catch {
|
|
1034
|
-
let safe = rawUrl;
|
|
1035
|
-
for (const param of SENSITIVE_QUERY_PARAMS) {
|
|
1036
|
-
const reg = new RegExp(`([?&]${param}=)[^&#]+`, "gi");
|
|
1037
|
-
safe = safe.replace(reg, `$1${REDACTED_PLACEHOLDER}`);
|
|
1038
|
-
}
|
|
1039
|
-
return safe;
|
|
1040
|
-
}
|
|
1041
|
-
}
|
|
1042
|
-
function redactSensitiveData(data, maxDepth = 6, currentDepth = 0) {
|
|
1043
|
-
if (data === null || data === void 0) return data;
|
|
1044
|
-
if (typeof data !== "object") return data;
|
|
1045
|
-
if (currentDepth > maxDepth) return "[DEPTH_EXCEEDED]";
|
|
1046
|
-
if (Array.isArray(data)) {
|
|
1047
|
-
return data.map((item) => redactSensitiveData(item, maxDepth, currentDepth + 1));
|
|
1048
|
-
}
|
|
1049
|
-
const result = {};
|
|
1050
|
-
for (const [key, value] of Object.entries(data)) {
|
|
1051
|
-
if (typeof value === "object" && value !== null) {
|
|
1052
|
-
result[key] = redactSensitiveData(value, maxDepth, currentDepth + 1);
|
|
1053
|
-
} else if (isSensitiveKey(key)) {
|
|
1054
|
-
result[key] = REDACTED_PLACEHOLDER;
|
|
1055
|
-
} else if (typeof value === "string") {
|
|
1056
|
-
if (value.startsWith("http://") || value.startsWith("https://") || value.includes("?")) {
|
|
1057
|
-
result[key] = redactUrl(value);
|
|
1058
|
-
} else {
|
|
1059
|
-
try {
|
|
1060
|
-
result[key] = visulimaRedact(value, BROWSER_SECURITY_RULES);
|
|
1061
|
-
} catch {
|
|
1062
|
-
result[key] = value;
|
|
1063
|
-
}
|
|
1064
|
-
}
|
|
1065
|
-
} else {
|
|
1066
|
-
result[key] = value;
|
|
1067
|
-
}
|
|
1068
|
-
}
|
|
1069
|
-
return result;
|
|
1070
|
-
}
|
|
1071
|
-
|
|
1072
|
-
// packages/daemon/src/incidents/engine.ts
|
|
1073
1127
|
var IncidentEngine = class {
|
|
1074
1128
|
db;
|
|
1075
1129
|
screenshotStore;
|
|
@@ -1657,141 +1711,254 @@ var VerificationEngine = class {
|
|
|
1657
1711
|
import fs4 from "fs";
|
|
1658
1712
|
import path5 from "path";
|
|
1659
1713
|
import { fileURLToPath } from "url";
|
|
1660
|
-
function
|
|
1661
|
-
return (
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
activeSessions: sessionManager.getActiveCount(),
|
|
1680
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
1681
|
-
})
|
|
1682
|
-
);
|
|
1683
|
-
return;
|
|
1684
|
-
}
|
|
1685
|
-
if (pathname === "/client.js" || pathname === "/browserdiag.js") {
|
|
1686
|
-
let scriptContent = "";
|
|
1714
|
+
function readJsonBody(req, res, maxSizeBytes = 10 * 1024 * 1024) {
|
|
1715
|
+
return new Promise((resolve, reject) => {
|
|
1716
|
+
let body = "";
|
|
1717
|
+
let isTooLarge = false;
|
|
1718
|
+
req.on("data", (chunk) => {
|
|
1719
|
+
if (isTooLarge) return;
|
|
1720
|
+
body += chunk;
|
|
1721
|
+
if (body.length > maxSizeBytes) {
|
|
1722
|
+
isTooLarge = true;
|
|
1723
|
+
if (!res.headersSent) {
|
|
1724
|
+
res.writeHead(413, { "Content-Type": "application/json" });
|
|
1725
|
+
res.end(JSON.stringify({ ok: false, error: "Payload too large" }));
|
|
1726
|
+
}
|
|
1727
|
+
req.destroy();
|
|
1728
|
+
reject(new Error("Payload too large"));
|
|
1729
|
+
}
|
|
1730
|
+
});
|
|
1731
|
+
req.on("end", () => {
|
|
1732
|
+
if (isTooLarge) return;
|
|
1687
1733
|
try {
|
|
1688
|
-
const
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1734
|
+
const parsed = body ? JSON.parse(body) : {};
|
|
1735
|
+
resolve(parsed);
|
|
1736
|
+
} catch (err) {
|
|
1737
|
+
if (!res.headersSent) {
|
|
1738
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1739
|
+
res.end(JSON.stringify({ ok: false, error: "Malformed JSON payload" }));
|
|
1740
|
+
}
|
|
1741
|
+
reject(err);
|
|
1742
|
+
}
|
|
1743
|
+
});
|
|
1744
|
+
req.on("error", (err) => {
|
|
1745
|
+
reject(err);
|
|
1746
|
+
});
|
|
1747
|
+
});
|
|
1748
|
+
}
|
|
1749
|
+
function createHttpHandler(db, sessionManager, baseScreenshotsDir, verificationEngine, noteVerificationEngine) {
|
|
1750
|
+
return async (req, res) => {
|
|
1751
|
+
try {
|
|
1752
|
+
const parsedUrl = new URL(req.url || "/", `http://${req.headers.host || "127.0.0.1"}`);
|
|
1753
|
+
const pathname = parsedUrl.pathname;
|
|
1754
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
1755
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
1756
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
1757
|
+
if (req.method === "OPTIONS") {
|
|
1758
|
+
res.writeHead(204);
|
|
1759
|
+
res.end();
|
|
1760
|
+
return;
|
|
1761
|
+
}
|
|
1762
|
+
if (pathname === "/health" || pathname === "/") {
|
|
1763
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1764
|
+
res.end(
|
|
1765
|
+
JSON.stringify({
|
|
1766
|
+
status: "ok",
|
|
1767
|
+
name: "browsertrack",
|
|
1768
|
+
version: "0.1.0",
|
|
1769
|
+
activeSessions: sessionManager.getActiveCount(),
|
|
1770
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
1771
|
+
})
|
|
1772
|
+
);
|
|
1773
|
+
return;
|
|
1774
|
+
}
|
|
1775
|
+
if (pathname === "/client.js" || pathname === "/browserdiag.js") {
|
|
1776
|
+
let scriptContent = "";
|
|
1777
|
+
try {
|
|
1778
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
1779
|
+
const __dirname = path5.dirname(__filename);
|
|
1780
|
+
const candidatePaths = [
|
|
1781
|
+
path5.resolve(__dirname, "../client.iife.js"),
|
|
1782
|
+
path5.resolve(__dirname, "../../dist/client.iife.js"),
|
|
1783
|
+
path5.resolve(__dirname, "../../../dist/client.iife.js")
|
|
1784
|
+
];
|
|
1785
|
+
for (const p of candidatePaths) {
|
|
1786
|
+
if (fs4.existsSync(p)) {
|
|
1787
|
+
scriptContent = fs4.readFileSync(p, "utf-8");
|
|
1788
|
+
break;
|
|
1789
|
+
}
|
|
1699
1790
|
}
|
|
1791
|
+
} catch {
|
|
1700
1792
|
}
|
|
1701
|
-
|
|
1793
|
+
if (!scriptContent) {
|
|
1794
|
+
scriptContent = `console.warn("[BrowserTrack] Standalone client bundle not built yet. Run 'npm run build'.");`;
|
|
1795
|
+
}
|
|
1796
|
+
res.writeHead(200, {
|
|
1797
|
+
"Content-Type": "application/javascript; charset=utf-8",
|
|
1798
|
+
"Cache-Control": "no-cache"
|
|
1799
|
+
});
|
|
1800
|
+
res.end(scriptContent);
|
|
1801
|
+
return;
|
|
1702
1802
|
}
|
|
1703
|
-
if (
|
|
1704
|
-
|
|
1803
|
+
if (pathname === "/api/projects") {
|
|
1804
|
+
const projects = db.listProjects();
|
|
1805
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1806
|
+
res.end(JSON.stringify({ ok: true, projects }));
|
|
1807
|
+
return;
|
|
1705
1808
|
}
|
|
1706
|
-
|
|
1707
|
-
"
|
|
1708
|
-
"
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
}
|
|
1713
|
-
if (pathname === "/api/projects") {
|
|
1714
|
-
const projects = db.listProjects();
|
|
1715
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1716
|
-
res.end(JSON.stringify({ ok: true, projects }));
|
|
1717
|
-
return;
|
|
1718
|
-
}
|
|
1719
|
-
if (pathname === "/api/sessions") {
|
|
1720
|
-
const projectId = parsedUrl.searchParams.get("project") || void 0;
|
|
1721
|
-
const activeOnly = parsedUrl.searchParams.get("active") === "true";
|
|
1722
|
-
const sessions = db.listSessions(projectId, activeOnly);
|
|
1723
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1724
|
-
res.end(JSON.stringify({ ok: true, sessions }));
|
|
1725
|
-
return;
|
|
1726
|
-
}
|
|
1727
|
-
if (pathname === "/api/incidents") {
|
|
1728
|
-
const projectId = parsedUrl.searchParams.get("project") || void 0;
|
|
1729
|
-
const status = parsedUrl.searchParams.get("status") || void 0;
|
|
1730
|
-
const limit = parseInt(parsedUrl.searchParams.get("limit") || "50", 10);
|
|
1731
|
-
const incidents = db.listIncidents({ projectId, status, limit });
|
|
1732
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1733
|
-
res.end(JSON.stringify({ ok: true, incidents }));
|
|
1734
|
-
return;
|
|
1735
|
-
}
|
|
1736
|
-
if (pathname.startsWith("/api/incidents/")) {
|
|
1737
|
-
const incidentId = pathname.replace("/api/incidents/", "");
|
|
1738
|
-
const incident = db.getIncident(incidentId);
|
|
1739
|
-
if (!incident) {
|
|
1740
|
-
res.writeHead(404, { "Content-Type": "application/json" });
|
|
1741
|
-
res.end(JSON.stringify({ ok: false, error: "Incident not found" }));
|
|
1809
|
+
if (pathname === "/api/sessions") {
|
|
1810
|
+
const projectId = parsedUrl.searchParams.get("project") || void 0;
|
|
1811
|
+
const activeOnly = parsedUrl.searchParams.get("active") === "true";
|
|
1812
|
+
const sessions = db.listSessions(projectId, activeOnly);
|
|
1813
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1814
|
+
res.end(JSON.stringify({ ok: true, sessions }));
|
|
1742
1815
|
return;
|
|
1743
1816
|
}
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1817
|
+
if (pathname === "/api/incidents") {
|
|
1818
|
+
const projectId = parsedUrl.searchParams.get("project") || void 0;
|
|
1819
|
+
const status = parsedUrl.searchParams.get("status") || void 0;
|
|
1820
|
+
const limit = parseInt(parsedUrl.searchParams.get("limit") || "50", 10);
|
|
1821
|
+
const incidents = db.listIncidents({ projectId, status, limit });
|
|
1822
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1823
|
+
res.end(JSON.stringify({ ok: true, incidents }));
|
|
1824
|
+
return;
|
|
1825
|
+
}
|
|
1826
|
+
if (pathname.startsWith("/api/incidents/")) {
|
|
1827
|
+
const incidentId = pathname.replace("/api/incidents/", "");
|
|
1828
|
+
const incident = db.getIncident(incidentId);
|
|
1829
|
+
if (!incident) {
|
|
1830
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
1831
|
+
res.end(JSON.stringify({ ok: false, error: "Incident not found" }));
|
|
1832
|
+
return;
|
|
1833
|
+
}
|
|
1834
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1835
|
+
res.end(JSON.stringify({ ok: true, incident }));
|
|
1836
|
+
return;
|
|
1837
|
+
}
|
|
1838
|
+
if (pathname === "/api/notes") {
|
|
1839
|
+
const projectId = parsedUrl.searchParams.get("project") || void 0;
|
|
1840
|
+
const status = parsedUrl.searchParams.get("status") || void 0;
|
|
1841
|
+
const limit = parseInt(parsedUrl.searchParams.get("limit") || "50", 10);
|
|
1842
|
+
const notes = db.listNotes({ projectId, status, limit });
|
|
1843
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1844
|
+
res.end(JSON.stringify({ ok: true, notes }));
|
|
1845
|
+
return;
|
|
1846
|
+
}
|
|
1847
|
+
if (pathname.startsWith("/api/notes/")) {
|
|
1848
|
+
const noteId = pathname.replace("/api/notes/", "");
|
|
1849
|
+
const note = db.getNote(noteId);
|
|
1850
|
+
if (!note) {
|
|
1851
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
1852
|
+
res.end(JSON.stringify({ ok: false, error: "Note not found" }));
|
|
1853
|
+
return;
|
|
1854
|
+
}
|
|
1855
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1856
|
+
res.end(JSON.stringify({ ok: true, note }));
|
|
1857
|
+
return;
|
|
1858
|
+
}
|
|
1859
|
+
if (pathname.startsWith("/screenshots/")) {
|
|
1860
|
+
const relativePath = pathname.replace("/screenshots/", "");
|
|
1861
|
+
const filePath = path5.join(baseScreenshotsDir, relativePath);
|
|
1862
|
+
if (fs4.existsSync(filePath) && fs4.statSync(filePath).isFile()) {
|
|
1863
|
+
const ext = path5.extname(filePath).toLowerCase();
|
|
1864
|
+
const mimeTypes = {
|
|
1865
|
+
".webp": "image/webp",
|
|
1866
|
+
".png": "image/png",
|
|
1867
|
+
".jpg": "image/jpeg",
|
|
1868
|
+
".jpeg": "image/jpeg"
|
|
1869
|
+
};
|
|
1870
|
+
res.writeHead(200, { "Content-Type": mimeTypes[ext] || "application/octet-stream" });
|
|
1871
|
+
fs4.createReadStream(filePath).pipe(res);
|
|
1872
|
+
return;
|
|
1873
|
+
}
|
|
1761
1874
|
res.writeHead(404, { "Content-Type": "application/json" });
|
|
1762
|
-
res.end(JSON.stringify({ ok: false, error: "
|
|
1875
|
+
res.end(JSON.stringify({ ok: false, error: "Screenshot not found" }));
|
|
1763
1876
|
return;
|
|
1764
1877
|
}
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1878
|
+
if (pathname === "/api/command" && req.method === "POST") {
|
|
1879
|
+
try {
|
|
1880
|
+
const data = await readJsonBody(req, res);
|
|
1881
|
+
const sessionId = data.sessionId || sessionManager.getAnyActiveSession()?.id;
|
|
1882
|
+
if (!sessionId) {
|
|
1883
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1884
|
+
res.end(JSON.stringify({ ok: false, error: "No active browser session connected" }));
|
|
1885
|
+
return;
|
|
1886
|
+
}
|
|
1887
|
+
const cmdRes = await sessionManager.sendCommand(sessionId, data.command, data.timeoutMs || 5e3);
|
|
1888
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1889
|
+
res.end(JSON.stringify(cmdRes));
|
|
1890
|
+
} catch (err) {
|
|
1891
|
+
if (!res.headersSent) {
|
|
1892
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
1893
|
+
res.end(JSON.stringify({ ok: false, error: err?.message || String(err) }));
|
|
1894
|
+
}
|
|
1895
|
+
}
|
|
1896
|
+
return;
|
|
1897
|
+
}
|
|
1898
|
+
if (pathname === "/api/verify/incident" && req.method === "POST") {
|
|
1899
|
+
try {
|
|
1900
|
+
if (!verificationEngine) {
|
|
1901
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
1902
|
+
res.end(JSON.stringify({ ok: false, error: "Verification engine not attached to daemon" }));
|
|
1903
|
+
return;
|
|
1904
|
+
}
|
|
1905
|
+
const data = await readJsonBody(req, res);
|
|
1906
|
+
const result = await verificationEngine.verifyIncident(data.incidentId, data.options);
|
|
1907
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1908
|
+
res.end(JSON.stringify({ ok: true, result }));
|
|
1909
|
+
} catch (err) {
|
|
1910
|
+
if (!res.headersSent) {
|
|
1911
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
1912
|
+
res.end(JSON.stringify({ ok: false, error: err?.message || String(err) }));
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
1915
|
+
return;
|
|
1916
|
+
}
|
|
1917
|
+
if (pathname === "/api/verify/note" && req.method === "POST") {
|
|
1918
|
+
try {
|
|
1919
|
+
if (!noteVerificationEngine) {
|
|
1920
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
1921
|
+
res.end(JSON.stringify({ ok: false, error: "Note verification engine not attached to daemon" }));
|
|
1922
|
+
return;
|
|
1923
|
+
}
|
|
1924
|
+
const data = await readJsonBody(req, res);
|
|
1925
|
+
const result = await noteVerificationEngine.verifyNote(data.noteId, data.options);
|
|
1926
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1927
|
+
res.end(JSON.stringify({ ok: true, result }));
|
|
1928
|
+
} catch (err) {
|
|
1929
|
+
if (!res.headersSent) {
|
|
1930
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
1931
|
+
res.end(JSON.stringify({ ok: false, error: err?.message || String(err) }));
|
|
1932
|
+
}
|
|
1933
|
+
}
|
|
1782
1934
|
return;
|
|
1783
1935
|
}
|
|
1784
1936
|
res.writeHead(404, { "Content-Type": "application/json" });
|
|
1785
|
-
res.end(JSON.stringify({ ok: false, error: "
|
|
1786
|
-
|
|
1937
|
+
res.end(JSON.stringify({ ok: false, error: "Not found" }));
|
|
1938
|
+
} catch (err) {
|
|
1939
|
+
if (!res.headersSent) {
|
|
1940
|
+
try {
|
|
1941
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
1942
|
+
res.end(JSON.stringify({ ok: false, error: err?.message || "Internal Server Error" }));
|
|
1943
|
+
} catch {
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1787
1946
|
}
|
|
1788
|
-
res.writeHead(404, { "Content-Type": "application/json" });
|
|
1789
|
-
res.end(JSON.stringify({ ok: false, error: "Not found" }));
|
|
1790
1947
|
};
|
|
1791
1948
|
}
|
|
1792
1949
|
|
|
1793
1950
|
// packages/daemon/src/server/ws.ts
|
|
1794
1951
|
import crypto5 from "crypto";
|
|
1952
|
+
function safeWsSend(ws, payload) {
|
|
1953
|
+
if (ws.readyState !== 1) return false;
|
|
1954
|
+
try {
|
|
1955
|
+
const raw = typeof payload === "string" ? payload : safeJsonStringify(payload);
|
|
1956
|
+
ws.send(raw);
|
|
1957
|
+
return true;
|
|
1958
|
+
} catch {
|
|
1959
|
+
return false;
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1795
1962
|
function setupWebSocketServer(wss, db, sessionManager, incidentEngine, notesEngine, maxEventsPerSession = 1e3, verbose = false) {
|
|
1796
1963
|
wss.on("connection", (ws) => {
|
|
1797
1964
|
let currentSessionId = null;
|
|
@@ -1834,21 +2001,17 @@ function setupWebSocketServer(wss, db, sessionManager, incidentEngine, notesEngi
|
|
|
1834
2001
|
active: true
|
|
1835
2002
|
});
|
|
1836
2003
|
sessionManager.registerSocket(sessionId, ws, hello.origin || "", project.id);
|
|
1837
|
-
ws
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
})
|
|
1844
|
-
);
|
|
2004
|
+
safeWsSend(ws, {
|
|
2005
|
+
type: "hello_ack",
|
|
2006
|
+
sessionId,
|
|
2007
|
+
projectId: project.id,
|
|
2008
|
+
projectName: project.name
|
|
2009
|
+
});
|
|
1845
2010
|
const existingNotes = db.listNotes({ projectId: project.id, limit: 100 });
|
|
1846
|
-
ws
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
})
|
|
1851
|
-
);
|
|
2011
|
+
safeWsSend(ws, {
|
|
2012
|
+
type: "notes_sync",
|
|
2013
|
+
notes: existingNotes
|
|
2014
|
+
});
|
|
1852
2015
|
if (verbose) {
|
|
1853
2016
|
console.log(`[BrowserTrack] New session connected: ${sessionId} (${project.name} @ ${hello.origin})`);
|
|
1854
2017
|
}
|
|
@@ -1900,15 +2063,13 @@ function setupWebSocketServer(wss, db, sessionManager, incidentEngine, notesEngi
|
|
|
1900
2063
|
`[BrowserTrack] Visual note created: ${note.id} on ${note.route} ("${note.message}")${note.scenarioId ? ` [Scenario: ${note.scenarioTitle || note.scenarioId} Step ${note.stepNumber}]` : ""}`
|
|
1901
2064
|
);
|
|
1902
2065
|
}
|
|
1903
|
-
ws
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
})
|
|
1911
|
-
);
|
|
2066
|
+
safeWsSend(ws, {
|
|
2067
|
+
type: "note_created_ack",
|
|
2068
|
+
noteId: note.id,
|
|
2069
|
+
status: note.status,
|
|
2070
|
+
scenarioId: note.scenarioId,
|
|
2071
|
+
stepNumber: note.stepNumber
|
|
2072
|
+
});
|
|
1912
2073
|
const allNotes = db.listNotes({ projectId: note.projectId, limit: 100 });
|
|
1913
2074
|
sessionManager.broadcastToProject(note.projectId, {
|
|
1914
2075
|
type: "notes_sync",
|
|
@@ -1969,12 +2130,10 @@ function setupWebSocketServer(wss, db, sessionManager, incidentEngine, notesEngi
|
|
|
1969
2130
|
const projectId = data.projectId || session?.projectId;
|
|
1970
2131
|
if (projectId) {
|
|
1971
2132
|
const allNotes = db.listNotes({ projectId, limit: 100 });
|
|
1972
|
-
ws
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
})
|
|
1977
|
-
);
|
|
2133
|
+
safeWsSend(ws, {
|
|
2134
|
+
type: "notes_sync",
|
|
2135
|
+
notes: allNotes
|
|
2136
|
+
});
|
|
1978
2137
|
}
|
|
1979
2138
|
return;
|
|
1980
2139
|
}
|
|
@@ -2035,7 +2194,13 @@ var BrowserTrackDaemon = class {
|
|
|
2035
2194
|
}
|
|
2036
2195
|
async start() {
|
|
2037
2196
|
if (this.isRunning) return;
|
|
2038
|
-
const httpHandler = createHttpHandler(
|
|
2197
|
+
const httpHandler = createHttpHandler(
|
|
2198
|
+
this.db,
|
|
2199
|
+
this.sessionManager,
|
|
2200
|
+
this.config.screenshotsDir,
|
|
2201
|
+
this.verificationEngine,
|
|
2202
|
+
this.noteVerificationEngine
|
|
2203
|
+
);
|
|
2039
2204
|
this.httpServer = http.createServer(httpHandler);
|
|
2040
2205
|
this.wss = new WebSocketServer({ server: this.httpServer });
|
|
2041
2206
|
setupWebSocketServer(
|
|
@@ -2106,11 +2271,117 @@ function createDaemon(config = {}) {
|
|
|
2106
2271
|
}
|
|
2107
2272
|
|
|
2108
2273
|
// packages/mcp/src/server.ts
|
|
2274
|
+
import fs5 from "fs";
|
|
2275
|
+
import path6 from "path";
|
|
2276
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
2277
|
+
import { spawn } from "child_process";
|
|
2109
2278
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
2110
2279
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
2111
2280
|
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
2112
2281
|
|
|
2113
2282
|
// packages/mcp/src/handlers.ts
|
|
2283
|
+
async function sendSessionCommand(ctx, sessionId, command, timeoutMs = 5e3) {
|
|
2284
|
+
if (ctx.daemonInitPromise) {
|
|
2285
|
+
try {
|
|
2286
|
+
await Promise.race([ctx.daemonInitPromise, new Promise((r) => setTimeout(r, 2e3))]);
|
|
2287
|
+
} catch {
|
|
2288
|
+
}
|
|
2289
|
+
}
|
|
2290
|
+
if (ctx.sessionManager && ctx.sessionManager.getActiveCount() > 0) {
|
|
2291
|
+
const targetSession = sessionId ? ctx.db.getSession(sessionId) : ctx.sessionManager.getAnyActiveSession();
|
|
2292
|
+
if (targetSession) {
|
|
2293
|
+
const res = await ctx.sessionManager.sendCommand(targetSession.id, command, timeoutMs);
|
|
2294
|
+
if (!res.ok) {
|
|
2295
|
+
throw new Error(res.error || res.reason || `Command ${command.type} failed`);
|
|
2296
|
+
}
|
|
2297
|
+
return res;
|
|
2298
|
+
}
|
|
2299
|
+
}
|
|
2300
|
+
if (ctx.daemonUrl) {
|
|
2301
|
+
try {
|
|
2302
|
+
const resp = await fetch(`${ctx.daemonUrl}/api/command`, {
|
|
2303
|
+
method: "POST",
|
|
2304
|
+
headers: { "Content-Type": "application/json" },
|
|
2305
|
+
body: JSON.stringify({ sessionId, command, timeoutMs }),
|
|
2306
|
+
signal: AbortSignal.timeout(timeoutMs + 2e3)
|
|
2307
|
+
});
|
|
2308
|
+
if (resp.ok) {
|
|
2309
|
+
const res = await resp.json();
|
|
2310
|
+
if (!res.ok) {
|
|
2311
|
+
throw new Error(res.error || res.reason || `Command ${command.type} failed`);
|
|
2312
|
+
}
|
|
2313
|
+
return res;
|
|
2314
|
+
}
|
|
2315
|
+
const errJson = await resp.json().catch(() => ({}));
|
|
2316
|
+
if (errJson.error) {
|
|
2317
|
+
throw new Error(errJson.error);
|
|
2318
|
+
}
|
|
2319
|
+
} catch (err) {
|
|
2320
|
+
if (err.message && !err.message.includes("fetch failed") && !err.message.includes("ECONNREFUSED")) {
|
|
2321
|
+
throw err;
|
|
2322
|
+
}
|
|
2323
|
+
}
|
|
2324
|
+
}
|
|
2325
|
+
throw new Error(
|
|
2326
|
+
'No active browser session connected. Please ensure the BrowserTrack daemon is running ("browsertrack start") and your application tab is open in the browser.'
|
|
2327
|
+
);
|
|
2328
|
+
}
|
|
2329
|
+
async function runVerifyIncident(ctx, incidentId, options) {
|
|
2330
|
+
if (ctx.verificationEngine && ctx.sessionManager && ctx.sessionManager.getActiveCount() > 0) {
|
|
2331
|
+
return await ctx.verificationEngine.verifyIncident(incidentId, options);
|
|
2332
|
+
}
|
|
2333
|
+
if (ctx.daemonUrl) {
|
|
2334
|
+
try {
|
|
2335
|
+
const timeoutMs = (options?.observationWindowMs || 3e3) + 7e3;
|
|
2336
|
+
const resp = await fetch(`${ctx.daemonUrl}/api/verify/incident`, {
|
|
2337
|
+
method: "POST",
|
|
2338
|
+
headers: { "Content-Type": "application/json" },
|
|
2339
|
+
body: JSON.stringify({ incidentId, options }),
|
|
2340
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
2341
|
+
});
|
|
2342
|
+
if (resp.ok) {
|
|
2343
|
+
const data = await resp.json();
|
|
2344
|
+
if (data.ok) return data.result;
|
|
2345
|
+
throw new Error(data.error);
|
|
2346
|
+
}
|
|
2347
|
+
} catch (err) {
|
|
2348
|
+
if (err.message && !err.message.includes("fetch failed") && !err.message.includes("ECONNREFUSED")) {
|
|
2349
|
+
throw err;
|
|
2350
|
+
}
|
|
2351
|
+
}
|
|
2352
|
+
}
|
|
2353
|
+
throw new Error(
|
|
2354
|
+
'Verification failed: No active browser session connected. Please ensure the BrowserTrack daemon is running ("browsertrack start") and your application tab is open in the browser.'
|
|
2355
|
+
);
|
|
2356
|
+
}
|
|
2357
|
+
async function runVerifyNote(ctx, noteId, options) {
|
|
2358
|
+
if (ctx.noteVerificationEngine && ctx.sessionManager && ctx.sessionManager.getActiveCount() > 0) {
|
|
2359
|
+
return await ctx.noteVerificationEngine.verifyNote(noteId, options);
|
|
2360
|
+
}
|
|
2361
|
+
if (ctx.daemonUrl) {
|
|
2362
|
+
try {
|
|
2363
|
+
const timeoutMs = (options?.observationWindowMs || 3e3) + 7e3;
|
|
2364
|
+
const resp = await fetch(`${ctx.daemonUrl}/api/verify/note`, {
|
|
2365
|
+
method: "POST",
|
|
2366
|
+
headers: { "Content-Type": "application/json" },
|
|
2367
|
+
body: JSON.stringify({ noteId, options }),
|
|
2368
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
2369
|
+
});
|
|
2370
|
+
if (resp.ok) {
|
|
2371
|
+
const data = await resp.json();
|
|
2372
|
+
if (data.ok) return data.result;
|
|
2373
|
+
throw new Error(data.error);
|
|
2374
|
+
}
|
|
2375
|
+
} catch (err) {
|
|
2376
|
+
if (err.message && !err.message.includes("fetch failed") && !err.message.includes("ECONNREFUSED")) {
|
|
2377
|
+
throw err;
|
|
2378
|
+
}
|
|
2379
|
+
}
|
|
2380
|
+
}
|
|
2381
|
+
throw new Error(
|
|
2382
|
+
'Verification failed: No active browser session connected. Please ensure the BrowserTrack daemon is running ("browsertrack start") and your application tab is open in the browser.'
|
|
2383
|
+
);
|
|
2384
|
+
}
|
|
2114
2385
|
async function handleToolCall(name, args, ctx) {
|
|
2115
2386
|
const { db, sessionManager, verificationEngine, noteVerificationEngine } = ctx;
|
|
2116
2387
|
switch (name) {
|
|
@@ -2260,38 +2531,18 @@ async function handleToolCall(name, args, ctx) {
|
|
|
2260
2531
|
};
|
|
2261
2532
|
}
|
|
2262
2533
|
case "get_page_state": {
|
|
2263
|
-
|
|
2264
|
-
throw new Error("Live browser connection not available: Daemon session manager not attached.");
|
|
2265
|
-
}
|
|
2266
|
-
let session = args.sessionId ? db.getSession(args.sessionId) : sessionManager.getAnyActiveSession();
|
|
2267
|
-
if (!session) {
|
|
2268
|
-
throw new Error("No active browser session connected.");
|
|
2269
|
-
}
|
|
2270
|
-
const cmdRes = await sessionManager.sendCommand(session.id, {
|
|
2534
|
+
const cmdRes = await sendSessionCommand(ctx, args.sessionId, {
|
|
2271
2535
|
id: `cmd_mcp_${Date.now()}`,
|
|
2272
2536
|
type: "get_page_state"
|
|
2273
2537
|
});
|
|
2274
|
-
if (!cmdRes.ok) {
|
|
2275
|
-
throw new Error(cmdRes.error || "Failed to retrieve page state from browser.");
|
|
2276
|
-
}
|
|
2277
2538
|
return cmdRes.result;
|
|
2278
2539
|
}
|
|
2279
2540
|
case "capture_element": {
|
|
2280
|
-
|
|
2281
|
-
throw new Error("Live browser connection not available: Daemon session manager not attached.");
|
|
2282
|
-
}
|
|
2283
|
-
let session = args.sessionId ? db.getSession(args.sessionId) : sessionManager.getAnyActiveSession();
|
|
2284
|
-
if (!session) {
|
|
2285
|
-
throw new Error("No active browser session connected.");
|
|
2286
|
-
}
|
|
2287
|
-
const cmdRes = await sessionManager.sendCommand(session.id, {
|
|
2541
|
+
const cmdRes = await sendSessionCommand(ctx, args.sessionId, {
|
|
2288
2542
|
id: `cmd_mcp_${Date.now()}`,
|
|
2289
2543
|
type: "capture_element",
|
|
2290
2544
|
params: { selector: args.selector }
|
|
2291
2545
|
});
|
|
2292
|
-
if (!cmdRes.ok) {
|
|
2293
|
-
throw new Error(cmdRes.error || cmdRes.reason || "Failed to capture element screenshot.");
|
|
2294
|
-
}
|
|
2295
2546
|
return {
|
|
2296
2547
|
ok: true,
|
|
2297
2548
|
format: cmdRes.result?.format || "webp",
|
|
@@ -2301,10 +2552,7 @@ async function handleToolCall(name, args, ctx) {
|
|
|
2301
2552
|
};
|
|
2302
2553
|
}
|
|
2303
2554
|
case "verify_incident": {
|
|
2304
|
-
|
|
2305
|
-
throw new Error("Verification engine not available: Daemon session manager not attached.");
|
|
2306
|
-
}
|
|
2307
|
-
const res = await verificationEngine.verifyIncident(args.incidentId, {
|
|
2555
|
+
const res = await runVerifyIncident(ctx, args.incidentId, {
|
|
2308
2556
|
route: args.route,
|
|
2309
2557
|
targetSelector: args.targetSelector,
|
|
2310
2558
|
expect: args.expect,
|
|
@@ -2455,10 +2703,7 @@ async function handleToolCall(name, args, ctx) {
|
|
|
2455
2703
|
};
|
|
2456
2704
|
}
|
|
2457
2705
|
case "verify_note": {
|
|
2458
|
-
|
|
2459
|
-
throw new Error("Note verification engine not available: Daemon session manager not attached.");
|
|
2460
|
-
}
|
|
2461
|
-
const res = await noteVerificationEngine.verifyNote(args.noteId, {
|
|
2706
|
+
const res = await runVerifyNote(ctx, args.noteId, {
|
|
2462
2707
|
observationWindowMs: args.observationWindowMs
|
|
2463
2708
|
});
|
|
2464
2709
|
return res;
|
|
@@ -2471,24 +2716,17 @@ async function handleToolCall(name, args, ctx) {
|
|
|
2471
2716
|
return v;
|
|
2472
2717
|
}
|
|
2473
2718
|
case "capture_note_context": {
|
|
2474
|
-
|
|
2475
|
-
throw new Error("Live browser connection not available: Daemon session manager not attached.");
|
|
2476
|
-
}
|
|
2477
|
-
let session = args.sessionId ? db.getSession(args.sessionId) : sessionManager.getAnyActiveSession();
|
|
2478
|
-
if (!session) {
|
|
2479
|
-
throw new Error("No active browser session connected.");
|
|
2480
|
-
}
|
|
2481
|
-
const queryCmd = await sessionManager.sendCommand(session.id, {
|
|
2719
|
+
const queryCmd = await sendSessionCommand(ctx, args.sessionId, {
|
|
2482
2720
|
id: `cmd_ctx_${Date.now()}`,
|
|
2483
2721
|
type: "query_element",
|
|
2484
2722
|
params: { selector: args.selector }
|
|
2485
2723
|
});
|
|
2486
|
-
const overflowCmd = await
|
|
2724
|
+
const overflowCmd = await sendSessionCommand(ctx, args.sessionId, {
|
|
2487
2725
|
id: `cmd_ovf_${Date.now()}`,
|
|
2488
2726
|
type: "check_overflow",
|
|
2489
2727
|
params: { selector: args.selector }
|
|
2490
2728
|
});
|
|
2491
|
-
const styleCmd = await
|
|
2729
|
+
const styleCmd = await sendSessionCommand(ctx, args.sessionId, {
|
|
2492
2730
|
id: `cmd_sty_${Date.now()}`,
|
|
2493
2731
|
type: "get_element_style",
|
|
2494
2732
|
params: { selector: args.selector }
|
|
@@ -2770,8 +3008,76 @@ var TOOLS = [
|
|
|
2770
3008
|
];
|
|
2771
3009
|
|
|
2772
3010
|
// packages/mcp/src/server.ts
|
|
3011
|
+
async function isDaemonRunning(host, port) {
|
|
3012
|
+
try {
|
|
3013
|
+
const res = await fetch(`http://${host}:${port}/health`, {
|
|
3014
|
+
signal: AbortSignal.timeout(600)
|
|
3015
|
+
});
|
|
3016
|
+
if (res.ok) {
|
|
3017
|
+
const data = await res.json().catch(() => ({}));
|
|
3018
|
+
return data?.name === "browsertrack";
|
|
3019
|
+
}
|
|
3020
|
+
} catch {
|
|
3021
|
+
}
|
|
3022
|
+
return false;
|
|
3023
|
+
}
|
|
3024
|
+
function resolveCliPath() {
|
|
3025
|
+
if (process.argv[1]) {
|
|
3026
|
+
const candidate = process.argv[1];
|
|
3027
|
+
if (candidate.endsWith("cli/index.js") || candidate.endsWith("browsertrack") || candidate.endsWith("bin/browsertrack.js") || candidate.endsWith("dist/cli/index.js")) {
|
|
3028
|
+
if (fs5.existsSync(candidate)) return candidate;
|
|
3029
|
+
}
|
|
3030
|
+
}
|
|
3031
|
+
try {
|
|
3032
|
+
const currentDir = path6.dirname(fileURLToPath2(import.meta.url));
|
|
3033
|
+
const paths = [
|
|
3034
|
+
path6.resolve(currentDir, "../cli/index.js"),
|
|
3035
|
+
path6.resolve(currentDir, "../../cli/index.js"),
|
|
3036
|
+
path6.resolve(currentDir, "../../dist/cli/index.js")
|
|
3037
|
+
];
|
|
3038
|
+
for (const p of paths) {
|
|
3039
|
+
if (fs5.existsSync(p)) return p;
|
|
3040
|
+
}
|
|
3041
|
+
} catch {
|
|
3042
|
+
}
|
|
3043
|
+
return null;
|
|
3044
|
+
}
|
|
3045
|
+
function acquireBootLock(lockFile) {
|
|
3046
|
+
try {
|
|
3047
|
+
fs5.mkdirSync(path6.dirname(lockFile), { recursive: true });
|
|
3048
|
+
const fd = fs5.openSync(lockFile, "wx");
|
|
3049
|
+
fs5.writeSync(fd, String(process.pid));
|
|
3050
|
+
fs5.closeSync(fd);
|
|
3051
|
+
return true;
|
|
3052
|
+
} catch {
|
|
3053
|
+
try {
|
|
3054
|
+
const stats = fs5.statSync(lockFile);
|
|
3055
|
+
if (Date.now() - stats.mtimeMs > 5e3) {
|
|
3056
|
+
fs5.unlinkSync(lockFile);
|
|
3057
|
+
const fd = fs5.openSync(lockFile, "wx");
|
|
3058
|
+
fs5.writeSync(fd, String(process.pid));
|
|
3059
|
+
fs5.closeSync(fd);
|
|
3060
|
+
return true;
|
|
3061
|
+
}
|
|
3062
|
+
} catch {
|
|
3063
|
+
}
|
|
3064
|
+
return false;
|
|
3065
|
+
}
|
|
3066
|
+
}
|
|
3067
|
+
function releaseBootLock(lockFile) {
|
|
3068
|
+
try {
|
|
3069
|
+
if (fs5.existsSync(lockFile)) {
|
|
3070
|
+
fs5.unlinkSync(lockFile);
|
|
3071
|
+
}
|
|
3072
|
+
} catch {
|
|
3073
|
+
}
|
|
3074
|
+
}
|
|
2773
3075
|
function createMcpServer(options = {}) {
|
|
2774
|
-
const config = getDaemonConfig({
|
|
3076
|
+
const config = getDaemonConfig({
|
|
3077
|
+
dbPath: options.dbPath,
|
|
3078
|
+
port: options.port,
|
|
3079
|
+
host: options.host
|
|
3080
|
+
});
|
|
2775
3081
|
const db = options.context?.db || new StorageDB(config.dbPath);
|
|
2776
3082
|
const screenshotStore = new ScreenshotStore(config.screenshotsDir);
|
|
2777
3083
|
const sessionManager = options.context?.sessionManager || new SessionManager(db);
|
|
@@ -2786,6 +3092,103 @@ function createMcpServer(options = {}) {
|
|
|
2786
3092
|
daemonUrl: `http://${config.host}:${config.port}`,
|
|
2787
3093
|
...options.context
|
|
2788
3094
|
};
|
|
3095
|
+
let embeddedDaemon = null;
|
|
3096
|
+
async function ensureDaemon() {
|
|
3097
|
+
if (options.autoStartDaemon === false || options.context?.sessionManager) {
|
|
3098
|
+
return;
|
|
3099
|
+
}
|
|
3100
|
+
if (await isDaemonRunning(config.host, config.port)) {
|
|
3101
|
+
console.error(`[BrowserTrack MCP] Connected to active singleton daemon at http://${config.host}:${config.port}`);
|
|
3102
|
+
return;
|
|
3103
|
+
}
|
|
3104
|
+
const lockFile = path6.join(config.dataDir, "daemon_boot.lock");
|
|
3105
|
+
const hasLock = acquireBootLock(lockFile);
|
|
3106
|
+
if (!hasLock) {
|
|
3107
|
+
for (let i = 0; i < 30; i++) {
|
|
3108
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
3109
|
+
if (await isDaemonRunning(config.host, config.port)) {
|
|
3110
|
+
console.error(`[BrowserTrack MCP] Connected to shared singleton daemon at http://${config.host}:${config.port}`);
|
|
3111
|
+
return;
|
|
3112
|
+
}
|
|
3113
|
+
}
|
|
3114
|
+
}
|
|
3115
|
+
try {
|
|
3116
|
+
if (await isDaemonRunning(config.host, config.port)) {
|
|
3117
|
+
console.error(`[BrowserTrack MCP] Connected to shared singleton daemon at http://${config.host}:${config.port}`);
|
|
3118
|
+
return;
|
|
3119
|
+
}
|
|
3120
|
+
if (options.detached !== false) {
|
|
3121
|
+
const cliPath = resolveCliPath();
|
|
3122
|
+
if (cliPath && fs5.existsSync(cliPath)) {
|
|
3123
|
+
try {
|
|
3124
|
+
const nodePaths = [
|
|
3125
|
+
"/usr/local/bin",
|
|
3126
|
+
"/opt/homebrew/bin",
|
|
3127
|
+
process.env.HOME ? `${process.env.HOME}/.nvm/versions/node/v20.19.5/bin` : "",
|
|
3128
|
+
process.env.HOME ? `${process.env.HOME}/.nvm/versions/node/v23.1.0/bin` : ""
|
|
3129
|
+
].filter(Boolean);
|
|
3130
|
+
const extendedPath = `${process.env.PATH || ""}:${nodePaths.join(":")}`;
|
|
3131
|
+
const child = spawn(
|
|
3132
|
+
process.execPath,
|
|
3133
|
+
[cliPath, "start", "--port", String(config.port), "--host", config.host],
|
|
3134
|
+
{
|
|
3135
|
+
detached: true,
|
|
3136
|
+
stdio: "ignore",
|
|
3137
|
+
env: { ...process.env, PATH: extendedPath, BROWSERTRACK_DAEMON_DETACHED: "1" }
|
|
3138
|
+
}
|
|
3139
|
+
);
|
|
3140
|
+
child.unref();
|
|
3141
|
+
for (let i = 0; i < 30; i++) {
|
|
3142
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
3143
|
+
if (await isDaemonRunning(config.host, config.port)) {
|
|
3144
|
+
console.error(
|
|
3145
|
+
`[BrowserTrack MCP] Started singleton background daemon on http://${config.host}:${config.port}`
|
|
3146
|
+
);
|
|
3147
|
+
return;
|
|
3148
|
+
}
|
|
3149
|
+
}
|
|
3150
|
+
} catch (spawnErr) {
|
|
3151
|
+
console.error(
|
|
3152
|
+
`[BrowserTrack MCP] Detached daemon spawn failed (${spawnErr?.message}), falling back to in-process daemon.`
|
|
3153
|
+
);
|
|
3154
|
+
}
|
|
3155
|
+
}
|
|
3156
|
+
}
|
|
3157
|
+
embeddedDaemon = createDaemon({
|
|
3158
|
+
host: config.host,
|
|
3159
|
+
port: config.port,
|
|
3160
|
+
dbPath: config.dbPath,
|
|
3161
|
+
screenshotsDir: config.screenshotsDir,
|
|
3162
|
+
verbose: false
|
|
3163
|
+
});
|
|
3164
|
+
await embeddedDaemon.start();
|
|
3165
|
+
console.error(`[BrowserTrack MCP] Started singleton daemon on http://${config.host}:${config.port}`);
|
|
3166
|
+
ctx.db = embeddedDaemon.db;
|
|
3167
|
+
ctx.sessionManager = embeddedDaemon.sessionManager;
|
|
3168
|
+
ctx.verificationEngine = embeddedDaemon.verificationEngine;
|
|
3169
|
+
ctx.noteVerificationEngine = embeddedDaemon.noteVerificationEngine;
|
|
3170
|
+
const cleanup = () => {
|
|
3171
|
+
if (embeddedDaemon) {
|
|
3172
|
+
try {
|
|
3173
|
+
embeddedDaemon.stop();
|
|
3174
|
+
} catch {
|
|
3175
|
+
}
|
|
3176
|
+
embeddedDaemon = null;
|
|
3177
|
+
}
|
|
3178
|
+
};
|
|
3179
|
+
process.on("exit", cleanup);
|
|
3180
|
+
process.on("SIGINT", cleanup);
|
|
3181
|
+
process.on("SIGTERM", cleanup);
|
|
3182
|
+
} catch (err) {
|
|
3183
|
+
console.error(
|
|
3184
|
+
`[BrowserTrack MCP] Note: Daemon startup skipped (${err?.message}). Running MCP in standalone database mode.`
|
|
3185
|
+
);
|
|
3186
|
+
} finally {
|
|
3187
|
+
if (hasLock) {
|
|
3188
|
+
releaseBootLock(lockFile);
|
|
3189
|
+
}
|
|
3190
|
+
}
|
|
3191
|
+
}
|
|
2789
3192
|
const server = new Server(
|
|
2790
3193
|
{
|
|
2791
3194
|
name: "browsertrack-mcp",
|
|
@@ -2803,7 +3206,10 @@ function createMcpServer(options = {}) {
|
|
|
2803
3206
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
2804
3207
|
const { name, arguments: args } = request.params;
|
|
2805
3208
|
try {
|
|
2806
|
-
const
|
|
3209
|
+
const timeoutPromise = new Promise(
|
|
3210
|
+
(_, reject) => setTimeout(() => reject(new Error(`Tool execution timed out after 20s: ${name}`)), 2e4)
|
|
3211
|
+
);
|
|
3212
|
+
const result = await Promise.race([handleToolCall(name, args || {}, ctx), timeoutPromise]);
|
|
2807
3213
|
return {
|
|
2808
3214
|
content: [
|
|
2809
3215
|
{
|
|
@@ -2826,30 +3232,48 @@ function createMcpServer(options = {}) {
|
|
|
2826
3232
|
});
|
|
2827
3233
|
return {
|
|
2828
3234
|
server,
|
|
3235
|
+
ctx,
|
|
3236
|
+
ensureDaemon,
|
|
3237
|
+
async stopDaemon() {
|
|
3238
|
+
if (embeddedDaemon) {
|
|
3239
|
+
await embeddedDaemon.stop();
|
|
3240
|
+
embeddedDaemon = null;
|
|
3241
|
+
}
|
|
3242
|
+
},
|
|
2829
3243
|
async startStdio() {
|
|
2830
3244
|
const transport = new StdioServerTransport();
|
|
2831
3245
|
await server.connect(transport);
|
|
3246
|
+
const daemonInitPromise = ensureDaemon().catch((err) => {
|
|
3247
|
+
console.error(`[BrowserTrack MCP] Daemon background init note: ${err?.message || err}`);
|
|
3248
|
+
});
|
|
3249
|
+
ctx.daemonInitPromise = daemonInitPromise;
|
|
2832
3250
|
}
|
|
2833
3251
|
};
|
|
2834
3252
|
}
|
|
2835
3253
|
|
|
2836
3254
|
// packages/cli/src/index.ts
|
|
3255
|
+
process.on("uncaughtException", (err) => {
|
|
3256
|
+
console.error("[BrowserTrack] Uncaught Exception:", err?.message || err);
|
|
3257
|
+
});
|
|
3258
|
+
process.on("unhandledRejection", (reason) => {
|
|
3259
|
+
console.error("[BrowserTrack] Unhandled Rejection:", reason?.message || reason);
|
|
3260
|
+
});
|
|
2837
3261
|
var program = new Command();
|
|
2838
3262
|
program.name("browsertrack").description("Local browser diagnostics + MCP bridge for coding agents").version("0.1.0");
|
|
2839
3263
|
function getPidFilePath() {
|
|
2840
3264
|
const config = getDaemonConfig();
|
|
2841
|
-
return
|
|
3265
|
+
return path7.join(config.dataDir, "daemon.pid");
|
|
2842
3266
|
}
|
|
2843
3267
|
function savePid(pid) {
|
|
2844
3268
|
const pidFile = getPidFilePath();
|
|
2845
|
-
|
|
2846
|
-
|
|
3269
|
+
fs6.mkdirSync(path7.dirname(pidFile), { recursive: true });
|
|
3270
|
+
fs6.writeFileSync(pidFile, String(pid), "utf-8");
|
|
2847
3271
|
}
|
|
2848
3272
|
function readPid() {
|
|
2849
3273
|
try {
|
|
2850
3274
|
const pidFile = getPidFilePath();
|
|
2851
|
-
if (
|
|
2852
|
-
const pidStr =
|
|
3275
|
+
if (fs6.existsSync(pidFile)) {
|
|
3276
|
+
const pidStr = fs6.readFileSync(pidFile, "utf-8").trim();
|
|
2853
3277
|
const pid = parseInt(pidStr, 10);
|
|
2854
3278
|
if (!isNaN(pid)) return pid;
|
|
2855
3279
|
}
|
|
@@ -2860,8 +3284,8 @@ function readPid() {
|
|
|
2860
3284
|
function removePid() {
|
|
2861
3285
|
try {
|
|
2862
3286
|
const pidFile = getPidFilePath();
|
|
2863
|
-
if (
|
|
2864
|
-
|
|
3287
|
+
if (fs6.existsSync(pidFile)) {
|
|
3288
|
+
fs6.unlinkSync(pidFile);
|
|
2865
3289
|
}
|
|
2866
3290
|
} catch {
|
|
2867
3291
|
}
|
|
@@ -2881,6 +3305,11 @@ program.command("start").description("Start the local BrowserTrack daemon and HT
|
|
|
2881
3305
|
return;
|
|
2882
3306
|
}
|
|
2883
3307
|
const port = parseInt(options.port, 10);
|
|
3308
|
+
const isRunningHttp = await isDaemonRunning(options.host, port);
|
|
3309
|
+
if (isRunningHttp) {
|
|
3310
|
+
console.log(`[BrowserTrack] Daemon is already running on http://${options.host}:${port} (active via MCP Server).`);
|
|
3311
|
+
return;
|
|
3312
|
+
}
|
|
2884
3313
|
const daemon = createDaemon({
|
|
2885
3314
|
port,
|
|
2886
3315
|
host: options.host,
|
|
@@ -2924,6 +3353,13 @@ program.command("start").description("Start the local BrowserTrack daemon and HT
|
|
|
2924
3353
|
program.command("stop").description("Stop the running BrowserTrack daemon").action(async () => {
|
|
2925
3354
|
const pid = readPid();
|
|
2926
3355
|
if (!pid || !isProcessRunning(pid)) {
|
|
3356
|
+
const config = getDaemonConfig();
|
|
3357
|
+
const isRunningHttp = await isDaemonRunning(config.host, config.port);
|
|
3358
|
+
if (isRunningHttp) {
|
|
3359
|
+
console.log("[BrowserTrack] Daemon is actively maintained by an MCP Server process in your IDE and will shut down when your editor session ends.");
|
|
3360
|
+
removePid();
|
|
3361
|
+
return;
|
|
3362
|
+
}
|
|
2927
3363
|
console.log("[BrowserTrack] Daemon is not currently running.");
|
|
2928
3364
|
removePid();
|
|
2929
3365
|
return;
|
|
@@ -2938,13 +3374,21 @@ program.command("stop").description("Stop the running BrowserTrack daemon").acti
|
|
|
2938
3374
|
});
|
|
2939
3375
|
program.command("status").description("Check if the BrowserTrack daemon is running and view active sessions").action(async () => {
|
|
2940
3376
|
const pid = readPid();
|
|
2941
|
-
const isRunning = pid ? isProcessRunning(pid) : false;
|
|
2942
3377
|
const config = getDaemonConfig();
|
|
3378
|
+
let isRunning = pid ? isProcessRunning(pid) : false;
|
|
3379
|
+
let detail = isRunning ? ` (PID: ${pid})` : "";
|
|
3380
|
+
if (!isRunning) {
|
|
3381
|
+
const isRunningHttp = await isDaemonRunning(config.host, config.port);
|
|
3382
|
+
if (isRunningHttp) {
|
|
3383
|
+
isRunning = true;
|
|
3384
|
+
detail = " (Active via MCP Server)";
|
|
3385
|
+
}
|
|
3386
|
+
}
|
|
2943
3387
|
console.log("\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501");
|
|
2944
|
-
console.log(` Status: ${isRunning ? "\u{1F7E2} RUNNING" : "\u26AA STOPPED"}${
|
|
3388
|
+
console.log(` Status: ${isRunning ? "\u{1F7E2} RUNNING" : "\u26AA STOPPED"}${detail}`);
|
|
2945
3389
|
console.log(` Endpoint: http://${config.host}:${config.port}`);
|
|
2946
3390
|
console.log(` Database: ${config.dbPath}`);
|
|
2947
|
-
if (
|
|
3391
|
+
if (fs6.existsSync(config.dbPath)) {
|
|
2948
3392
|
try {
|
|
2949
3393
|
const db = new StorageDB(config.dbPath);
|
|
2950
3394
|
const projects = db.listProjects();
|
|
@@ -2962,172 +3406,240 @@ program.command("status").description("Check if the BrowserTrack daemon is runni
|
|
|
2962
3406
|
});
|
|
2963
3407
|
var projectCommand = program.command("project").description("Manage tracked project mappings");
|
|
2964
3408
|
projectCommand.command("add <name>").description("Register a project with origin and filesystem path").requiredOption("-o, --origin <origin>", "Project origin (e.g. http://localhost:5173)").option("-p, --path <path>", "Filesystem path (e.g. /path/to/project)").action((name, options) => {
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
|
|
2971
|
-
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
2977
|
-
|
|
2978
|
-
|
|
3409
|
+
try {
|
|
3410
|
+
const config = getDaemonConfig();
|
|
3411
|
+
const db = new StorageDB(config.dbPath);
|
|
3412
|
+
try {
|
|
3413
|
+
const resolvedPath = options.path ? path7.resolve(options.path) : void 0;
|
|
3414
|
+
const proj = db.upsertProject({
|
|
3415
|
+
id: `proj_${name}`,
|
|
3416
|
+
name,
|
|
3417
|
+
origin: options.origin,
|
|
3418
|
+
path: resolvedPath
|
|
3419
|
+
});
|
|
3420
|
+
console.log(`[BrowserTrack] Registered project '${proj.name}':`);
|
|
3421
|
+
console.log(` ID: ${proj.id}`);
|
|
3422
|
+
console.log(` Origin: ${proj.origin}`);
|
|
3423
|
+
console.log(` Path: ${proj.path || "(none)"}`);
|
|
3424
|
+
} finally {
|
|
3425
|
+
db.close();
|
|
3426
|
+
}
|
|
3427
|
+
} catch (err) {
|
|
3428
|
+
console.error("[BrowserTrack] Failed to register project:", err?.message || err);
|
|
3429
|
+
}
|
|
2979
3430
|
});
|
|
2980
3431
|
program.command("projects").description("List all tracked projects").action(() => {
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
|
|
2989
|
-
|
|
2990
|
-
|
|
2991
|
-
|
|
2992
|
-
|
|
2993
|
-
|
|
3432
|
+
try {
|
|
3433
|
+
const config = getDaemonConfig();
|
|
3434
|
+
const db = new StorageDB(config.dbPath);
|
|
3435
|
+
try {
|
|
3436
|
+
const projects = db.listProjects();
|
|
3437
|
+
if (projects.length === 0) {
|
|
3438
|
+
console.log("[BrowserTrack] No projects registered yet. Projects will be auto-detected upon browser connection.");
|
|
3439
|
+
} else {
|
|
3440
|
+
console.log("\nTracked Projects:");
|
|
3441
|
+
for (const p of projects) {
|
|
3442
|
+
console.log(` \u2022 ${p.name.padEnd(16)} | ${p.origin.padEnd(26)} | ${p.path || "(auto-detected)"}`);
|
|
3443
|
+
}
|
|
3444
|
+
console.log("");
|
|
3445
|
+
}
|
|
3446
|
+
} finally {
|
|
3447
|
+
db.close();
|
|
3448
|
+
}
|
|
3449
|
+
} catch (err) {
|
|
3450
|
+
console.error("[BrowserTrack] Failed to list projects:", err?.message || err);
|
|
3451
|
+
}
|
|
2994
3452
|
});
|
|
2995
3453
|
program.command("errors").description("List recorded runtime errors and incidents").option("-p, --project <project>", "Filter by project name or ID").option("-s, --status <status>", "Filter by status (OPEN, VERIFIED, FAILED, etc.)").option("-l, --limit <number>", "Limit result count", "20").action((options) => {
|
|
2996
|
-
|
|
2997
|
-
|
|
2998
|
-
|
|
2999
|
-
|
|
3000
|
-
|
|
3001
|
-
|
|
3002
|
-
|
|
3003
|
-
|
|
3004
|
-
|
|
3005
|
-
|
|
3006
|
-
|
|
3007
|
-
|
|
3454
|
+
try {
|
|
3455
|
+
const config = getDaemonConfig();
|
|
3456
|
+
const db = new StorageDB(config.dbPath);
|
|
3457
|
+
try {
|
|
3458
|
+
const limit = parseInt(options.limit, 10);
|
|
3459
|
+
const incidents = db.listIncidents({
|
|
3460
|
+
projectId: options.project,
|
|
3461
|
+
status: options.status,
|
|
3462
|
+
limit
|
|
3463
|
+
});
|
|
3464
|
+
if (incidents.length === 0) {
|
|
3465
|
+
console.log("[BrowserTrack] No incidents found matching the filter.");
|
|
3466
|
+
} else {
|
|
3467
|
+
console.log(`
|
|
3008
3468
|
Incidents (${incidents.length}):`);
|
|
3009
|
-
|
|
3010
|
-
|
|
3011
|
-
|
|
3012
|
-
|
|
3469
|
+
for (const inc of incidents) {
|
|
3470
|
+
const statusBadge = inc.status === "OPEN" ? "\u{1F534} OPEN" : inc.status === "VERIFIED" ? "\u{1F7E2} VERIFIED" : inc.status === "FAILED" ? "\u274C FAILED" : `\u26AA ${inc.status}`;
|
|
3471
|
+
console.log(` ${inc.id.padEnd(12)} [${statusBadge}] (${inc.occurrences}x) ${inc.type}: ${inc.message}`);
|
|
3472
|
+
console.log(` Source: ${inc.source.file}:${inc.source.line} | Route: ${inc.route}`);
|
|
3473
|
+
}
|
|
3474
|
+
console.log("");
|
|
3475
|
+
}
|
|
3476
|
+
} finally {
|
|
3477
|
+
db.close();
|
|
3013
3478
|
}
|
|
3014
|
-
|
|
3479
|
+
} catch (err) {
|
|
3480
|
+
console.error("[BrowserTrack] Failed to list incidents:", err?.message || err);
|
|
3015
3481
|
}
|
|
3016
|
-
db.close();
|
|
3017
3482
|
});
|
|
3018
3483
|
var noteCmd = program.command("note").description("Inspect or manage visual development notes");
|
|
3019
3484
|
noteCmd.command("show <noteId>").description("Show full context for a specific visual note").action((noteId) => {
|
|
3020
|
-
|
|
3021
|
-
|
|
3022
|
-
|
|
3023
|
-
|
|
3024
|
-
|
|
3025
|
-
|
|
3026
|
-
|
|
3027
|
-
|
|
3028
|
-
|
|
3029
|
-
|
|
3030
|
-
|
|
3031
|
-
|
|
3032
|
-
|
|
3033
|
-
|
|
3034
|
-
|
|
3035
|
-
|
|
3036
|
-
|
|
3037
|
-
|
|
3038
|
-
|
|
3039
|
-
|
|
3485
|
+
try {
|
|
3486
|
+
const config = getDaemonConfig();
|
|
3487
|
+
const db = new StorageDB(config.dbPath);
|
|
3488
|
+
try {
|
|
3489
|
+
const note = db.getNote(noteId);
|
|
3490
|
+
if (!note) {
|
|
3491
|
+
console.log(`[BrowserTrack] Note '${noteId}' not found.`);
|
|
3492
|
+
} else {
|
|
3493
|
+
console.log("\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501");
|
|
3494
|
+
console.log(` \u{1F4DD} Visual Note: ${note.id} [${note.status}]`);
|
|
3495
|
+
console.log(` \u{1F4CD} Route: ${note.route} (${note.url})`);
|
|
3496
|
+
console.log(` \u{1F4D0} Viewport: ${note.viewport.width} \xD7 ${note.viewport.height} (dpr: ${note.viewport.devicePixelRatio})`);
|
|
3497
|
+
if (note.target) {
|
|
3498
|
+
console.log(` \u{1F3AF} Target: ${note.target.selector}`);
|
|
3499
|
+
console.log(` Bounds: x:${note.target.boundingRect.x}, y:${note.target.boundingRect.y}, ${note.target.boundingRect.width}\xD7${note.target.boundingRect.height}`);
|
|
3500
|
+
}
|
|
3501
|
+
console.log(` \u{1F4AC} Note: "${note.message}"`);
|
|
3502
|
+
if (note.screenshots?.original) {
|
|
3503
|
+
console.log(` \u{1F5BC}\uFE0F Screenshot: ${note.screenshots.original}`);
|
|
3504
|
+
}
|
|
3505
|
+
console.log(` \u{1F552} Created: ${note.createdAt}`);
|
|
3506
|
+
console.log("\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501");
|
|
3507
|
+
}
|
|
3508
|
+
} finally {
|
|
3509
|
+
db.close();
|
|
3510
|
+
}
|
|
3511
|
+
} catch (err) {
|
|
3512
|
+
console.error("[BrowserTrack] Failed to retrieve note:", err?.message || err);
|
|
3040
3513
|
}
|
|
3041
|
-
db.close();
|
|
3042
3514
|
});
|
|
3043
3515
|
noteCmd.command("resolve <noteId>").description("Mark a visual note as resolved").action((noteId) => {
|
|
3044
|
-
|
|
3045
|
-
|
|
3046
|
-
|
|
3047
|
-
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
|
|
3051
|
-
|
|
3052
|
-
|
|
3053
|
-
|
|
3516
|
+
try {
|
|
3517
|
+
const config = getDaemonConfig();
|
|
3518
|
+
const db = new StorageDB(config.dbPath);
|
|
3519
|
+
try {
|
|
3520
|
+
const note = db.getNote(noteId);
|
|
3521
|
+
if (!note) {
|
|
3522
|
+
console.log(`[BrowserTrack] Note '${noteId}' not found.`);
|
|
3523
|
+
} else {
|
|
3524
|
+
db.updateNoteStatus(noteId, "RESOLVED");
|
|
3525
|
+
console.log(`[BrowserTrack] Marked note '${noteId}' as RESOLVED.`);
|
|
3526
|
+
}
|
|
3527
|
+
} finally {
|
|
3528
|
+
db.close();
|
|
3529
|
+
}
|
|
3530
|
+
} catch (err) {
|
|
3531
|
+
console.error("[BrowserTrack] Failed to resolve note:", err?.message || err);
|
|
3532
|
+
}
|
|
3054
3533
|
});
|
|
3055
3534
|
noteCmd.command("reopen <noteId>").description("Reopen a visual note").action((noteId) => {
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
|
|
3535
|
+
try {
|
|
3536
|
+
const config = getDaemonConfig();
|
|
3537
|
+
const db = new StorageDB(config.dbPath);
|
|
3538
|
+
try {
|
|
3539
|
+
const note = db.getNote(noteId);
|
|
3540
|
+
if (!note) {
|
|
3541
|
+
console.log(`[BrowserTrack] Note '${noteId}' not found.`);
|
|
3542
|
+
} else {
|
|
3543
|
+
db.updateNoteStatus(noteId, "OPEN");
|
|
3544
|
+
console.log(`[BrowserTrack] Reopened note '${noteId}' (status: OPEN).`);
|
|
3545
|
+
}
|
|
3546
|
+
} finally {
|
|
3547
|
+
db.close();
|
|
3548
|
+
}
|
|
3549
|
+
} catch (err) {
|
|
3550
|
+
console.error("[BrowserTrack] Failed to reopen note:", err?.message || err);
|
|
3551
|
+
}
|
|
3066
3552
|
});
|
|
3067
3553
|
program.command("notes").description("List visual development notes").option("-p, --project <project>", "Filter by project name or ID").option("-s, --status <status>", "Filter by status (OPEN, RESOLVED, etc.)").option("-l, --limit <number>", "Limit result count", "20").action((options) => {
|
|
3068
|
-
|
|
3069
|
-
|
|
3070
|
-
|
|
3071
|
-
|
|
3072
|
-
|
|
3073
|
-
|
|
3074
|
-
|
|
3075
|
-
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
|
|
3554
|
+
try {
|
|
3555
|
+
const config = getDaemonConfig();
|
|
3556
|
+
const db = new StorageDB(config.dbPath);
|
|
3557
|
+
try {
|
|
3558
|
+
const limit = parseInt(options.limit, 10);
|
|
3559
|
+
const notes = db.listNotes({
|
|
3560
|
+
projectId: options.project,
|
|
3561
|
+
status: options.status,
|
|
3562
|
+
limit
|
|
3563
|
+
});
|
|
3564
|
+
if (notes.length === 0) {
|
|
3565
|
+
console.log("[BrowserTrack] No visual notes found.");
|
|
3566
|
+
} else {
|
|
3567
|
+
console.log(`
|
|
3080
3568
|
Visual Notes (${notes.length}):`);
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
3569
|
+
for (const n of notes) {
|
|
3570
|
+
const badge = n.status === "OPEN" ? "\u{1F7E1} OPEN" : n.status === "RESOLVED" ? "\u{1F7E2} RESOLVED" : `\u26AA ${n.status}`;
|
|
3571
|
+
console.log(` ${n.id.padEnd(12)} [${badge}] Route: ${n.route.padEnd(16)} | Target: ${n.target?.selector || n.type}`);
|
|
3572
|
+
console.log(` Note: "${n.message}" (${n.viewport.width}\xD7${n.viewport.height})`);
|
|
3573
|
+
}
|
|
3574
|
+
console.log("");
|
|
3575
|
+
}
|
|
3576
|
+
} finally {
|
|
3577
|
+
db.close();
|
|
3085
3578
|
}
|
|
3086
|
-
|
|
3579
|
+
} catch (err) {
|
|
3580
|
+
console.error("[BrowserTrack] Failed to list visual notes:", err?.message || err);
|
|
3087
3581
|
}
|
|
3088
|
-
db.close();
|
|
3089
3582
|
});
|
|
3090
3583
|
program.command("inbox").description("View combined developer inbox with active runtime errors and visual notes").option("-p, --project <project>", "Filter by project name or ID").action((options) => {
|
|
3091
|
-
|
|
3092
|
-
|
|
3093
|
-
|
|
3094
|
-
|
|
3095
|
-
|
|
3096
|
-
|
|
3097
|
-
|
|
3098
|
-
|
|
3099
|
-
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
|
|
3584
|
+
try {
|
|
3585
|
+
const config = getDaemonConfig();
|
|
3586
|
+
const db = new StorageDB(config.dbPath);
|
|
3587
|
+
try {
|
|
3588
|
+
const incidents = db.listIncidents({ projectId: options.project, status: "OPEN", limit: 20 });
|
|
3589
|
+
const notes = db.listNotes({ projectId: options.project, status: "OPEN", limit: 20 });
|
|
3590
|
+
console.log("\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501");
|
|
3591
|
+
console.log(` \u{1F4E5} Browser Development Inbox ${options.project ? `(${options.project})` : ""}`);
|
|
3592
|
+
console.log("\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501");
|
|
3593
|
+
if (incidents.length === 0 && notes.length === 0) {
|
|
3594
|
+
console.log(" \u2728 All clear! No open errors or visual notes.");
|
|
3595
|
+
} else {
|
|
3596
|
+
if (incidents.length > 0) {
|
|
3597
|
+
console.log(`
|
|
3103
3598
|
\u{1F6A8} Runtime Errors (${incidents.length}):`);
|
|
3104
|
-
|
|
3105
|
-
|
|
3106
|
-
|
|
3107
|
-
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
3599
|
+
for (const inc of incidents) {
|
|
3600
|
+
console.log(` \u2022 ${inc.id} (${inc.occurrences}x) ${inc.type}: ${inc.message}`);
|
|
3601
|
+
console.log(` Route: ${inc.route} | Source: ${inc.source.file}:${inc.source.line}`);
|
|
3602
|
+
}
|
|
3603
|
+
}
|
|
3604
|
+
if (notes.length > 0) {
|
|
3605
|
+
console.log(`
|
|
3111
3606
|
\u{1F4DD} Visual Notes (${notes.length}):`);
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
3607
|
+
for (const n of notes) {
|
|
3608
|
+
console.log(` \u2022 ${n.id} on ${n.route} (${n.viewport.width}\xD7${n.viewport.height})`);
|
|
3609
|
+
console.log(` Target: ${n.target?.selector || n.type} | Note: "${n.message}"`);
|
|
3610
|
+
}
|
|
3611
|
+
}
|
|
3115
3612
|
}
|
|
3613
|
+
console.log("\n\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501");
|
|
3614
|
+
} finally {
|
|
3615
|
+
db.close();
|
|
3116
3616
|
}
|
|
3617
|
+
} catch (err) {
|
|
3618
|
+
console.error("[BrowserTrack] Failed to display inbox:", err?.message || err);
|
|
3117
3619
|
}
|
|
3118
|
-
console.log("\n\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501");
|
|
3119
|
-
db.close();
|
|
3120
3620
|
});
|
|
3121
3621
|
program.command("clear").description("Clear all stored incidents, events, and sessions from the database").action(() => {
|
|
3122
|
-
|
|
3123
|
-
|
|
3124
|
-
|
|
3125
|
-
|
|
3126
|
-
|
|
3622
|
+
try {
|
|
3623
|
+
const config = getDaemonConfig();
|
|
3624
|
+
const db = new StorageDB(config.dbPath);
|
|
3625
|
+
try {
|
|
3626
|
+
db.clearAll();
|
|
3627
|
+
console.log("[BrowserTrack] Database cleared.");
|
|
3628
|
+
} finally {
|
|
3629
|
+
db.close();
|
|
3630
|
+
}
|
|
3631
|
+
} catch (err) {
|
|
3632
|
+
console.error("[BrowserTrack] Failed to clear database:", err?.message || err);
|
|
3633
|
+
}
|
|
3127
3634
|
});
|
|
3128
|
-
program.command("mcp").description("Launch the Model Context Protocol (MCP) server over stdio").action(async () => {
|
|
3635
|
+
program.command("mcp").description("Launch the Model Context Protocol (MCP) server over stdio").option("--no-daemon", "Do not auto-start background daemon if offline").action(async (options) => {
|
|
3129
3636
|
try {
|
|
3130
|
-
|
|
3637
|
+
console.log = (...args) => {
|
|
3638
|
+
console.error(...args);
|
|
3639
|
+
};
|
|
3640
|
+
const server = createMcpServer({
|
|
3641
|
+
autoStartDaemon: options.daemon !== false
|
|
3642
|
+
});
|
|
3131
3643
|
await server.startStdio();
|
|
3132
3644
|
} catch (err) {
|
|
3133
3645
|
console.error("[BrowserTrack] MCP Server error:", err?.message);
|