@realuptime/errors 0.4.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/scrub.ts ADDED
@@ -0,0 +1,181 @@
1
+ import type { WireBreadcrumb, WireDevice, WireEvent, WireRequest, WireUser } from "./types.ts";
2
+
3
+ /**
4
+ * PII scrub-by-default, client-side (docs/errors-plan.md, "PII
5
+ * scrub-by-default, specified"). Runs BEFORE serialization so the sensitive
6
+ * value never transits realuptime's wire; the server runs the same rules
7
+ * again as a second net (packages/db/error-scrub.ts), and the shared
8
+ * vectors in scrub-vectors.json pin all implementations -- this one, the
9
+ * Python SDK's, and the server's -- to byte-identical behavior.
10
+ *
11
+ * The rules, in the order they run over every scrubbed string:
12
+ *
13
+ * 1. JWT three-dot shape (`eyJ...` header, two more base64url segments).
14
+ * 2. Prefixed keys: sk_/pk_/rk_/ghp_/gho_/ghs_/xox?_/rua_/rue_/ru_live_
15
+ * followed by 8+ token characters.
16
+ * 3. Card-shaped digit runs: 13-19 digits, optional space/dash
17
+ * separators, passing Luhn. A digit run that fails Luhn is an order
18
+ * id, not a card, and stays.
19
+ * 4. Long hex runs (32+).
20
+ * 5. Long base64-ish runs (40+, no slash -- file paths stay readable).
21
+ *
22
+ * Scrubbed strings: the message, every request header value, the request
23
+ * path and route, every breadcrumb message and breadcrumb data value
24
+ * (Phase 3), any source-context lines a frame carries (a hardcoded secret
25
+ * in source code must not ride a stack trace), and every v2 field a
26
+ * customer can put their own bytes in -- the user id, every tag value,
27
+ * every custom-context value, every device value, and every captured local
28
+ * variable. NOT scrubbed: frame file paths, function names and line/column
29
+ * numbers (they are the product), exceptionType, release, environment, and
30
+ * tag/context KEYS (a key is a label the integrator chose, and scrubbing it
31
+ * would make an issue's tag filter depend on whether the value beside it
32
+ * happened to look like a card number).
33
+ *
34
+ * REMOVAL (value replaced whole) unless the field is allow-listed:
35
+ *
36
+ * - Headers: Authorization, Proxy-Authorization, Cookie, Set-Cookie,
37
+ * case-insensitive, allow-listed by lowercased header name.
38
+ * - Identity (v2, REA-182): `user.email` and `user.username`, allow-listed
39
+ * by those exact dotted names. `user.id` is NOT removed: an opaque
40
+ * identifier in the customer's own key space is the field that makes
41
+ * "how many users hit this" answerable, and it is not contact data.
42
+ *
43
+ * `allowFields` opts a field back in BY NAME, per field and explicit in
44
+ * code, never a global switch -- and an allowed field's value still passes
45
+ * the pattern scrub, so opting one back in can never smuggle a raw token
46
+ * through.
47
+ */
48
+
49
+ export const SCRUBBED = "[scrubbed]";
50
+
51
+ /** Headers whose value is removed whole unless the field is allow-listed. */
52
+ const REMOVED_HEADERS = new Set(["authorization", "proxy-authorization", "cookie", "set-cookie"]);
53
+
54
+ /** Identity fields removed whole unless allow-listed by these exact names.
55
+ * `user.id` is deliberately absent: see the module doc. */
56
+ export const REMOVED_USER_FIELDS = ["user.email", "user.username"] as const;
57
+
58
+ const JWT_RE = /\beyJ[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\b/g;
59
+ const PREFIXED_KEY_RE = /\b(?:sk|pk|rk|ghp|gho|ghs|xox[a-z]|rua|rue|ru_live)_[A-Za-z0-9_-]{8,}/g;
60
+ const CARD_RE = /(?<!\d)(?:\d[ -]?){12,18}\d(?!\d)/g;
61
+ const HEX_RE = /\b[0-9a-fA-F]{32,}\b/g;
62
+ const BASE64_RE = /(?<![A-Za-z0-9+_=-])[A-Za-z0-9+_-]{40,}={0,2}/g;
63
+
64
+ function luhnValid(digits: string): boolean {
65
+ let sum = 0;
66
+ let double = false;
67
+ for (let i = digits.length - 1; i >= 0; i--) {
68
+ let d = digits.charCodeAt(i) - 48;
69
+ if (double) {
70
+ d *= 2;
71
+ if (d > 9) d -= 9;
72
+ }
73
+ sum += d;
74
+ double = !double;
75
+ }
76
+ return sum % 10 === 0;
77
+ }
78
+
79
+ /** Applies the five pattern rules to one string. Pure; exported so the
80
+ * vector tests can drive it directly. */
81
+ export function scrubString(value: string): string {
82
+ let out = value.replace(JWT_RE, SCRUBBED);
83
+ out = out.replace(PREFIXED_KEY_RE, SCRUBBED);
84
+ out = out.replace(CARD_RE, (run) => {
85
+ const digits = run.replace(/[ -]/g, "");
86
+ return digits.length >= 13 && digits.length <= 19 && luhnValid(digits) ? SCRUBBED : run;
87
+ });
88
+ out = out.replace(HEX_RE, SCRUBBED);
89
+ out = out.replace(BASE64_RE, SCRUBBED);
90
+ return out;
91
+ }
92
+
93
+ function scrubRequest(request: WireRequest, allowFields: ReadonlySet<string>): WireRequest {
94
+ const out: WireRequest = { ...request };
95
+ if (typeof out.path === "string") out.path = scrubString(out.path);
96
+ if (typeof out.route === "string") out.route = scrubString(out.route);
97
+ if (out.headers) {
98
+ const headers: Record<string, string> = {};
99
+ for (const [name, value] of Object.entries(out.headers)) {
100
+ const lower = name.toLowerCase();
101
+ if (REMOVED_HEADERS.has(lower) && !allowFields.has(lower)) {
102
+ headers[name] = SCRUBBED;
103
+ } else {
104
+ headers[name] = scrubString(value);
105
+ }
106
+ }
107
+ out.headers = headers;
108
+ }
109
+ return out;
110
+ }
111
+
112
+ function scrubBreadcrumb(crumb: WireBreadcrumb): WireBreadcrumb {
113
+ let data: Record<string, string> | null = null;
114
+ if (crumb.data) {
115
+ data = {};
116
+ for (const [name, value] of Object.entries(crumb.data)) data[name] = scrubString(value);
117
+ }
118
+ return { ...crumb, message: scrubString(crumb.message), data };
119
+ }
120
+
121
+ /** v2: identity. `id` passes the pattern rules; `email` and `username` are
122
+ * removed whole unless the customer opted that exact dotted name in. An
123
+ * opted-in value still passes the pattern rules. */
124
+ function scrubUser(user: WireUser, allowFields: ReadonlySet<string>): WireUser {
125
+ const out: WireUser = { ...user };
126
+ if (typeof out.id === "string") out.id = scrubString(out.id);
127
+ for (const field of REMOVED_USER_FIELDS) {
128
+ const key = field.slice("user.".length) as "email" | "username";
129
+ const value = out[key];
130
+ if (typeof value !== "string") continue;
131
+ out[key] = allowFields.has(field) ? scrubString(value) : SCRUBBED;
132
+ }
133
+ return out;
134
+ }
135
+
136
+ /** v2: a flat string map (tags, custom context, device, frame locals).
137
+ * Values pass the pattern rules; keys are left alone (see the module doc). */
138
+ function scrubStringMap(map: Record<string, string>): Record<string, string> {
139
+ const out: Record<string, string> = {};
140
+ for (const [name, value] of Object.entries(map)) {
141
+ out[name] = typeof value === "string" ? scrubString(value) : value;
142
+ }
143
+ return out;
144
+ }
145
+
146
+ function scrubDevice(device: WireDevice): WireDevice {
147
+ return scrubStringMap(device as Record<string, string>) as WireDevice;
148
+ }
149
+
150
+ /** Scrubs one event. Returns a new object; the input is never mutated (the
151
+ * caller may still be holding it, e.g. inside an exception handler).
152
+ *
153
+ * v2 fields are touched only when PRESENT: a v1 event goes through here and
154
+ * comes out with exactly the v1 key set it went in with, which is what
155
+ * makes the vector table's v1 expectations still hold byte-for-byte. */
156
+ export function scrubEvent(event: WireEvent, allowFields: readonly string[] = []): WireEvent {
157
+ const allowed = new Set(allowFields.map((f) => f.toLowerCase()));
158
+ return {
159
+ ...event,
160
+ message: scrubString(event.message),
161
+ request: event.request ? scrubRequest(event.request, allowed) : event.request,
162
+ breadcrumbs: event.breadcrumbs ? event.breadcrumbs.map(scrubBreadcrumb) : event.breadcrumbs,
163
+ frames: event.frames
164
+ ? event.frames.map((frame) =>
165
+ frame.contextLine != null || frame.preContext?.length || frame.postContext?.length || frame.vars
166
+ ? {
167
+ ...frame,
168
+ ...(frame.contextLine != null ? { contextLine: scrubString(frame.contextLine) } : {}),
169
+ ...(frame.preContext ? { preContext: frame.preContext.map(scrubString) } : {}),
170
+ ...(frame.postContext ? { postContext: frame.postContext.map(scrubString) } : {}),
171
+ ...(frame.vars ? { vars: scrubStringMap(frame.vars) } : {}),
172
+ }
173
+ : frame,
174
+ )
175
+ : event.frames,
176
+ ...(event.user ? { user: scrubUser(event.user, allowed) } : {}),
177
+ ...(event.tags ? { tags: scrubStringMap(event.tags) } : {}),
178
+ ...(event.context ? { context: scrubStringMap(event.context) } : {}),
179
+ ...(event.device ? { device: scrubDevice(event.device) } : {}),
180
+ };
181
+ }
package/sourcemaps.ts ADDED
@@ -0,0 +1,318 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readdirSync, readFileSync, statSync } from "node:fs";
3
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
4
+
5
+ /**
6
+ * Source-map upload core (REA-254), shared by the CLI command
7
+ * (`realuptime errors sourcemaps upload`, packages/cli/commands/errors.ts)
8
+ * and the bundler plugins beside this file (`vite.ts`, `webpack.ts`).
9
+ *
10
+ * It wraps exactly one endpoint, `POST /api/errors/v1/sourcemaps/<key>`
11
+ * (apps/web/app/api/errors/v1/sourcemaps/[key]/route.ts), and nothing else.
12
+ * The endpoint is authenticated by the project's INGEST key, so the only
13
+ * secret a CI step needs is the DSN the app already has; this module derives
14
+ * the upload URL from that DSN (or from a bare key plus an origin).
15
+ *
16
+ * Honesty rules, same as the rest of the SDK:
17
+ * - Local refusals happen BEFORE any byte is sent (a file over the per-file
18
+ * cap, more files than a release may hold, no `.map` files found), and
19
+ * the message names the file. A half-uploaded release is a symbolication
20
+ * lottery, so this module never starts an upload it already knows the
21
+ * server will refuse.
22
+ * - Every file is content-addressed: its sha256 is computed locally and
23
+ * reported in the progress output, so two runs over the same build are
24
+ * visibly the same upload. Re-uploading is idempotent server-side (a
25
+ * same-named file for the same release is replaced, never duplicated); the
26
+ * summary reports `stored` and `replaced` exactly as the server answered,
27
+ * never a local guess.
28
+ * - Zero dependencies: Node builtins only (enforced by the errors-js
29
+ * dependency-hygiene test).
30
+ */
31
+
32
+ /** Mirrors packages/db/error-sourcemaps.ts; the server is the authority and
33
+ * the upload response carries its live `limits`, these are the local
34
+ * pre-flight copies. */
35
+ export const MAX_SOURCEMAP_BYTES = 2 * 1024 * 1024;
36
+ export const MAX_ARTIFACTS_PER_RELEASE = 50;
37
+ export const MAX_ARTIFACTS_PER_UPLOAD = 10;
38
+
39
+ export const DEFAULT_API_ORIGIN = "https://realuptime.io";
40
+
41
+ const INGEST_KEY_SHAPE = /^rue_[0-9a-f]{32,128}$/;
42
+
43
+ export class SourcemapUploadError extends Error {
44
+ /** Non-2xx answers carry the server's own status and RU- code verbatim. */
45
+ readonly httpStatus?: number;
46
+ readonly code?: string;
47
+ constructor(message: string, opts: { httpStatus?: number; code?: string } = {}) {
48
+ super(message);
49
+ this.name = "SourcemapUploadError";
50
+ this.httpStatus = opts.httpStatus;
51
+ this.code = opts.code;
52
+ }
53
+ }
54
+
55
+ export interface UploadTarget {
56
+ /** The project DSN, e.g. `https://realuptime.io/api/errors/v1/ingest/rue_...`. */
57
+ dsn?: string;
58
+ /** A bare ingest key, used with `origin` when no DSN is at hand. */
59
+ ingestKey?: string;
60
+ /** Origin for the bare-key form; defaults to https://realuptime.io. */
61
+ origin?: string;
62
+ }
63
+
64
+ /**
65
+ * `https://host/api/errors/v1/ingest/<key>` -> `https://host/api/errors/v1/sourcemaps/<key>`.
66
+ * A DSN wins over a bare key when both are given; either is enough.
67
+ */
68
+ export function resolveSourcemapUploadUrl(target: UploadTarget): string {
69
+ if (target.dsn) {
70
+ let url: URL;
71
+ try {
72
+ url = new URL(target.dsn);
73
+ } catch {
74
+ throw new SourcemapUploadError("The DSN is not a valid URL.");
75
+ }
76
+ const match = /^(.*\/api\/errors\/v1)\/ingest\/(rue_[0-9a-f]+)\/?$/.exec(url.pathname);
77
+ if (!match) {
78
+ throw new SourcemapUploadError("The DSN does not look like a realuptime errors DSN (expected .../api/errors/v1/ingest/rue_...).");
79
+ }
80
+ return `${url.origin}${match[1]}/sourcemaps/${match[2]}`;
81
+ }
82
+ if (target.ingestKey) {
83
+ if (!INGEST_KEY_SHAPE.test(target.ingestKey)) {
84
+ throw new SourcemapUploadError("The ingest key does not look like a realuptime errors ingest key (rue_...).");
85
+ }
86
+ const origin = (target.origin ?? DEFAULT_API_ORIGIN).replace(/\/+$/, "");
87
+ return `${origin}/api/errors/v1/sourcemaps/${target.ingestKey}`;
88
+ }
89
+ throw new SourcemapUploadError("No ingest credential: pass a DSN (REALUPTIME_ERRORS_DSN) or an ingest key (REALUPTIME_ERRORS_INGEST_KEY).");
90
+ }
91
+
92
+ export interface SourcemapFile {
93
+ /** The artifact name sent to the server: the path relative to the
94
+ * directory it was collected from, with `urlPrefix` (if any) in front.
95
+ * Symbolication matches this against a frame's file by path suffix
96
+ * (packages/db/error-sourcemaps.ts `artifactMatchesFrameFile`). */
97
+ name: string;
98
+ /** Absolute path on disk, for messages. */
99
+ path: string;
100
+ content: string;
101
+ bytes: number;
102
+ sha256: string;
103
+ }
104
+
105
+ function walkMaps(dir: string, out: string[]): void {
106
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
107
+ const full = join(dir, entry.name);
108
+ if (entry.isDirectory()) {
109
+ if (entry.name === "node_modules" || entry.name === ".git") continue;
110
+ walkMaps(full, out);
111
+ } else if (entry.isFile() && entry.name.endsWith(".map")) {
112
+ out.push(full);
113
+ }
114
+ }
115
+ }
116
+
117
+ /** Joins a URL-ish prefix and a relative file path with exactly one slash
118
+ * and forward slashes throughout (artifact names are compared against URL
119
+ * paths, never OS paths). */
120
+ export function artifactName(relativePath: string, urlPrefix?: string): string {
121
+ const rel = relativePath.split(sep).join("/").replace(/^\.\//, "").replace(/^\/+/, "");
122
+ if (!urlPrefix) return rel;
123
+ const prefix = urlPrefix.replace(/^~\/?/, "").replace(/\/+$/, "");
124
+ return prefix.length > 0 ? `${prefix}/${rel}` : rel;
125
+ }
126
+
127
+ /**
128
+ * Collects `.map` files from each given path: a directory is walked
129
+ * recursively (node_modules and .git skipped) and each map is named relative
130
+ * to THAT directory; a file is taken as-is and named by its basename
131
+ * (relative to its own directory). Names are de-duplicated; the first
132
+ * occurrence wins and later duplicates are reported back so the caller can
133
+ * say so rather than silently dropping one.
134
+ */
135
+ export function collectSourcemapFiles(
136
+ paths: readonly string[],
137
+ options: { cwd?: string; urlPrefix?: string } = {},
138
+ ): { files: SourcemapFile[]; duplicates: string[]; missing: string[] } {
139
+ const cwd = options.cwd ?? process.cwd();
140
+ const files: SourcemapFile[] = [];
141
+ const duplicates: string[] = [];
142
+ const missing: string[] = [];
143
+ const seen = new Set<string>();
144
+ for (const given of paths) {
145
+ const abs = isAbsolute(given) ? given : resolve(cwd, given);
146
+ let stat: ReturnType<typeof statSync>;
147
+ try {
148
+ stat = statSync(abs);
149
+ } catch {
150
+ missing.push(given);
151
+ continue;
152
+ }
153
+ const candidates: { full: string; base: string }[] = [];
154
+ if (stat.isDirectory()) {
155
+ const found: string[] = [];
156
+ walkMaps(abs, found);
157
+ for (const full of found.sort()) candidates.push({ full, base: abs });
158
+ } else {
159
+ candidates.push({ full: abs, base: resolve(abs, "..") });
160
+ }
161
+ for (const { full, base } of candidates) {
162
+ const name = artifactName(relative(base, full), options.urlPrefix);
163
+ if (seen.has(name)) {
164
+ duplicates.push(name);
165
+ continue;
166
+ }
167
+ seen.add(name);
168
+ const content = readFileSync(full, "utf8");
169
+ files.push({
170
+ name,
171
+ path: full,
172
+ content,
173
+ bytes: Buffer.byteLength(content, "utf8"),
174
+ sha256: createHash("sha256").update(content).digest("hex"),
175
+ });
176
+ }
177
+ }
178
+ return { files, duplicates, missing };
179
+ }
180
+
181
+ /** Local pre-flight: the refusals the server would answer anyway, caught
182
+ * before a single batch leaves, so a bad set never half-uploads. Returns the
183
+ * first problem as a message, or null. */
184
+ export function preflightSourcemapFiles(files: readonly SourcemapFile[]): string | null {
185
+ if (files.length === 0) return "No .map files found in the given paths.";
186
+ if (files.length > MAX_ARTIFACTS_PER_RELEASE) {
187
+ return `${files.length} source maps found; a release holds at most ${MAX_ARTIFACTS_PER_RELEASE}. Narrow the paths (or pass only the maps for your entry chunks).`;
188
+ }
189
+ for (const file of files) {
190
+ if (file.bytes > MAX_SOURCEMAP_BYTES) {
191
+ return `${file.name} is ${file.bytes} bytes; the per-file limit is ${MAX_SOURCEMAP_BYTES}. Nothing was uploaded.`;
192
+ }
193
+ }
194
+ return null;
195
+ }
196
+
197
+ export function batchSourcemapFiles<T>(files: readonly T[], size = MAX_ARTIFACTS_PER_UPLOAD): T[][] {
198
+ const batches: T[][] = [];
199
+ for (let i = 0; i < files.length; i += size) batches.push(files.slice(i, i + size));
200
+ return batches;
201
+ }
202
+
203
+ export interface UploadProgress {
204
+ batch: number;
205
+ batches: number;
206
+ files: readonly SourcemapFile[];
207
+ /** Filled in after the batch's response arrives. */
208
+ stored?: number;
209
+ replaced?: number;
210
+ }
211
+
212
+ export interface UploadSummary {
213
+ release: string;
214
+ uploaded: number;
215
+ /** Summed across batches, exactly as the server answered each one. */
216
+ stored: number;
217
+ replaced: number;
218
+ /** The endpoint, with the key masked (this lands in CI logs). */
219
+ url: string;
220
+ /** The server's post-upload view of the release's files. */
221
+ releaseFiles: { name: string; size: number }[];
222
+ limits: { maxFilesPerRelease: number; maxBytesPerFile: number } | null;
223
+ files: { name: string; bytes: number; sha256: string }[];
224
+ }
225
+
226
+ export interface UploadOptions {
227
+ url: string;
228
+ release: string;
229
+ files: readonly SourcemapFile[];
230
+ fetchImpl?: typeof fetch;
231
+ onProgress?: (progress: UploadProgress) => void;
232
+ }
233
+
234
+ /**
235
+ * Uploads in batches of MAX_ARTIFACTS_PER_UPLOAD, sequentially (the per-key
236
+ * upload budget is 10/min and the server takes a per-release lock; parallel
237
+ * batches would only race that). Stops at the first refusal and throws with
238
+ * the server's own message and code; an earlier batch that succeeded is
239
+ * reported in the thrown error's message so the caller knows the release is
240
+ * now partially updated rather than untouched.
241
+ */
242
+ export async function uploadSourcemaps(options: UploadOptions): Promise<UploadSummary> {
243
+ const fetchImpl = options.fetchImpl ?? fetch;
244
+ const batches = batchSourcemapFiles(options.files);
245
+ let stored = 0;
246
+ let replaced = 0;
247
+ let releaseFiles: UploadSummary["releaseFiles"] = [];
248
+ let limits: UploadSummary["limits"] = null;
249
+ for (let index = 0; index < batches.length; index++) {
250
+ const batch = batches[index];
251
+ const progress: UploadProgress = { batch: index + 1, batches: batches.length, files: batch };
252
+ options.onProgress?.(progress);
253
+ let response: Response;
254
+ try {
255
+ response = await fetchImpl(options.url, {
256
+ method: "POST",
257
+ headers: { "content-type": "application/json" },
258
+ body: JSON.stringify({
259
+ release: options.release,
260
+ files: batch.map((file) => ({ name: file.name, content: file.content })),
261
+ }),
262
+ });
263
+ } catch (cause) {
264
+ const reason = cause instanceof Error ? cause.message : String(cause);
265
+ throw new SourcemapUploadError(`Could not reach the upload endpoint: ${reason}${partialNote(index, batches.length)}`);
266
+ }
267
+ const text = await response.text();
268
+ let body: Record<string, unknown> = {};
269
+ try {
270
+ body = text.length > 0 ? (JSON.parse(text) as Record<string, unknown>) : {};
271
+ } catch {
272
+ body = {};
273
+ }
274
+ if (!response.ok) {
275
+ const message = typeof body.error === "string" ? body.error : `Upload failed with status ${response.status}.`;
276
+ const retry = response.headers.get("retry-after");
277
+ throw new SourcemapUploadError(`${message}${retry ? ` Retry after ${retry}s.` : ""}${partialNote(index, batches.length)}`, {
278
+ httpStatus: response.status,
279
+ code: typeof body.code === "string" ? body.code : undefined,
280
+ });
281
+ }
282
+ const batchStored = typeof body.stored === "number" ? body.stored : batch.length;
283
+ const batchReplaced = typeof body.replaced === "number" ? body.replaced : 0;
284
+ stored += batchStored;
285
+ replaced += batchReplaced;
286
+ if (Array.isArray(body.files)) releaseFiles = body.files as UploadSummary["releaseFiles"];
287
+ if (body.limits && typeof body.limits === "object") limits = body.limits as UploadSummary["limits"];
288
+ progress.stored = batchStored;
289
+ progress.replaced = batchReplaced;
290
+ options.onProgress?.(progress);
291
+ }
292
+ return {
293
+ release: options.release,
294
+ // The key is a secret; the summary lands in CI logs, so it is masked.
295
+ url: options.url.replace(/rue_[0-9a-f]+/, "rue_..."),
296
+ uploaded: options.files.length,
297
+ stored,
298
+ replaced,
299
+ releaseFiles,
300
+ limits,
301
+ files: options.files.map((file) => ({ name: file.name, bytes: file.bytes, sha256: file.sha256 })),
302
+ };
303
+ }
304
+
305
+ function partialNote(failedIndex: number, batches: number): string {
306
+ if (failedIndex === 0) return " Nothing was uploaded.";
307
+ return ` Batches 1-${failedIndex} of ${batches} were already stored; the release is partially updated. Re-run to finish (re-uploads replace, never duplicate).`;
308
+ }
309
+
310
+ /** One human line per progress event, for the CLI's stderr and the plugins'
311
+ * logger. Hashes are shortened to 12 chars; full hashes are in the summary. */
312
+ export function formatProgressLine(progress: UploadProgress): string {
313
+ const names = progress.files.map((file) => `${file.name} (sha256 ${file.sha256.slice(0, 12)}, ${file.bytes} bytes)`);
314
+ if (progress.stored === undefined) {
315
+ return `uploading batch ${progress.batch}/${progress.batches}: ${names.join(", ")}`;
316
+ }
317
+ return `batch ${progress.batch}/${progress.batches} ok: stored ${progress.stored}, replaced ${progress.replaced}`;
318
+ }