kiroo 0.8.0 → 0.9.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/src/env.js CHANGED
@@ -1,6 +1,33 @@
1
1
  import chalk from 'chalk';
2
2
  import Table from 'cli-table3';
3
3
  import { loadEnv, saveEnv } from './storage.js';
4
+ import { isSensitiveKey } from './sanitizer.js';
5
+
6
+ function maskEnvValue(key, value) {
7
+ if (!isSensitiveKey(key)) {
8
+ return String(value);
9
+ }
10
+
11
+ const raw = String(value || '');
12
+ if (raw.length <= 4) {
13
+ return '<REDACTED>';
14
+ }
15
+ return `${raw.slice(0, 2)}***${raw.slice(-2)}`;
16
+ }
17
+
18
+ export function getCurrentEnvVars() {
19
+ const env = loadEnv();
20
+ if (!env.environments[env.current]) {
21
+ env.environments[env.current] = {};
22
+ saveEnv(env);
23
+ }
24
+ return env.environments[env.current];
25
+ }
26
+
27
+ export function getEnvVar(key) {
28
+ const vars = getCurrentEnvVars();
29
+ return vars[key];
30
+ }
4
31
 
5
32
  export function listEnv() {
6
33
  const env = loadEnv();
@@ -19,8 +46,8 @@ export function listEnv() {
19
46
  colWidths: [20, 40]
20
47
  });
21
48
 
