kiroo 0.8.0 → 0.9.5

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/src/export.js CHANGED
@@ -1,93 +1,560 @@
1
- import { writeFileSync } from 'fs';
2
- import { join } from 'path';
3
- import chalk from 'chalk';
4
- import { getAllInteractions } from './storage.js';
5
-
6
- export function exportToPostman(outFileName) {
7
- try {
8
- const interactions = getAllInteractions();
9
-
10
- if (interactions.length === 0) {
11
- console.log(chalk.yellow('\n ⚠️ No interactions found to export.'));
12
- console.log(chalk.gray(' Run some requests first before exporting.\n'));
13
- return;
14
- }
15
-
16
- const postmanCollection = {
17
- info: {
18
- name: `Kiroo Export - ${new Date().toISOString().split('T')[0]}`,
19
- schema: "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
20
- },
21
- item: interactions.map(int => {
22
- // Map Headers
23
- const headerList = Object.entries(int.request.headers || {}).map(([key, value]) => ({
24
- key,
25
- value: value.toString(),
26
- type: "text"
27
- }));
28
-
29
- // Format body
30
- let rawBody = '';
31
- if (int.request.body) {
32
- rawBody = typeof int.request.body === 'object'
33
- ? JSON.stringify(int.request.body, null, 2)
34
- : int.request.body.toString();
35
- }
36
-
37
- // Response Body
38
- let resBodyStr = '';
39
- if (int.response.body) {
40
- resBodyStr = typeof int.response.body === 'object'
41
- ? JSON.stringify(int.response.body, null, 2)
42
- : int.response.body.toString();
43
- }
44
-
45
- return {
46
- name: `[${int.request.method}] ${int.request.url}`,
47
- request: {
48
- method: int.request.method.toUpperCase(),
49
- header: headerList,
50
- url: {
51
- raw: int.request.url
52
- },
53
- ...(rawBody ? {
54
- body: {
55
- mode: "raw",
56
- raw: rawBody,
57
- options: {
58
- raw: { language: "json" }
59
- }
60
- }
61
- } : {})
62
- },
63
- response: [
64
- {
65
- name: "Saved Example from Kiroo",
66
- originalRequest: {
67
- method: int.request.method.toUpperCase(),
68
- header: headerList,
69
- url: { raw: int.request.url }
70
- },
71
- status: "Saved Response",
72
- code: int.response.status,
73
- _postman_previewlanguage: "json",
74
- header: [],
75
- cookie: [],
76
- body: resBodyStr
77
- }
78
- ]
79
- };
80
- })
81
- };
82
-
83
- const outputPath = join(process.cwd(), outFileName);
84
- writeFileSync(outputPath, JSON.stringify(postmanCollection, null, 2));
85
-
86
- console.log(chalk.green(`\n ✅ Collection exported successfully!`));
87
- console.log(chalk.gray(` Saved to: ${outputPath}`));
88
- console.log(chalk.magenta(` You can now import this file directly into Postman/Insomnia.\n`));
89
-
90
- } catch (error) {
91
- console.error(chalk.red('\n ✗ Export failed:'), error.message, '\n');
92
- }
93
- }
1
+ import { writeFileSync } from 'fs';
2
+ import { join } from 'path';
3
+ import chalk from 'chalk';
4
+ import { getAllInteractions } from './storage.js';
5
+ import { stableJSONStringify } from './deterministic.js';
6
+ import { loadKirooConfig } from './config.js';
7
+
8
+ function normalizeInteractions() {
9
+ return getAllInteractions()
10
+ .map((int) => ({
11
+ ...int,
12
+ request: int.request || {},
13
+ response: {
14
+ ...(int.response || {}),
15
+ body: int.response?.body ?? int.response?.data
16
+ }
17
+ }))
18
+ .sort((a, b) => {
19
+ const methodA = String(a.request.method || '').toUpperCase();
20
+ const methodB = String(b.request.method || '').toUpperCase();
21
+ if (methodA !== methodB) return methodA.localeCompare(methodB);
22
+ return String(a.request.url || '').localeCompare(String(b.request.url || ''));
23
+ });
24
+ }
25
+
26
+ function buildPostmanCollection(interactions) {
27
+ return {
28
+ info: {
29
+ name: `Kiroo Export - ${new Date().toISOString().split('T')[0]}`,
30
+ schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json'
31
+ },
32
+ item: interactions.map((int) => {
33
+ const headerList = Object.entries(int.request.headers || {})
34
+ .sort(([a], [b]) => a.localeCompare(b))
35
+ .map(([key, value]) => ({ key, value: String(value), type: 'text' }));
36
+
37
+ const rawBody = int.request.body === undefined
38
+ ? ''
39
+ : typeof int.request.body === 'object'
40
+ ? JSON.stringify(int.request.body, null, 2)
41
+ : String(int.request.body);
42
+
43
+ const resBodyStr = int.response.body === undefined
44
+ ? ''
45
+ : typeof int.response.body === 'object'
46
+ ? JSON.stringify(int.response.body, null, 2)
47
+ : String(int.response.body);
48
+
49
+ return {
50
+ name: `[${String(int.request.method || '').toUpperCase()}] ${int.request.url}`,
51
+ request: {
52
+ method: String(int.request.method || 'GET').toUpperCase(),
53
+ header: headerList,
54
+ url: { raw: int.request.url },
55
+ ...(rawBody ? {
56
+ body: {
57
+ mode: 'raw',
58
+ raw: rawBody,
59
+ options: {
60
+ raw: { language: 'json' }
61
+ }
62
+ }
63
+ } : {})
64
+ },
65
+ response: [
66
+ {
67
+ name: 'Saved Response from Kiroo',
68
+ originalRequest: {
69
+ method: String(int.request.method || 'GET').toUpperCase(),
70
+ header: headerList,
71
+ url: { raw: int.request.url }
72
+ },
73
+ status: 'Saved Response',
74
+ code: int.response.status,
75
+ _postman_previewlanguage: 'json',
76
+ header: [],
77
+ cookie: [],
78
+ body: resBodyStr
79
+ }
80
+ ]
81
+ };
82
+ })
83
+ };
84
+ }
85
+
86
+ function getPathAndOrigin(rawUrl) {
87
+ try {
88
+ const parsed = new URL(rawUrl);
89
+ return { path: parsed.pathname || '/', origin: parsed.origin, query: parsed.searchParams };
90
+ } catch {
91
+ if (typeof rawUrl === 'string' && rawUrl.startsWith('/')) {
92
+ return { path: rawUrl, origin: null, query: new URLSearchParams() };
93
+ }
94
+ return { path: '/', origin: null, query: new URLSearchParams() };
95
+ }
96
+ }
97
+
98
+ function inferSchema(value) {
99
+ if (value === null) return { type: 'object', nullable: true };
100
+ if (Array.isArray(value)) {
101
+ if (value.length === 0) return { type: 'array', items: {} };
102
+ // Only infer from first 5 items to avoid noise from duplicates
103
+ const sample = value.slice(0, 5);
104
+ const mergedItem = sample.map((item) => inferSchema(item)).reduce(mergeSchemas, {});
105
+ return { type: 'array', items: mergedItem };
106
+ }
107
+ if (value && typeof value === 'object') {
108
+ const keys = Object.keys(value).sort((a, b) => a.localeCompare(b));
109
+ const properties = {};
110
+ keys.forEach((key) => {
111
+ properties[key] = inferSchema(value[key]);
112
+ });
113
+ return {
114
+ type: 'object',
115
+ properties,
116
+ required: keys
117
+ };
118
+ }
119
+ if (typeof value === 'number') {
120
+ return Number.isInteger(value) ? { type: 'integer' } : { type: 'number' };
121
+ }
122
+ if (typeof value === 'boolean') return { type: 'boolean' };
123
+ if (typeof value === 'string') return { type: 'string' };
124
+ return {};
125
+ }
126
+
127
+ function mergeSchemas(a, b) {
128
+ const left = a || {};
129
+ const right = b || {};
130
+ const leftType = left.type;
131
+ const rightType = right.type;
132
+
133
+ if (!leftType) return right;
134
+ if (!rightType) return left;
135
+ if (leftType !== rightType) {
136
+ return { oneOf: [left, right] };
137
+ }
138
+
139
+ if (leftType === 'object') {
140
+ const leftProps = left.properties || {};
141
+ const rightProps = right.properties || {};
142
+ const keys = new Set([...Object.keys(leftProps), ...Object.keys(rightProps)]);
143
+ const mergedProperties = {};
144
+
145
+ keys.forEach((key) => {
146
+ if (leftProps[key] && rightProps[key]) mergedProperties[key] = mergeSchemas(leftProps[key], rightProps[key]);
147
+ else mergedProperties[key] = leftProps[key] || rightProps[key];
148
+ });
149
+
150
+ const leftReq = new Set(left.required || []);
151
+ const rightReq = new Set(right.required || []);
152
+ const mergedRequired = [...leftReq].filter((k) => rightReq.has(k)).sort((x, y) => x.localeCompare(y));
153
+
154
+ return {
155
+ type: 'object',
156
+ properties: mergedProperties,
157
+ ...(mergedRequired.length ? { required: mergedRequired } : {})
158
+ };
159
+ }
160
+
161
+ if (leftType === 'array') {
162
+ return {
163
+ type: 'array',
164
+ items: mergeSchemas(left.items || {}, right.items || {})
165
+ };
166
+ }
167
+
168
+ return left;
169
+ }
170
+
171
+ function headerValue(headers, key) {
172
+ if (!headers || typeof headers !== 'object') return '';
173
+ const found = Object.entries(headers).find(([k]) => k.toLowerCase() === key.toLowerCase());
174
+ return found ? String(found[1]) : '';
175
+ }
176
+
177
+ function contentTypeFromHeaders(headers, fallback = 'application/json') {
178
+ const raw = headerValue(headers, 'content-type');
179
+ if (!raw) return fallback;
180
+ return raw.split(';')[0].trim() || fallback;
181
+ }
182
+
183
+ function isLikelyIdSegment(segment) {
184
+ if (!segment) return false;
185
+ if (/^\d+$/.test(segment)) return true;
186
+ if (/^[0-9a-f]{24}$/i.test(segment)) return true;
187
+ if (/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(segment)) return true;
188
+ if (/^[A-Za-z0-9_-]{10,}$/.test(segment) && /[A-Za-z]/.test(segment) && /\d/.test(segment)) return true;
189
+ return false;
190
+ }
191
+
192
+ function singularize(word) {
193
+ if (!word) return 'id';
194
+ if (word.endsWith('ies')) return `${word.slice(0, -3)}y`;
195
+ if (word.endsWith('s') && word.length > 1) return word.slice(0, -1);
196
+ return word;
197
+ }
198
+
199
+ function normalizePathForOpenApi(path) {
200
+ const rawSegments = String(path || '/').split('/').filter(Boolean);
201
+ if (rawSegments.length === 0) {
202
+ return { path: '/', params: [] };
203
+ }
204
+
205
+ const params = [];
206
+ const usedNames = new Set();
207
+ const normalizedSegments = rawSegments.map((segment, index) => {
208
+ if (!isLikelyIdSegment(segment)) {
209
+ return segment;
210
+ }
211
+
212
+ const prev = rawSegments[index - 1] || 'id';
213
+ let baseName = singularize(prev).replace(/[^a-zA-Z0-9]/g, '');
214
+ if (!baseName) baseName = 'id';
215
+ let paramName = `${baseName.charAt(0).toLowerCase()}${baseName.slice(1)}Id`;
216
+
217
+ let i = 2;
218
+ while (usedNames.has(paramName)) {
219
+ paramName = `${baseName.charAt(0).toLowerCase()}${baseName.slice(1)}Id${i}`;
220
+ i += 1;
221
+ }
222
+ usedNames.add(paramName);
223
+ params.push({ name: paramName, example: segment });
224
+ return `{${paramName}}`;
225
+ });
226
+
227
+ return { path: `/${normalizedSegments.join('/')}`, params };
228
+ }
229
+
230
+ function getTagFromPath(path) {
231
+ const segments = String(path || '/').split('/').filter(Boolean);
232
+ if (segments.length === 0) return 'general';
233
+ if (segments[0].toLowerCase() === 'api' && segments.length > 1) {
234
+ return segments[1];
235
+ }
236
+ return segments[0];
237
+ }
238
+
239
+ function toOperationId(method, path) {
240
+ const methodPart = String(method || 'get').toLowerCase();
241
+ const segments = String(path || '/')
242
+ .split('/')
243
+ .filter(Boolean)
244
+ .map((s) => {
245
+ if (s.startsWith('{') && s.endsWith('}')) {
246
+ const p = s.slice(1, -1);
247
+ return `by${p.charAt(0).toUpperCase()}${p.slice(1)}`;
248
+ }
249
+ return s.replace(/[^a-zA-Z0-9]/g, '');
250
+ });
251
+ return [methodPart, ...segments].join('_') || `${methodPart}_root`;
252
+ }
253
+
254
+ function hasHeader(headers, name) {
255
+ return Object.keys(headers || {}).some((k) => k.toLowerCase() === name.toLowerCase());
256
+ }
257
+
258
+ const SENSITIVE_PATTERNS = [
259
+ { regex: /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g, replace: '<REDACTED_EMAIL>' },
260
+ { regex: /eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g, replace: '<REDACTED_JWT>' },
261
+ { regex: /\b(sk_live_|pk_live_|sk_test_|pk_test_)[A-Za-z0-9_-]{10,}/g, replace: '<REDACTED_KEY>' },
262
+ { regex: /\b(gsk_|ghp_|gho_|glpat-)[A-Za-z0-9_-]{10,}/g, replace: '<REDACTED_KEY>' },
263
+ ];
264
+
265
+ function redactValue(val) {
266
+ if (typeof val !== 'string') return val;
267
+ let result = val;
268
+ for (const { regex, replace } of SENSITIVE_PATTERNS) {
269
+ result = result.replace(regex, replace);
270
+ }
271
+ return result;
272
+ }
273
+
274
+ function truncateAndRedact(value, maxArrayItems = 3) {
275
+ if (value === null || value === undefined) return value;
276
+ if (typeof value === 'string') return redactValue(value);
277
+ if (typeof value !== 'object') return value;
278
+ if (Array.isArray(value)) {
279
+ const sliced = value.slice(0, maxArrayItems);
280
+ return sliced.map((item) => truncateAndRedact(item, maxArrayItems));
281
+ }
282
+ const result = {};
283
+ for (const [k, v] of Object.entries(value)) {
284
+ result[k] = truncateAndRedact(v, maxArrayItems);
285
+ }
286
+ return result;
287
+ }
288
+
289
+ function buildOpenApiSpec(interactions, options = {}) {
290
+ const title = options.title || 'Kiroo Traffic API';
291
+ const version = options.apiVersion || '1.0.0';
292
+ const operations = new Map();
293
+ const origins = new Set();
294
+ const includeBearer = { value: false };
295
+ const includeApiKey = { value: false };
296
+
297
+ interactions.forEach((int) => {
298
+ const method = String(int.request.method || 'get').toLowerCase();
299
+ const { path, origin, query } = getPathAndOrigin(int.request.url || '/');
300
+ const normalized = normalizePathForOpenApi(path);
301
+ if (origin) origins.add(origin);
302
+
303
+ const key = `${method} ${normalized.path}`;
304
+ if (!operations.has(key)) {
305
+ operations.set(key, {
306
+ method,
307
+ path: normalized.path,
308
+ pathParams: new Map(),
309
+ queryParams: new Set(),
310
+ requestBodies: [],
311
+ requestMimeTypes: new Set(),
312
+ responses: new Map(),
313
+ hasBearerAuth: false,
314
+ hasApiKeyAuth: false
315
+ });
316
+ }
317
+
318
+ const op = operations.get(key);
319
+ normalized.params.forEach((p) => {
320
+ if (!op.pathParams.has(p.name)) op.pathParams.set(p.name, p.example);
321
+ });
322
+ for (const queryKey of Array.from(query.keys())) {
323
+ op.queryParams.add(queryKey);
324
+ }
325
+
326
+ if (hasHeader(int.request.headers, 'authorization')) {
327
+ op.hasBearerAuth = true;
328
+ includeBearer.value = true;
329
+ }
330
+ if (hasHeader(int.request.headers, 'x-api-key') || hasHeader(int.request.headers, 'api-key')) {
331
+ op.hasApiKeyAuth = true;
332
+ includeApiKey.value = true;
333
+ }
334
+
335
+ if (int.request.body !== undefined) {
336
+ op.requestBodies.push(int.request.body);
337
+ op.requestMimeTypes.add(contentTypeFromHeaders(int.request.headers, 'application/json'));
338
+ }
339
+
340
+ const statusCode = String(int.response.status || 'default');
341
+ if (!op.responses.has(statusCode)) {
342
+ op.responses.set(statusCode, {
343
+ bodies: [],
344
+ mimeTypes: new Set(),
345
+ example: undefined
346
+ });
347
+ }
348
+
349
+ const res = op.responses.get(statusCode);
350
+ if (int.response.body !== undefined) {
351
+ res.bodies.push(int.response.body);
352
+ if (res.example === undefined) res.example = int.response.body;
353
+ res.mimeTypes.add(contentTypeFromHeaders(int.response.headers, 'application/json'));
354
+ }
355
+ });
356
+
357
+ const sortedOps = Array.from(operations.values()).sort((a, b) => {
358
+ if (a.path !== b.path) return a.path.localeCompare(b.path);
359
+ return a.method.localeCompare(b.method);
360
+ });
361
+
362
+ const paths = {};
363
+ sortedOps.forEach((op) => {
364
+ if (!paths[op.path]) paths[op.path] = {};
365
+
366
+ const pathParamEntries = Array.from(op.pathParams.entries())
367
+ .sort(([a], [b]) => a.localeCompare(b))
368
+ .map(([name, example]) => ({
369
+ name,
370
+ in: 'path',
371
+ required: true,
372
+ schema: { type: 'string' },
373
+ example
374
+ }));
375
+
376
+ const queryParamEntries = Array.from(op.queryParams)
377
+ .sort((a, b) => a.localeCompare(b))
378
+ .map((name) => ({
379
+ name,
380
+ in: 'query',
381
+ required: false,
382
+ schema: { type: 'string' }
383
+ }));
384
+
385
+ const operation = {
386
+ summary: `${op.method.toUpperCase()} ${op.path}`,
387
+ operationId: toOperationId(op.method, op.path),
388
+ tags: [getTagFromPath(op.path)],
389
+ responses: {}
390
+ };
391
+
392
+ if (pathParamEntries.length || queryParamEntries.length) {
393
+ operation.parameters = [...pathParamEntries, ...queryParamEntries];
394
+ }
395
+
396
+ if (op.requestBodies.length > 0) {
397
+ const mergedBodySchema = op.requestBodies.map((b) => inferSchema(b)).reduce(mergeSchemas, {});
398
+ const mimeType = Array.from(op.requestMimeTypes)[0] || 'application/json';
399
+ operation.requestBody = {
400
+ required: op.method !== 'get' && op.method !== 'delete',
401
+ content: {
402
+ [mimeType]: {
403
+ schema: mergedBodySchema,
404
+ example: truncateAndRedact(op.requestBodies[0])
405
+ }
406
+ }
407
+ };
408
+ }
409
+
410
+ const sortedStatuses = Array.from(op.responses.keys()).sort((a, b) => a.localeCompare(b));
411
+ sortedStatuses.forEach((status) => {
412
+ const res = op.responses.get(status);
413
+ const statusNum = parseInt(status, 10);
414
+
415
+ // Meaningful description based on status code
416
+ const description = statusNum === 200 ? 'Successful response'
417
+ : statusNum === 201 ? 'Resource created'
418
+ : statusNum === 204 ? 'No content'
419
+ : statusNum === 304 ? 'Not modified'
420
+ : statusNum === 400 ? 'Bad request'
421
+ : statusNum === 401 ? 'Unauthorized'
422
+ : statusNum === 403 ? 'Forbidden'
423
+ : statusNum === 404 ? 'Not found'
424
+ : statusNum === 409 ? 'Conflict'
425
+ : statusNum === 500 ? 'Internal server error'
426
+ : `Response ${status}`;
427
+
428
+ // 204 and 304 typically have no body
429
+ if (statusNum === 204 || statusNum === 304) {
430
+ operation.responses[status] = { description };
431
+ return;
432
+ }
433
+
434
+ const mimeType = Array.from(res.mimeTypes)[0] || 'application/json';
435
+ const schema = res.bodies.length > 0
436
+ ? res.bodies.map((b) => inferSchema(b)).reduce(mergeSchemas, {})
437
+ : {};
438
+
439
+ // Recursively truncate arrays and redact sensitive data in examples
440
+ let example = res.example !== undefined ? truncateAndRedact(res.example) : undefined;
441
+
442
+ operation.responses[status] = {
443
+ description,
444
+ content: {
445
+ [mimeType]: {
446
+ schema,
447
+ ...(example !== undefined ? { example } : {})
448
+ }
449
+ }
450
+ };
451
+ });
452
+
453
+ if (op.hasBearerAuth || op.hasApiKeyAuth) {
454
+ operation.security = [];
455
+ if (op.hasBearerAuth) operation.security.push({ bearerAuth: [] });
456
+ if (op.hasApiKeyAuth) operation.security.push({ apiKeyAuth: [] });
457
+ }
458
+
459
+ paths[op.path][op.method] = operation;
460
+ });
461
+
462
+ const serverList = options.server
463
+ ? [{ url: options.server }]
464
+ : Array.from(origins).sort((a, b) => a.localeCompare(b)).map((url) => ({ url }));
465
+
466
+ const spec = {
467
+ openapi: '3.0.3',
468
+ info: { title, version },
469
+ ...(serverList.length ? { servers: serverList } : {}),
470
+ paths
471
+ };
472
+
473
+ if (includeBearer.value || includeApiKey.value) {
474
+ spec.components = { securitySchemes: {} };
475
+ if (includeBearer.value) {
476
+ spec.components.securitySchemes.bearerAuth = {
477
+ type: 'http',
478
+ scheme: 'bearer',
479
+ bearerFormat: 'JWT'
480
+ };
481
+ }
482
+ if (includeApiKey.value) {
483
+ spec.components.securitySchemes.apiKeyAuth = {
484
+ type: 'apiKey',
485
+ in: 'header',
486
+ name: 'x-api-key'
487
+ };
488
+ }
489
+ }
490
+
491
+ return spec;
492
+ }
493
+
494
+ export function exportInteractions(options = {}) {
495
+ try {
496
+ const config = loadKirooConfig();
497
+ const sortKeys = config.settings?.determinism?.sortKeys !== false;
498
+ let interactions = normalizeInteractions();
499
+ const pathPrefix = options.pathPrefix ? String(options.pathPrefix) : '';
500
+ const minSamples = Number.parseInt(options.minSamples, 10);
501
+
502
+ if (pathPrefix) {
503
+ interactions = interactions.filter((int) => {
504
+ const { path } = getPathAndOrigin(int.request.url || '/');
505
+ return path.startsWith(pathPrefix);
506
+ });
507
+ }
508
+
509
+ if (interactions.length === 0) {
510
+ console.log(chalk.yellow('\n ⚠️ No interactions found to export.'));
511
+ console.log(chalk.gray(' Run some requests first before exporting.\n'));
512
+ return;
513
+ }
514
+
515
+ const format = String(options.format || 'postman').toLowerCase();
516
+ const outFileName = options.out || (format === 'openapi' ? 'openapi.json' : 'kiroo-collection.json');
517
+ const outputPath = join(process.cwd(), outFileName);
518
+
519
+ let payloadObject;
520
+ if (format === 'postman') {
521
+ payloadObject = buildPostmanCollection(interactions);
522
+ } else if (format === 'openapi') {
523
+ if (!Number.isNaN(minSamples) && minSamples > 1) {
524
+ const counts = new Map();
525
+ interactions.forEach((int) => {
526
+ const method = String(int.request.method || '').toLowerCase();
527
+ const { path } = getPathAndOrigin(int.request.url || '/');
528
+ const normalizedPath = normalizePathForOpenApi(path).path;
529
+ const key = `${method} ${normalizedPath}`;
530
+ counts.set(key, (counts.get(key) || 0) + 1);
531
+ });
532
+ interactions = interactions.filter((int) => {
533
+ const method = String(int.request.method || '').toLowerCase();
534
+ const { path } = getPathAndOrigin(int.request.url || '/');
535
+ const normalizedPath = normalizePathForOpenApi(path).path;
536
+ const key = `${method} ${normalizedPath}`;
537
+ return (counts.get(key) || 0) >= minSamples;
538
+ });
539
+ }
540
+ payloadObject = buildOpenApiSpec(interactions, options);
541
+ } else {
542
+ console.error(chalk.red(`\n ✗ Unsupported export format: ${format}`));
543
+ console.log(chalk.gray(' Use: postman | openapi\n'));
544
+ process.exit(1);
545
+ }
546
+
547
+ const payload = sortKeys ? stableJSONStringify(payloadObject, 2) : JSON.stringify(payloadObject, null, 2);
548
+ writeFileSync(outputPath, payload);
549
+
550
+ console.log(chalk.green(`\n ✅ Export successful (${format})`));
551
+ console.log(chalk.gray(` Saved to: ${outputPath}\n`));
552
+ } catch (error) {
553
+ console.error(chalk.red('\n ✗ Export failed:'), error.message, '\n');
554
+ process.exit(1);
555
+ }
556
+ }
557
+
558
+ export function exportToPostman(outFileName) {
559
+ exportInteractions({ format: 'postman', out: outFileName });
560
+ }