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/init.js CHANGED
@@ -1,48 +1,80 @@
1
- import chalk from 'chalk';
2
- import inquirer from 'inquirer';
3
- import { writeFileSync, existsSync } from 'fs';
4
- import { ensureKirooDir } from './storage.js';
5
-
6
- export async function initProject() {
7
- console.log('');
8
- console.log(chalk.cyan.bold(' 🚀 Welcome to Kiroo'));
9
- console.log(chalk.gray(' Git for API interactions\n'));
10
-
11
- if (existsSync('.kiroo')) {
12
- console.log(chalk.yellow(' ⚠️ Kiroo is already initialized in this directory.\n'));
13
- return;
14
- }
15
-
16
- const answers = await inquirer.prompt([
17
- {
18
- type: 'input',
19
- name: 'projectName',
20
- message: 'Project name:',
21
- default: 'my-api-project',
22
- },
23
- {
24
- type: 'input',
25
- name: 'baseUrl',
26
- message: 'Base URL (optional):',
27
- default: '',
28
- },
29
- ]);
30
-
31
- ensureKirooDir();
32
-
33
- const config = {
34
- projectName: answers.projectName,
35
- baseUrl: answers.baseUrl,
36
- createdAt: new Date().toISOString(),
37
- };
38
-
39
- writeFileSync('.kiroo/config.json', JSON.stringify(config, null, 2));
40
-
41
- console.log('');
42
- console.log(chalk.green(' ✅ Kiroo initialized successfully!'));
43
- console.log('');
44
- console.log(chalk.gray(' Next steps:'));
45
- console.log(chalk.white(' kiroo POST https://api.example.com/login email=test@test.com'));
46
- console.log(chalk.white(' kiroo list'));
47
- console.log(chalk.white(' kiroo snapshot save initial\n'));
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,32 @@
1
- import { LingoDotDevEngine } from "lingo.dev/sdk";
2
- import chalk from "chalk";
3
- import { loadEnv } from "./storage.js";
4
-
5
- function getLingoEngine() {
6
- const envData = loadEnv();
7
- const currentEnvVars = envData.environments[envData.current] || {};
8
-
9
- // Prioritize process.env, fallback to kiroo environments
10
- const apiKey = currentEnvVars.LINGODOTDEV_API_KEY;
11
-
12
- if (!apiKey) {
13
- console.log(chalk.yellow(`\n ⚠️ LINGODOTDEV_API_KEY not found.`));
14
- console.log(chalk.gray(`run 'kiroo env set LINGODOTDEV_API_KEY <your_key>'\n`));
15
- return null;
16
- }
17
-
18
- return new LingoDotDevEngine({ apiKey });
19
- }
20
-
21
- export async function translateText(text, targetLang) {
22
- const engine = getLingoEngine();
23
- if (!engine) return text;
24
-
25
- try {
26
- const result = await engine.localizeText(text, {
27
- sourceLocale: 'en',
28
- targetLocale: targetLang,
29
- fast: true
30
- });
31
- return result;
32
- } catch (error) {
33
- console.log(chalk.red(`\n ⚠️ Translation failed: ${error.message}`));
34
- return text;
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
+ const engine = getLingoEngine();
19
+ if (!engine) return text;
20
+
21
+ try {
22
+ const result = await engine.localizeText(text, {
23
+ sourceLocale: 'en',
24
+ targetLocale: targetLang,
25
+ fast: true
26
+ });
27
+ return result;
28
+ } catch (error) {
29
+ console.log(chalk.red(`\n ⚠️ Translation failed: ${error.message}`));
30
+ return text;
31
+ }
32
+ }
package/src/proxy.js ADDED
@@ -0,0 +1,120 @@
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
+ const server = http.createServer((req, res) => {
95
+ req.kirooStartTime = Date.now();
96
+ let bodyChunks = [];
97
+
98
+ req.on('data', (chunk) => {
99
+ bodyChunks.push(chunk);
100
+ });
101
+
102
+ req.on('end', () => {
103
+ const bodyBuffer = Buffer.concat(bodyChunks);
104
+ req.kirooBodyData = bodyBuffer.toString('utf8');
105
+
106
+ // We must re-stream the body because the original req stream is exhausted
107
+ const bufferStream = new stream.PassThrough();
108
+ bufferStream.end(bodyBuffer);
109
+
110
+ proxy.web(req, res, { buffer: bufferStream });
111
+ });
112
+ });
113
+
114
+ server.listen(port, () => {
115
+ console.log(chalk.cyan(`\n 📡 Kiroo Proxy Started`));
116
+ console.log(chalk.white(` Listening on : http://localhost:${port}`));
117
+ console.log(chalk.white(` Forwarding to: ${targetHost}`));
118
+ console.log(chalk.gray(`\n (Press Ctrl+C to stop recording)\n`));
119
+ });
120
+ }
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
- interactions = interactions.filter(int => int.id.startsWith(options.date));
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(int.request.url.substring(0, 42) + (int.request.url.length > 42 ? '...' : '')),
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
 
@@ -0,0 +1,100 @@
1
+ const REDACTED = '<REDACTED>';
2
+
3
+ const SENSITIVE_KEY_PATTERN =
4
+ /authorization|cookie|set-cookie|token|secret|password|passwd|pwd|api[-_]?key|x-api-key|client[-_]?secret|session|jwt|access[-_]?token|refresh[-_]?token/i;
5
+
6
+ function redactSensitiveString(value, redactedValue = REDACTED) {
7
+ if (typeof value !== 'string') {
8
+ return redactedValue;
9
+ }
10
+
11
+ if (/^bearer\s+/i.test(value)) {
12
+ return `Bearer ${redactedValue}`;
13
+ }
14
+
15
+ if (/^basic\s+/i.test(value)) {
16
+ return `Basic ${redactedValue}`;
17
+ }
18
+
19
+ return redactedValue;
20
+ }
21
+
22
+ export function isSensitiveKey(key, sensitiveKeyPattern = SENSITIVE_KEY_PATTERN) {
23
+ return sensitiveKeyPattern.test(String(key || ''));
24
+ }
25
+
26
+ export function redactSensitiveInUrl(urlValue, options = {}) {
27
+ const redactedValue = options.redactedValue || REDACTED;
28
+ const sensitiveKeyPattern = options.sensitiveKeyPattern || SENSITIVE_KEY_PATTERN;
29
+
30
+ if (typeof urlValue !== 'string' || !urlValue.includes('?')) {
31
+ return urlValue;
32
+ }
33
+
34
+ const sanitizeParams = (url) => {
35
+ for (const key of Array.from(url.searchParams.keys())) {
36
+ if (isSensitiveKey(key, sensitiveKeyPattern)) {
37
+ url.searchParams.set(key, redactedValue);
38
+ }
39
+ }
40
+ return url.toString();
41
+ };
42
+
43
+ try {
44
+ return sanitizeParams(new URL(urlValue));
45
+ } catch {
46
+ try {
47
+ const parsed = new URL(urlValue, 'http://kiroo.local');
48
+ sanitizeParams(parsed);
49
+ return `${parsed.pathname}${parsed.search}${parsed.hash}`;
50
+ } catch {
51
+ return urlValue;
52
+ }
53
+ }
54
+ }
55
+
56
+ function sanitizeValue(value, currentKey = '', options = {}) {
57
+ const redactedValue = options.redactedValue || REDACTED;
58
+ const sensitiveKeyPattern = options.sensitiveKeyPattern || SENSITIVE_KEY_PATTERN;
59
+
60
+ if (isSensitiveKey(currentKey, sensitiveKeyPattern)) {
61
+ return redactSensitiveString(value, redactedValue);
62
+ }
63
+
64
+ if (typeof value === 'string') {
65
+ if (currentKey.toLowerCase() === 'url') {
66
+ return redactSensitiveInUrl(value, options);
67
+ }
68
+ return value;
69
+ }
70
+
71
+ if (Array.isArray(value)) {
72
+ return value.map((item) => sanitizeValue(item, currentKey, options));
73
+ }
74
+
75
+ if (value && typeof value === 'object') {
76
+ const out = {};
77
+ for (const [key, nestedValue] of Object.entries(value)) {
78
+ out[key] = sanitizeValue(nestedValue, key, options);
79
+ }
80
+ return out;
81
+ }
82
+
83
+ return value;
84
+ }
85
+
86
+ export function sanitizeInteractionRecord(record, options = {}) {
87
+ const mergedOptions = {
88
+ enabled: options.enabled !== false,
89
+ redactedValue: options.redactedValue || REDACTED,
90
+ sensitiveKeyPattern: options.sensitiveKeyPattern || SENSITIVE_KEY_PATTERN
91
+ };
92
+
93
+ if (!mergedOptions.enabled) {
94
+ return record;
95
+ }
96
+
97
+ return sanitizeValue(record, '', mergedOptions);
98
+ }
99
+
100
+ export { REDACTED };
package/src/snapshot.js CHANGED
@@ -60,7 +60,7 @@ export async function compareSnapshots(tag1, tag2, lang) {
60
60
  console.log(chalk.magenta(` 🌍 Translating output to: ${chalk.white(lang.toUpperCase())} using Lingo.dev...`));
61
61
  }
62
62
 
63
- const results = [];
63
+ const resultMap = new Map();
64
64
  let breakingChanges = 0;
65
65
 
66
66
  // Helper to get path from URL string
@@ -74,27 +74,66 @@ export async function compareSnapshots(tag1, tag2, lang) {
74
74
  }
75
75
  };