22
- Object.entries(currentVars).forEach(([k, v]) => {
23
- table.push([chalk.white(k), chalk.gray(String(v))]);
49
+ Object.entries(currentVars).sort(([a], [b]) => a.localeCompare(b)).forEach(([k, v]) => {
50
+ table.push([chalk.white(k), chalk.gray(maskEnvValue(k, v))]);
24
51
  });
25
52
  console.log(table.toString());
26
53
  } else {
@@ -49,7 +76,8 @@ export function setVar(key, value) {
49
76
  const env = loadEnv();
50
77
  env.environments[env.current][key] = value;
51
78
  saveEnv(env);
52
- console.log(chalk.green(` ✅ Set ${key}=${value} in`), chalk.white(env.current));
79
+ const printValue = isSensitiveKey(key) ? '<REDACTED>' : value;
80
+ console.log(chalk.green(` ✅ Set ${key}=${printValue} in`), chalk.white(env.current));
53
81
  }
54
82
 
55
83
  export function deleteVar(key) {
package/src/executor.js CHANGED
@@ -230,6 +230,23 @@ export async function executeRequest(method, url, options = {}) {
230
230
  console.error(chalk.red('\n ✗ Error:'), error.message, '\n');
231
231
  }
232
232
 
233
+ // Save failed interaction
234
+ await saveInteraction({
235
+ method,
236
+ url,
237
+ headers,
238
+ body,
239
+ response: {
240
+ status: 0,
241
+ statusText: error.code || 'FAILED',
242
+ headers: {},
243
+ data: { error: error.message, code: error.code }
244
+ },
245
+ duration,
246
+ saves: [],
247
+ uses: Array.from(usedKeys)
248
+ });
249
+
233
250
  process.exit(1);
234
251
  }
235
252
  }
package/src/export.js CHANGED
@@ -1,93 +1,503 @@
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 { nullable: true };
100
+ if (Array.isArray(value)) {
101
+ if (value.length === 0) return { type: 'array', items: {} };
102
+ const mergedItem = value.map((item) => inferSchema(item)).reduce(mergeSchemas, {});
103
+ return { type: 'array', items: mergedItem };
104
+ }
105
+ if (value && typeof value === 'object') {
106
+ const keys = Object.keys(value).sort((a, b) => a.localeCompare(b));
107
+ const properties = {};
108
+ keys.forEach((key) => {
109
+ properties[key] = inferSchema(value[key]);
110
+ });
111
+ return {
112
+ type: 'object',
113
+ properties,
114
+ required: keys
115
+ };
116
+ }
117
+ if (typeof value === 'number') {
118
+ return Number.isInteger(value) ? { type: 'integer' } : { type: 'number' };
119
+ }
120
+ if (typeof value === 'boolean') return { type: 'boolean' };
121
+ if (typeof value === 'string') return { type: 'string' };
122
+ return {};
123
+ }
124
+
125
+ function mergeSchemas(a, b) {
126
+ const left = a || {};
127
+ const right = b || {};
128
+ const leftType = left.type;
129
+ const rightType = right.type;
130
+
131
+ if (!leftType) return right;
132
+ if (!rightType) return left;
133
+ if (leftType !== rightType) {
134
+ return { oneOf: [left, right] };
135
+ }
136
+
137
+ if (leftType === 'object') {
138
+ const leftProps = left.properties || {};
139
+ const rightProps = right.properties || {};
140
+ const keys = new Set([...Object.keys(leftProps), ...Object.keys(rightProps)]);
141
+ const mergedProperties = {};
142
+
143
+ keys.forEach((key) => {
144
+ if (leftProps[key] && rightProps[key]) mergedProperties[key] = mergeSchemas(leftProps[key], rightProps[key]);
145
+ else mergedProperties[key] = leftProps[key] || rightProps[key];
146
+ });
147
+
148
+ const leftReq = new Set(left.required || []);
149
+ const rightReq = new Set(right.required || []);
150
+ const mergedRequired = [...leftReq].filter((k) => rightReq.has(k)).sort((x, y) => x.localeCompare(y));
151
+
152
+ return {
153
+ type: 'object',
154
+ properties: mergedProperties,
155
+ ...(mergedRequired.length ? { required: mergedRequired } : {})
156
+ };
157
+ }
158
+
159
+ if (leftType === 'array') {
160
+ return {
161
+ type: 'array',
162
+ items: mergeSchemas(left.items || {}, right.items || {})
163
+ };
164
+ }
165
+
166
+ return left;
167
+ }
168
+
169
+ function headerValue(headers, key) {
170
+ if (!headers || typeof headers !== 'object') return '';
171
+ const found = Object.entries(headers).find(([k]) => k.toLowerCase() === key.toLowerCase());
172
+ return found ? String(found[1]) : '';
173
+ }
174
+
175
+ function contentTypeFromHeaders(headers, fallback = 'application/json') {
176
+ const raw = headerValue(headers, 'content-type');
177
+ if (!raw) return fallback;
178
+ return raw.split(';')[0].trim() || fallback;
179
+ }
180
+
181
+ function isLikelyIdSegment(segment) {
182
+ if (!segment) return false;
183
+ if (/^\d+$/.test(segment)) return true;
184
+ if (/^[0-9a-f]{24}$/i.test(segment)) return true;
185
+ 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;
186
+ if (/^[A-Za-z0-9_-]{10,}$/.test(segment) && /[A-Za-z]/.test(segment) && /\d/.test(segment)) return true;
187
+ return false;
188
+ }
189
+
190
+ function singularize(word) {
191
+ if (!word) return 'id';
192
+ if (word.endsWith('ies')) return `${word.slice(0, -3)}y`;
193
+ if (word.endsWith('s') && word.length > 1) return word.slice(0, -1);
194
+ return word;
195
+ }
196
+
197
+ function normalizePathForOpenApi(path) {
198
+ const rawSegments = String(path || '/').split('/').filter(Boolean);
199
+ if (rawSegments.length === 0) {
200
+ return { path: '/', params: [] };
201
+ }
202
+
203
+ const params = [];
204
+ const usedNames = new Set();
205
+ const normalizedSegments = rawSegments.map((segment, index) => {
206
+ if (!isLikelyIdSegment(segment)) {
207
+ return segment;
208
+ }
209
+
210
+ const prev = rawSegments[index - 1] || 'id';
211
+ let baseName = singularize(prev).replace(/[^a-zA-Z0-9]/g, '');
212
+ if (!baseName) baseName = 'id';
213
+ let paramName = `${baseName.charAt(0).toLowerCase()}${baseName.slice(1)}Id`;
214
+
215
+ let i = 2;
216
+ while (usedNames.has(paramName)) {
217
+ paramName = `${baseName.charAt(0).toLowerCase()}${baseName.slice(1)}Id${i}`;
218
+ i += 1;
219
+ }
220
+ usedNames.add(paramName);
221
+ params.push({ name: paramName, example: segment });
222
+ return `{${paramName}}`;
223
+ });
224
+
225
+ return { path: `/${normalizedSegments.join('/')}`, params };
226
+ }
227
+
228
+ function getTagFromPath(path) {
229
+ const segments = String(path || '/').split('/').filter(Boolean);
230
+ if (segments.length === 0) return 'general';
231
+ if (segments[0].toLowerCase() === 'api' && segments.length > 1) {
232
+ return segments[1];
233
+ }
234
+ return segments[0];
235
+ }
236
+
237
+ function toOperationId(method, path) {
238
+ const methodPart = String(method || 'get').toLowerCase();
239
+ const segments = String(path || '/')
240
+ .split('/')
241
+ .filter(Boolean)
242
+ .map((s) => {
243
+ if (s.startsWith('{') && s.endsWith('}')) {
244
+ const p = s.slice(1, -1);
245
+ return `by${p.charAt(0).toUpperCase()}${p.slice(1)}`;
246
+ }
247
+ return s.replace(/[^a-zA-Z0-9]/g, '');
248
+ });
249
+ return [methodPart, ...segments].join('_') || `${methodPart}_root`;
250
+ }
251
+
252
+ function hasHeader(headers, name) {
253
+ return Object.keys(headers || {}).some((k) => k.toLowerCase() === name.toLowerCase());
254
+ }
255
+
256
+ function buildOpenApiSpec(interactions, options = {}) {
257
+ const title = options.title || 'Kiroo Traffic API';
258
+ const version = options.apiVersion || '1.0.0';
259
+ const operations = new Map();
260
+ const origins = new Set();
261
+ const includeBearer = { value: false };
262
+ const includeApiKey = { value: false };
263
+
264
+ interactions.forEach((int) => {
265
+ const method = String(int.request.method || 'get').toLowerCase();
266
+ const { path, origin, query } = getPathAndOrigin(int.request.url || '/');
267
+ const normalized = normalizePathForOpenApi(path);
268
+ if (origin) origins.add(origin);
269
+
270
+ const key = `${method} ${normalized.path}`;
271
+ if (!operations.has(key)) {
272
+ operations.set(key, {
273
+ method,
274
+ path: normalized.path,
275
+ pathParams: new Map(),
276
+ queryParams: new Set(),
277
+ requestBodies: [],
278
+ requestMimeTypes: new Set(),
279
+ responses: new Map(),
280
+ hasBearerAuth: false,
281
+ hasApiKeyAuth: false
282
+ });
283
+ }
284
+
285
+ const op = operations.get(key);
286
+ normalized.params.forEach((p) => {
287
+ if (!op.pathParams.has(p.name)) op.pathParams.set(p.name, p.example);
288
+ });
289
+ for (const queryKey of Array.from(query.keys())) {
290
+ op.queryParams.add(queryKey);
291
+ }
292
+
293
+ if (hasHeader(int.request.headers, 'authorization')) {
294
+ op.hasBearerAuth = true;
295
+ includeBearer.value = true;
296
+ }
297
+ if (hasHeader(int.request.headers, 'x-api-key') || hasHeader(int.request.headers, 'api-key')) {
298
+ op.hasApiKeyAuth = true;
299
+ includeApiKey.value = true;
300
+ }
301
+
302
+ if (int.request.body !== undefined) {
303
+ op.requestBodies.push(int.request.body);
304
+ op.requestMimeTypes.add(contentTypeFromHeaders(int.request.headers, 'application/json'));
305
+ }
306
+
307
+ const statusCode = String(int.response.status || 'default');
308
+ if (!op.responses.has(statusCode)) {
309
+ op.responses.set(statusCode, {
310
+ bodies: [],
311
+ mimeTypes: new Set(),
312
+ example: undefined
313
+ });
314
+ }
315
+
316
+ const res = op.responses.get(statusCode);
317
+ if (int.response.body !== undefined) {
318
+ res.bodies.push(int.response.body);
319
+ if (res.example === undefined) res.example = int.response.body;
320
+ res.mimeTypes.add(contentTypeFromHeaders(int.response.headers, 'application/json'));
321
+ }
322
+ });
323
+
324
+ const sortedOps = Array.from(operations.values()).sort((a, b) => {
325
+ if (a.path !== b.path) return a.path.localeCompare(b.path);
326
+ return a.method.localeCompare(b.method);
327
+ });
328
+
329
+ const paths = {};
330
+ sortedOps.forEach((op) => {
331
+ if (!paths[op.path]) paths[op.path] = {};
332
+
333
+ const pathParamEntries = Array.from(op.pathParams.entries())
334
+ .sort(([a], [b]) => a.localeCompare(b))
335
+ .map(([name, example]) => ({
336
+ name,
337
+ in: 'path',
338
+ required: true,
339
+ schema: { type: 'string' },
340
+ example
341
+ }));
342
+
343
+ const queryParamEntries = Array.from(op.queryParams)
344
+ .sort((a, b) => a.localeCompare(b))
345
+ .map((name) => ({
346
+ name,
347
+ in: 'query',
348
+ required: false,
349
+ schema: { type: 'string' }
350
+ }));
351
+
352
+ const operation = {
353
+ summary: `${op.method.toUpperCase()} ${op.path}`,
354
+ operationId: toOperationId(op.method, op.path),
355
+ tags: [getTagFromPath(op.path)],
356
+ responses: {}
357
+ };
358
+
359
+ if (pathParamEntries.length || queryParamEntries.length) {
360
+ operation.parameters = [...pathParamEntries, ...queryParamEntries];
361
+ }
362
+
363
+ if (op.requestBodies.length > 0) {
364
+ const mergedBodySchema = op.requestBodies.map((b) => inferSchema(b)).reduce(mergeSchemas, {});
365
+ const mimeType = Array.from(op.requestMimeTypes)[0] || 'application/json';
366
+ operation.requestBody = {
367
+ required: op.method !== 'get' && op.method !== 'delete',
368
+ content: {
369
+ [mimeType]: {
370
+ schema: mergedBodySchema,
371
+ example: op.requestBodies[0]
372
+ }
373
+ }
374
+ };
375
+ }
376
+
377
+ const sortedStatuses = Array.from(op.responses.keys()).sort((a, b) => a.localeCompare(b));
378
+ sortedStatuses.forEach((status) => {
379
+ const res = op.responses.get(status);
380
+ const mimeType = Array.from(res.mimeTypes)[0] || 'application/json';
381
+ const schema = res.bodies.length > 0
382
+ ? res.bodies.map((b) => inferSchema(b)).reduce(mergeSchemas, {})
383
+ : {};
384
+
385
+ operation.responses[status] = {
386
+ description: 'Observed response',
387
+ content: {
388
+ [mimeType]: {
389
+ schema,
390
+ ...(res.example !== undefined ? { example: res.example } : {})
391
+ }
392
+ }
393
+ };
394
+ });
395
+
396
+ if (op.hasBearerAuth || op.hasApiKeyAuth) {
397
+ operation.security = [];
398
+ if (op.hasBearerAuth) operation.security.push({ bearerAuth: [] });
399
+ if (op.hasApiKeyAuth) operation.security.push({ apiKeyAuth: [] });
400
+ }
401
+
402
+ paths[op.path][op.method] = operation;
403
+ });
404
+
405
+ const serverList = options.server
406
+ ? [{ url: options.server }]
407
+ : Array.from(origins).sort((a, b) => a.localeCompare(b)).map((url) => ({ url }));
408
+
409
+ const spec = {
410
+ openapi: '3.0.3',
411
+ info: { title, version },
412
+ ...(serverList.length ? { servers: serverList } : {}),
413
+ paths
414
+ };
415
+
416
+ if (includeBearer.value || includeApiKey.value) {
417
+ spec.components = { securitySchemes: {} };
418
+ if (includeBearer.value) {
419
+ spec.components.securitySchemes.bearerAuth = {
420
+ type: 'http',
421
+ scheme: 'bearer',
422
+ bearerFormat: 'JWT'
423
+ };
424
+ }
425
+ if (includeApiKey.value) {
426
+ spec.components.securitySchemes.apiKeyAuth = {
427
+ type: 'apiKey',
428
+ in: 'header',
429
+ name: 'x-api-key'
430
+ };
431
+ }
432
+ }
433
+
434
+ return spec;
435
+ }
436
+
437
+ export function exportInteractions(options = {}) {
438
+ try {
439
+ const config = loadKirooConfig();
440
+ const sortKeys = config.settings?.determinism?.sortKeys !== false;
441
+ let interactions = normalizeInteractions();
442
+ const pathPrefix = options.pathPrefix ? String(options.pathPrefix) : '';
443
+ const minSamples = Number.parseInt(options.minSamples, 10);
444
+
445
+ if (pathPrefix) {
446
+ interactions = interactions.filter((int) => {
447
+ const { path } = getPathAndOrigin(int.request.url || '/');
448
+ return path.startsWith(pathPrefix);
449
+ });
450
+ }
451
+
452
+ if (interactions.length === 0) {
453
+ console.log(chalk.yellow('\n ⚠️ No interactions found to export.'));
454
+ console.log(chalk.gray(' Run some requests first before exporting.\n'));
455
+ return;
456
+ }
457
+
458
+ const format = String(options.format || 'postman').toLowerCase();
459
+ const outFileName = options.out || (format === 'openapi' ? 'openapi.json' : 'kiroo-collection.json');
460
+ const outputPath = join(process.cwd(), outFileName);
461
+
462
+ let payloadObject;
463
+ if (format === 'postman') {
464
+ payloadObject = buildPostmanCollection(interactions);
465
+ } else if (format === 'openapi') {
466
+ if (!Number.isNaN(minSamples) && minSamples > 1) {
467
+ const counts = new Map();
468
+ interactions.forEach((int) => {
469
+ const method = String(int.request.method || '').toLowerCase();
470
+ const { path } = getPathAndOrigin(int.request.url || '/');
471
+ const normalizedPath = normalizePathForOpenApi(path).path;
472
+ const key = `${method} ${normalizedPath}`;
473
+ counts.set(key, (counts.get(key) || 0) + 1);
474
+ });
475
+ interactions = interactions.filter((int) => {
476
+ const method = String(int.request.method || '').toLowerCase();
477
+ const { path } = getPathAndOrigin(int.request.url || '/');
478
+ const normalizedPath = normalizePathForOpenApi(path).path;
479
+ const key = `${method} ${normalizedPath}`;
480
+ return (counts.get(key) || 0) >= minSamples;
481
+ });
482
+ }
483
+ payloadObject = buildOpenApiSpec(interactions, options);
484
+ } else {
485
+ console.error(chalk.red(`\n ✗ Unsupported export format: ${format}`));
486
+ console.log(chalk.gray(' Use: postman | openapi\n'));
487
+ process.exit(1);
488
+ }
489
+
490
+ const payload = sortKeys ? stableJSONStringify(payloadObject, 2) : JSON.stringify(payloadObject, null, 2);
491
+ writeFileSync(outputPath, payload);
492
+
493
+ console.log(chalk.green(`\n ✅ Export successful (${format})`));
494
+ console.log(chalk.gray(` Saved to: ${outputPath}\n`));
495
+ } catch (error) {
496
+ console.error(chalk.red('\n ✗ Export failed:'), error.message, '\n');
497
+ process.exit(1);
498
+ }
499
+ }
500
+
501
+ export function exportToPostman(outFileName) {
502
+ exportInteractions({ format: 'postman', out: outFileName });
503
+ }