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/formatter.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
import { translateText, translateResponseData } from './lingo.js';
|
|
4
|
+
|
|
5
|
+
export async function formatResponse(response, lang) {
|
|
4
6
|
const lines = [];
|
|
5
7
|
|
|
6
8
|
// Status
|
|
@@ -21,7 +23,10 @@ export function formatResponse(response) {
|
|
|
21
23
|
.slice(0, 3);
|
|
22
24
|
|
|
23
25
|
if (headers.length > 0) {
|
|
24
|
-
|
|
26
|
+
let headersLabel = ' Headers:';
|
|
27
|
+
if (lang) headersLabel = await translateText(headersLabel, lang);
|
|
28
|
+
lines.push(chalk.gray(headersLabel));
|
|
29
|
+
|
|
25
30
|
headers.forEach(([key, value]) => {
|
|
26
31
|
const displayValue = typeof value === 'string' && value.length > 50
|
|
27
32
|
? value.substring(0, 50) + '...'
|
|
@@ -33,17 +38,24 @@ export function formatResponse(response) {
|
|
|
33
38
|
|
|
34
39
|
// Body
|
|
35
40
|
if (response.data) {
|
|
36
|
-
|
|
41
|
+
let responseLabel = ' Response:';
|
|
42
|
+
if (lang) responseLabel = await translateText(responseLabel, lang);
|
|
43
|
+
lines.push(chalk.gray(responseLabel));
|
|
44
|
+
|
|
45
|
+
let displayData = response.data;
|
|
46
|
+
if (lang) {
|
|
47
|
+
displayData = await translateResponseData(response.data, lang);
|
|
48
|
+
}
|
|
37
49
|
|
|
38
|
-
if (typeof
|
|
50
|
+
if (typeof displayData === 'object') {
|
|
39
51
|
// Pretty print JSON
|
|
40
|
-
const json = JSON.stringify(
|
|
52
|
+
const json = JSON.stringify(displayData, null, 2);
|
|
41
53
|
json.split('\n').forEach(line => {
|
|
42
54
|
lines.push(chalk.cyan(` ${line}`));
|
|
43
55
|
});
|
|
44
56
|
} else {
|
|
45
57
|
// Plain text
|
|
46
|
-
const text = String(
|
|
58
|
+
const text = String(displayData);
|
|
47
59
|
const preview = text.length > 500 ? text.substring(0, 500) + '...' : text;
|
|
48
60
|
lines.push(chalk.white(` ${preview}`));
|
|
49
61
|
}
|
package/src/init.js
CHANGED
|
@@ -1,48 +1,80 @@
|
|
|
1
|
-
import chalk from 'chalk';
|
|
2
|
-
import inquirer from 'inquirer';
|
|
3
|
-
import {
|
|
4
|
-
import { ensureKirooDir } from './storage.js';
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
console.log(
|
|
9
|
-
console.log(chalk.
|
|
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
|
-
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import inquirer from 'inquirer';
|
|
3
|
+
import { existsSync } from 'fs';
|
|
4
|
+
import { ensureKirooDir, loadEnv, saveEnv } from './storage.js';
|
|
5
|
+
import { saveKirooConfig } from './config.js';
|
|
6
|
+
|
|
7
|
+
export async function initProject() {
|
|
8
|
+
console.log('');
|
|
9
|
+
console.log(chalk.cyan.bold(' 🚀 Welcome to Kiroo'));
|
|
10
|
+
console.log(chalk.gray(' Git for API interactions\n'));
|
|
11
|
+
|
|
12
|
+
if (existsSync('.kiroo')) {
|
|
13
|
+
console.log(chalk.yellow(' ⚠️ Kiroo is already initialized in this directory.\n'));
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const answers = await inquirer.prompt([
|
|
18
|
+
{
|
|
19
|
+
type: 'input',
|
|
20
|
+
name: 'projectName',
|
|
21
|
+
message: 'Project name:',
|
|
22
|
+
default: 'my-api-project',
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
type: 'input',
|
|
26
|
+
name: 'baseUrl',
|
|
27
|
+
message: 'Base URL (optional):',
|
|
28
|
+
default: '',
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
type: 'password',
|
|
32
|
+
name: 'groqApiKey',
|
|
33
|
+
message: 'Groq API Key (optional, hidden):',
|
|
34
|
+
default: '',
|
|
35
|
+
mask: '*',
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
type: 'password',
|
|
39
|
+
name: 'lingoApiKey',
|
|
40
|
+
message: 'Lingo.dev API Key (optional, hidden):',
|
|
41
|
+
default: '',
|
|
42
|
+
mask: '*',
|
|
43
|
+
},
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
ensureKirooDir();
|
|
47
|
+
|
|
48
|
+
const config = {
|
|
49
|
+
projectName: answers.projectName,
|
|
50
|
+
baseUrl: answers.baseUrl,
|
|
51
|
+
createdAt: new Date().toISOString(),
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
saveKirooConfig(config);
|
|
55
|
+
|
|
56
|
+
const envData = loadEnv();
|
|
57
|
+
const current = envData.current || 'default';
|
|
58
|
+
if (!envData.environments[current]) {
|
|
59
|
+
envData.environments[current] = {};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (answers.baseUrl) {
|
|
63
|
+
envData.environments[current].baseUrl = answers.baseUrl;
|
|
64
|
+
}
|
|
65
|
+
if (answers.groqApiKey) {
|
|
66
|
+
envData.environments[current].GROQ_API_KEY = answers.groqApiKey;
|
|
67
|
+
}
|
|
68
|
+
if (answers.lingoApiKey) {
|
|
69
|
+
envData.environments[current].LINGODOTDEV_API_KEY = answers.lingoApiKey;
|
|
70
|
+
}
|
|
71
|
+
saveEnv(envData);
|
|
72
|
+
|
|
73
|
+
console.log('');
|
|
74
|
+
console.log(chalk.green(' ✅ Kiroo initialized successfully!'));
|
|
75
|
+
console.log('');
|
|
76
|
+
console.log(chalk.gray(' Next steps:'));
|
|
77
|
+
console.log(chalk.white(' kiroo POST https://api.example.com/login email=test@test.com'));
|
|
78
|
+
console.log(chalk.white(' kiroo list'));
|
|
79
|
+
console.log(chalk.white(' kiroo snapshot save initial\n'));
|
|
80
|
+
}
|
package/src/lingo.js
CHANGED
|
@@ -1,36 +1,55 @@
|
|
|
1
|
-
import { LingoDotDevEngine } from "lingo.dev/sdk";
|
|
2
|
-
import chalk from "chalk";
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
function getLingoEngine() {
|
|
6
|
-
const
|
|
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
|
-
return
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
1
|
+
import { LingoDotDevEngine } from "lingo.dev/sdk";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import { getEnvVar } from "./env.js";
|
|
4
|
+
|
|
5
|
+
function getLingoEngine() {
|
|
6
|
+
const apiKey = getEnvVar('LINGODOTDEV_API_KEY') || getEnvVar('LINGO_API_KEY');
|
|
7
|
+
|
|
8
|
+
if (!apiKey) {
|
|
9
|
+
console.log(chalk.yellow(`\n ⚠️ Lingo API key not found in .kiroo/env.json.`));
|
|
10
|
+
console.log(chalk.gray(` Run 'kiroo env set LINGODOTDEV_API_KEY <your_key>' or re-run 'kiroo init'.\n`));
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
return new LingoDotDevEngine({ apiKey });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function translateText(text, targetLang) {
|
|
18
|
+
if (!text || typeof text !== 'string' || text.trim() === '') return text;
|
|
19
|
+
const engine = getLingoEngine();
|
|
20
|
+
if (!engine) return text;
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const result = await engine.localizeText(text, {
|
|
24
|
+
sourceLocale: 'en',
|
|
25
|
+
targetLocale: targetLang,
|
|
26
|
+
fast: true
|
|
27
|
+
});
|
|
28
|
+
return result;
|
|
29
|
+
} catch (error) {
|
|
30
|
+
// console.log(chalk.red(`\n ⚠️ Translation failed: ${error.message}`));
|
|
31
|
+
return text;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Recursively translates strings within an object or array
|
|
37
|
+
*/
|
|
38
|
+
export async function translateResponseData(data, targetLang) {
|
|
39
|
+
if (!data) return data;
|
|
40
|
+
if (typeof data === 'string') {
|
|
41
|
+
return await translateText(data, targetLang);
|
|
42
|
+
}
|
|
43
|
+
if (Array.isArray(data)) {
|
|
44
|
+
return await Promise.all(data.map(item => translateResponseData(item, targetLang)));
|
|
45
|
+
}
|
|
46
|
+
if (typeof data === 'object') {
|
|
47
|
+
const translated = {};
|
|
48
|
+
for (const [key, value] of Object.entries(data)) {
|
|
49
|
+
// Don't translate keys, just values
|
|
50
|
+
translated[key] = await translateResponseData(value, targetLang);
|
|
51
|
+
}
|
|
52
|
+
return translated;
|
|
53
|
+
}
|
|
54
|
+
return data;
|
|
55
|
+
}
|
package/src/proxy.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import http from 'http';
|
|
2
|
+
import httpProxy from 'http-proxy';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import { URL } from 'url';
|
|
5
|
+
import stream from 'stream';
|
|
6
|
+
import { saveInteraction } from './storage.js';
|
|
7
|
+
|
|
8
|
+
export async function runProxy(targetHost, options = {}) {
|
|
9
|
+
const port = parseInt(options.port, 10) || 8080;
|
|
10
|
+
|
|
11
|
+
if (isNaN(port) || port <= 0 || port > 65535) {
|
|
12
|
+
console.error(chalk.red('\n ✗ Invalid port provided.'), options.port);
|
|
13
|
+
console.log(chalk.gray(' Port must be a number between 1 and 65535.\n'));
|
|
14
|
+
process.exit(1);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Validate target URL
|
|
18
|
+
try {
|
|
19
|
+
new URL(targetHost);
|
|
20
|
+
} catch (err) {
|
|
21
|
+
console.error(chalk.red('\n ✗ Invalid target URL provided.'), targetHost);
|
|
22
|
+
console.log(chalk.gray(' Example: kiroo proxy --target http://localhost:3000\n'));
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const proxy = httpProxy.createProxyServer({
|
|
27
|
+
target: targetHost,
|
|
28
|
+
changeOrigin: true,
|
|
29
|
+
secure: false,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// Handle Proxy Errors
|
|
33
|
+
proxy.on('error', (err, req, res) => {
|
|
34
|
+
let errorMsg = err.message;
|
|
35
|
+
if (err.code === 'ECONNREFUSED') {
|
|
36
|
+
errorMsg = `Connection Refused. Is your backend server running on ${targetHost}?`;
|
|
37
|
+
} else if (err.code === 'ENOTFOUND') {
|
|
38
|
+
errorMsg = `Target host not found: ${targetHost}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
console.error(chalk.red(`\n ✗ Proxy error for ${req.method} ${req.url}`));
|
|
42
|
+
console.error(chalk.yellow(` Error: ${errorMsg} (${err.code || 'UNKNOWN'})\n`));
|
|
43
|
+
|
|
44
|
+
if (!res.headersSent && res.writeHead) {
|
|
45
|
+
res.writeHead(502, { 'Content-Type': 'application/json' });
|
|
46
|
+
}
|
|
47
|
+
if (res.end) {
|
|
48
|
+
res.end(JSON.stringify({ error: 'Proxy Error', message: errorMsg, code: err.code }));
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Intercept Response to save interaction
|
|
53
|
+
proxy.on('proxyRes', (proxyRes, req, res) => {
|
|
54
|
+
let responseBody = '';
|
|
55
|
+
proxyRes.on('data', (chunk) => {
|
|
56
|
+
responseBody += chunk.toString('utf8');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
proxyRes.on('end', () => {
|
|
60
|
+
const duration = Date.now() - req.kirooStartTime;
|
|
61
|
+
const fullUrl = new URL(req.url, targetHost).toString();
|
|
62
|
+
|
|
63
|
+
let parsedReqBody = req.kirooBodyData;
|
|
64
|
+
if (req.kirooBodyData) {
|
|
65
|
+
try { parsedReqBody = JSON.parse(req.kirooBodyData); } catch (e) { }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let parsedResBody = responseBody;
|
|
69
|
+
if (responseBody) {
|
|
70
|
+
try { parsedResBody = JSON.parse(responseBody); } catch (e) { }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Only save if it's not a CORS preflight with no content
|
|
74
|
+
if (req.method !== 'OPTIONS' || proxyRes.statusCode !== 204) {
|
|
75
|
+
saveInteraction({
|
|
76
|
+
method: req.method,
|
|
77
|
+
url: fullUrl,
|
|
78
|
+
headers: req.headers,
|
|
79
|
+
body: parsedReqBody || undefined,
|
|
80
|
+
response: {
|
|
81
|
+
status: proxyRes.statusCode,
|
|
82
|
+
headers: proxyRes.headers,
|
|
83
|
+
body: parsedResBody || undefined
|
|
84
|
+
},
|
|
85
|
+
duration: duration
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const statusColor = proxyRes.statusCode >= 400 ? chalk.red : chalk.green;
|
|
89
|
+
console.log(` ${statusColor(req.method)} ${chalk.dim(fullUrl)} ${statusColor(proxyRes.statusCode)} - ${duration}ms`);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// Add CORS headers to proxied responses
|
|
95
|
+
proxy.on('proxyRes', function (proxyRes, req, res) {
|
|
96
|
+
proxyRes.headers['access-control-allow-origin'] = req.headers.origin || '*';
|
|
97
|
+
proxyRes.headers['access-control-allow-credentials'] = 'true';
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const server = http.createServer((req, res) => {
|
|
101
|
+
req.kirooStartTime = Date.now();
|
|
102
|
+
|
|
103
|
+
// Handle CORS preflight
|
|
104
|
+
if (req.method === 'OPTIONS') {
|
|
105
|
+
res.writeHead(204, {
|
|
106
|
+
'Access-Control-Allow-Origin': req.headers.origin || '*',
|
|
107
|
+
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
|
|
108
|
+
'Access-Control-Allow-Headers': req.headers['access-control-request-headers'] || '*',
|
|
109
|
+
'Access-Control-Allow-Credentials': 'true',
|
|
110
|
+
'Access-Control-Max-Age': '86400',
|
|
111
|
+
});
|
|
112
|
+
res.end();
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
let bodyChunks = [];
|
|
117
|
+
|
|
118
|
+
req.on('data', (chunk) => {
|
|
119
|
+
bodyChunks.push(chunk);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
req.on('end', () => {
|
|
123
|
+
const bodyBuffer = Buffer.concat(bodyChunks);
|
|
124
|
+
req.kirooBodyData = bodyBuffer.toString('utf8');
|
|
125
|
+
|
|
126
|
+
// We must re-stream the body because the original req stream is exhausted
|
|
127
|
+
const bufferStream = new stream.PassThrough();
|
|
128
|
+
bufferStream.end(bodyBuffer);
|
|
129
|
+
|
|
130
|
+
proxy.web(req, res, { buffer: bufferStream });
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
server.listen(port, () => {
|
|
135
|
+
console.log(chalk.cyan(`\n 📡 Kiroo Proxy Started`));
|
|
136
|
+
console.log(chalk.white(` Listening on : http://localhost:${port}`));
|
|
137
|
+
console.log(chalk.white(` Forwarding to: ${targetHost}`));
|
|
138
|
+
console.log(chalk.gray(`\n (Press Ctrl+C to stop recording)\n`));
|
|
139
|
+
});
|
|
140
|
+
}
|
package/src/replay.js
CHANGED
|
@@ -11,7 +11,7 @@ export async function listInteractions(options) {
|
|
|
11
11
|
|
|
12
12
|
// Apply Filters
|
|
13
13
|
if (options.date) {
|
|
14
|
-
|
|
14
|
+
interactions = interactions.filter(int => int.id.startsWith(options.date));
|
|
15
15
|
}
|
|
16
16
|
if (options.url) {
|
|
17
17
|
interactions = interactions.filter(int => int.request.url.toLowerCase().includes(options.url.toLowerCase()));
|
|
@@ -42,12 +42,13 @@ export async function listInteractions(options) {
|
|
|
42
42
|
? chalk.red
|
|
43
43
|
: chalk.yellow;
|
|
44
44
|
|
|
45
|
+
const url = int.request.url || 'N/A';
|
|
45
46
|
table.push([
|
|
46
47
|
chalk.white(int.id),
|
|
47
|
-
chalk.white(int.request.method),
|
|
48
|
-
chalk.gray(
|
|
48
|
+
chalk.white(int.request.method || '???'),
|
|
49
|
+
chalk.gray(url.substring(0, 42) + (url.length > 42 ? '...' : '')),
|
|
49
50
|
statusColor(int.response.status),
|
|
50
|
-
chalk.gray(int.metadata.duration_ms + 'ms'),
|
|
51
|
+
chalk.gray((int.metadata.duration_ms || 0) + 'ms'),
|
|
51
52
|
]);
|
|
52
53
|
});
|
|
53
54
|
|
package/src/run.js
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import ora from 'ora';
|
|
4
|
+
import { loadSnapshotData, loadEnv } from './storage.js';
|
|
5
|
+
|
|
6
|
+
const REDACTED_RE = /<REDACTED[^>]*>/i;
|
|
7
|
+
|
|
8
|
+
function isRedacted(val) {
|
|
9
|
+
return typeof val === 'string' && REDACTED_RE.test(val.trim());
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Sort interactions chronologically (oldest first) so login
|
|
14
|
+
* runs before authenticated requests.
|
|
15
|
+
*/
|
|
16
|
+
function sortChronologically(interactions) {
|
|
17
|
+
return [...interactions].sort((a, b) => {
|
|
18
|
+
// IDs are timestamps like "2026-03-15T08-10-57-031Z"
|
|
19
|
+
const idA = a.id || '';
|
|
20
|
+
const idB = b.id || '';
|
|
21
|
+
return idA.localeCompare(idB);
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Deduplicate redundant interactions (e.g. 10 identical GET /api/projects 304s).
|
|
27
|
+
* Keeps unique method+path combinations, but allows duplicates for different
|
|
28
|
+
* status codes or non-cacheable methods (POST/PUT/DELETE).
|
|
29
|
+
*/
|
|
30
|
+
function deduplicateInteractions(interactions) {
|
|
31
|
+
const seen = new Map();
|
|
32
|
+
return interactions.filter((int) => {
|
|
33
|
+
const method = String(int.method || int.request?.method || 'GET').toUpperCase();
|
|
34
|
+
// Always keep non-GET (side-effectful) methods
|
|
35
|
+
if (method !== 'GET') return true;
|
|
36
|
+
|
|
37
|
+
const url = int.url || int.request?.url || '/';
|
|
38
|
+
const status = int.response?.status || 0;
|
|
39
|
+
const key = `${method}|${url}|${status}`;
|
|
40
|
+
if (seen.has(key)) return false;
|
|
41
|
+
seen.set(key, true);
|
|
42
|
+
return true;
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Run all interactions from a snapshot sequentially.
|
|
48
|
+
* - Sorted oldest-first so login precedes authenticated requests
|
|
49
|
+
* - Deduplicates redundant 304 GET requests
|
|
50
|
+
* - Auto-chains tokens from auth responses
|
|
51
|
+
* - Resolves <REDACTED> headers/body from env vars
|
|
52
|
+
*/
|
|
53
|
+
export async function runSnapshot(tag, options = {}) {
|
|
54
|
+
let snapshot;
|
|
55
|
+
try {
|
|
56
|
+
snapshot = loadSnapshotData(tag);
|
|
57
|
+
} catch (err) {
|
|
58
|
+
console.error(chalk.red(`\n ✗ Snapshot not found: ${tag}`));
|
|
59
|
+
console.log(chalk.gray(` Run 'kiroo snapshot list' to see available snapshots.\n`));
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const raw = snapshot.interactions || [];
|
|
64
|
+
if (raw.length === 0) {
|
|
65
|
+
console.log(chalk.yellow(`\n ⚠️ Snapshot "${tag}" has no interactions.\n`));
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const env = loadEnv();
|
|
70
|
+
const envVars = env.environments[env.current] || {};
|
|
71
|
+
const baseUrl = envVars.baseUrl || '';
|
|
72
|
+
|
|
73
|
+
// Sort chronologically, then deduplicate
|
|
74
|
+
const sorted = sortChronologically(raw);
|
|
75
|
+
const interactions = deduplicateInteractions(sorted);
|
|
76
|
+
|
|
77
|
+
// Captured variables during run (auto-chained)
|
|
78
|
+
const captured = {};
|
|
79
|
+
|
|
80
|
+
console.log(chalk.cyan(`\n ▶ Running snapshot: ${chalk.white(tag)}`));
|
|
81
|
+
console.log(chalk.gray(` ${interactions.length} interactions (${raw.length - interactions.length} duplicates skipped)\n`));
|
|
82
|
+
|
|
83
|
+
let passed = 0;
|
|
84
|
+
let failed = 0;
|
|
85
|
+
|
|
86
|
+
for (let i = 0; i < interactions.length; i++) {
|
|
87
|
+
const int = interactions[i];
|
|
88
|
+
const method = String(int.method || int.request?.method || 'GET').toUpperCase();
|
|
89
|
+
let url = int.url || int.request?.url || '/';
|
|
90
|
+
const headers = { ...(int.request?.headers || {}) };
|
|
91
|
+
let body = int.request?.body ? JSON.parse(JSON.stringify(int.request.body)) : undefined;
|
|
92
|
+
|
|
93
|
+
// Remove internal/hop-by-hop headers
|
|
94
|
+
for (const h of ['host', 'content-length', 'connection', 'accept-encoding', 'if-none-match',
|
|
95
|
+
'sec-ch-ua', 'sec-ch-ua-mobile', 'sec-ch-ua-platform', 'sec-fetch-dest', 'sec-fetch-mode',
|
|
96
|
+
'sec-fetch-site', 'dnt', 'origin', 'referer', 'user-agent']) {
|
|
97
|
+
delete headers[h];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Resolve URL — if relative, prepend baseUrl
|
|
101
|
+
if (url.startsWith('/') && baseUrl) {
|
|
102
|
+
const normalized = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
|
|
103
|
+
url = normalized + url;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Fix redacted auth headers
|
|
107
|
+
for (const [key, val] of Object.entries(headers)) {
|
|
108
|
+
if (isRedacted(String(val))) {
|
|
109
|
+
const k = key.toLowerCase();
|
|
110
|
+
if (k === 'authorization') {
|
|
111
|
+
const token = captured.token || envVars.token;
|
|
112
|
+
if (token) headers[key] = token.startsWith('Bearer') ? token : `Bearer ${token}`;
|
|
113
|
+
else delete headers[key];
|
|
114
|
+
} else if (k === 'x-api-key' || k === 'api-key') {
|
|
115
|
+
const apiKey = captured.apiKey || envVars.sk || envVars.apiKey || envVars['x-api-key'];
|
|
116
|
+
if (apiKey) headers[key] = apiKey;
|
|
117
|
+
else delete headers[key];
|
|
118
|
+
} else {
|
|
119
|
+
delete headers[key];
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Fix redacted body fields (e.g. password)
|
|
125
|
+
if (body && typeof body === 'object') {
|
|
126
|
+
for (const [key, val] of Object.entries(body)) {
|
|
127
|
+
if (isRedacted(String(val))) {
|
|
128
|
+
// Try to find from env vars (password, secret, etc.)
|
|
129
|
+
const envVal = envVars[key] || envVars[key.toLowerCase()];
|
|
130
|
+
if (envVal) {
|
|
131
|
+
body[key] = envVal;
|
|
132
|
+
}
|
|
133
|
+
// If no env val found, leave <REDACTED> — will fail but at least user knows
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Replace {{var}} placeholders
|
|
139
|
+
const allVars = { ...envVars, ...captured };
|
|
140
|
+
url = replaceVars(url, allVars);
|
|
141
|
+
if (typeof body === 'string') body = replaceVars(body, allVars);
|
|
142
|
+
if (body && typeof body === 'object') body = replaceVarsDeep(body, allVars);
|
|
143
|
+
for (const [key, val] of Object.entries(headers)) {
|
|
144
|
+
if (typeof val === 'string') headers[key] = replaceVars(val, allVars);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Execute
|
|
148
|
+
const shortUrl = url.replace(/^https?:\/\/[^/]+/, '');
|
|
149
|
+
const label = `[${i + 1}/${interactions.length}] ${method} ${shortUrl}`;
|
|
150
|
+
const spinner = ora({ text: chalk.gray(label), spinner: 'dots' }).start();
|
|
151
|
+
const start = Date.now();
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
const res = await axios({
|
|
155
|
+
method: method.toLowerCase(),
|
|
156
|
+
url,
|
|
157
|
+
headers,
|
|
158
|
+
data: body,
|
|
159
|
+
timeout: options.timeout || 30000,
|
|
160
|
+
validateStatus: () => true,
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
const duration = Date.now() - start;
|
|
164
|
+
const status = res.status;
|
|
165
|
+
const statusColor = status >= 400 ? chalk.red : chalk.green;
|
|
166
|
+
|
|
167
|
+
spinner.stopAndPersist({
|
|
168
|
+
symbol: status >= 400 ? chalk.red('✗') : chalk.green('✓'),
|
|
169
|
+
text: `${statusColor(`${status}`)} ${chalk.gray(`${method} ${shortUrl}`)} ${chalk.dim(`${duration}ms`)}`,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// Auto-capture tokens from auth-like responses
|
|
173
|
+
if (res.data && typeof res.data === 'object') {
|
|
174
|
+
const data = res.data;
|
|
175
|
+
for (const field of ['token', 'accessToken', 'access_token', 'jwt', 'authToken', 'auth_token']) {
|
|
176
|
+
if (data[field] && typeof data[field] === 'string') {
|
|
177
|
+
captured.token = data[field];
|
|
178
|
+
if (options.verbose) console.log(chalk.dim(` 🔑 Captured ${field}`));
|
|
179
|
+
}
|
|
180
|
+
if (data.data && typeof data.data === 'object' && data.data[field]) {
|
|
181
|
+
captured.token = data.data[field];
|
|
182
|
+
if (options.verbose) console.log(chalk.dim(` 🔑 Captured data.${field}`));
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
for (const field of ['apiKey', 'api_key', 'key', 'publishableKey']) {
|
|
186
|
+
if (data[field] && typeof data[field] === 'string') {
|
|
187
|
+
captured.apiKey = data[field];
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (options.verbose && res.data) {
|
|
193
|
+
const bodyStr = typeof res.data === 'object' ? JSON.stringify(res.data, null, 2) : String(res.data);
|
|
194
|
+
const lines = bodyStr.split('\n');
|
|
195
|
+
const preview = lines.length > 6 ? lines.slice(0, 6).join('\n') + '\n ...' : lines.join('\n');
|
|
196
|
+
console.log(chalk.gray(` ↳ ${preview.split('\n').join('\n ')}`));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (status < 400) passed++;
|
|
200
|
+
else failed++;
|
|
201
|
+
|
|
202
|
+
} catch (err) {
|
|
203
|
+
const duration = Date.now() - start;
|
|
204
|
+
spinner.stopAndPersist({
|
|
205
|
+
symbol: chalk.red('✗'),
|
|
206
|
+
text: `${chalk.red('ERR')} ${chalk.gray(`${method} ${shortUrl}`)} ${chalk.dim(`${duration}ms`)} ${chalk.red(err.code || err.message)}`,
|
|
207
|
+
});
|
|
208
|
+
failed++;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Summary
|
|
213
|
+
console.log(chalk.cyan(`\n ── Run Complete ──`));
|
|
214
|
+
console.log(chalk.white(` Snapshot: ${tag}`));
|
|
215
|
+
console.log(chalk.green(` Passed: ${passed}`));
|
|
216
|
+
if (failed > 0) console.log(chalk.red(` Failed: ${failed}`));
|
|
217
|
+
console.log(chalk.gray(` Total: ${interactions.length}\n`));
|
|
218
|
+
|
|
219
|
+
if (captured.token) {
|
|
220
|
+
console.log(chalk.dim(` 🔑 Auto-captured token from auth response`));
|
|
221
|
+
console.log('');
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (failed > 0 && options.failFast) {
|
|
225
|
+
process.exit(1);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function replaceVars(str, vars) {
|
|
230
|
+
return str.replace(/\{\{(.+?)\}\}/g, (match, key) => {
|
|
231
|
+
return vars[key] !== undefined ? vars[key] : match;
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function replaceVarsDeep(obj, vars) {
|
|
236
|
+
if (typeof obj === 'string') return replaceVars(obj, vars);
|
|
237
|
+
if (Array.isArray(obj)) return obj.map(item => replaceVarsDeep(item, vars));
|
|
238
|
+
if (obj && typeof obj === 'object') {
|
|
239
|
+
const result = {};
|
|
240
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
241
|
+
result[k] = replaceVarsDeep(v, vars);
|
|
242
|
+
}
|
|
243
|
+
return result;
|
|
244
|
+
}
|
|
245
|
+
return obj;
|
|
246
|
+
}
|