76
76
 
77
+ const compareByMethodAndPath = (a, b) => {
78
+ const pathA = getPath(a.url || '');
79
+ const pathB = getPath(b.url || '');
80
+ const methodA = String(a.method || '').toUpperCase();
81
+ const methodB = String(b.method || '').toUpperCase();
82
+ if (methodA !== methodB) return methodA.localeCompare(methodB);
83
+ return pathA.localeCompare(pathB);
84
+ };
85
+
86
+ const s1Interactions = [...s1.interactions].sort(compareByMethodAndPath);
87
+ const s2Interactions = [...s2.interactions].sort(compareByMethodAndPath);
88
+ const consumedS1Indexes = new Set();
89
+
90
+ const isBreakingStatusChange = (beforeStatus, afterStatus) => {
91
+ const before2xx = beforeStatus >= 200 && beforeStatus < 300;
92
+ const after3xx = afterStatus >= 300 && afterStatus < 400;
93
+ const after4xx5xx = afterStatus >= 400;
94
+
95
+ if (before2xx && afterStatus === 304) return false;
96
+ if (before2xx && after3xx) return false;
97
+ if (before2xx && after4xx5xx) return true;
98
+ return beforeStatus !== afterStatus;
99
+ };
100
+
101
+ const addResult = (type, method, url, msg) => {
102
+ const cleanMethod = String(method || '').toUpperCase();
103
+ const key = `${type}|${cleanMethod}|${url}`;
104
+ if (!resultMap.has(key)) {
105
+ resultMap.set(key, { type, method: cleanMethod, url, messages: [], occurrences: 0 });
106
+ }
107
+ const row = resultMap.get(key);
108
+ row.occurrences += 1;
109
+ if (msg && !row.messages.includes(msg)) {
110
+ row.messages.push(msg);
111
+ }
112
+ };
113
+
77
114
  // Domain-agnostic comparison: match by Path and Method
