surf-cli 2.0.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,87 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const os = require("os");
4
+
5
+ const CONFIG_NAME = "surf.json";
6
+
7
+ let cachedConfig = null;
8
+ let cachedConfigPath = null;
9
+
10
+ const STARTER_CONFIG = {
11
+ routes: {
12
+ main: ["http://localhost:3000"]
13
+ },
14
+ selectors: {
15
+ chatgpt: {
16
+ input: "#prompt-textarea"
17
+ }
18
+ }
19
+ };
20
+
21
+ function findConfigPath() {
22
+ const cwdPath = path.join(process.cwd(), CONFIG_NAME);
23
+ if (fs.existsSync(cwdPath)) {
24
+ return cwdPath;
25
+ }
26
+ const homePath = path.join(os.homedir(), CONFIG_NAME);
27
+ if (fs.existsSync(homePath)) {
28
+ return homePath;
29
+ }
30
+ return null;
31
+ }
32
+
33
+ function loadConfig() {
34
+ if (cachedConfig !== null) {
35
+ return cachedConfig;
36
+ }
37
+ const configPath = findConfigPath();
38
+ if (!configPath) {
39
+ cachedConfig = {};
40
+ cachedConfigPath = null;
41
+ return cachedConfig;
42
+ }
43
+ try {
44
+ const content = fs.readFileSync(configPath, "utf-8");
45
+ cachedConfig = JSON.parse(content);
46
+ cachedConfigPath = configPath;
47
+ return cachedConfig;
48
+ } catch (err) {
49
+ console.error(`Warning: Failed to parse ${configPath}: ${err.message}`);
50
+ cachedConfig = {};
51
+ cachedConfigPath = null;
52
+ return cachedConfig;
53
+ }
54
+ }
55
+
56
+ function getConfigPath() {
57
+ if (cachedConfig === null) {
58
+ loadConfig();
59
+ }
60
+ return cachedConfigPath;
61
+ }
62
+
63
+ function createStarterConfig(targetDir = process.cwd()) {
64
+ const targetPath = path.join(targetDir, CONFIG_NAME);
65
+ if (fs.existsSync(targetPath)) {
66
+ return { success: false, error: "Config already exists", path: targetPath };
67
+ }
68
+ try {
69
+ fs.writeFileSync(targetPath, JSON.stringify(STARTER_CONFIG, null, 2) + "\n");
70
+ return { success: true, path: targetPath };
71
+ } catch (err) {
72
+ return { success: false, error: err.message, path: targetPath };
73
+ }
74
+ }
75
+
76
+ function clearCache() {
77
+ cachedConfig = null;
78
+ cachedConfigPath = null;
79
+ }
80
+
81
+ module.exports = {
82
+ loadConfig,
83
+ getConfigPath,
84
+ createStarterConfig,
85
+ clearCache,
86
+ STARTER_CONFIG,
87
+ };
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Device presets for emulation (based on Chrome DevTools)
3
+ */
4
+
5
+ const DEVICE_PRESETS = {
6
+ // Apple devices
7
+ "iPhone 12": {
8
+ width: 390,
9
+ height: 844,
10
+ deviceScaleFactor: 3,
11
+ mobile: true,
12
+ touch: true,
13
+ userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Mobile/15E148 Safari/604.1",
14
+ },
15
+ "iPhone 13": {
16
+ width: 390,
17
+ height: 844,
18
+ deviceScaleFactor: 3,
19
+ mobile: true,
20
+ touch: true,
21
+ userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 Mobile/15E148 Safari/604.1",
22
+ },
23
+ "iPhone 14": {
24
+ width: 390,
25
+ height: 844,
26
+ deviceScaleFactor: 3,
27
+ mobile: true,
28
+ touch: true,
29
+ userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1",
30
+ },
31
+ "iPhone 14 Pro": {
32
+ width: 393,
33
+ height: 852,
34
+ deviceScaleFactor: 3,
35
+ mobile: true,
36
+ touch: true,
37
+ userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1",
38
+ },
39
+ "iPhone 14 Pro Max": {
40
+ width: 430,
41
+ height: 932,
42
+ deviceScaleFactor: 3,
43
+ mobile: true,
44
+ touch: true,
45
+ userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1",
46
+ },
47
+ "iPhone SE": {
48
+ width: 375,
49
+ height: 667,
50
+ deviceScaleFactor: 2,
51
+ mobile: true,
52
+ touch: true,
53
+ userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 Mobile/15E148 Safari/604.1",
54
+ },
55
+ "iPad": {
56
+ width: 768,
57
+ height: 1024,
58
+ deviceScaleFactor: 2,
59
+ mobile: true,
60
+ touch: true,
61
+ userAgent: "Mozilla/5.0 (iPad; CPU OS 14_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Mobile/15E148 Safari/604.1",
62
+ },
63
+ "iPad Pro": {
64
+ width: 1024,
65
+ height: 1366,
66
+ deviceScaleFactor: 2,
67
+ mobile: true,
68
+ touch: true,
69
+ userAgent: "Mozilla/5.0 (iPad; CPU OS 14_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Mobile/15E148 Safari/604.1",
70
+ },
71
+ "iPad Mini": {
72
+ width: 768,
73
+ height: 1024,
74
+ deviceScaleFactor: 2,
75
+ mobile: true,
76
+ touch: true,
77
+ userAgent: "Mozilla/5.0 (iPad; CPU OS 14_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Mobile/15E148 Safari/604.1",
78
+ },
79
+
80
+ // Google devices
81
+ "Pixel 5": {
82
+ width: 393,
83
+ height: 851,
84
+ deviceScaleFactor: 2.75,
85
+ mobile: true,
86
+ touch: true,
87
+ userAgent: "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.91 Mobile Safari/537.36",
88
+ },
89
+ "Pixel 6": {
90
+ width: 412,
91
+ height: 915,
92
+ deviceScaleFactor: 2.625,
93
+ mobile: true,
94
+ touch: true,
95
+ userAgent: "Mozilla/5.0 (Linux; Android 12; Pixel 6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.45 Mobile Safari/537.36",
96
+ },
97
+ "Pixel 7": {
98
+ width: 412,
99
+ height: 915,
100
+ deviceScaleFactor: 2.625,
101
+ mobile: true,
102
+ touch: true,
103
+ userAgent: "Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Mobile Safari/537.36",
104
+ },
105
+ "Pixel 7 Pro": {
106
+ width: 412,
107
+ height: 892,
108
+ deviceScaleFactor: 3.5,
109
+ mobile: true,
110
+ touch: true,
111
+ userAgent: "Mozilla/5.0 (Linux; Android 13; Pixel 7 Pro) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Mobile Safari/537.36",
112
+ },
113
+
114
+ // Samsung devices
115
+ "Galaxy S21": {
116
+ width: 360,
117
+ height: 800,
118
+ deviceScaleFactor: 3,
119
+ mobile: true,
120
+ touch: true,
121
+ userAgent: "Mozilla/5.0 (Linux; Android 11; SM-G991B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.210 Mobile Safari/537.36",
122
+ },
123
+ "Galaxy S22": {
124
+ width: 360,
125
+ height: 780,
126
+ deviceScaleFactor: 3,
127
+ mobile: true,
128
+ touch: true,
129
+ userAgent: "Mozilla/5.0 (Linux; Android 12; SM-S901B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.127 Mobile Safari/537.36",
130
+ },
131
+ "Galaxy S23": {
132
+ width: 360,
133
+ height: 780,
134
+ deviceScaleFactor: 3,
135
+ mobile: true,
136
+ touch: true,
137
+ userAgent: "Mozilla/5.0 (Linux; Android 13; SM-S911B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Mobile Safari/537.36",
138
+ },
139
+ "Galaxy Tab S7": {
140
+ width: 800,
141
+ height: 1280,
142
+ deviceScaleFactor: 2,
143
+ mobile: true,
144
+ touch: true,
145
+ userAgent: "Mozilla/5.0 (Linux; Android 10; SM-T870) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.91 Safari/537.36",
146
+ },
147
+
148
+ // Other common devices
149
+ "Nest Hub": {
150
+ width: 1024,
151
+ height: 600,
152
+ deviceScaleFactor: 2,
153
+ mobile: false,
154
+ touch: true,
155
+ userAgent: "Mozilla/5.0 (Linux; Android) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.109 Safari/537.36 CrKey/1.54.248666",
156
+ },
157
+ "Nest Hub Max": {
158
+ width: 1280,
159
+ height: 800,
160
+ deviceScaleFactor: 2,
161
+ mobile: false,
162
+ touch: true,
163
+ userAgent: "Mozilla/5.0 (Linux; Android) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.109 Safari/537.36 CrKey/1.54.248666",
164
+ },
165
+ };
166
+
167
+ // Aliases for common searches
168
+ const DEVICE_ALIASES = {
169
+ "iphone": "iPhone 14",
170
+ "iphone14": "iPhone 14",
171
+ "iphone13": "iPhone 13",
172
+ "iphone12": "iPhone 12",
173
+ "iphonese": "iPhone SE",
174
+ "pixel": "Pixel 7",
175
+ "pixel7": "Pixel 7",
176
+ "pixel6": "Pixel 6",
177
+ "galaxy": "Galaxy S23",
178
+ "galaxys23": "Galaxy S23",
179
+ "ipad": "iPad",
180
+ "ipadpro": "iPad Pro",
181
+ };
182
+
183
+ function findDevice(name) {
184
+ // Exact match first
185
+ if (DEVICE_PRESETS[name]) {
186
+ return { name, preset: DEVICE_PRESETS[name] };
187
+ }
188
+
189
+ // Check aliases (case-insensitive, no spaces)
190
+ const normalized = name.toLowerCase().replace(/\s+/g, "");
191
+ if (DEVICE_ALIASES[normalized]) {
192
+ const deviceName = DEVICE_ALIASES[normalized];
193
+ return { name: deviceName, preset: DEVICE_PRESETS[deviceName] };
194
+ }
195
+
196
+ // Fuzzy match by partial name
197
+ const lowerName = name.toLowerCase();
198
+ for (const [deviceName, preset] of Object.entries(DEVICE_PRESETS)) {
199
+ if (deviceName.toLowerCase().includes(lowerName)) {
200
+ return { name: deviceName, preset };
201
+ }
202
+ }
203
+
204
+ return null;
205
+ }
206
+
207
+ function listDevices() {
208
+ return Object.keys(DEVICE_PRESETS).sort();
209
+ }
210
+
211
+ module.exports = { DEVICE_PRESETS, findDevice, listDevices };
@@ -0,0 +1,402 @@
1
+ // Network request formatters for surf-cli
2
+
3
+ /**
4
+ * Format bytes to human readable size
5
+ */
6
+ function formatSize(bytes) {
7
+ if (bytes === undefined || bytes === null) return '-';
8
+ if (bytes < 1024) return `${bytes}B`;
9
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}K`;
10
+ return `${(bytes / (1024 * 1024)).toFixed(1)}M`;
11
+ }
12
+
13
+ /**
14
+ * Format milliseconds to human readable duration
15
+ */
16
+ function formatDuration(ms) {
17
+ if (ms === undefined || ms === null) return '-';
18
+ if (ms < 1000) return `${Math.round(ms)}ms`;
19
+ return `${(ms / 1000).toFixed(1)}s`;
20
+ }
21
+
22
+ /**
23
+ * Format timestamp to readable time
24
+ */
25
+ function formatTimestamp(ts) {
26
+ if (!ts) return '-';
27
+ return new Date(ts).toLocaleTimeString('en-US', {
28
+ hour12: false,
29
+ hour: '2-digit',
30
+ minute: '2-digit',
31
+ second: '2-digit'
32
+ });
33
+ }
34
+
35
+ /**
36
+ * Get content type shorthand
37
+ */
38
+ function getContentTypeShort(contentType) {
39
+ if (!contentType) return '-';
40
+ const ct = contentType.toLowerCase();
41
+ if (ct.includes('json')) return 'json';
42
+ if (ct.includes('html')) return 'html';
43
+ if (ct.includes('javascript')) return 'js';
44
+ if (ct.includes('css')) return 'css';
45
+ if (ct.includes('image/')) return 'img';
46
+ if (ct.includes('font')) return 'font';
47
+ if (ct.includes('xml')) return 'xml';
48
+ if (ct.includes('text/plain')) return 'text';
49
+ if (ct.includes('protobuf') || ct.includes('proto')) return 'proto';
50
+ if (ct.includes('octet-stream')) return 'bin';
51
+ if (ct.includes('form')) return 'form';
52
+ return ct.split('/').pop()?.split(';')[0]?.slice(0, 6) || '-';
53
+ }
54
+
55
+ /**
56
+ * Get status code style indicator
57
+ */
58
+ function getStatusIndicator(status) {
59
+ if (!status) return '...';
60
+ if (status >= 200 && status < 300) return String(status);
61
+ if (status >= 300 && status < 400) return `${status}→`;
62
+ if (status >= 400 && status < 500) return `${status}!`;
63
+ if (status >= 500) return `${status}!!`;
64
+ return String(status);
65
+ }
66
+
67
+ /**
68
+ * Compact table format (default)
69
+ * Groups by origin with time gaps
70
+ */
71
+ function formatCompact(entries, options = {}) {
72
+ if (!entries || entries.length === 0) {
73
+ return 'No network requests captured';
74
+ }
75
+
76
+ const lines = [];
77
+ const { verbose } = options;
78
+
79
+ // Header
80
+ const header = 'ID │ Method │ Status │ Type │ Size │ Time │ URL';
81
+ const separator = '─────────┼────────┼────────┼───────┼────────┼────────┼' + '─'.repeat(50);
82
+ lines.push(header);
83
+ lines.push(separator);
84
+
85
+ let lastOrigin = null;
86
+ let lastTime = null;
87
+
88
+ for (const e of entries) {
89
+ const origin = e.origin || new URL(e.url).origin;
90
+ const timestamp = e.timestamp || e.startTime;
91
+
92
+ // Add origin separator if changed
93
+ if (lastOrigin && origin !== lastOrigin) {
94
+ lines.push('─────────┼────────┼────────┼───────┼────────┼────────┼' + '─'.repeat(50));
95
+ }
96
+
97
+ // Add time gap indicator (> 5 seconds)
98
+ if (lastTime && timestamp && (timestamp - lastTime) > 5000) {
99
+ const gap = formatDuration(timestamp - lastTime);
100
+ lines.push(` │ │ │ │ │ +${gap.padEnd(5)} │`);
101
+ }
102
+
103
+ const id = (e.requestId || e.id || '-').slice(0, 8).padEnd(8);
104
+ const method = (e.method || 'GET').padEnd(6);
105
+ const status = getStatusIndicator(e.status).padEnd(6);
106
+ const type = getContentTypeShort(e.contentType || e.responseHeaders?.['content-type']).padEnd(5);
107
+ const size = formatSize(e.responseSize || e.encodedDataLength).padEnd(6);
108
+ const time = formatDuration(e.duration || e.time).padEnd(6);
109
+ const url = truncateUrl(e.url, 60);
110
+
111
+ lines.push(`${id} │ ${method} │ ${status} │ ${type} │ ${size} │ ${time} │ ${url}`);
112
+
113
+ lastOrigin = origin;
114
+ lastTime = timestamp;
115
+ }
116
+
117
+ // Summary
118
+ lines.push('');
119
+ const totalSize = entries.reduce((acc, e) => acc + (e.responseSize || e.encodedDataLength || 0), 0);
120
+ lines.push(`Total: ${entries.length} requests, ${formatSize(totalSize)}`);
121
+
122
+ return lines.join('\n');
123
+ }
124
+
125
+ /**
126
+ * Just URLs with method
127
+ */
128
+ function formatUrls(entries) {
129
+ if (!entries || entries.length === 0) {
130
+ return '';
131
+ }
132
+ return entries.map(e => `${(e.method || 'GET').padEnd(6)} ${e.url}`).join('\n');
133
+ }
134
+
135
+ /**
136
+ * Generate curl command for a single request
137
+ */
138
+ function formatCurl(entry) {
139
+ if (!entry) return '';
140
+
141
+ let cmd = `curl -X ${entry.method || 'GET'} '${entry.url}'`;
142
+
143
+ const headers = entry.requestHeaders || {};
144
+ const skipHeaders = ['host', 'content-length', 'connection', 'accept-encoding'];
145
+
146
+ for (const [key, value] of Object.entries(headers)) {
147
+ if (!skipHeaders.includes(key.toLowerCase())) {
148
+ const escapedValue = String(value).replace(/'/g, "'\\''");
149
+ cmd += ` \\\n -H '${key}: ${escapedValue}'`;
150
+ }
151
+ }
152
+
153
+ if (entry.requestBody) {
154
+ const escapedBody = entry.requestBody.replace(/'/g, "'\\''");
155
+ cmd += ` \\\n -d '${escapedBody}'`;
156
+ }
157
+
158
+ return cmd;
159
+ }
160
+
161
+ /**
162
+ * Generate curl commands for multiple entries
163
+ */
164
+ function formatCurlBatch(entries) {
165
+ if (!entries || entries.length === 0) {
166
+ return '';
167
+ }
168
+ return entries.map(e => formatCurl(e)).join('\n\n');
169
+ }
170
+
171
+ /**
172
+ * Full JSON output
173
+ */
174
+ function formatRaw(entries) {
175
+ // Return as object with entries key so CLI can detect it
176
+ return JSON.stringify({ entries, _format: 'raw' }, null, 2);
177
+ }
178
+
179
+ /**
180
+ * Verbose format with headers and body preview
181
+ */
182
+ function formatVerbose(entries, level = 1) {
183
+ if (!entries || entries.length === 0) {
184
+ return 'No network requests captured';
185
+ }
186
+
187
+ const lines = [];
188
+ const bodyLimit = level >= 2 ? Infinity : 2048;
189
+
190
+ for (const e of entries) {
191
+ lines.push('═'.repeat(80));
192
+ lines.push(`${e.method || 'GET'} ${e.url}`);
193
+ lines.push(`ID: ${e.requestId || e.id || '-'} Status: ${e.status || 'pending'} Time: ${formatDuration(e.duration || e.time)}`);
194
+ lines.push('');
195
+
196
+ // Request headers
197
+ if (e.requestHeaders && Object.keys(e.requestHeaders).length > 0) {
198
+ lines.push('▶ Request Headers:');
199
+ const reqHeaders = level >= 2
200
+ ? e.requestHeaders
201
+ : pickHeaders(e.requestHeaders, ['content-type', 'authorization', 'cookie', 'user-agent', 'accept']);
202
+ for (const [k, v] of Object.entries(reqHeaders)) {
203
+ lines.push(` ${k}: ${truncateValue(v, 100)}`);
204
+ }
205
+ lines.push('');
206
+ }
207
+
208
+ // Request body
209
+ if (e.requestBody) {
210
+ lines.push('▶ Request Body:');
211
+ lines.push(formatBody(e.requestBody, bodyLimit));
212
+ lines.push('');
213
+ }
214
+
215
+ // Response headers
216
+ if (e.responseHeaders && Object.keys(e.responseHeaders).length > 0) {
217
+ lines.push('◀ Response Headers:');
218
+ const resHeaders = level >= 2
219
+ ? e.responseHeaders
220
+ : pickHeaders(e.responseHeaders, ['content-type', 'content-length', 'set-cookie', 'location', 'cache-control']);
221
+ for (const [k, v] of Object.entries(resHeaders)) {
222
+ lines.push(` ${k}: ${truncateValue(v, 100)}`);
223
+ }
224
+ lines.push('');
225
+ }
226
+
227
+ // Response body preview
228
+ if (e.responseBody) {
229
+ lines.push('◀ Response Body:');
230
+ lines.push(formatBody(e.responseBody, bodyLimit));
231
+ lines.push('');
232
+ }
233
+ }
234
+
235
+ return lines.join('\n');
236
+ }
237
+
238
+ /**
239
+ * Format a single entry in detail
240
+ */
241
+ function formatEntry(entry) {
242
+ if (!entry) return 'Request not found';
243
+ return formatVerbose([entry], 2);
244
+ }
245
+
246
+ /**
247
+ * Origins summary table
248
+ * Accepts either array of {origin, count, totalSize, lastSeen} or
249
+ * object map of {origin: {count, size, lastSeen}}
250
+ */
251
+ function formatOrigins(origins) {
252
+ if (!origins) {
253
+ return 'No origins captured';
254
+ }
255
+
256
+ // Convert object map to array if needed
257
+ let originsArray;
258
+ if (Array.isArray(origins)) {
259
+ originsArray = origins;
260
+ } else if (typeof origins === 'object') {
261
+ originsArray = Object.entries(origins).map(([origin, data]) => ({
262
+ origin,
263
+ count: data.count || 0,
264
+ totalSize: data.size || data.totalSize || 0,
265
+ lastSeen: data.lastSeen || 0
266
+ }));
267
+ } else {
268
+ return 'No origins captured';
269
+ }
270
+
271
+ if (originsArray.length === 0) {
272
+ return 'No origins captured';
273
+ }
274
+
275
+ const lines = [];
276
+ const header = 'Origin'.padEnd(40) + ' │ Requests │ Size │ Last Seen';
277
+ const separator = '─'.repeat(40) + '─┼──────────┼─────────┼' + '─'.repeat(20);
278
+ lines.push(header);
279
+ lines.push(separator);
280
+
281
+ for (const o of originsArray) {
282
+ const origin = truncateUrl(o.origin, 38).padEnd(40);
283
+ const count = String(o.count || 0).padEnd(8);
284
+ const size = formatSize(o.totalSize || 0).padEnd(7);
285
+ const lastSeen = formatTimestamp(o.lastSeen);
286
+
287
+ lines.push(`${origin} │ ${count} │ ${size} │ ${lastSeen}`);
288
+ }
289
+
290
+ return lines.join('\n');
291
+ }
292
+
293
+ /**
294
+ * Format network stats
295
+ */
296
+ function formatStats(stats) {
297
+ if (!stats) return 'No stats available';
298
+
299
+ const lines = [];
300
+ lines.push('Network Capture Statistics');
301
+ lines.push('═'.repeat(40));
302
+ lines.push(`Total Requests: ${stats.totalRequests || 0}`);
303
+ lines.push(`Total Size: ${formatSize(stats.totalSize || 0)}`);
304
+ lines.push(`Unique Origins: ${stats.uniqueOrigins || 0}`);
305
+ lines.push(`Capture Start: ${formatTimestamp(stats.startTime)}`);
306
+ lines.push(`Duration: ${formatDuration(stats.duration)}`);
307
+ lines.push('');
308
+
309
+ if (stats.byMethod) {
310
+ lines.push('By Method:');
311
+ for (const [method, count] of Object.entries(stats.byMethod)) {
312
+ lines.push(` ${method.padEnd(8)} ${count}`);
313
+ }
314
+ lines.push('');
315
+ }
316
+
317
+ if (stats.byStatus) {
318
+ lines.push('By Status:');
319
+ for (const [status, count] of Object.entries(stats.byStatus)) {
320
+ lines.push(` ${status.padEnd(8)} ${count}`);
321
+ }
322
+ }
323
+
324
+ return lines.join('\n');
325
+ }
326
+
327
+ // Helper functions
328
+
329
+ function truncateUrl(url, maxLen = 60) {
330
+ if (!url) return '-';
331
+ if (url.length <= maxLen) return url;
332
+
333
+ try {
334
+ const u = new URL(url);
335
+ const pathLen = maxLen - u.origin.length - 3;
336
+ if (pathLen > 10) {
337
+ return u.origin + u.pathname.slice(0, pathLen) + '...';
338
+ }
339
+ } catch {}
340
+
341
+ return url.slice(0, maxLen - 3) + '...';
342
+ }
343
+
344
+ function truncateValue(value, maxLen = 100) {
345
+ const str = String(value);
346
+ if (str.length <= maxLen) return str;
347
+ return str.slice(0, maxLen - 3) + '...';
348
+ }
349
+
350
+ function pickHeaders(headers, keys) {
351
+ const result = {};
352
+ for (const key of keys) {
353
+ const lowerKey = key.toLowerCase();
354
+ for (const [k, v] of Object.entries(headers)) {
355
+ if (k.toLowerCase() === lowerKey) {
356
+ result[k] = v;
357
+ break;
358
+ }
359
+ }
360
+ }
361
+ return result;
362
+ }
363
+
364
+ function formatBody(body, maxLen = 2048) {
365
+ if (!body) return ' (empty)';
366
+
367
+ let str = typeof body === 'string' ? body : JSON.stringify(body);
368
+
369
+ // Try to pretty-print JSON
370
+ try {
371
+ const parsed = JSON.parse(str);
372
+ str = JSON.stringify(parsed, null, 2);
373
+ } catch {}
374
+
375
+ // Indent and truncate
376
+ const lines = str.split('\n');
377
+ const truncated = lines.slice(0, 50).map(l => ' ' + l);
378
+
379
+ if (str.length > maxLen) {
380
+ truncated.push(` ... (${formatSize(str.length)} total, truncated)`);
381
+ } else if (lines.length > 50) {
382
+ truncated.push(` ... (${lines.length - 50} more lines)`);
383
+ }
384
+
385
+ return truncated.join('\n');
386
+ }
387
+
388
+ module.exports = {
389
+ formatCompact,
390
+ formatUrls,
391
+ formatCurl,
392
+ formatCurlBatch,
393
+ formatRaw,
394
+ formatVerbose,
395
+ formatEntry,
396
+ formatOrigins,
397
+ formatStats,
398
+ formatSize,
399
+ formatDuration,
400
+ formatTimestamp,
401
+ getContentTypeShort
402
+ };