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/README.md +386 -293
- package/bin/kiroo.js +412 -288
- package/package.json +2 -1
- package/src/analyze.js +568 -0
- package/src/bench.js +11 -4
- package/src/checker.js +26 -9
- package/src/config.js +109 -0
- package/src/deterministic.js +22 -0
- package/src/env.js +31 -3
- package/src/executor.js +18 -1
- package/src/export.js +560 -93
- package/src/formatter.js +18 -6
- package/src/init.js +80 -48
- package/src/lingo.js +55 -36
- package/src/proxy.js +140 -0
- package/src/replay.js +5 -4
- package/src/run.js +246 -0
- package/src/sanitizer.js +100 -0
- package/src/snapshot.js +76 -19
- package/src/stats.js +15 -5
- package/src/storage.js +223 -142
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
} catch
|
|
91
|
-
|
|
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
|
+
}
|