@merittdev/horus-lens 0.3.0 → 0.5.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.cjs ADDED
@@ -0,0 +1,353 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ // src/cli.ts
5
+ var import_node_fs2 = require("fs");
6
+
7
+ // src/cli-lib.ts
8
+ var import_node_fs = require("fs");
9
+ var import_node_path = require("path");
10
+ var DEFAULT_ENDPOINT = "https://api.horus.sh";
11
+ var UPLOAD_KEY_ENV = "LENS_UPLOAD_KEY";
12
+ var MAX_FILES = 200;
13
+ var USAGE = `Usage: horus-lens upload-sourcemaps --release <release> [options] <dir...>
14
+
15
+ Upload JavaScript sourcemaps (*.map) for a release to Horus Lens.
16
+
17
+ Arguments:
18
+ <dir...> One or more directories to search recursively for .map files
19
+
20
+ Options:
21
+ --release <release> Release identifier, e.g. a git SHA [required]
22
+ --key <las_...> Per-site upload key (las_-prefixed).
23
+ Falls back to the ${UPLOAD_KEY_ENV} environment variable.
24
+ --endpoint <url> API endpoint (default: ${DEFAULT_ENDPOINT})
25
+ --dry-run List what would be uploaded, then exit without uploading
26
+ -h, --help Show this help
27
+
28
+ Example:
29
+ npx @merittdev/horus-lens upload-sourcemaps --release "$GIT_SHA" dist/`;
30
+ function parseArgs(argv, env = process.env) {
31
+ if (argv.length === 0) return { kind: "help" };
32
+ const [command, ...rest] = argv;
33
+ if (command === "-h" || command === "--help" || command === "help") {
34
+ return { kind: "help" };
35
+ }
36
+ if (command !== "upload-sourcemaps") {
37
+ return { kind: "error", message: `Unknown command: ${command}` };
38
+ }
39
+ let release;
40
+ let key;
41
+ let endpoint = DEFAULT_ENDPOINT;
42
+ let dryRun = false;
43
+ const dirs = [];
44
+ for (let i = 0; i < rest.length; i++) {
45
+ const token = rest[i];
46
+ if (token === "-h" || token === "--help") return { kind: "help" };
47
+ if (token.startsWith("--")) {
48
+ let flag = token;
49
+ let value;
50
+ const eq = token.indexOf("=");
51
+ if (eq !== -1) {
52
+ flag = token.slice(0, eq);
53
+ value = token.slice(eq + 1);
54
+ }
55
+ if (flag === "--dry-run") {
56
+ dryRun = true;
57
+ continue;
58
+ }
59
+ if (flag === "--release" || flag === "--key" || flag === "--endpoint") {
60
+ if (value === void 0) {
61
+ const next = rest[i + 1];
62
+ if (next === void 0 || next.startsWith("-")) {
63
+ return { kind: "error", message: `Option ${flag} requires a value.` };
64
+ }
65
+ value = next;
66
+ i++;
67
+ }
68
+ if (flag === "--release") release = value;
69
+ else if (flag === "--key") key = value;
70
+ else endpoint = value;
71
+ continue;
72
+ }
73
+ return { kind: "error", message: `Unknown option: ${flag}` };
74
+ }
75
+ dirs.push(token);
76
+ }
77
+ const resolvedKey = (key ?? env[UPLOAD_KEY_ENV] ?? "").trim();
78
+ const missing = [];
79
+ if (!release) missing.push("--release <release>");
80
+ if (dirs.length === 0) missing.push("<dir...>");
81
+ if (!dryRun && !resolvedKey) {
82
+ missing.push(`--key <las_...> (or set ${UPLOAD_KEY_ENV})`);
83
+ }
84
+ if (missing.length > 0) {
85
+ return {
86
+ kind: "error",
87
+ message: `Missing required argument(s): ${missing.join(", ")}`
88
+ };
89
+ }
90
+ return {
91
+ kind: "run",
92
+ args: { release, key: resolvedKey, endpoint, dryRun, dirs }
93
+ };
94
+ }
95
+ function discoverSourcemaps(dirs, opts = {}) {
96
+ const maxFiles = opts.maxFiles ?? MAX_FILES;
97
+ const warnings = [];
98
+ const seen = /* @__PURE__ */ new Map();
99
+ const files = [];
100
+ let duplicatesDropped = 0;
101
+ const walk = (dir) => {
102
+ let entries;
103
+ try {
104
+ entries = (0, import_node_fs.readdirSync)(dir, { withFileTypes: true });
105
+ } catch {
106
+ warnings.push(`could not read directory: ${dir}`);
107
+ return;
108
+ }
109
+ entries.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
110
+ for (const entry of entries) {
111
+ if (entry.name === "node_modules") continue;
112
+ const full = (0, import_node_path.join)(dir, entry.name);
113
+ if (entry.isDirectory()) {
114
+ walk(full);
115
+ continue;
116
+ }
117
+ if (!entry.isFile() || !entry.name.endsWith(".map")) continue;
118
+ const name = entry.name;
119
+ const existing = seen.get(name);
120
+ if (existing !== void 0) {
121
+ duplicatesDropped++;
122
+ warnings.push(
123
+ `DUPLICATE sourcemap basename "${name}" \u2014 the server identifies maps by basename, so only ${existing} will be uploaded; ${full} is SKIPPED and its bundle will have NO sourcemap. Rename the bundle/map to disambiguate.`
124
+ );
125
+ continue;
126
+ }
127
+ let byteSize;
128
+ try {
129
+ byteSize = (0, import_node_fs.statSync)(full).size;
130
+ } catch {
131
+ warnings.push(`could not stat file: ${full}`);
132
+ continue;
133
+ }
134
+ seen.set(name, full);
135
+ files.push({ name, byteSize, absolutePath: full });
136
+ }
137
+ };
138
+ for (const dir of dirs) walk((0, import_node_path.resolve)(dir));
139
+ if (duplicatesDropped > 0) {
140
+ warnings.push(
141
+ `${duplicatesDropped} sourcemap(s) SKIPPED due to duplicate basenames \u2014 those bundles will have no sourcemap on the server. See the warnings above.`
142
+ );
143
+ }
144
+ let truncated = false;
145
+ let result = files;
146
+ if (files.length > maxFiles) {
147
+ truncated = true;
148
+ warnings.push(
149
+ `found ${files.length} sourcemaps; capping at ${maxFiles} \u2014 the rest will be skipped.`
150
+ );
151
+ result = files.slice(0, maxFiles);
152
+ }
153
+ return { files: result, warnings, truncated, duplicatesDropped };
154
+ }
155
+ function formatBytes(bytes) {
156
+ if (!Number.isFinite(bytes) || bytes < 0) return "0 B";
157
+ if (bytes < 1024) return `${bytes} B`;
158
+ const units = ["KB", "MB", "GB", "TB"];
159
+ let value = bytes / 1024;
160
+ let unitIndex = 0;
161
+ while (value >= 1024 && unitIndex < units.length - 1) {
162
+ value /= 1024;
163
+ unitIndex++;
164
+ }
165
+ return `${value.toFixed(1)} ${units[unitIndex]}`;
166
+ }
167
+
168
+ // src/cli.ts
169
+ var PUT_CONCURRENCY = 4;
170
+ async function postJson(url, key, body) {
171
+ try {
172
+ const res = await fetch(url, {
173
+ method: "POST",
174
+ headers: {
175
+ authorization: `Bearer ${key}`,
176
+ "content-type": "application/json"
177
+ },
178
+ body: JSON.stringify(body)
179
+ });
180
+ return { ok: res.ok, status: res.status, body: await res.text() };
181
+ } catch (err) {
182
+ return { ok: false, status: 0, body: `network error: ${err.message}` };
183
+ }
184
+ }
185
+ async function putFile(file, target) {
186
+ const bytes = (0, import_node_fs2.readFileSync)(file.absolutePath);
187
+ const method = target.method ?? "PUT";
188
+ const headers = target.headers ?? {};
189
+ let lastError = "";
190
+ for (let attempt = 0; attempt < 2; attempt++) {
191
+ try {
192
+ const res = await fetch(target.url, { method, headers, body: bytes });
193
+ if (res.ok) return;
194
+ lastError = `HTTP ${res.status}`;
195
+ if (res.status < 500) break;
196
+ } catch (err) {
197
+ lastError = err.message;
198
+ }
199
+ }
200
+ throw new Error(lastError || "upload failed");
201
+ }
202
+ async function runPool(items, concurrency, worker) {
203
+ const failures = [];
204
+ let index = 0;
205
+ const next = async () => {
206
+ while (index < items.length) {
207
+ const item = items[index++];
208
+ try {
209
+ await worker(item);
210
+ } catch (err) {
211
+ failures.push({ item, error: err.message });
212
+ }
213
+ }
214
+ };
215
+ const runners = [];
216
+ for (let i = 0; i < Math.min(concurrency, items.length); i++) runners.push(next());
217
+ await Promise.all(runners);
218
+ return failures;
219
+ }
220
+ async function main(argv) {
221
+ const parsed = parseArgs(argv);
222
+ if (parsed.kind === "help") {
223
+ process.stdout.write(`${USAGE}
224
+ `);
225
+ return 0;
226
+ }
227
+ if (parsed.kind === "error") {
228
+ process.stderr.write(`error: ${parsed.message}
229
+
230
+ ${USAGE}
231
+ `);
232
+ return 1;
233
+ }
234
+ const { release, key, endpoint, dryRun, dirs } = parsed.args;
235
+ const { files, warnings, duplicatesDropped } = discoverSourcemaps(dirs);
236
+ for (const w of warnings) process.stderr.write(`warning: ${w}
237
+ `);
238
+ if (files.length === 0) {
239
+ process.stderr.write(
240
+ `warning: no .map files found in: ${dirs.join(", ")} \u2014 nothing to upload.
241
+ `
242
+ );
243
+ return 0;
244
+ }
245
+ const totalBytes = files.reduce((sum, f) => sum + f.byteSize, 0);
246
+ if (dryRun) {
247
+ process.stdout.write(
248
+ `Dry run \u2014 would upload ${files.length} sourcemap(s) for release ${release} (${formatBytes(totalBytes)}):
249
+ `
250
+ );
251
+ for (const f of files) {
252
+ process.stdout.write(` ${f.name} (${formatBytes(f.byteSize)})
253
+ `);
254
+ }
255
+ process.stdout.write("Nothing uploaded (--dry-run).\n");
256
+ return 0;
257
+ }
258
+ const base = endpoint.replace(/\/+$/, "");
259
+ process.stdout.write(
260
+ `Uploading ${files.length} sourcemap(s) for release ${release} to ${base} ...
261
+ `
262
+ );
263
+ const initiate = await postJson(`${base}/v1/lens/sourcemaps/initiate`, key, {
264
+ release,
265
+ files: files.map((f) => ({ name: f.name, byteSize: f.byteSize }))
266
+ });
267
+ if (!initiate.ok) {
268
+ process.stderr.write(
269
+ `error: initiate failed (HTTP ${initiate.status})
270
+ ${initiate.body}
271
+ `
272
+ );
273
+ return 1;
274
+ }
275
+ let initiateData;
276
+ try {
277
+ initiateData = JSON.parse(initiate.body);
278
+ } catch {
279
+ process.stderr.write(
280
+ `error: could not parse initiate response as JSON
281
+ ${initiate.body}
282
+ `
283
+ );
284
+ return 1;
285
+ }
286
+ const byName = new Map(files.map((f) => [f.name, f]));
287
+ const jobs = [];
288
+ for (const target of initiateData.uploads ?? []) {
289
+ const file = byName.get(target.name);
290
+ if (!file) {
291
+ process.stderr.write(
292
+ `warning: server returned an upload target for unknown file "${target.name}" \u2014 skipping
293
+ `
294
+ );
295
+ continue;
296
+ }
297
+ jobs.push({ file, target });
298
+ }
299
+ const targeted = new Set(jobs.map((j) => j.file.name));
300
+ const undelivered = files.filter((f) => !targeted.has(f.name));
301
+ if (undelivered.length > 0) {
302
+ process.stderr.write(
303
+ `error: server returned upload targets for ${targeted.size} of ${files.length} file(s); no target for: ${undelivered.map((f) => f.name).join(", ")}
304
+ `
305
+ );
306
+ return 1;
307
+ }
308
+ const failures = await runPool(jobs, PUT_CONCURRENCY, async ({ file, target }) => {
309
+ await putFile(file, target);
310
+ process.stdout.write(` \u2713 ${file.name}
311
+ `);
312
+ });
313
+ if (failures.length > 0) {
314
+ for (const { item, error } of failures) {
315
+ process.stderr.write(`error: failed to upload ${item.file.name}: ${error}
316
+ `);
317
+ }
318
+ return 1;
319
+ }
320
+ const uploaded = jobs.map((j) => j.file);
321
+ const finalize = await postJson(`${base}/v1/lens/sourcemaps/finalize`, key, {
322
+ release,
323
+ files: uploaded.map((f) => ({ name: f.name, byteSize: f.byteSize }))
324
+ });
325
+ if (!finalize.ok) {
326
+ process.stderr.write(
327
+ `error: finalize failed (HTTP ${finalize.status})
328
+ ${finalize.body}
329
+ `
330
+ );
331
+ return 1;
332
+ }
333
+ const uploadedBytes = uploaded.reduce((sum, f) => sum + f.byteSize, 0);
334
+ process.stdout.write(
335
+ `\u2713 uploaded ${uploaded.length} sourcemaps for release ${release} (${formatBytes(uploadedBytes)})
336
+ `
337
+ );
338
+ if (duplicatesDropped > 0) {
339
+ process.stdout.write(
340
+ ` note: ${duplicatesDropped} sourcemap(s) were skipped due to duplicate basenames (see warnings above).
341
+ `
342
+ );
343
+ }
344
+ return 0;
345
+ }
346
+ main(process.argv.slice(2)).then((code) => {
347
+ process.exitCode = code;
348
+ }).catch((err) => {
349
+ process.stderr.write(`error: ${err.message}
350
+ `);
351
+ process.exitCode = 1;
352
+ });
353
+ //# sourceMappingURL=cli.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/cli.ts","../src/cli-lib.ts"],"sourcesContent":["/**\n * `horus-lens upload-sourcemaps` — CI-friendly sourcemap uploader.\n *\n * npx @merittdev/horus-lens upload-sourcemaps --release <sha> dist/\n *\n * Node >= 18: uses native `fetch` and `node:fs` recursion — no runtime deps.\n * Pure logic (arg parsing, discovery, byte formatting) lives in `./cli-lib`.\n */\nimport { readFileSync } from 'node:fs';\n\nimport {\n discoverSourcemaps,\n formatBytes,\n parseArgs,\n USAGE,\n type DiscoveredFile,\n} from './cli-lib.js';\n\nconst PUT_CONCURRENCY = 4;\n\ninterface UploadTarget {\n name: string;\n url: string;\n method?: string;\n headers?: Record<string, string>;\n}\n\ninterface InitiateResponse {\n uploads?: UploadTarget[];\n}\n\ninterface HttpResult {\n ok: boolean;\n status: number;\n body: string;\n}\n\nasync function postJson(url: string, key: string, body: unknown): Promise<HttpResult> {\n try {\n const res = await fetch(url, {\n method: 'POST',\n headers: {\n authorization: `Bearer ${key}`,\n 'content-type': 'application/json',\n },\n body: JSON.stringify(body),\n });\n return { ok: res.ok, status: res.status, body: await res.text() };\n } catch (err) {\n return { ok: false, status: 0, body: `network error: ${(err as Error).message}` };\n }\n}\n\n/**\n * PUT one file's raw bytes to its presigned URL, retrying once on *transient*\n * failure only.\n *\n * Headers come from the server: presigned URLs are signed with the exact\n * headers the storage backend expects, so we send `target.headers` verbatim and\n * do NOT invent a `content-type` (a wrong/extra Content-Type breaks the\n * signature on S3-style URLs). If the server said nothing, send nothing.\n */\nasync function putFile(file: DiscoveredFile, target: UploadTarget): Promise<void> {\n const bytes = readFileSync(file.absolutePath);\n const method = target.method ?? 'PUT';\n const headers = target.headers ?? {};\n let lastError = '';\n\n for (let attempt = 0; attempt < 2; attempt++) {\n try {\n const res = await fetch(target.url, { method, headers, body: bytes });\n if (res.ok) return;\n lastError = `HTTP ${res.status}`;\n // Only network failures and 5xx are worth a retry. A 4xx is a permanent\n // client error (expired URL, bad signature, wrong headers) — retrying\n // just wastes time and hides the real cause, so fail fast.\n if (res.status < 500) break;\n } catch (err) {\n lastError = (err as Error).message;\n }\n }\n throw new Error(lastError || 'upload failed');\n}\n\n/** Run `worker` over `items` with bounded concurrency; collect failures. */\nasync function runPool<T>(\n items: T[],\n concurrency: number,\n worker: (item: T) => Promise<void>,\n): Promise<{ item: T; error: string }[]> {\n const failures: { item: T; error: string }[] = [];\n let index = 0;\n\n const next = async (): Promise<void> => {\n while (index < items.length) {\n const item = items[index++]!;\n try {\n await worker(item);\n } catch (err) {\n failures.push({ item, error: (err as Error).message });\n }\n }\n };\n\n const runners: Promise<void>[] = [];\n for (let i = 0; i < Math.min(concurrency, items.length); i++) runners.push(next());\n await Promise.all(runners);\n return failures;\n}\n\nasync function main(argv: string[]): Promise<number> {\n const parsed = parseArgs(argv);\n if (parsed.kind === 'help') {\n process.stdout.write(`${USAGE}\\n`);\n return 0;\n }\n if (parsed.kind === 'error') {\n process.stderr.write(`error: ${parsed.message}\\n\\n${USAGE}\\n`);\n return 1;\n }\n\n const { release, key, endpoint, dryRun, dirs } = parsed.args;\n\n const { files, warnings, duplicatesDropped } = discoverSourcemaps(dirs);\n for (const w of warnings) process.stderr.write(`warning: ${w}\\n`);\n\n // Zero files is not a CI failure — a build may legitimately emit no maps.\n if (files.length === 0) {\n process.stderr.write(\n `warning: no .map files found in: ${dirs.join(', ')} — nothing to upload.\\n`,\n );\n return 0;\n }\n\n const totalBytes = files.reduce((sum, f) => sum + f.byteSize, 0);\n\n if (dryRun) {\n process.stdout.write(\n `Dry run — would upload ${files.length} sourcemap(s) for release ${release} (${formatBytes(totalBytes)}):\\n`,\n );\n for (const f of files) {\n process.stdout.write(` ${f.name} (${formatBytes(f.byteSize)})\\n`);\n }\n process.stdout.write('Nothing uploaded (--dry-run).\\n');\n return 0;\n }\n\n const base = endpoint.replace(/\\/+$/, '');\n process.stdout.write(\n `Uploading ${files.length} sourcemap(s) for release ${release} to ${base} ...\\n`,\n );\n\n // 1. initiate — exchange file list for presigned upload targets.\n const initiate = await postJson(`${base}/v1/lens/sourcemaps/initiate`, key, {\n release,\n files: files.map((f) => ({ name: f.name, byteSize: f.byteSize })),\n });\n if (!initiate.ok) {\n process.stderr.write(\n `error: initiate failed (HTTP ${initiate.status})\\n${initiate.body}\\n`,\n );\n return 1;\n }\n\n let initiateData: InitiateResponse;\n try {\n initiateData = JSON.parse(initiate.body) as InitiateResponse;\n } catch {\n process.stderr.write(\n `error: could not parse initiate response as JSON\\n${initiate.body}\\n`,\n );\n return 1;\n }\n\n const byName = new Map(files.map((f) => [f.name, f]));\n const jobs: { file: DiscoveredFile; target: UploadTarget }[] = [];\n for (const target of initiateData.uploads ?? []) {\n const file = byName.get(target.name);\n if (!file) {\n process.stderr.write(\n `warning: server returned an upload target for unknown file \"${target.name}\" — skipping\\n`,\n );\n continue;\n }\n jobs.push({ file, target });\n }\n\n // Every discovered file must have its own upload target. If the server\n // returned fewer targets than files (or omitted some), finalizing anyway\n // would commit a release with missing sourcemaps and still exit 0 — the\n // failure mode this guard exists to prevent. Fail loudly with the gap listed.\n const targeted = new Set(jobs.map((j) => j.file.name));\n const undelivered = files.filter((f) => !targeted.has(f.name));\n if (undelivered.length > 0) {\n process.stderr.write(\n `error: server returned upload targets for ${targeted.size} of ${files.length} file(s); ` +\n `no target for: ${undelivered.map((f) => f.name).join(', ')}\\n`,\n );\n return 1;\n }\n\n // 2. PUT each file (concurrency-limited, one retry each).\n const failures = await runPool(jobs, PUT_CONCURRENCY, async ({ file, target }) => {\n await putFile(file, target);\n process.stdout.write(` ✓ ${file.name}\\n`);\n });\n if (failures.length > 0) {\n for (const { item, error } of failures) {\n process.stderr.write(`error: failed to upload ${item.file.name}: ${error}\\n`);\n }\n return 1;\n }\n\n // 3. finalize — commit the release with the files we actually uploaded.\n const uploaded = jobs.map((j) => j.file);\n const finalize = await postJson(`${base}/v1/lens/sourcemaps/finalize`, key, {\n release,\n files: uploaded.map((f) => ({ name: f.name, byteSize: f.byteSize })),\n });\n if (!finalize.ok) {\n process.stderr.write(\n `error: finalize failed (HTTP ${finalize.status})\\n${finalize.body}\\n`,\n );\n return 1;\n }\n\n const uploadedBytes = uploaded.reduce((sum, f) => sum + f.byteSize, 0);\n process.stdout.write(\n `✓ uploaded ${uploaded.length} sourcemaps for release ${release} (${formatBytes(uploadedBytes)})\\n`,\n );\n if (duplicatesDropped > 0) {\n process.stdout.write(\n ` note: ${duplicatesDropped} sourcemap(s) were skipped due to duplicate basenames (see warnings above).\\n`,\n );\n }\n return 0;\n}\n\nmain(process.argv.slice(2))\n .then((code) => {\n process.exitCode = code;\n })\n .catch((err: unknown) => {\n process.stderr.write(`error: ${(err as Error).message}\\n`);\n process.exitCode = 1;\n });\n","/**\n * Pure, side-effect-light logic for the `upload-sourcemaps` CLI: argument\n * parsing, filesystem discovery of `*.map` files, and human byte formatting.\n *\n * Kept separate from `cli.ts` (which owns process/network I/O) so this logic\n * is unit-testable without spawning a subprocess or hitting the network.\n */\nimport { readdirSync, statSync } from 'node:fs';\nimport { join, resolve } from 'node:path';\n\n/**\n * Default Horus Lens API endpoint. Mirrors `DEFAULT_ENDPOINT` in\n * `@horus-lens/core` (packages/core/src/constants.ts). Duplicated as a literal\n * rather than imported because core's only export entrypoint pulls in the full\n * browser SDK (rrweb / DOM globals), which has no place in a Node CLI bundle.\n * Keep this value in sync with core.\n */\nexport const DEFAULT_ENDPOINT = 'https://api.horus.sh';\n\n/** Environment variable consulted for the upload key when `--key` is omitted. */\nexport const UPLOAD_KEY_ENV = 'LENS_UPLOAD_KEY';\n\n/** Hard cap on the number of sourcemaps uploaded per invocation. */\nexport const MAX_FILES = 200;\n\nexport const USAGE = `Usage: horus-lens upload-sourcemaps --release <release> [options] <dir...>\n\nUpload JavaScript sourcemaps (*.map) for a release to Horus Lens.\n\nArguments:\n <dir...> One or more directories to search recursively for .map files\n\nOptions:\n --release <release> Release identifier, e.g. a git SHA [required]\n --key <las_...> Per-site upload key (las_-prefixed).\n Falls back to the ${UPLOAD_KEY_ENV} environment variable.\n --endpoint <url> API endpoint (default: ${DEFAULT_ENDPOINT})\n --dry-run List what would be uploaded, then exit without uploading\n -h, --help Show this help\n\nExample:\n npx @merittdev/horus-lens upload-sourcemaps --release \"$GIT_SHA\" dist/`;\n\nexport interface ParsedArgs {\n release: string;\n /** Resolved upload key (from --key or the env fallback). May be empty for --dry-run. */\n key: string;\n endpoint: string;\n dryRun: boolean;\n dirs: string[];\n}\n\nexport type ParseResult =\n | { kind: 'run'; args: ParsedArgs }\n | { kind: 'help' }\n | { kind: 'error'; message: string };\n\n/**\n * Parse the CLI argv (everything after the binary name) into a structured\n * result. Never throws — validation failures become `{ kind: 'error' }`.\n */\nexport function parseArgs(\n argv: string[],\n env: Record<string, string | undefined> = process.env,\n): ParseResult {\n if (argv.length === 0) return { kind: 'help' };\n\n const [command, ...rest] = argv;\n if (command === '-h' || command === '--help' || command === 'help') {\n return { kind: 'help' };\n }\n if (command !== 'upload-sourcemaps') {\n return { kind: 'error', message: `Unknown command: ${command}` };\n }\n\n let release: string | undefined;\n let key: string | undefined;\n let endpoint = DEFAULT_ENDPOINT;\n let dryRun = false;\n const dirs: string[] = [];\n\n for (let i = 0; i < rest.length; i++) {\n const token = rest[i]!;\n\n if (token === '-h' || token === '--help') return { kind: 'help' };\n\n if (token.startsWith('--')) {\n let flag = token;\n let value: string | undefined;\n const eq = token.indexOf('=');\n if (eq !== -1) {\n flag = token.slice(0, eq);\n value = token.slice(eq + 1);\n }\n\n if (flag === '--dry-run') {\n dryRun = true;\n continue;\n }\n\n if (flag === '--release' || flag === '--key' || flag === '--endpoint') {\n if (value === undefined) {\n const next = rest[i + 1];\n if (next === undefined || next.startsWith('-')) {\n return { kind: 'error', message: `Option ${flag} requires a value.` };\n }\n value = next;\n i++;\n }\n if (flag === '--release') release = value;\n else if (flag === '--key') key = value;\n else endpoint = value;\n continue;\n }\n\n return { kind: 'error', message: `Unknown option: ${flag}` };\n }\n\n dirs.push(token);\n }\n\n const resolvedKey = (key ?? env[UPLOAD_KEY_ENV] ?? '').trim();\n\n const missing: string[] = [];\n if (!release) missing.push('--release <release>');\n if (dirs.length === 0) missing.push('<dir...>');\n // The key is only needed to actually talk to the server; --dry-run is offline.\n if (!dryRun && !resolvedKey) {\n missing.push(`--key <las_...> (or set ${UPLOAD_KEY_ENV})`);\n }\n\n if (missing.length > 0) {\n return {\n kind: 'error',\n message: `Missing required argument(s): ${missing.join(', ')}`,\n };\n }\n\n return {\n kind: 'run',\n args: { release: release!, key: resolvedKey, endpoint, dryRun, dirs },\n };\n}\n\nexport interface DiscoveredFile {\n /** Basename, e.g. `main.js.map`. Sent to the server as the file identity. */\n name: string;\n byteSize: number;\n absolutePath: string;\n}\n\nexport interface DiscoveryResult {\n files: DiscoveredFile[];\n warnings: string[];\n truncated: boolean;\n /**\n * Number of files skipped because another file with the same basename was\n * already discovered. The server identifies maps by basename (see\n * `DiscoveredFile.name`), so these collisions cannot be uploaded and their\n * bundles will have no sourcemap. Surfaced so callers can flag it in a summary.\n */\n duplicatesDropped: number;\n}\n\n/**\n * Recursively discover `*.map` files under the given directories.\n *\n * - `node_modules` directories are skipped entirely.\n * - Duplicate basenames (across or within dirs) keep the first seen and warn.\n * - Results are capped at `maxFiles` (warns + truncates rather than failing).\n * - Unreadable dirs / unstattable files warn and are skipped.\n */\nexport function discoverSourcemaps(\n dirs: string[],\n opts: { maxFiles?: number } = {},\n): DiscoveryResult {\n const maxFiles = opts.maxFiles ?? MAX_FILES;\n const warnings: string[] = [];\n const seen = new Map<string, string>(); // basename -> first absolute path\n const files: DiscoveredFile[] = [];\n let duplicatesDropped = 0;\n\n const walk = (dir: string): void => {\n let entries;\n try {\n entries = readdirSync(dir, { withFileTypes: true });\n } catch {\n warnings.push(`could not read directory: ${dir}`);\n return;\n }\n // Sort for deterministic ordering so \"keep the first\" is stable.\n entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));\n\n for (const entry of entries) {\n if (entry.name === 'node_modules') continue;\n const full = join(dir, entry.name);\n\n if (entry.isDirectory()) {\n walk(full);\n continue;\n }\n if (!entry.isFile() || !entry.name.endsWith('.map')) continue;\n\n const name = entry.name;\n const existing = seen.get(name);\n if (existing !== undefined) {\n duplicatesDropped++;\n warnings.push(\n `DUPLICATE sourcemap basename \"${name}\" — the server identifies maps by ` +\n `basename, so only ${existing} will be uploaded; ${full} is SKIPPED and ` +\n `its bundle will have NO sourcemap. Rename the bundle/map to disambiguate.`,\n );\n continue;\n }\n\n let byteSize: number;\n try {\n byteSize = statSync(full).size;\n } catch {\n warnings.push(`could not stat file: ${full}`);\n continue;\n }\n\n seen.set(name, full);\n files.push({ name, byteSize, absolutePath: full });\n }\n };\n\n for (const dir of dirs) walk(resolve(dir));\n\n // Prominent tally so a single dropped map isn't lost in the per-file noise.\n if (duplicatesDropped > 0) {\n warnings.push(\n `${duplicatesDropped} sourcemap(s) SKIPPED due to duplicate basenames — those ` +\n `bundles will have no sourcemap on the server. See the warnings above.`,\n );\n }\n\n let truncated = false;\n let result = files;\n if (files.length > maxFiles) {\n truncated = true;\n warnings.push(\n `found ${files.length} sourcemaps; capping at ${maxFiles} — the rest will be skipped.`,\n );\n result = files.slice(0, maxFiles);\n }\n\n return { files: result, warnings, truncated, duplicatesDropped };\n}\n\n/** Format a byte count as a short human string, e.g. `3.4 MB`, `512 B`. */\nexport function formatBytes(bytes: number): string {\n if (!Number.isFinite(bytes) || bytes < 0) return '0 B';\n if (bytes < 1024) return `${bytes} B`;\n const units = ['KB', 'MB', 'GB', 'TB'];\n let value = bytes / 1024;\n let unitIndex = 0;\n while (value >= 1024 && unitIndex < units.length - 1) {\n value /= 1024;\n unitIndex++;\n }\n return `${value.toFixed(1)} ${units[unitIndex]!}`;\n}\n"],"mappings":"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","names":["import_node_fs"]}
package/dist/index.cjs CHANGED
@@ -29111,7 +29111,7 @@ async function domToBlob(node2, options) {
29111
29111
 
29112
29112
  // ../core/dist/index.js
29113
29113
  var SDK_NAME = "@merittdev/horus-lens";
29114
- var SDK_VERSION = true ? "0.3.0" : "0.0.0-dev";
29114
+ var SDK_VERSION = true ? "0.5.0" : "0.0.0-dev";
29115
29115
  var DEFAULT_ENDPOINT = "https://api.horus.sh";
29116
29116
  var DEFAULT_REPLAY_WINDOW_MS = 15e3;
29117
29117
  var DEFAULT_MAX_CONSOLE_ENTRIES = 200;
@@ -29172,11 +29172,15 @@ function startAutoCapture(client, options = {}) {
29172
29172
  const last = lastSeen.get(signature);
29173
29173
  if (last !== void 0 && now - last < AUTO_CAPTURE_COOLDOWN_MS) return;
29174
29174
  if (sessionCount >= AUTO_CAPTURE_SESSION_CAP) return;
29175
+ const previousSeen = lastSeen.get(signature);
29175
29176
  lastSeen.set(signature, now);
29176
29177
  sessionCount += 1;
29177
29178
  submitting = true;
29178
29179
  options.onCapture?.(signature);
29179
29180
  void Promise.resolve().then(() => client.submitReport({ trigger: "error", include: { screenshot: false } })).catch(() => {
29181
+ if (previousSeen === void 0) lastSeen.delete(signature);
29182
+ else lastSeen.set(signature, previousSeen);
29183
+ sessionCount -= 1;
29180
29184
  }).finally(() => {
29181
29185
  submitting = false;
29182
29186
  });
@@ -29231,6 +29235,14 @@ var RingBuffer = class {
29231
29235
  return this.items.length;
29232
29236
  }
29233
29237
  };
29238
+ var HttpError = class extends Error {
29239
+ constructor(status, message) {
29240
+ super(message);
29241
+ this.status = status;
29242
+ this.name = "HttpError";
29243
+ }
29244
+ status;
29245
+ };
29234
29246
  var Transport = class {
29235
29247
  constructor(config2) {
29236
29248
  this.config = config2;
@@ -29238,18 +29250,23 @@ var Transport = class {
29238
29250
  config;
29239
29251
  async initiate(assets) {
29240
29252
  const body = { accessId: this.config.accessId, assets };
29241
- return this.postJson(`${this.config.endpoint}/v1/reports/initiate`, body);
29253
+ return this.postJson(
29254
+ `${this.config.endpoint}/v1/reports/initiate`,
29255
+ body,
29256
+ { retries: 0 }
29257
+ );
29242
29258
  }
29243
29259
  /** Record a late-uploaded asset (background screenshot) on a finalized report. */
29244
29260
  async attachAsset(reportId, asset) {
29245
29261
  const body = { accessId: this.config.accessId, ...asset };
29246
29262
  await this.postJson(`${this.config.endpoint}/v1/reports/${encodeURIComponent(reportId)}/assets`, body);
29247
29263
  }
29248
- async uploadAsset(upload, blob2) {
29264
+ async uploadAsset(upload, blob2, signal) {
29249
29265
  const response = await fetch(upload.url, {
29250
29266
  method: upload.method,
29251
29267
  headers: upload.headers,
29252
- body: blob2
29268
+ body: blob2,
29269
+ signal
29253
29270
  });
29254
29271
  if (!response.ok) {
29255
29272
  throw new Error(`Asset upload failed (${upload.kind}): ${response.status}`);
@@ -29257,12 +29274,20 @@ var Transport = class {
29257
29274
  }
29258
29275
  async finalize(reportId, metadata, uploadedAssets) {
29259
29276
  const body = { accessId: this.config.accessId, metadata, uploadedAssets };
29260
- return this.postJson(
29261
- `${this.config.endpoint}/v1/reports/${reportId}/finalize`,
29262
- body
29263
- );
29277
+ try {
29278
+ return await this.postJson(
29279
+ `${this.config.endpoint}/v1/reports/${reportId}/finalize`,
29280
+ body
29281
+ );
29282
+ } catch (err) {
29283
+ if (err instanceof HttpError && err.status === 409) {
29284
+ return { reportId, status: "submitted" };
29285
+ }
29286
+ throw err;
29287
+ }
29264
29288
  }
29265
- async postJson(url2, body, retries = 1) {
29289
+ async postJson(url2, body, opts = {}) {
29290
+ const retries = opts.retries ?? 1;
29266
29291
  let lastError;
29267
29292
  for (let attempt = 0; attempt <= retries; attempt++) {
29268
29293
  try {
@@ -29273,7 +29298,10 @@ var Transport = class {
29273
29298
  });
29274
29299
  if (!response.ok) {
29275
29300
  const text = await response.text().catch(() => "");
29276
- throw new Error(`Request to ${url2} failed: ${response.status} ${text}`.trim());
29301
+ throw new HttpError(
29302
+ response.status,
29303
+ `Request to ${url2} failed: ${response.status} ${text}`.trim()
29304
+ );
29277
29305
  }
29278
29306
  return await response.json();
29279
29307
  } catch (err) {
@@ -29714,8 +29742,14 @@ async function captureScreenshot(excludeHost, crop, composite) {
29714
29742
  return null;
29715
29743
  }
29716
29744
  }
29717
- if (crop) {
29718
- const cropped = await cropBlob(blob2, crop, scale);
29745
+ const effectiveCrop = crop ?? (composite ? void 0 : {
29746
+ x: window.scrollX,
29747
+ y: window.scrollY,
29748
+ width: window.innerWidth,
29749
+ height: window.innerHeight
29750
+ });
29751
+ if (effectiveCrop) {
29752
+ const cropped = await cropBlob(blob2, effectiveCrop, scale);
29719
29753
  return cropped ?? blob2;
29720
29754
  }
29721
29755
  return blob2;
@@ -30149,12 +30183,22 @@ var LensClient = class {
30149
30183
  input2.screenshotCrop,
30150
30184
  input2.annotationComposite
30151
30185
  ) : Promise.resolve(null);
30186
+ const consumingManualRecording = wantReplay && (this.replay?.hasManualRecording() ?? false) && !(this.replay?.isManualRecording() ?? false);
30152
30187
  const replayEvents = wantReplay ? this.replay?.snapshot() ?? [] : [];
30153
30188
  const errorEntries = this.errorBuffer.drain();
30154
- const consoleTail = this.consoleBuffer.tail(CONSOLE_TAIL_SIZE);
30155
- const networkTail = this.networkBuffer.tail(NETWORK_TAIL_SIZE);
30156
30189
  const consoleEntries = this.consoleBuffer.drain();
30157
30190
  const networkEntries = this.networkBuffer.drain();
30191
+ this.errorBuffer.clear();
30192
+ this.consoleBuffer.clear();
30193
+ this.networkBuffer.clear();
30194
+ const consoleTail = consoleEntries.slice(-CONSOLE_TAIL_SIZE);
30195
+ const isNetFailure = (e) => e.ok !== true || Boolean(e.error);
30196
+ const recentFailures = networkEntries.filter(isNetFailure).slice(-NETWORK_TAIL_SIZE);
30197
+ const keep = /* @__PURE__ */ new Set([
30198
+ ...recentFailures,
30199
+ ...networkEntries.slice(-NETWORK_TAIL_SIZE)
30200
+ ]);
30201
+ const networkTail = networkEntries.filter((e) => keep.has(e)).slice(-NETWORK_TAIL_SIZE);
30158
30202
  let metadata = {
30159
30203
  annotations: input2.annotations ?? [],
30160
30204
  env: collectEnv(),
@@ -30208,13 +30252,14 @@ var LensClient = class {
30208
30252
  }
30209
30253
  if (!asset.blob) continue;
30210
30254
  try {
30211
- await this.transport.uploadAsset(upload, asset.blob);
30255
+ await this.uploadAssetWithTimeout(upload, asset.blob);
30212
30256
  uploadedAssets.push({ kind: upload.kind, key: upload.key, byteSize: asset.blob.size });
30213
30257
  } catch (err) {
30214
30258
  this.warn(`asset upload failed (${upload.kind})`, err);
30215
30259
  }
30216
30260
  }
30217
30261
  const finalized = await this.transport.finalize(initiated.reportId, metadata, uploadedAssets);
30262
+ if (consumingManualRecording) this.replay?.discardManualRecording();
30218
30263
  if (screenshotUpload) {
30219
30264
  const upload = screenshotUpload;
30220
30265
  void screenshotPromise.then(async (blob2) => {
@@ -30232,6 +30277,21 @@ var LensClient = class {
30232
30277
  }
30233
30278
  return { reportId: finalized.reportId };
30234
30279
  }
30280
+ /**
30281
+ * Upload a foreground asset with a bounded timeout. Without this a stuck S3
30282
+ * PUT (dropped connection, half-open socket) would hang `submitReport`
30283
+ * forever; the timeout aborts the individual PUT so it fails as one asset
30284
+ * (caught by the caller) instead of wedging the whole submit.
30285
+ */
30286
+ async uploadAssetWithTimeout(upload, blob2) {
30287
+ const controller = new AbortController();
30288
+ const timer = setTimeout(() => controller.abort(), SCREENSHOT_TIMEOUT_MS);
30289
+ try {
30290
+ await this.transport.uploadAsset(upload, blob2, controller.signal);
30291
+ } finally {
30292
+ clearTimeout(timer);
30293
+ }
30294
+ }
30235
30295
  warn(message, err) {
30236
30296
  if (!this.debug) return;
30237
30297
  if (err !== void 0) console.warn(`[lens] ${message}`, err);