error-mom 0.5.0 → 0.7.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/dist/cli.js +149 -37
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -2,17 +2,73 @@
|
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
4
|
import { execFileSync } from "child_process";
|
|
5
|
-
import { chmod, mkdir, readFile
|
|
5
|
+
import { chmod, mkdir, readFile as readFile2, stat, writeFile } from "fs/promises";
|
|
6
6
|
import { existsSync } from "fs";
|
|
7
7
|
import { homedir } from "os";
|
|
8
|
-
import { basename, join } from "path";
|
|
8
|
+
import { basename as basename2, join as join2 } from "path";
|
|
9
9
|
import { Command } from "commander";
|
|
10
10
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
11
11
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
12
12
|
import { z } from "zod";
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
|
|
14
|
+
// src/sourcemap-files.ts
|
|
15
|
+
import { readFile, readdir } from "fs/promises";
|
|
16
|
+
import { basename, dirname, join } from "path";
|
|
17
|
+
async function associateMaps(dir) {
|
|
18
|
+
const { jsFiles, mapFiles } = await findBuildFiles(dir);
|
|
19
|
+
const associations = /* @__PURE__ */ new Map();
|
|
20
|
+
const referencedMaps = /* @__PURE__ */ new Set();
|
|
21
|
+
for (const jsFile of jsFiles) {
|
|
22
|
+
const contents = await readFile(jsFile, "utf8");
|
|
23
|
+
const matches = [...contents.matchAll(/\/\/[#@] sourceMappingURL=([^\s'"]+)/g)];
|
|
24
|
+
const reference = matches[matches.length - 1]?.[1];
|
|
25
|
+
if (!reference || reference.startsWith("data:")) continue;
|
|
26
|
+
const mapFile = join(dirname(jsFile), decodeURIComponent(reference));
|
|
27
|
+
if (!mapFiles.includes(mapFile)) continue;
|
|
28
|
+
const fileName = basename(jsFile);
|
|
29
|
+
referencedMaps.add(mapFile);
|
|
30
|
+
associations.set(`${mapFile}\0${fileName}`, { mapFile, fileName });
|
|
31
|
+
}
|
|
32
|
+
for (const mapFile of mapFiles) {
|
|
33
|
+
if (referencedMaps.has(mapFile)) continue;
|
|
34
|
+
associations.set(`${mapFile}\0`, {
|
|
35
|
+
mapFile,
|
|
36
|
+
fileName: basename(mapFile).replace(/\.map$/, "")
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
return [...associations.values()].sort((a, b) => a.fileName.localeCompare(b.fileName));
|
|
40
|
+
}
|
|
41
|
+
async function findBuildFiles(dir) {
|
|
42
|
+
const jsFiles = [];
|
|
43
|
+
const mapFiles = [];
|
|
44
|
+
let entries;
|
|
45
|
+
try {
|
|
46
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
47
|
+
} catch {
|
|
48
|
+
throw new Error(`Cannot read directory ${dir}.`);
|
|
49
|
+
}
|
|
50
|
+
for (const entry of entries) {
|
|
51
|
+
const fullPath = join(dir, entry.name);
|
|
52
|
+
if (entry.isDirectory() && entry.name !== "node_modules") {
|
|
53
|
+
const nested = await findBuildFiles(fullPath);
|
|
54
|
+
jsFiles.push(...nested.jsFiles);
|
|
55
|
+
mapFiles.push(...nested.mapFiles);
|
|
56
|
+
} else if (entry.isFile() && entry.name.endsWith(".map")) {
|
|
57
|
+
mapFiles.push(fullPath);
|
|
58
|
+
} else if (entry.isFile() && /\.(js|mjs|cjs)$/.test(entry.name)) {
|
|
59
|
+
jsFiles.push(fullPath);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return { jsFiles: jsFiles.sort(), mapFiles: mapFiles.sort() };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// src/cli.ts
|
|
66
|
+
var VERSION = "0.7.0";
|
|
67
|
+
var CONFIG_DIR = join2(homedir(), ".error-mom");
|
|
68
|
+
var CONFIG_FILE = join2(CONFIG_DIR, "config.json");
|
|
69
|
+
function slugifyName(value) {
|
|
70
|
+
return value.toLowerCase().normalize("NFKD").replace(/[\u0300-\u036f]/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 64);
|
|
71
|
+
}
|
|
16
72
|
var program = new Command().name("error-mom").description("Query and operate a self-hosted Error Mom incident desk").version(VERSION);
|
|
17
73
|
program.command("login").description("Save the URL and private admin token for one Error Mom deployment").argument("<server>", "Error Mom server URL").requiredOption("--token <token>", "ERROR_MOM_ADMIN_TOKEN value").action(async (server, options) => {
|
|
18
74
|
const normalized = normalizeServer(server);
|
|
@@ -75,7 +131,10 @@ program.command("delete-project").description("Permanently delete one project an
|
|
|
75
131
|
)
|
|
76
132
|
);
|
|
77
133
|
});
|
|
78
|
-
program.command("doctor").description("Verify collector health, credentials, and optional project ingestion").option("--project-key <key>", "Send and verify a synthetic event with this write-only key").
|
|
134
|
+
program.command("doctor").description("Verify collector health, credentials, and optional project ingestion").option("--project-key <key>", "Send and verify a synthetic event with this write-only key").option(
|
|
135
|
+
"--symbolication",
|
|
136
|
+
"Round-trip a synthetic minified stack through the server's symbolication engine"
|
|
137
|
+
).action(async (options) => {
|
|
79
138
|
const config = await loadConfig();
|
|
80
139
|
const health = await request(config.server, void 0, "/api/health");
|
|
81
140
|
const projects = await request(config.server, config.adminToken, "/api/v1/projects");
|
|
@@ -89,19 +148,42 @@ program.command("doctor").description("Verify collector health, credentials, and
|
|
|
89
148
|
}
|
|
90
149
|
});
|
|
91
150
|
}
|
|
92
|
-
|
|
151
|
+
let symbolication = "not tested";
|
|
152
|
+
if (options.symbolication) {
|
|
153
|
+
const result = await request(config.server, config.adminToken, "/api/v1/sourcemaps/check", {
|
|
154
|
+
body: {
|
|
155
|
+
stack: "ErrorMomDoctor: synthetic minified stack\n at t.xyz (https://app.example/assets/doctor-abc.js:1:11)",
|
|
156
|
+
fileName: "doctor-abc.js",
|
|
157
|
+
map: {
|
|
158
|
+
version: 3,
|
|
159
|
+
file: "doctor-abc.js",
|
|
160
|
+
sources: ["src/doctor-fixture.ts"],
|
|
161
|
+
names: ["doctorBoom"],
|
|
162
|
+
mappings: "UAIEA"
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
const roundTripped = result.symbolicated === true && typeof result.stack === "string" && result.stack.includes("doctorBoom (src/doctor-fixture.ts:5:3)");
|
|
167
|
+
if (!roundTripped) {
|
|
168
|
+
throw new Error(
|
|
169
|
+
`Symbolication round-trip failed: expected the synthetic frame to rewrite to src/doctor-fixture.ts:5:3, got ${JSON.stringify(result)}`
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
symbolication = { verified: true, rewrittenFrame: "doctorBoom (src/doctor-fixture.ts:5:3)" };
|
|
173
|
+
}
|
|
174
|
+
print({ healthy: true, health, projects, ingestion, symbolication });
|
|
93
175
|
});
|
|
94
176
|
program.command("init").description("Create/select a project, install the SDK, and generate framework-aware setup").option("--name <name>", "Project name").option("--project <slug>", "Use an existing project slug").option("--skip-install", "Generate setup without invoking the package manager").action(async (options) => {
|
|
95
177
|
const config = await loadConfig();
|
|
96
178
|
const packageJson = await readPackageJson(process.cwd());
|
|
97
|
-
const name = options.name ?? (typeof packageJson.name === "string" ? packageJson.name :
|
|
179
|
+
const name = options.name ?? (typeof packageJson.name === "string" ? packageJson.name : basename2(process.cwd()));
|
|
98
180
|
const projectResponse = await request(
|
|
99
181
|
config.server,
|
|
100
182
|
config.adminToken,
|
|
101
183
|
"/api/v1/projects"
|
|
102
184
|
);
|
|
103
185
|
let project = options.project ? projectResponse.projects.find((candidate) => candidate.slug === options.project) : projectResponse.projects.find(
|
|
104
|
-
(candidate) => candidate.name.toLowerCase() === name.toLowerCase()
|
|
186
|
+
(candidate) => candidate.name.toLowerCase() === name.toLowerCase() || candidate.slug === slugifyName(name)
|
|
105
187
|
);
|
|
106
188
|
if (!project) {
|
|
107
189
|
const created = await request(config.server, config.adminToken, "/api/v1/projects", {
|
|
@@ -134,13 +216,14 @@ program.command("init").description("Create/select a project, install the SDK, a
|
|
|
134
216
|
});
|
|
135
217
|
program.command("sourcemaps").description("Upload production source maps so minified stacks symbolicate on ingest").argument("<dir>", "Build output directory containing *.map files (e.g. dist)").requiredOption("--release <release>", "Release the maps belong to (must match the SDK release)").requiredOption("--project <id-or-slug>", "Project id or slug").action(async (dir, options) => {
|
|
136
218
|
const config = await loadConfig();
|
|
137
|
-
const
|
|
138
|
-
if (
|
|
219
|
+
const associations = await associateMaps(dir);
|
|
220
|
+
if (associations.length === 0) {
|
|
139
221
|
throw new Error(`No .map files found under ${dir}. Build with source maps enabled first.`);
|
|
140
222
|
}
|
|
141
223
|
const uploaded = [];
|
|
142
224
|
const skipped = [];
|
|
143
|
-
|
|
225
|
+
const warnings = [];
|
|
226
|
+
for (const { mapFile, fileName } of associations) {
|
|
144
227
|
const info = await stat(mapFile);
|
|
145
228
|
if (info.size > 20 * 1024 * 1024) {
|
|
146
229
|
skipped.push({ file: mapFile, reason: "larger than 20 MB" });
|
|
@@ -148,12 +231,17 @@ program.command("sourcemaps").description("Upload production source maps so mini
|
|
|
148
231
|
}
|
|
149
232
|
let map;
|
|
150
233
|
try {
|
|
151
|
-
map = JSON.parse(await
|
|
234
|
+
map = JSON.parse(await readFile2(mapFile, "utf8"));
|
|
152
235
|
} catch {
|
|
153
236
|
skipped.push({ file: mapFile, reason: "not valid JSON" });
|
|
154
237
|
continue;
|
|
155
238
|
}
|
|
156
|
-
const
|
|
239
|
+
const sourcesContent = map.sourcesContent;
|
|
240
|
+
if (!Array.isArray(sourcesContent) || sourcesContent.every((entry) => entry == null)) {
|
|
241
|
+
warnings.push(
|
|
242
|
+
`${fileName}: no sourcesContent \u2014 frames still symbolicate to file:line, but agents cannot read the original code from the map. Enable it in the bundler if you want richer context.`
|
|
243
|
+
);
|
|
244
|
+
}
|
|
157
245
|
try {
|
|
158
246
|
await request(config.server, config.adminToken, "/api/v1/sourcemaps", {
|
|
159
247
|
body: { projectId: options.project, release: options.release, fileName, map }
|
|
@@ -166,7 +254,8 @@ program.command("sourcemaps").description("Upload production source maps so mini
|
|
|
166
254
|
});
|
|
167
255
|
}
|
|
168
256
|
}
|
|
169
|
-
|
|
257
|
+
warnings.push(...await releaseMismatchWarning(config, options.project, options.release));
|
|
258
|
+
print({ release: options.release, project: options.project, uploaded, skipped, warnings });
|
|
170
259
|
});
|
|
171
260
|
program.command("mcp").description("Run Error Mom tools over MCP stdio for coding agents").action(async () => {
|
|
172
261
|
await runMcpServer();
|
|
@@ -236,6 +325,22 @@ async function runMcpServer() {
|
|
|
236
325
|
)
|
|
237
326
|
)
|
|
238
327
|
);
|
|
328
|
+
server.registerTool(
|
|
329
|
+
"check_symbolication",
|
|
330
|
+
{
|
|
331
|
+
description: "Dry-run a minified stack against a project's uploaded source maps to verify frames rewrite to original file:line. Nothing is stored.",
|
|
332
|
+
inputSchema: {
|
|
333
|
+
projectId: z.string().min(1).describe("Project id or slug"),
|
|
334
|
+
release: z.string().min(1),
|
|
335
|
+
stack: z.string().min(1).max(5e4)
|
|
336
|
+
}
|
|
337
|
+
},
|
|
338
|
+
async ({ projectId, release, stack }) => toolResult(
|
|
339
|
+
await request(config.server, config.adminToken, "/api/v1/sourcemaps/check", {
|
|
340
|
+
body: { projectId, release, stack }
|
|
341
|
+
})
|
|
342
|
+
)
|
|
343
|
+
);
|
|
239
344
|
server.registerTool(
|
|
240
345
|
"delete_project",
|
|
241
346
|
{
|
|
@@ -268,7 +373,7 @@ async function loadConfig() {
|
|
|
268
373
|
};
|
|
269
374
|
}
|
|
270
375
|
try {
|
|
271
|
-
const config = JSON.parse(await
|
|
376
|
+
const config = JSON.parse(await readFile2(CONFIG_FILE, "utf8"));
|
|
272
377
|
if (!config.server || !config.adminToken) throw new Error("Incomplete config");
|
|
273
378
|
return { server: normalizeServer(config.server), adminToken: config.adminToken };
|
|
274
379
|
} catch {
|
|
@@ -294,7 +399,7 @@ async function request(server, token, path, options = {}) {
|
|
|
294
399
|
}
|
|
295
400
|
async function readPackageJson(cwd) {
|
|
296
401
|
try {
|
|
297
|
-
return JSON.parse(await
|
|
402
|
+
return JSON.parse(await readFile2(join2(cwd, "package.json"), "utf8"));
|
|
298
403
|
} catch {
|
|
299
404
|
throw new Error(
|
|
300
405
|
"Run error-mom init from a JavaScript or TypeScript project containing package.json."
|
|
@@ -422,8 +527,8 @@ function detectFramework(packageJson) {
|
|
|
422
527
|
};
|
|
423
528
|
}
|
|
424
529
|
function detectPackageManager(cwd) {
|
|
425
|
-
if (existsSync(
|
|
426
|
-
if (existsSync(
|
|
530
|
+
if (existsSync(join2(cwd, "pnpm-lock.yaml"))) return "pnpm";
|
|
531
|
+
if (existsSync(join2(cwd, "yarn.lock"))) return "yarn";
|
|
427
532
|
return "npm";
|
|
428
533
|
}
|
|
429
534
|
function installSdk(packageManager) {
|
|
@@ -438,8 +543,8 @@ function installSdk(packageManager) {
|
|
|
438
543
|
});
|
|
439
544
|
}
|
|
440
545
|
async function writeSetup(framework, server, ingestKey) {
|
|
441
|
-
const sourceDirectory = existsSync(
|
|
442
|
-
const relativePath =
|
|
546
|
+
const sourceDirectory = existsSync(join2(process.cwd(), "src")) ? "src" : ".";
|
|
547
|
+
const relativePath = join2(sourceDirectory, "error-mom.ts");
|
|
443
548
|
if (framework.id === "next") await writeNextInstrumentation(sourceDirectory, server, ingestKey);
|
|
444
549
|
const environment = framework.envStyle === "next" ? "process.env.NEXT_PUBLIC_" : framework.envStyle === "vite" ? "import.meta.env.VITE_" : "process.env.";
|
|
445
550
|
const moduleName = framework.envStyle === "node" ? "@kenkaiiii/error-mom/node" : "@kenkaiiii/error-mom";
|
|
@@ -458,11 +563,11 @@ export const errorMom = initErrorMom({
|
|
|
458
563
|
...(release ? { release } : {}),
|
|
459
564
|
});
|
|
460
565
|
`;
|
|
461
|
-
await writeFile(
|
|
566
|
+
await writeFile(join2(process.cwd(), relativePath), contents);
|
|
462
567
|
return relativePath;
|
|
463
568
|
}
|
|
464
569
|
async function writeNextInstrumentation(sourceDirectory, configuredServer, ingestKey) {
|
|
465
|
-
const file =
|
|
570
|
+
const file = join2(process.cwd(), sourceDirectory, "instrumentation.ts");
|
|
466
571
|
if (existsSync(file)) return;
|
|
467
572
|
const contents = `import type { Instrumentation } from "next";
|
|
468
573
|
|
|
@@ -510,23 +615,30 @@ function syntheticEvent() {
|
|
|
510
615
|
context: {}
|
|
511
616
|
};
|
|
512
617
|
}
|
|
513
|
-
async function
|
|
514
|
-
const found = [];
|
|
515
|
-
let entries;
|
|
618
|
+
async function releaseMismatchWarning(config, projectIdOrSlug, release) {
|
|
516
619
|
try {
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
const
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
620
|
+
const { projects } = await request(config.server, config.adminToken, "/api/v1/projects");
|
|
621
|
+
const project = projects.find(
|
|
622
|
+
(candidate) => candidate.id === projectIdOrSlug || candidate.slug === projectIdOrSlug
|
|
623
|
+
);
|
|
624
|
+
if (!project) return [];
|
|
625
|
+
const { issues } = await request(
|
|
626
|
+
config.server,
|
|
627
|
+
config.adminToken,
|
|
628
|
+
`/api/v1/issues?${new URLSearchParams({ projectId: project.id, status: "all" })}`
|
|
629
|
+
);
|
|
630
|
+
const seenReleases = [
|
|
631
|
+
...new Set(issues.map((issue) => issue.latestRelease).filter((value) => value !== null))
|
|
632
|
+
];
|
|
633
|
+
if (seenReleases.length > 0 && !seenReleases.includes(release)) {
|
|
634
|
+
return [
|
|
635
|
+
`release "${release}" has not been reported by any event yet (recent releases: ${seenReleases.slice(0, 5).join(", ")}). Maps only apply when the SDK's release matches --release exactly.`
|
|
636
|
+
];
|
|
527
637
|
}
|
|
638
|
+
return [];
|
|
639
|
+
} catch {
|
|
640
|
+
return [];
|
|
528
641
|
}
|
|
529
|
-
return found.sort();
|
|
530
642
|
}
|
|
531
643
|
function normalizeServer(server) {
|
|
532
644
|
return server.replace(/\/$/, "");
|