fboxrec 0.1.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.
@@ -0,0 +1,723 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ var __create = Object.create;
4
+ var __defProp = Object.defineProperty;
5
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
6
+ var __getOwnPropNames = Object.getOwnPropertyNames;
7
+ var __getProtoOf = Object.getPrototypeOf;
8
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
9
+ var __copyProps = (to, from, except, desc) => {
10
+ if (from && typeof from === "object" || typeof from === "function") {
11
+ for (let key of __getOwnPropNames(from))
12
+ if (!__hasOwnProp.call(to, key) && key !== except)
13
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
14
+ }
15
+ return to;
16
+ };
17
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
18
+ // If the importer is in node compatibility mode or this is not an ESM
19
+ // file that has been converted to a CommonJS file using a Babel-
20
+ // compatible transform (i.e. "__esModule" has not been set), then set
21
+ // "default" to the CommonJS "module.exports" for node compatibility.
22
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
23
+ mod
24
+ ));
25
+
26
+ // src/dump/serializer.ts
27
+ var import_node_zlib = require("zlib");
28
+
29
+ // src/encoder.ts
30
+ var import_msgpackr = require("msgpackr");
31
+
32
+ // ../format/src/parse.ts
33
+ var MAX_DECOMPRESSED_BYTES = 1024 * 1024 * 1024;
34
+
35
+ // src/dump/serializer.ts
36
+ var FLIGHTBOX_VERSION = "0.1.0";
37
+
38
+ // src/cli/open.ts
39
+ var fs = __toESM(require("fs"));
40
+ var path = __toESM(require("path"));
41
+ var http = __toESM(require("http"));
42
+ var import_node_crypto2 = require("crypto");
43
+ var import_node_child_process = require("child_process");
44
+
45
+ // src/dump/sinks/sigv4.ts
46
+ var import_node_crypto = require("crypto");
47
+ function sha256Hex(data) {
48
+ return (0, import_node_crypto.createHash)("sha256").update(data).digest("hex");
49
+ }
50
+ function hmac(key, data) {
51
+ return (0, import_node_crypto.createHmac)("sha256", key).update(data, "utf8").digest();
52
+ }
53
+ function uriEncode(str, encodeSlash) {
54
+ let out = "";
55
+ for (const ch of str) {
56
+ if (/[A-Za-z0-9\-._~]/.test(ch) || ch === "/" && !encodeSlash) {
57
+ out += ch;
58
+ } else {
59
+ for (const byte of Buffer.from(ch, "utf8")) {
60
+ out += "%" + byte.toString(16).toUpperCase().padStart(2, "0");
61
+ }
62
+ }
63
+ }
64
+ return out;
65
+ }
66
+ function timestamps(now) {
67
+ const iso = now.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, "");
68
+ return { amzDate: iso, dateStamp: iso.slice(0, 8) };
69
+ }
70
+ function canonicalUri(url) {
71
+ return url.pathname.split("/").map((seg) => uriEncode(safeDecode(seg), true)).join("/") || "/";
72
+ }
73
+ function safeDecode(seg) {
74
+ try {
75
+ return decodeURIComponent(seg);
76
+ } catch {
77
+ return seg;
78
+ }
79
+ }
80
+ function byCodePoint(a, b) {
81
+ return a < b ? -1 : a > b ? 1 : 0;
82
+ }
83
+ function canonicalQuery(params) {
84
+ return params.map(([k, v]) => [uriEncode(k, true), uriEncode(v, true)]).sort((a, b) => a[0] === b[0] ? byCodePoint(a[1], b[1]) : byCodePoint(a[0], b[0])).map(([k, v]) => `${k}=${v}`).join("&");
85
+ }
86
+ function signingKey(secret, dateStamp, region, service) {
87
+ return hmac(hmac(hmac(hmac(`AWS4${secret}`, dateStamp), region), service), "aws4_request");
88
+ }
89
+ function signHeaders(input) {
90
+ const service = input.service ?? "s3";
91
+ const { amzDate, dateStamp } = timestamps(input.now ?? /* @__PURE__ */ new Date());
92
+ const headers = {
93
+ host: input.url.host,
94
+ "x-amz-content-sha256": input.payloadHash,
95
+ "x-amz-date": amzDate,
96
+ ...input.creds.sessionToken ? { "x-amz-security-token": input.creds.sessionToken } : {},
97
+ ...Object.fromEntries(
98
+ Object.entries(input.headers ?? {}).map(([k, v]) => [k.toLowerCase(), v])
99
+ )
100
+ };
101
+ const names = Object.keys(headers).sort();
102
+ const canonicalHeaders = names.map((n) => `${n}:${headers[n].trim()}
103
+ `).join("");
104
+ const signedHeaders = names.join(";");
105
+ const canonicalRequest = [
106
+ input.method.toUpperCase(),
107
+ canonicalUri(input.url),
108
+ canonicalQuery([...input.url.searchParams.entries()]),
109
+ canonicalHeaders,
110
+ signedHeaders,
111
+ input.payloadHash
112
+ ].join("\n");
113
+ const scope = `${dateStamp}/${input.region}/${service}/aws4_request`;
114
+ const stringToSign = [
115
+ "AWS4-HMAC-SHA256",
116
+ amzDate,
117
+ scope,
118
+ sha256Hex(canonicalRequest)
119
+ ].join("\n");
120
+ const signature = (0, import_node_crypto.createHmac)(
121
+ "sha256",
122
+ signingKey(input.creds.secretAccessKey, dateStamp, input.region, service)
123
+ ).update(stringToSign, "utf8").digest("hex");
124
+ headers.authorization = `AWS4-HMAC-SHA256 Credential=${input.creds.accessKeyId}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
125
+ return headers;
126
+ }
127
+ function presignUrl(input) {
128
+ const service = input.service ?? "s3";
129
+ const method = input.method ?? "GET";
130
+ const { amzDate, dateStamp } = timestamps(input.now ?? /* @__PURE__ */ new Date());
131
+ const scope = `${dateStamp}/${input.region}/${service}/aws4_request`;
132
+ const params = [
133
+ ...input.url.searchParams.entries(),
134
+ ["X-Amz-Algorithm", "AWS4-HMAC-SHA256"],
135
+ ["X-Amz-Credential", `${input.creds.accessKeyId}/${scope}`],
136
+ ["X-Amz-Date", amzDate],
137
+ ["X-Amz-Expires", String(Math.floor(input.expiresSec))],
138
+ ["X-Amz-SignedHeaders", "host"]
139
+ ];
140
+ if (input.creds.sessionToken) {
141
+ params.push(["X-Amz-Security-Token", input.creds.sessionToken]);
142
+ }
143
+ const query = canonicalQuery(params);
144
+ const canonicalRequest = [
145
+ method.toUpperCase(),
146
+ canonicalUri(input.url),
147
+ query,
148
+ `host:${input.url.host}
149
+ `,
150
+ "host",
151
+ "UNSIGNED-PAYLOAD"
152
+ ].join("\n");
153
+ const stringToSign = [
154
+ "AWS4-HMAC-SHA256",
155
+ amzDate,
156
+ scope,
157
+ sha256Hex(canonicalRequest)
158
+ ].join("\n");
159
+ const signature = (0, import_node_crypto.createHmac)(
160
+ "sha256",
161
+ signingKey(input.creds.secretAccessKey, dateStamp, input.region, service)
162
+ ).update(stringToSign, "utf8").digest("hex");
163
+ return `${input.url.origin}${canonicalUri(input.url)}?${query}&X-Amz-Signature=${signature}`;
164
+ }
165
+
166
+ // src/dump/sinks/credentials.ts
167
+ var REFRESH_MARGIN_MS = 5 * 6e4;
168
+ var METADATA_TIMEOUT_MS = 1e3;
169
+ var cache = null;
170
+ function fromStatic(cfg) {
171
+ const key = cfg.accessKeyId ?? process.env.FLIGHTBOX_S3_KEY;
172
+ const secret = cfg.secretAccessKey ?? process.env.FLIGHTBOX_S3_SECRET;
173
+ if (!key || !secret) return null;
174
+ return {
175
+ accessKeyId: key,
176
+ secretAccessKey: secret,
177
+ sessionToken: cfg.sessionToken ?? process.env.FLIGHTBOX_S3_TOKEN,
178
+ source: "static"
179
+ };
180
+ }
181
+ function fromAwsEnv() {
182
+ const key = process.env.AWS_ACCESS_KEY_ID;
183
+ const secret = process.env.AWS_SECRET_ACCESS_KEY;
184
+ if (!key || !secret) return null;
185
+ return {
186
+ accessKeyId: key,
187
+ secretAccessKey: secret,
188
+ sessionToken: process.env.AWS_SESSION_TOKEN,
189
+ source: "aws-env"
190
+ };
191
+ }
192
+ async function fetchJson(url, init) {
193
+ try {
194
+ const res = await fetch(url, {
195
+ ...init,
196
+ signal: AbortSignal.timeout(METADATA_TIMEOUT_MS)
197
+ });
198
+ if (!res.ok) return null;
199
+ return await res.json();
200
+ } catch {
201
+ return null;
202
+ }
203
+ }
204
+ async function fromEcs() {
205
+ const fullUri = process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI;
206
+ const relativeUri = process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI;
207
+ if (!fullUri && !relativeUri) return null;
208
+ const url = fullUri ?? `http://169.254.170.2${relativeUri}`;
209
+ const body = await fetchJson(url);
210
+ if (!body?.AccessKeyId || !body?.SecretAccessKey) return null;
211
+ return {
212
+ accessKeyId: body.AccessKeyId,
213
+ secretAccessKey: body.SecretAccessKey,
214
+ sessionToken: body.Token,
215
+ expiresAtMs: body.Expiration ? Date.parse(body.Expiration) : void 0,
216
+ source: "ecs-task-role"
217
+ };
218
+ }
219
+ async function fromImdsV2() {
220
+ const base = process.env.AWS_EC2_METADATA_SERVICE_ENDPOINT?.replace(/\/$/, "") ?? "http://169.254.169.254";
221
+ let token;
222
+ try {
223
+ const res = await fetch(`${base}/latest/api/token`, {
224
+ method: "PUT",
225
+ headers: { "x-aws-ec2-metadata-token-ttl-seconds": "21600" },
226
+ signal: AbortSignal.timeout(METADATA_TIMEOUT_MS)
227
+ });
228
+ if (!res.ok) return null;
229
+ token = await res.text();
230
+ } catch {
231
+ return null;
232
+ }
233
+ const tokenHeader = { "x-aws-ec2-metadata-token": token };
234
+ let role;
235
+ try {
236
+ const res = await fetch(`${base}/latest/meta-data/iam/security-credentials/`, {
237
+ headers: tokenHeader,
238
+ signal: AbortSignal.timeout(METADATA_TIMEOUT_MS)
239
+ });
240
+ if (!res.ok) return null;
241
+ role = (await res.text()).split("\n")[0].trim();
242
+ if (!role) return null;
243
+ } catch {
244
+ return null;
245
+ }
246
+ const body = await fetchJson(
247
+ `${base}/latest/meta-data/iam/security-credentials/${role}`,
248
+ { headers: tokenHeader }
249
+ );
250
+ if (!body?.AccessKeyId || !body?.SecretAccessKey) return null;
251
+ return {
252
+ accessKeyId: body.AccessKeyId,
253
+ secretAccessKey: body.SecretAccessKey,
254
+ sessionToken: body.Token,
255
+ expiresAtMs: body.Expiration ? Date.parse(body.Expiration) : void 0,
256
+ source: "ec2-imdsv2"
257
+ };
258
+ }
259
+ async function resolveCredentials(cfg = {}) {
260
+ const staticCreds = fromStatic(cfg);
261
+ if (staticCreds) return staticCreds;
262
+ const envCreds = fromAwsEnv();
263
+ if (envCreds) return envCreds;
264
+ if (cache && (cache.expiresAtMs === void 0 || cache.expiresAtMs - Date.now() > REFRESH_MARGIN_MS)) {
265
+ return cache;
266
+ }
267
+ cache = await fromEcs() ?? await fromImdsV2();
268
+ return cache;
269
+ }
270
+
271
+ // src/cli/open.ts
272
+ var MIME = {
273
+ ".html": "text/html; charset=utf-8",
274
+ ".js": "text/javascript",
275
+ ".css": "text/css",
276
+ ".map": "application/json",
277
+ ".json": "application/json",
278
+ ".svg": "image/svg+xml",
279
+ ".png": "image/png",
280
+ ".ico": "image/x-icon"
281
+ };
282
+ function viewerDistDir() {
283
+ return path.resolve(__dirname, "..", "..", "viewer-dist");
284
+ }
285
+ async function fetchS3(source) {
286
+ const m = /^s3:\/\/([^/]+)\/(.+)$/.exec(source);
287
+ if (!m) throw new Error(`invalid s3 url: ${source}`);
288
+ const [, bucket, key] = m;
289
+ const creds = await resolveCredentials({});
290
+ if (!creds) {
291
+ throw new Error(
292
+ "no AWS credentials found (chain: FLIGHTBOX_S3_KEY/SECRET -> AWS_* env -> ECS -> IMDSv2)"
293
+ );
294
+ }
295
+ const endpoint = process.env.FLIGHTBOX_S3_ENDPOINT?.replace(/\/$/, "");
296
+ const region = process.env.FLIGHTBOX_S3_REGION || (endpoint ? "auto" : "us-east-1");
297
+ const url = endpoint ? new URL(`${endpoint}/${bucket}/${key}`) : new URL(`https://${bucket}.s3.${region}.amazonaws.com/${key}`);
298
+ const presigned = presignUrl({ url, creds, region, expiresSec: 300 });
299
+ return fetchCapped(presigned);
300
+ }
301
+ var MAX_DOWNLOAD_BYTES = 512 * 1024 * 1024;
302
+ var DOWNLOAD_TIMEOUT_MS = 6e4;
303
+ async function fetchCapped(url) {
304
+ const res = await fetch(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) });
305
+ if (!res.ok) throw new Error(`fetch failed: HTTP ${res.status}`);
306
+ const len = Number(res.headers.get("content-length") ?? 0);
307
+ if (len > MAX_DOWNLOAD_BYTES) {
308
+ throw new Error(`remote file is ${len} bytes \u2014 larger than any plausible incident`);
309
+ }
310
+ const buf = Buffer.from(await res.arrayBuffer());
311
+ if (buf.length > MAX_DOWNLOAD_BYTES) {
312
+ throw new Error(`remote file exceeds the ${MAX_DOWNLOAD_BYTES} byte limit`);
313
+ }
314
+ return buf;
315
+ }
316
+ async function loadSource(source) {
317
+ if (source.startsWith("s3://")) {
318
+ return { data: await fetchS3(source), label: source };
319
+ }
320
+ if (source.startsWith("http://") || source.startsWith("https://")) {
321
+ return { data: await fetchCapped(source), label: source };
322
+ }
323
+ return { data: fs.readFileSync(source), label: path.resolve(source) };
324
+ }
325
+ async function openCommand(source, opts = {}) {
326
+ const dist = viewerDistDir();
327
+ if (!fs.existsSync(path.join(dist, "index.html"))) {
328
+ throw new Error(
329
+ `bundled viewer not found at ${dist} \u2014 this build of flightbox was packaged without it (run: pnpm build)`
330
+ );
331
+ }
332
+ const { data, label } = await loadSource(source);
333
+ const incidentPath = `/__incident-${(0, import_node_crypto2.randomBytes)(16).toString("hex")}`;
334
+ const server = http.createServer((req, res) => {
335
+ const host = String(req.headers.host ?? "");
336
+ const hostname = host.replace(/:\d+$/, "");
337
+ if (hostname !== "localhost" && hostname !== "127.0.0.1" && hostname !== "[::1]") {
338
+ res.writeHead(403).end("forbidden");
339
+ return;
340
+ }
341
+ const url = new URL(req.url ?? "/", "http://localhost");
342
+ if (url.pathname === incidentPath) {
343
+ res.writeHead(200, {
344
+ "content-type": "application/octet-stream",
345
+ "content-length": data.length
346
+ });
347
+ res.end(data);
348
+ return;
349
+ }
350
+ let rel = url.pathname === "/" ? "/index.html" : url.pathname;
351
+ const file = path.join(dist, path.normalize(rel));
352
+ if (!file.startsWith(dist) || !fs.existsSync(file) || !fs.statSync(file).isFile()) {
353
+ res.writeHead(404).end("not found");
354
+ return;
355
+ }
356
+ res.writeHead(200, {
357
+ "content-type": MIME[path.extname(file)] ?? "application/octet-stream"
358
+ });
359
+ res.end(fs.readFileSync(file));
360
+ });
361
+ await new Promise((resolve3) => server.listen(opts.port ?? 4560, "127.0.0.1", resolve3));
362
+ const port = server.address().port;
363
+ const viewerUrl = `http://localhost:${port}/?src=${encodeURIComponent(incidentPath)}`;
364
+ process.stderr.write(`flightbox: serving ${label}
365
+ `);
366
+ process.stdout.write(`${viewerUrl}
367
+ `);
368
+ if (opts.openBrowser !== false) {
369
+ const cmd = process.platform === "win32" ? ["cmd", ["/c", "start", "", viewerUrl]] : process.platform === "darwin" ? ["open", [viewerUrl]] : ["xdg-open", [viewerUrl]];
370
+ try {
371
+ (0, import_node_child_process.spawn)(cmd[0], cmd[1], { detached: true, stdio: "ignore" }).unref();
372
+ } catch {
373
+ }
374
+ }
375
+ return server;
376
+ }
377
+
378
+ // src/cli/doctor.ts
379
+ var fs2 = __toESM(require("fs"));
380
+ var path3 = __toESM(require("path"));
381
+
382
+ // src/config.ts
383
+ var path2 = __toESM(require("path"));
384
+ var DEFAULTS = {
385
+ bufferMb: 64,
386
+ dir: ".flightbox",
387
+ slowRequestMs: 5e3,
388
+ cooldownMs: 6e4,
389
+ heapPct: 0.9,
390
+ stallMs: 1e3,
391
+ shedLagMs: 50,
392
+ maxStageMb: 500,
393
+ minDiskFreePct: 5,
394
+ viewerOrigin: "https://viewer.flightbox.dev"
395
+ };
396
+ function envNumber(name) {
397
+ const raw = process.env[name];
398
+ if (raw === void 0 || raw === "") return void 0;
399
+ const n = Number(raw);
400
+ return Number.isFinite(n) ? n : void 0;
401
+ }
402
+ function envBool(name) {
403
+ return process.env[name] === "1" || process.env[name] === "true";
404
+ }
405
+ function envS3Sink() {
406
+ const bucket = process.env.FLIGHTBOX_S3_BUCKET;
407
+ if (!bucket) return null;
408
+ return {
409
+ type: "s3",
410
+ bucket,
411
+ prefix: process.env.FLIGHTBOX_S3_PREFIX ?? "",
412
+ endpoint: process.env.FLIGHTBOX_S3_ENDPOINT,
413
+ region: process.env.FLIGHTBOX_S3_REGION,
414
+ presign: { enabled: true }
415
+ };
416
+ }
417
+ function validateSink(sink) {
418
+ switch (sink.type) {
419
+ case "s3": {
420
+ if (!sink.bucket || typeof sink.bucket !== "string") {
421
+ throw new Error('flightbox: s3 sink requires a non-empty "bucket"');
422
+ }
423
+ if (sink.endpoint !== void 0 && !/^https?:\/\//.test(sink.endpoint)) {
424
+ throw new Error(`flightbox: s3 sink "endpoint" must be an http(s) URL, got ${sink.endpoint}`);
425
+ }
426
+ if (sink.prefix !== void 0 && typeof sink.prefix !== "string") {
427
+ throw new Error('flightbox: s3 sink "prefix" must be a string');
428
+ }
429
+ const hrs = sink.presign?.expiresHours;
430
+ if (hrs !== void 0 && (!Number.isFinite(hrs) || hrs <= 0 || hrs > 168)) {
431
+ throw new Error(`flightbox: s3 presign.expiresHours must be in (0, 168], got ${hrs}`);
432
+ }
433
+ break;
434
+ }
435
+ case "http": {
436
+ if (!/^https?:\/\//.test(sink.url ?? "")) {
437
+ throw new Error(`flightbox: http sink "url" must be an http(s) URL, got ${sink.url}`);
438
+ }
439
+ break;
440
+ }
441
+ case "disk": {
442
+ if (!sink.dir || typeof sink.dir !== "string") {
443
+ throw new Error('flightbox: disk sink requires a non-empty "dir"');
444
+ }
445
+ if (sink.maxMb !== void 0 && (!Number.isFinite(sink.maxMb) || sink.maxMb < 1)) {
446
+ throw new Error(`flightbox: disk sink "maxMb" must be >= 1, got ${sink.maxMb}`);
447
+ }
448
+ break;
449
+ }
450
+ default:
451
+ throw new Error(`flightbox: unknown sink type "${sink.type}"`);
452
+ }
453
+ }
454
+ function resolveConfig(user = {}) {
455
+ const bufferMb = envNumber("FLIGHTBOX_BUFFER_MB") ?? user.bufferMb ?? DEFAULTS.bufferMb;
456
+ const dir = process.env.FLIGHTBOX_DIR || user.dir || DEFAULTS.dir;
457
+ const service = process.env.FLIGHTBOX_SERVICE || user.service || path2.basename(process.cwd());
458
+ const slowRequestMs = envNumber("FLIGHTBOX_TRIGGER_SLOW_MS") ?? user.triggers?.slowRequestMs ?? DEFAULTS.slowRequestMs;
459
+ const cooldownMs = user.triggers?.cooldownMs ?? DEFAULTS.cooldownMs;
460
+ const heapPct = envNumber("FLIGHTBOX_TRIGGER_HEAP_PCT") ?? user.triggers?.heapPct ?? DEFAULTS.heapPct;
461
+ const stallMs = envNumber("FLIGHTBOX_TRIGGER_STALL_MS") ?? user.triggers?.stallMs ?? DEFAULTS.stallMs;
462
+ const shedLagMs = envNumber("FLIGHTBOX_SHED_LAG_MS") ?? user.shedding?.shedLagMs ?? DEFAULTS.shedLagMs;
463
+ const maxStageMb = envNumber("FLIGHTBOX_MAX_STAGE_MB") ?? user.staging?.maxStageMb ?? DEFAULTS.maxStageMb;
464
+ const minDiskFreePct = user.staging?.minDiskFreePct ?? DEFAULTS.minDiskFreePct;
465
+ if (!Number.isFinite(bufferMb) || bufferMb < 1 || bufferMb > 4096) {
466
+ throw new RangeError(`flightbox: bufferMb must be between 1 and 4096, got ${bufferMb}`);
467
+ }
468
+ if (!Number.isFinite(slowRequestMs) || slowRequestMs < 50) {
469
+ throw new RangeError(`flightbox: triggers.slowRequestMs must be >= 50, got ${slowRequestMs}`);
470
+ }
471
+ if (!Number.isFinite(cooldownMs) || cooldownMs < 0) {
472
+ throw new RangeError(`flightbox: triggers.cooldownMs must be >= 0, got ${cooldownMs}`);
473
+ }
474
+ if (!Number.isFinite(heapPct) || heapPct <= 0 || heapPct >= 1) {
475
+ throw new RangeError(`flightbox: triggers.heapPct must be in (0,1), got ${heapPct}`);
476
+ }
477
+ if (!Number.isFinite(stallMs) || stallMs < 50) {
478
+ throw new RangeError(`flightbox: triggers.stallMs must be >= 50, got ${stallMs}`);
479
+ }
480
+ if (!Number.isFinite(shedLagMs) || shedLagMs < 1) {
481
+ throw new RangeError(`flightbox: shedding.shedLagMs must be >= 1, got ${shedLagMs}`);
482
+ }
483
+ if (!Number.isFinite(maxStageMb) || maxStageMb < 1) {
484
+ throw new RangeError(`flightbox: staging.maxStageMb must be >= 1, got ${maxStageMb}`);
485
+ }
486
+ if (!Number.isFinite(minDiskFreePct) || minDiskFreePct < 0 || minDiskFreePct > 50) {
487
+ throw new RangeError(`flightbox: staging.minDiskFreePct must be 0-50, got ${minDiskFreePct}`);
488
+ }
489
+ const sinks = [...user.sinks ?? []];
490
+ const s3FromEnv = envS3Sink();
491
+ if (s3FromEnv && !sinks.some((s) => s.type === "s3")) sinks.push(s3FromEnv);
492
+ sinks.forEach(validateSink);
493
+ return {
494
+ service,
495
+ bufferMb,
496
+ dir: path2.resolve(dir),
497
+ triggers: { slowRequestMs, cooldownMs, heapPct, stallMs },
498
+ staging: { maxStageMb, minDiskFreePct },
499
+ shedding: { shedLagMs },
500
+ token: process.env.FLIGHTBOX_TOKEN || user.token,
501
+ viewerOrigin: process.env.FLIGHTBOX_VIEWER_ORIGIN || user.viewerOrigin || DEFAULTS.viewerOrigin,
502
+ sinks,
503
+ allowWorkerThreads: envBool("FLIGHTBOX_ALLOW_WORKER_THREADS") || (user.allowWorkerThreads ?? false),
504
+ log: user.log ?? ((msg) => process.stderr.write(`flightbox: ${msg}
505
+ `))
506
+ };
507
+ }
508
+
509
+ // src/cli/doctor.ts
510
+ var failures = 0;
511
+ function ok(msg) {
512
+ process.stdout.write(` \u2713 ${msg}
513
+ `);
514
+ }
515
+ function warn(msg) {
516
+ process.stdout.write(` ! ${msg}
517
+ `);
518
+ }
519
+ function fail(msg) {
520
+ failures++;
521
+ process.stdout.write(` \u2717 ${msg}
522
+ `);
523
+ }
524
+ var CORS_FIX = (origin) => JSON.stringify(
525
+ [
526
+ {
527
+ AllowedOrigins: [origin],
528
+ AllowedMethods: ["GET"],
529
+ AllowedHeaders: ["*"],
530
+ MaxAgeSeconds: 3600
531
+ }
532
+ ],
533
+ null,
534
+ 2
535
+ );
536
+ async function doctorCommand() {
537
+ process.stdout.write("flightbox doctor\n\n");
538
+ const major = Number(process.versions.node.split(".")[0]);
539
+ if (major >= 18) ok(`node ${process.versions.node} (>= 18)`);
540
+ else fail(`node ${process.versions.node} \u2014 flightbox needs >= 18 (global fetch, stable ALS)`);
541
+ let config;
542
+ try {
543
+ config = resolveConfig();
544
+ ok(
545
+ `config: service=${config.service}, bufferMb=${config.bufferMb}, dir=${config.dir}, slowMs=${config.triggers.slowRequestMs}, sinks=[${config.sinks.map((s) => s.type).join(", ") || "none"}]`
546
+ );
547
+ } catch (err) {
548
+ fail(`config invalid: ${err.message}`);
549
+ return 1;
550
+ }
551
+ try {
552
+ const probe = path3.join(config.dir, "staging", `doctor-${process.pid}.probe`);
553
+ fs2.mkdirSync(path3.dirname(probe), { recursive: true });
554
+ fs2.writeFileSync(probe, "ok");
555
+ fs2.unlinkSync(probe);
556
+ ok(`staging dir writable: ${path3.join(config.dir, "staging")}`);
557
+ } catch (err) {
558
+ fail(`staging dir not writable: ${err.message}`);
559
+ }
560
+ const statfsSync2 = fs2.statfsSync;
561
+ if (typeof statfsSync2 === "function") {
562
+ try {
563
+ const s = statfsSync2(config.dir);
564
+ const freePct = Number(s.bavail) / Number(s.blocks) * 100;
565
+ if (freePct >= config.staging.minDiskFreePct) {
566
+ ok(`disk ${freePct.toFixed(1)}% free (floor: ${config.staging.minDiskFreePct}%)`);
567
+ } else {
568
+ fail(`disk only ${freePct.toFixed(1)}% free \u2014 below the ${config.staging.minDiskFreePct}% floor; the agent would disable itself (ADR 006)`);
569
+ }
570
+ } catch {
571
+ warn("could not stat disk free space");
572
+ }
573
+ }
574
+ if (!config.token) {
575
+ warn("FLIGHTBOX_TOKEN not set \u2014 the manual HTTP dump endpoint is disarmed");
576
+ } else {
577
+ ok("manual dump endpoint armed (/__flightbox/dump)");
578
+ }
579
+ const s3 = config.sinks.find((s) => s.type === "s3");
580
+ if (!s3) {
581
+ warn("no s3 sink configured \u2014 on ephemeral platforms (Render/Railway/Heroku) crash dumps need one (\xA75)");
582
+ } else {
583
+ await checkS3(s3, config.viewerOrigin);
584
+ }
585
+ process.stdout.write(failures === 0 ? "\nall clear \u2708\n" : `
586
+ ${failures} problem(s) found
587
+ `);
588
+ return failures === 0 ? 0 : 1;
589
+ }
590
+ async function checkS3(s3, viewerOrigin) {
591
+ const creds = await resolveCredentials(s3);
592
+ if (!creds) {
593
+ fail("S3 credentials: none resolved (chain: static -> AWS_* env -> ECS task role -> IMDSv2)");
594
+ return;
595
+ }
596
+ ok(
597
+ `S3 credentials via ${creds.source}` + (creds.expiresAtMs ? ` (expire ${new Date(creds.expiresAtMs).toISOString()})` : "")
598
+ );
599
+ warn(
600
+ "S3 check writes and deletes a probe object (needs PutObject + DeleteObject); a mid-run failure may leave one doctor-probe-*.txt behind"
601
+ );
602
+ const region = s3.region || (s3.endpoint ? "auto" : "us-east-1");
603
+ const key = `${s3.prefix ?? ""}doctor-probe-${Date.now()}.txt`;
604
+ const objectUrl = s3.endpoint ? new URL(`${s3.endpoint.replace(/\/$/, "")}/${s3.bucket}/${key}`) : new URL(`https://${s3.bucket}.s3.${region}.amazonaws.com/${key}`);
605
+ const body = Buffer.from("flightbox doctor probe");
606
+ try {
607
+ const headers = signHeaders({
608
+ method: "PUT",
609
+ url: objectUrl,
610
+ payloadHash: sha256Hex(body),
611
+ creds,
612
+ region
613
+ });
614
+ const res = await fetch(objectUrl, { method: "PUT", headers, body });
615
+ if (res.ok) ok(`S3 PUT s3://${s3.bucket}/${key}`);
616
+ else {
617
+ fail(`S3 PUT failed: HTTP ${res.status} ${(await res.text()).slice(0, 120)}`);
618
+ return;
619
+ }
620
+ } catch (err) {
621
+ fail(`S3 unreachable: ${err.message}`);
622
+ return;
623
+ }
624
+ const presigned = presignUrl({ url: objectUrl, creds, region, expiresSec: 300 });
625
+ try {
626
+ const res = await fetch(presigned);
627
+ if (res.ok && Buffer.from(await res.arrayBuffer()).equals(body)) {
628
+ ok("presigned GET round-trips (magic links will work)");
629
+ } else {
630
+ fail(`presigned GET failed: HTTP ${res.status}`);
631
+ }
632
+ } catch (err) {
633
+ fail(`presigned GET failed: ${err.message}`);
634
+ }
635
+ const origin = s3.presign?.viewerOrigin ?? viewerOrigin;
636
+ try {
637
+ const res = await fetch(presigned, {
638
+ method: "OPTIONS",
639
+ headers: { Origin: origin, "Access-Control-Request-Method": "GET" }
640
+ });
641
+ const allowed = res.headers.get("access-control-allow-origin");
642
+ if (allowed === "*" || allowed === origin) {
643
+ ok(`bucket CORS allows ${origin}`);
644
+ } else {
645
+ fail(
646
+ `bucket CORS does NOT allow ${origin} \u2014 magic links will fail in the browser.
647
+ Fix (AWS: bucket \u2192 Permissions \u2192 CORS; R2: bucket \u2192 Settings \u2192 CORS policy):
648
+ ` + CORS_FIX(origin).split("\n").map((l) => ` ${l}`).join("\n")
649
+ );
650
+ }
651
+ } catch {
652
+ warn("could not probe CORS (endpoint rejected OPTIONS) \u2014 verify manually");
653
+ }
654
+ try {
655
+ const headers = signHeaders({
656
+ method: "DELETE",
657
+ url: objectUrl,
658
+ payloadHash: sha256Hex(""),
659
+ creds,
660
+ region
661
+ });
662
+ await fetch(objectUrl, { method: "DELETE", headers });
663
+ } catch {
664
+ }
665
+ }
666
+
667
+ // src/cli/main.ts
668
+ var USAGE = `flightbox v${FLIGHTBOX_VERSION}
669
+
670
+ Usage:
671
+ flightbox open <source> [--port N] [--no-open]
672
+ Open an incident in the bundled local viewer (fully offline).
673
+ <source>: a local .fbox file, s3://bucket/key, or an https:// URL
674
+ (remote sources are downloaded with THIS machine's credentials \u2014
675
+ works inside air-gapped VPCs and over SSH tunnels).
676
+
677
+ flightbox doctor
678
+ Verify config, staging dir, disk headroom, S3 credentials chain,
679
+ sink connectivity, and bucket CORS for magic links.
680
+
681
+ flightbox --version
682
+ `;
683
+ async function main() {
684
+ const argv = process.argv.slice(2);
685
+ const command = argv[0];
686
+ switch (command) {
687
+ case "open": {
688
+ const source = argv[1];
689
+ if (!source) {
690
+ process.stderr.write("flightbox open: missing <source>\n");
691
+ process.exit(1);
692
+ }
693
+ const portIdx = argv.indexOf("--port");
694
+ const port = portIdx !== -1 ? Number(argv[portIdx + 1]) : 4560;
695
+ const openBrowser = !argv.includes("--no-open");
696
+ try {
697
+ await openCommand(source, { port, openBrowser });
698
+ } catch (err) {
699
+ process.stderr.write(`flightbox open: ${err.message}
700
+ `);
701
+ process.exit(1);
702
+ }
703
+ break;
704
+ }
705
+ case "doctor":
706
+ process.exit(await doctorCommand());
707
+ break;
708
+ case "--version":
709
+ case "-v":
710
+ process.stdout.write(`${FLIGHTBOX_VERSION}
711
+ `);
712
+ break;
713
+ case void 0:
714
+ case "--help":
715
+ case "-h":
716
+ process.stdout.write(USAGE);
717
+ break;
718
+ default:
719
+ process.stdout.write(USAGE);
720
+ process.exit(1);
721
+ }
722
+ }
723
+ void main();