kiroo 0.7.4 → 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 ADDED
@@ -0,0 +1,32 @@
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
@@ -1,6 +1,7 @@
1
1
  import chalk from 'chalk';
2
2
  import Table from 'cli-table3';
3
3
  import { getAllInteractions, saveSnapshotData, getAllSnapshots, loadSnapshotData } from './storage.js';
4
+ import { translateText } from './lingo.js';
4
5
 
5
6
  export async function saveSnapshot(tag) {
6
7
  const interactions = getAllInteractions();
@@ -49,14 +50,17 @@ export async function listSnapshots() {
49
50
  console.log('');
50
51
  }
51
52
 
52
- export async function compareSnapshots(tag1, tag2) {
53
+ export async function compareSnapshots(tag1, tag2, lang) {
53
54
  try {
54
55
  const s1 = loadSnapshotData(tag1);
55
56
  const s2 = loadSnapshotData(tag2);
56
57
 
57
58
  console.log(chalk.cyan(`\n 🔍 Comparing Snapshots:`), chalk.white(tag1), chalk.gray('vs'), chalk.white(tag2));
59
+ if (lang) {
60
+ console.log(chalk.magenta(` 🌍 Translating output to: ${chalk.white(lang.toUpperCase())} using Lingo.dev...`));
61
+ }
58
62
 
59
- const results = [];
63
+ const resultMap = new Map();
60
64
  let breakingChanges = 0;
61
65
 
62
66
  // Helper to get path from URL string
@@ -70,65 +74,183 @@ export async function compareSnapshots(tag1, tag2) {
70
74
  }
71
75
  };
72
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
+
73
114
  // Domain-agnostic comparison: match by Path and Method
74
- s2.interactions.forEach(int2 => {
115
+ s2Interactions.forEach(int2 => {
75
116
  const path2 = getPath(int2.url);
76
- 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;
77
122
 
78
123
  if (!int1) {
79
- results.push({
80
- type: 'NEW',
81
- method: int2.method,
82
- url: path2,
83
- msg: chalk.blue('New interaction added')
84
- });
124
+ addResult('NEW', int2.method, path2, 'New interaction added');
85
125
  return;
86
126
  }
127
+ consumedS1Indexes.add(match.index);
87
128
 
88
129
  const diffs = [];
89
130
 
90
131
  // Compare status
91
132
  if (int1.response.status !== int2.response.status) {
92
133
  diffs.push(`Status: ${chalk.gray(int1.response.status)} → ${chalk.red(int2.response.status)}`);
93
- breakingChanges++;
94
- }
95
-
96
- // Deep field comparison (very basic for MVP)
97
- if (typeof int1.response.body === 'object' && typeof int2.response.body === 'object' && int1.response.body !== null && int2.response.body !== null) {
98
- const keys1 = Object.keys(int1.response.body);
99
- const keys2 = Object.keys(int2.response.body);
100
-
101
- const removed = keys1.filter(k => !keys2.includes(k));
102
- if (removed.length > 0) {
103
- diffs.push(`Fields removed: ${chalk.red(removed.join(', '))}`);
134
+ if (isBreakingStatusChange(int1.response.status, int2.response.status)) {
104
135
  breakingChanges++;
105
136
  }
106
137
  }
107
138
 
139
+ // Helper for deep structural comparison
140
+ const deepCompare = (val1, val2, path = '') => {
141
+ const changes = [];
142
+
143
+ // Handle nulls
144
+ if (val1 === null && val2 !== null) return [{ path, msg: `type changed from null to ${typeof val2}`, breaking: false }];
145
+ if (val1 !== null && val2 === null) return [{ path, msg: `type changed from ${typeof val1} to null`, breaking: false }];
146
+ if (val1 === null && val2 === null) return changes;
147
+
148
+ const type1 = Array.isArray(val1) ? 'array' : typeof val1;
149
+ const type2 = Array.isArray(val2) ? 'array' : typeof val2;
150
+
151
+ if (type1 !== type2) {
152
+ changes.push({ path, msg: `type changed from ${chalk.yellow(type1)} to ${chalk.yellow(type2)}`, breaking: true });
153
+ return changes;
154
+ }
155
+
156
+ if (type1 === 'object') {
157
+ const keys1 = Object.keys(val1);
158
+ const keys2 = Object.keys(val2);
159
+
160
+ // Check for removed keys (Breaking)
161
+ for (const k of keys1) {
162
+ const currentPath = path ? `${path}.${k}` : k;
163
+ if (!keys2.includes(k)) {
164
+ changes.push({ path: currentPath, msg: `was ${chalk.red('removed')}`, breaking: true });
165
+ } else {
166
+ changes.push(...deepCompare(val1[k], val2[k], currentPath));
167
+ }
168
+ }
169
+
170
+ // Check for added keys (Non-breaking)
171
+ for (const k of keys2) {
172
+ const currentPath = path ? `${path}.${k}` : k;
173
+ if (!keys1.includes(k)) {
174
+ changes.push({ path: currentPath, msg: `was ${chalk.green('added')}`, breaking: false });
175
+ }
176
+ }
177
+ } else if (type1 === 'array') {
178
+ // Array structure validation (check first item schema only if exists)
179
+ if (val1.length > 0 && val2.length > 0) {
180
+ const itemPath = path ? `${path}[0]` : '[0]';
181
+ changes.push(...deepCompare(val1[0], val2[0], itemPath));
182
+ }
183
+ }
184
+
185
+ return changes;
186
+ };
187
+
188
+ if (int1.response.body !== undefined && int2.response.body !== undefined) {
189
+ const structuralChanges = deepCompare(int1.response.body, int2.response.body);
190
+ for (const change of structuralChanges) {
191
+ diffs.push(`${chalk.cyan(change.path || 'root')} ${change.msg}`);
192
+ if (change.breaking) breakingChanges++;
193
+ }
194
+ }
108
195
  if (diffs.length > 0) {
109
- results.push({
110
- type: 'CHANGE',
111
- method: int2.method,
112
- url: path2,
113
- msg: diffs.join('\n ')
114
- });
196
+ addResult('CHANGE', int2.method, path2, diffs.join('\n '));
115
197
  }
116
198
  });
117
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
+
118
209
  if (results.length === 0) {
119
- console.log(chalk.green('\n ✅ No differences detected. Your API is stable!\n'));
210
+ let finalMsg = 'No differences detected. Your API is stable!';
211
+ if (lang) finalMsg = await translateText(finalMsg, lang);
212
+ console.log(chalk.green(`\n ✅ ${finalMsg}\n`));
120
213
  } else {
121
214
  console.log('');
122
- results.forEach(res => {
123
- const symbol = res.type === 'NEW' ? chalk.blue('+') : chalk.yellow('âš ī¸');
124
- console.log(` ${symbol} ${chalk.white(res.method)} ${chalk.gray(res.url)}`);
125
- console.log(` ${res.msg}`);
215
+
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 || ''));
126
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
+ }
228
+ if (lang) {
229
+ // Basic translation hook for individual diff items (stripping ansi)
230
+ const cleanMsg = printMsg.replace(/\x1B\[[0-9;]*m/g, '');
231
+ const translatedMsg = await translateText(cleanMsg, lang);
232
+ printMsg = chalk.yellow('[Translated] ') + translatedMsg;
233
+ }
234
+
235
+ const symbol = res.type === 'NEW'
236
+ ? chalk.blue('+')
237
+ : res.type === 'REMOVED'
238
+ ? chalk.red('-')
239
+ : chalk.yellow('âš ī¸');
240
+ console.log(` ${symbol} ${chalk.white(res.method)} ${chalk.gray(res.url)}`);
241
+ console.log(` ${printMsg}`);
242
+ }
127
243
 
244
+ let alertMsg = breakingChanges > 0
245
+ ? `Detected ${breakingChanges} potential breaking changes!`
246
+ : `Non-breaking changes detected.`;
247
+
248
+ if (lang) alertMsg = await translateText(alertMsg, lang);
249
+
128
250
  if (breakingChanges > 0) {
129
- console.log(chalk.red(`\n 🚨 Detected ${breakingChanges} potential breaking changes!\n`));
251
+ console.log(chalk.red(`\n 🚨 ${alertMsg}\n`));
130
252
  } else {
131
- console.log(chalk.blue('\n â„šī¸ Non-breaking changes detected.\n'));
253
+ console.log(chalk.blue(`\n â„šī¸ ${alertMsg}\n`));
132
254
  }
133
255
  }
134
256