78
- s2.interactions.forEach(int2 => {
115
+ s2Interactions.forEach(int2 => {
79
116
  const path2 = getPath(int2.url);
80
- const int1 = s1.interactions.find(i => getPath(i.url) === path2 && i.method === int2.method);
117
+ const candidates = s1Interactions
118
+ .map((item, index) => ({ item, index }))
119
+ .filter(({ item, index }) => !consumedS1Indexes.has(index) && getPath(item.url) === path2 && item.method === int2.method);
120
+ const match = candidates.find(({ item }) => item.id && int2.id && item.id === int2.id) || candidates[0];
121
+ const int1 = match?.item;
81
122
 
82
123
  if (!int1) {
83
- results.push({
84
- type: 'NEW',
85
- method: int2.method,
86
- url: path2,
87
- msg: chalk.blue('New interaction added')
88
- });
124
+ addResult('NEW', int2.method, path2, 'New interaction added');
89
125
  return;
90
126
  }
127
+ consumedS1Indexes.add(match.index);
91
128
 
92
129
  const diffs = [];
93
130
 
94
131
  // Compare status
95
132
  if (int1.response.status !== int2.response.status) {
96
133
  diffs.push(`Status: ${chalk.gray(int1.response.status)} → ${chalk.red(int2.response.status)}`);
97
- breakingChanges++;
134
+ if (isBreakingStatusChange(int1.response.status, int2.response.status)) {
135
+ breakingChanges++;
136
+ }
98
137
  }
99
138
 
100
139
  // Helper for deep structural comparison
@@ -154,15 +193,19 @@ export async function compareSnapshots(tag1, tag2, lang) {
154
193
  }
155
194
  }
156
195
  if (diffs.length > 0) {
157
- results.push({
158
- type: 'CHANGE',
159
- method: int2.method,
160
- url: path2,
161
- msg: diffs.join('\n ')
162
- });
196
+ addResult('CHANGE', int2.method, path2, diffs.join('\n '));
163
197
  }
164
198
  });
165
199
 
200
+ s1Interactions.forEach((int1, index) => {
201
+ if (consumedS1Indexes.has(index)) return;
202
+ const path1 = getPath(int1.url);
203
+ addResult('REMOVED', int1.method, path1, 'Interaction removed in target snapshot');
204
+ breakingChanges++;
205
+ });
206
+
207
+ const results = [...resultMap.values()];
208
+
166
209
  if (results.length === 0) {
167
210
  let finalMsg = 'No differences detected. Your API is stable!';
168
211
  if (lang) finalMsg = await translateText(finalMsg, lang);
@@ -170,8 +213,18 @@ export async function compareSnapshots(tag1, tag2, lang) {
170
213
  } else {
171
214
  console.log('');
172
215
 
173
- for (const res of results) {
174
- let printMsg = res.msg;
216
+ const sortedResults = [...results].sort((a, b) => {
217
+ const methodA = String(a.method || '').toUpperCase();
218
+ const methodB = String(b.method || '').toUpperCase();
219
+ if (methodA !== methodB) return methodA.localeCompare(methodB);
220
+ return String(a.url || '').localeCompare(String(b.url || ''));
221
+ });
222
+
223
+ for (const res of sortedResults) {
224
+ let printMsg = res.messages.join('\n ');
225
+ if (res.occurrences > 1 && (res.type === 'NEW' || res.type === 'REMOVED')) {
226
+ printMsg += `\n (${res.occurrences} occurrences)`;
227
+ }
175
228
  if (lang) {
176
229
  // Basic translation hook for individual diff items (stripping ansi)
177
230
  const cleanMsg = printMsg.replace(/\x1B\[[0-9;]*m/g, '');
@@ -179,7 +232,11 @@ export async function compareSnapshots(tag1, tag2, lang) {
179
232
  printMsg = chalk.yellow('[Translated] ') + translatedMsg;
180
233
  }
181
234
 
182
- const symbol = res.type === 'NEW' ? chalk.blue('+') : chalk.yellow('⚠️');
235
+ const symbol = res.type === 'NEW'
236
+ ? chalk.blue('+')
237
+ : res.type === 'REMOVED'
238
+ ? chalk.red('-')
239
+ : chalk.yellow('⚠️');
183
240
  console.log(` ${symbol} ${chalk.white(res.method)} ${chalk.gray(res.url)}`);
184
241
  console.log(` ${printMsg}`);
185
242
  }