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/README.md +368 -293
- package/bin/kiroo.js +361 -288
- package/package.json +2 -1
- package/src/analyze.js +550 -0
- package/src/config.js +109 -0
- package/src/deterministic.js +22 -0
- package/src/env.js +31 -3
- package/src/executor.js +17 -0
- package/src/export.js +503 -93
- package/src/init.js +80 -48
- package/src/lingo.js +32 -36
- package/src/proxy.js +120 -0
- package/src/replay.js +5 -4
- package/src/sanitizer.js +100 -0
- package/src/snapshot.js +76 -19
- package/src/storage.js +223 -142
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(
|
|
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
|
-
|
|
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
|
-
|
|
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 { 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
|
+
}
|