kiroo 0.4.0 β 0.8.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 +223 -57
- package/bin/kiroo.js +93 -3
- package/package.json +3 -1
- package/src/bench.js +229 -0
- package/src/checker.js +99 -0
- package/src/edit.js +85 -0
- package/src/executor.js +8 -1
- package/src/export.js +93 -0
- package/src/lingo.js +36 -0
- package/src/replay.js +16 -3
- package/src/snapshot.js +82 -17
- package/src/storage.js +5 -0
package/README.md
CHANGED
|
@@ -16,48 +16,75 @@
|
|
|
16
16
|
|
|
17
17
|
---
|
|
18
18
|
|
|
19
|
-
## π
|
|
19
|
+
## π What is Kiroo?
|
|
20
20
|
|
|
21
|
-
Kiroo treats your
|
|
21
|
+
Kiroo is **Version Control for API Interactions**. It treats your requests and responses as first-class, versionable artifacts that live right alongside your code in your Git repository.
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
Stop copy-pasting JSON into Postman. Stop losing your API history. Start versioning it. π
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## πΈοΈ Visual Dependency Graph (`kiroo graph`)
|
|
28
|
+
|
|
29
|
+
Kiroo doesn't just record requests; it understands the **relationships** between them.
|
|
30
|
+
- **Auto-Tracking**: Kiroo tracks variables created via `--save` and consumed via `{{key}}`.
|
|
31
|
+
- **Insight**: Instantly see how data flows from your `/login` to your `/profile` and beyond.
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## π Insights & Performance Dashboard (`kiroo stats`)
|
|
36
|
+
|
|
37
|
+
Monitor your API's health directly from your terminal.
|
|
38
|
+
- **Success Rates**: Real-time 2xx/4xx/5xx distribution.
|
|
39
|
+
- **Performance**: Average response times across all interactions.
|
|
40
|
+
- **Bottlenecks**: Automatically identifies your top 5 slowest endpoints.
|
|
26
41
|
|
|
27
42
|
---
|
|
28
43
|
|
|
29
|
-
##
|
|
44
|
+
## π Instant cURL Import (`kiroo import`)
|
|
30
45
|
|
|
31
|
-
|
|
32
|
-
|
|
46
|
+
Coming from a browser? Don't type a single header.
|
|
47
|
+
- **Copy-Paste Magic**: Just `Copy as cURL` from Chrome/Firefox and run `kiroo import`.
|
|
48
|
+
- **Clean Parsing**: Automatically handles multi-line commands, quotes, and complex flags.
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## β¨ Features that WOW
|
|
53
|
+
|
|
54
|
+
### π’ **Git-Native Diffing & Translating**
|
|
55
|
+
Capture a **Snapshot** of your entire API state and compare versions.
|
|
56
|
+
- **Deep Structural Diffs**: Recursively tracks nested schema changes and silent datatype overrides.
|
|
57
|
+
- **Lingo.dev Translation**: Instantly localize breaking change alerts natively in your terminal.
|
|
33
58
|
```bash
|
|
34
|
-
kiroo
|
|
59
|
+
kiroo snapshot save v1-stable
|
|
60
|
+
kiroo --lang hi snapshot compare v1-stable current
|
|
35
61
|
```
|
|
36
62
|
|
|
37
|
-
###
|
|
38
|
-
|
|
63
|
+
### π **Variable Chaining**
|
|
64
|
+
Chain requests like a pro. Save a token from one response and inject it into the next.
|
|
39
65
|
```bash
|
|
40
|
-
kiroo
|
|
66
|
+
kiroo post /login --save jwt=data.token
|
|
67
|
+
kiroo get /users -H "Authorization: Bearer {{jwt}}"
|
|
41
68
|
```
|
|
42
69
|
|
|
43
|
-
###
|
|
44
|
-
|
|
70
|
+
### β¨οΈ **Shorthand JSON Parser**
|
|
71
|
+
Forget escaping quotes. Type JSON like a human.
|
|
45
72
|
```bash
|
|
46
|
-
|
|
47
|
-
kiroo post /login --save token=data.accessToken
|
|
48
|
-
|
|
49
|
-
# Use it in the next request
|
|
50
|
-
kiroo get /profile -H "Authorization: Bearer {{token}}"
|
|
73
|
+
kiroo post /api/user -d "name=Yash email=yash@kiroo.io role=admin"
|
|
51
74
|
```
|
|
52
75
|
|
|
53
|
-
|
|
54
|
-
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
### π§ͺ **Zero-Code Testing Framework**
|
|
79
|
+
Turn your terminal into an automated test runner. Validate responses on the fly without writing a single line of JS.
|
|
55
80
|
```bash
|
|
56
|
-
|
|
57
|
-
|
|
81
|
+
kiroo check /api/login -m POST -d "user=yash pass=123" --status 200 --has token
|
|
82
|
+
```
|
|
58
83
|
|
|
59
|
-
|
|
60
|
-
|
|
84
|
+
### π **Local Load Benchmarking**
|
|
85
|
+
Stress test endpoints instantly. Simulates massive concurrency and environment-variable-injected workloads to locate latency limits.
|
|
86
|
+
```bash
|
|
87
|
+
kiroo bench /api/reports -n 1000 -c 50 -v
|
|
61
88
|
```
|
|
62
89
|
|
|
63
90
|
---
|
|
@@ -66,53 +93,192 @@ kiroo snapshot compare v1-stable current
|
|
|
66
93
|
|
|
67
94
|
### 1. Installation
|
|
68
95
|
```bash
|
|
69
|
-
|
|
70
|
-
git clone https://github.com/yash-pouranik/kiroo.git
|
|
71
|
-
cd kiroo
|
|
72
|
-
|
|
73
|
-
# Install and link
|
|
74
|
-
npm install
|
|
75
|
-
npm link
|
|
96
|
+
npm install -g kiroo
|
|
76
97
|
```
|
|
77
98
|
|
|
78
|
-
### 2.
|
|
99
|
+
### 2. Initialization
|
|
79
100
|
```bash
|
|
80
101
|
kiroo init
|
|
81
102
|
```
|
|
82
103
|
|
|
83
|
-
### 3.
|
|
104
|
+
### 3. Record Your First Request
|
|
84
105
|
```bash
|
|
85
|
-
kiroo
|
|
86
|
-
kiroo get {{baseUrl}}/health
|
|
106
|
+
kiroo get https://api.github.com/users/yash-pouranik
|
|
87
107
|
```
|
|
88
108
|
|
|
89
109
|
---
|
|
90
110
|
|
|
91
|
-
##
|
|
92
|
-
|
|
93
|
-
###
|
|
94
|
-
Kiroo
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
```
|
|
101
|
-
|
|
102
|
-
kiroo
|
|
103
|
-
|
|
111
|
+
## π Full Command Documentation
|
|
112
|
+
|
|
113
|
+
### `kiroo init`
|
|
114
|
+
Initialize Kiroo in your current project.
|
|
115
|
+
- **Description**: Creates the `.kiroo/` directory structure and a default `env.json`.
|
|
116
|
+
- **Prerequisites**: None. Run once per project.
|
|
117
|
+
- **Example**:
|
|
118
|
+
```bash
|
|
119
|
+
kiroo init
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### `kiroo get/post/put/delete <url>`
|
|
123
|
+
Execute and record an API interaction.
|
|
124
|
+
- **Description**: Performs an HTTP request, displays the response, and saves it to history.
|
|
125
|
+
- **Prerequisites**: Access to the URL (or a `baseUrl` set in the environment).
|
|
126
|
+
- **Arguments**:
|
|
127
|
+
- `url`: The endpoint (Absolute URL or relative path if `baseUrl` exists).
|
|
128
|
+
- **Options**:
|
|
129
|
+
- `-H, --header <key:value>`: Add custom headers.
|
|
130
|
+
- `-d, --data <data>`: Request body (JSON or shorthand `key=val`).
|
|
131
|
+
- `-s, --save <key=path>`: Save response data to environment variables.
|
|
132
|
+
- **Example**:
|
|
133
|
+
```bash
|
|
134
|
+
kiroo post /api/auth/login -d "email=user@test.com password=123" --save token=data.token
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### `kiroo list`
|
|
138
|
+
View your interaction history.
|
|
139
|
+
- **Description**: Displays a paginated list of all recorded requests.
|
|
140
|
+
- **Arguments**: None.
|
|
141
|
+
- **Options**:
|
|
142
|
+
- `-n, --limit <number>`: How many records to show (Default: 10).
|
|
143
|
+
- `-o, --offset <number>`: How many records to skip (Default: 0).
|
|
144
|
+
- **Example**:
|
|
145
|
+
```bash
|
|
146
|
+
kiroo list -n 20
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### `kiroo replay <id>`
|
|
150
|
+
Re-run a specific interaction.
|
|
151
|
+
- **Description**: Fetches the original request from history and executes it again.
|
|
152
|
+
- **Arguments**:
|
|
153
|
+
- `id`: The timestamp ID of the interaction (found via `kiroo list`).
|
|
154
|
+
- **Example**:
|
|
155
|
+
```bash
|
|
156
|
+
kiroo replay 2026-03-10T14-30-05-123Z
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### `kiroo edit <id>`
|
|
160
|
+
Quick Refinement. Edit an interaction on the fly and replay it.
|
|
161
|
+
- **Description**: Opens the stored interaction JSON in your default system editor (VS Code, Nano, Vim, etc.). Edit the headers, body, or URL, save, and close. Kiroo immediately replays the updated request.
|
|
162
|
+
- **Arguments**:
|
|
163
|
+
- `id`: The timestamp ID of the interaction.
|
|
164
|
+
- **Example**:
|
|
165
|
+
```bash
|
|
166
|
+
kiroo edit 2026-03-10T14-30-05-123Z
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### `kiroo check <url>`
|
|
170
|
+
Zero-Code Testing engine.
|
|
171
|
+
- **Description**: Executes a request and runs assertions on the response. Exits with code 1 on failure.
|
|
172
|
+
- **Prerequisites**: Access to the URL.
|
|
173
|
+
- **Arguments**:
|
|
174
|
+
- `url`: The endpoint.
|
|
175
|
+
- **Options**:
|
|
176
|
+
- `-m, --method <method>`: HTTP method (GET, POST, etc. Default: GET).
|
|
177
|
+
- `-H, --header <key:value>`: Add custom headers.
|
|
178
|
+
- `-d, --data <data>`: Request body.
|
|
179
|
+
- `--status <numbers>`: Expected HTTP status code.
|
|
180
|
+
- `--has <fields>`: Comma-separated list of expected fields in JSON.
|
|
181
|
+
- `--match <key=val>`: Exact value matching for JSON fields.
|
|
182
|
+
- **Example**:
|
|
183
|
+
```bash
|
|
184
|
+
# Check if login is successful
|
|
185
|
+
kiroo check /api/login -m POST -d "user=yash pass=123" --status 200 --has token
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
### `kiroo bench <url>`
|
|
189
|
+
Local load testing and benchmarking.
|
|
190
|
+
- **Description**: Sends multiple concurrent HTTP requests to measure endpoint performance (Latency, RPS, Error Rate).
|
|
191
|
+
- **Prerequisites**: Access to the URL.
|
|
192
|
+
- **Arguments**:
|
|
193
|
+
- `url`: The endpoint (supports Auto-BaseURL).
|
|
194
|
+
- **Options**:
|
|
195
|
+
- `-m, --method <method>`: HTTP method (GET, POST, etc. Default: GET).
|
|
196
|
+
- `-n, --number <number>`: Total requests to send (Default: 10).
|
|
197
|
+
- `-c, --concurrent <number>`: Concurrent workers (Default: 1).
|
|
198
|
+
- `-v, --verbose`: Show the HTTP status, response time, and truncated response body for every individual request instead of a single progress spinner.
|
|
199
|
+
- `-H, --header <key:value>`: Add custom headers.
|
|
200
|
+
- `-d, --data <data>`: Request body.
|
|
201
|
+
- **Example**:
|
|
202
|
+
```bash
|
|
203
|
+
# Send 100 requests in batches of 10
|
|
204
|
+
kiroo bench /api/projects -n 100 -c 10
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
### `kiroo graph`
|
|
208
|
+
Visualize API dependencies.
|
|
209
|
+
- **Description**: Generates a tree view showing how data flows between endpoints via saved/used variables.
|
|
210
|
+
- **Prerequisites**: Recorded interactions that use `--save` and `{{variable}}`.
|
|
211
|
+
- **Example**:
|
|
212
|
+
```bash
|
|
213
|
+
kiroo graph
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
### `kiroo stats`
|
|
217
|
+
Analytics dashboard.
|
|
218
|
+
- **Description**: Shows performance metrics, success rates, and identify slow endpoints.
|
|
219
|
+
- **Example**:
|
|
220
|
+
```bash
|
|
221
|
+
kiroo stats
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
### `kiroo import`
|
|
225
|
+
Import from cURL.
|
|
226
|
+
- **Description**: Converts a cURL command into a Kiroo interaction. Opens an interactive editor if no argument is provided.
|
|
227
|
+
- **Arguments**:
|
|
228
|
+
- `curl`: (Optional) The raw cURL string in quotes.
|
|
229
|
+
- **Example**:
|
|
230
|
+
```bash
|
|
231
|
+
kiroo import "curl https://api.exa.com -H 'Auth: 123'"
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
### `kiroo snapshot`
|
|
235
|
+
Snapshot management.
|
|
236
|
+
- **Commands**:
|
|
237
|
+
- `save <tag>`: Save current history as a versioned state.
|
|
238
|
+
- `list`: List all saved snapshots.
|
|
239
|
+
- `compare <tag1> <tag2>`: Detect breaking changes between two states.
|
|
240
|
+
- **Example**:
|
|
241
|
+
```bash
|
|
242
|
+
kiroo snapshot compare v1.stable current
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
### `kiroo export`
|
|
246
|
+
Team Compatibility. Export to Postman.
|
|
247
|
+
- **Description**: Converts all stored Kiroo interactions and responses into a standard Postman Collection v2.1.0 format (`.json`) for seamless GUI import.
|
|
248
|
+
- **Options**:
|
|
249
|
+
- `-o, --out <filename>`: The output filename (Default: `kiroo-collection.json`).
|
|
250
|
+
- **Example**:
|
|
251
|
+
```bash
|
|
252
|
+
kiroo export --out my_api_collection.json
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
### `kiroo env`
|
|
256
|
+
Environment & Variable management.
|
|
257
|
+
- **Commands**:
|
|
258
|
+
- `list`: View all environments and their variables.
|
|
259
|
+
- `use <name>`: Switch active environment (e.g., `prod`, `local`).
|
|
260
|
+
- `set <key> <value>`: Set a variable in the active environment.
|
|
261
|
+
- `rm <key>`: Remove a variable.
|
|
262
|
+
- **Example**:
|
|
263
|
+
```bash
|
|
264
|
+
kiroo env set baseUrl https://api.myapp.com
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
### `kiroo clear`
|
|
268
|
+
Wipe history.
|
|
269
|
+
- **Description**: Deletes all recorded interactions to start fresh.
|
|
270
|
+
- **Options**:
|
|
271
|
+
- `-f, --force`: Clear without a confirmation prompt.
|
|
272
|
+
- **Example**:
|
|
273
|
+
```bash
|
|
274
|
+
kiroo clear --force
|
|
275
|
+
```
|
|
104
276
|
|
|
105
277
|
---
|
|
106
278
|
|
|
107
|
-
##
|
|
279
|
+
## π€ Contributing
|
|
108
280
|
|
|
109
|
-
|
|
110
|
-
| :--- | :---: | :---: | :---: |
|
|
111
|
-
| **CLI-First** | β | β οΈ | β
|
|
|
112
|
-
| **Git-Native** | β | β
| β
|
|
|
113
|
-
| **Auto-Recording** | β | β | β
|
|
|
114
|
-
| **Built-in Replay** | β | β | β
|
|
|
115
|
-
| **Variable Chaining** | β οΈ | β οΈ | β
|
|
|
281
|
+
Kiroo is an open-source project and we love contributions! Check out our [Contribution Guidelines](CONTRIBUTING.md).
|
|
116
282
|
|
|
117
283
|
---
|
|
118
284
|
|
|
@@ -123,5 +289,5 @@ Distributed under the MIT License. See `LICENSE` for more information.
|
|
|
123
289
|
---
|
|
124
290
|
|
|
125
291
|
<div align="center">
|
|
126
|
-
Built with β€οΈ for Developers
|
|
292
|
+
Built with β€οΈ for Developers
|
|
127
293
|
</div>
|
package/bin/kiroo.js
CHANGED
|
@@ -7,17 +7,22 @@ import { listInteractions, replayInteraction } from '../src/replay.js';
|
|
|
7
7
|
import { saveSnapshot, compareSnapshots, listSnapshots } from '../src/snapshot.js';
|
|
8
8
|
import { setEnv, setVar, deleteVar, listEnv } from '../src/env.js';
|
|
9
9
|
import { showGraph } from '../src/graph.js';
|
|
10
|
+
import { validateResponse, showCheckResult } from '../src/checker.js';
|
|
10
11
|
import { initProject } from '../src/init.js';
|
|
11
12
|
import { showStats } from '../src/stats.js';
|
|
12
13
|
import { handleImport } from '../src/import.js';
|
|
13
14
|
import { clearAllInteractions } from '../src/storage.js';
|
|
15
|
+
import { runBenchmark } from '../src/bench.js';
|
|
16
|
+
import { editInteraction } from '../src/edit.js';
|
|
17
|
+
import { exportToPostman } from '../src/export.js';
|
|
14
18
|
|
|
15
19
|
const program = new Command();
|
|
16
20
|
|
|
17
21
|
program
|
|
18
22
|
.name('kiroo')
|
|
19
23
|
.description('Git for API interactions. Record, replay, snapshot, and diff your APIs.')
|
|
20
|
-
.version('0.
|
|
24
|
+
.version('0.8.0')
|
|
25
|
+
.option('--lang <language>', 'Translate output to specified language (e.g., hi, es, fr)');
|
|
21
26
|
|
|
22
27
|
// Init command
|
|
23
28
|
program
|
|
@@ -26,6 +31,56 @@ program
|
|
|
26
31
|
.action(async () => {
|
|
27
32
|
await initProject();
|
|
28
33
|
});
|
|
34
|
+
|
|
35
|
+
// Check command (Zero-Code Testing)
|
|
36
|
+
program
|
|
37
|
+
.command('check <url>')
|
|
38
|
+
.description('Execute a request and validate the response against rules')
|
|
39
|
+
.option('-m, --method <method>', 'HTTP method (GET, POST, etc.)', 'GET')
|
|
40
|
+
.option('-H, --header <header...>', 'Add custom headers')
|
|
41
|
+
.option('-d, --data <data>', 'Request body (JSON or shorthand)')
|
|
42
|
+
.option('--status <code...>', 'Expected HTTP status code')
|
|
43
|
+
.option('--has <fields...>', 'Comma-separated list of expected fields in JSON response')
|
|
44
|
+
.option('--match <matches...>', 'Expected field values (e.g., status=active)')
|
|
45
|
+
.action(async (url, options) => {
|
|
46
|
+
// Execute request
|
|
47
|
+
const response = await executeRequest(options.method || 'GET', url, {
|
|
48
|
+
header: options.header,
|
|
49
|
+
data: options.data,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
if (!response) {
|
|
53
|
+
console.error(chalk.red('\n β No response received to validate.'));
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Parse matches: ["key1=val1", "key2=val2"] -> { key1: val1, key2: val2 }
|
|
58
|
+
const matchObj = {};
|
|
59
|
+
if (options.match) {
|
|
60
|
+
options.match.forEach(m => {
|
|
61
|
+
const [k, ...v] = m.split('=');
|
|
62
|
+
if (k) matchObj[k] = v.join('=');
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Parse has: ["id,name"] or ["id", "name"] -> ["id", "name"]
|
|
67
|
+
const hasFields = options.has ? options.has.flatMap(h => h.split(',')).map(f => f.trim()) : [];
|
|
68
|
+
|
|
69
|
+
// Construct rules
|
|
70
|
+
const rules = {
|
|
71
|
+
status: Array.isArray(options.status) ? options.status[0] : options.status,
|
|
72
|
+
has: hasFields,
|
|
73
|
+
match: matchObj
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// Validate
|
|
77
|
+
const validation = validateResponse(response, rules);
|
|
78
|
+
showCheckResult(validation);
|
|
79
|
+
|
|
80
|
+
if (!validation.passed) {
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
29
84
|
// sk_live_p7BWJjsYlKmauBOjiEeiLRuu4DokkBWsgYne_E6osTo
|
|
30
85
|
|
|
31
86
|
// HTTP methods as commands
|
|
@@ -47,7 +102,10 @@ program
|
|
|
47
102
|
.command('list')
|
|
48
103
|
.description('List all stored interactions')
|
|
49
104
|
.option('-n, --limit <number>', 'Number of interactions to show', '10')
|
|
50
|
-
.option('-o, --offset <number>', '
|
|
105
|
+
.option('-o, --offset <number>', 'Offset for pagination', '0')
|
|
106
|
+
.option('--date <date>', 'Filter by date (YYYY-MM-DD)')
|
|
107
|
+
.option('--url <url>', 'Filter by URL path')
|
|
108
|
+
.option('--status <status>', 'Filter by HTTP status')
|
|
51
109
|
.action(async (options) => {
|
|
52
110
|
await listInteractions(options);
|
|
53
111
|
});
|
|
@@ -60,6 +118,37 @@ program
|
|
|
60
118
|
await replayInteraction(id);
|
|
61
119
|
});
|
|
62
120
|
|
|
121
|
+
// Edit interaction
|
|
122
|
+
program
|
|
123
|
+
.command('edit <id>')
|
|
124
|
+
.description('Edit an interaction in your text editor and quickly replay it')
|
|
125
|
+
.action(async (id) => {
|
|
126
|
+
await editInteraction(id);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// Export interactions
|
|
130
|
+
program
|
|
131
|
+
.command('export')
|
|
132
|
+
.description('Export all stored interactions to a Postman Collection')
|
|
133
|
+
.option('-o, --out <filename>', 'Output JSON filename', 'kiroo-collection.json')
|
|
134
|
+
.action((options) => {
|
|
135
|
+
exportToPostman(options.out);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// Bench command (Load Testing)
|
|
139
|
+
program
|
|
140
|
+
.command('bench <url>')
|
|
141
|
+
.description('Run a basic load test against an endpoint')
|
|
142
|
+
.option('-m, --method <method>', 'HTTP method (GET, POST, etc.)', 'GET')
|
|
143
|
+
.option('-n, --number <number>', 'Number of total requests to send', '10')
|
|
144
|
+
.option('-c, --concurrent <number>', 'Number of concurrent requests', '1')
|
|
145
|
+
.option('-H, --header <header...>', 'Add custom headers')
|
|
146
|
+
.option('-v, --verbose', 'Show detailed output for every request')
|
|
147
|
+
.option('-d, --data <data>', 'Request body')
|
|
148
|
+
.action(async (url, options) => {
|
|
149
|
+
await runBenchmark(url, options);
|
|
150
|
+
});
|
|
151
|
+
|
|
63
152
|
// Clear command
|
|
64
153
|
program
|
|
65
154
|
.command('clear')
|
|
@@ -126,7 +215,8 @@ snapshot
|
|
|
126
215
|
.command('compare <tag1> <tag2>')
|
|
127
216
|
.description('Compare two snapshots')
|
|
128
217
|
.action(async (tag1, tag2) => {
|
|
129
|
-
|
|
218
|
+
const opts = program.opts();
|
|
219
|
+
await compareSnapshots(tag1, tag2, opts.lang);
|
|
130
220
|
});
|
|
131
221
|
|
|
132
222
|
// Graph command
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kiroo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Git for API interactions. Record, replay, snapshot, and diff your APIs.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -41,8 +41,10 @@
|
|
|
41
41
|
"chalk": "^5.3.0",
|
|
42
42
|
"cli-table3": "^0.6.3",
|
|
43
43
|
"commander": "^12.0.0",
|
|
44
|
+
"dotenv": "^17.3.1",
|
|
44
45
|
"inquirer": "^9.2.15",
|
|
45
46
|
"js-yaml": "^4.1.0",
|
|
47
|
+
"lingo.dev": "^0.133.1",
|
|
46
48
|
"ora": "^8.0.1"
|
|
47
49
|
},
|
|
48
50
|
"engines": {
|
package/src/bench.js
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import Table from 'cli-table3';
|
|
3
|
+
import axios from 'axios';
|
|
4
|
+
import ora from 'ora';
|
|
5
|
+
import { loadEnv } from './storage.js';
|
|
6
|
+
import { applyEnvReplacements } from './executor.js';
|
|
7
|
+
|
|
8
|
+
export async function runBenchmark(url, options) {
|
|
9
|
+
const envData = loadEnv();
|
|
10
|
+
const currentEnvVars = envData.environments[envData.current] || {};
|
|
11
|
+
|
|
12
|
+
url = applyEnvReplacements(url, currentEnvVars);
|
|
13
|
+
|
|
14
|
+
const method = (options.method || 'GET').toUpperCase();
|
|
15
|
+
const totalRequests = parseInt(options.number) || 10;
|
|
16
|
+
const concurrency = parseInt(options.concurrent) || 1;
|
|
17
|
+
const headersObj = {};
|
|
18
|
+
|
|
19
|
+
if (options.header) {
|
|
20
|
+
options.header.forEach(h => {
|
|
21
|
+
const parts = h.split(':');
|
|
22
|
+
if (parts.length >= 2) {
|
|
23
|
+
let val = parts.slice(1).join(':').trim();
|
|
24
|
+
val = applyEnvReplacements(val, currentEnvVars);
|
|
25
|
+
headersObj[parts[0].trim()] = val;
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Auto-BaseURL logic (Synced from executor.js)
|
|
31
|
+
let targetUrl = url;
|
|
32
|
+
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
|
33
|
+
if (currentEnvVars.baseUrl) {
|
|
34
|
+
let isRelative = false;
|
|
35
|
+
let pathPart = url;
|
|
36
|
+
|
|
37
|
+
// 1. Direct relative path
|
|
38
|
+
if (url.startsWith('/')) {
|
|
39
|
+
isRelative = true;
|
|
40
|
+
}
|
|
41
|
+
// 2. Windows Git Bash conversion: Detect "C:/..." style paths with no protocol
|
|
42
|
+
else if (process.platform === 'win32' && /^[a-zA-Z]:[/\\]/.test(url) && !url.includes('://')) {
|
|
43
|
+
isRelative = true;
|
|
44
|
+
const segments = url.split(/[/\\]/);
|
|
45
|
+
const apiIdx = segments.findIndex(s => s === 'api' || s === 'v1' || s === 'v2');
|
|
46
|
+
if (apiIdx !== -1) {
|
|
47
|
+
pathPart = '/' + segments.slice(apiIdx).join('/');
|
|
48
|
+
} else {
|
|
49
|
+
pathPart = url.replace(/^[a-zA-Z]:/, '').replace(/\\/g, '/');
|
|
50
|
+
if (!pathPart.startsWith('/')) pathPart = '/' + pathPart;
|
|
51
|
+
|
|
52
|
+
// Hard fix for Git Bash root expansion "C:/Program Files/Git/" -> "/"
|
|
53
|
+
const lowerPath = pathPart.toLowerCase();
|
|
54
|
+
if (lowerPath === '/program files/git/' || lowerPath === '/program files/git') {
|
|
55
|
+
pathPart = '/';
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
// 3. No leading slash but doesn't look like a host
|
|
60
|
+
else if (!url.includes('://') && !url.includes('.') && !url.includes(':') && !url.startsWith('localhost')) {
|
|
61
|
+
isRelative = true;
|
|
62
|
+
pathPart = '/' + url;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (isRelative) {
|
|
66
|
+
const baseUrl = currentEnvVars.baseUrl;
|
|
67
|
+
const normalizedBaseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
|
|
68
|
+
targetUrl = normalizedBaseUrl + (pathPart.startsWith('/') ? pathPart : '/' + pathPart);
|
|
69
|
+
}
|
|
70
|
+
} else {
|
|
71
|
+
console.log(chalk.red(`\n β Invalid URL and no baseUrl defined in environment '${envData.current}'\n`));
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Parse Body
|
|
77
|
+
let requestData = options.data;
|
|
78
|
+
if (options.data) {
|
|
79
|
+
requestData = applyEnvReplacements(options.data, currentEnvVars);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (requestData && typeof requestData === 'string' && !requestData.trim().startsWith('{')) {
|
|
83
|
+
const pairs = requestData.split(' ');
|
|
84
|
+
requestData = {};
|
|
85
|
+
pairs.forEach(pair => {
|
|
86
|
+
const [key, value] = pair.split('=');
|
|
87
|
+
if (key && value !== undefined) {
|
|
88
|
+
requestData[key] = value;
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
} else if (requestData && typeof requestData === 'string') {
|
|
92
|
+
try {
|
|
93
|
+
requestData = JSON.parse(requestData);
|
|
94
|
+
} catch(e) {}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const isVerbose = options.verbose;
|
|
98
|
+
let spinner;
|
|
99
|
+
|
|
100
|
+
if (!isVerbose) {
|
|
101
|
+
spinner = ora(`Benchmarking ${method} ${targetUrl} (Reqs: ${totalRequests}, Workers: ${concurrency})`).start();
|
|
102
|
+
} else {
|
|
103
|
+
console.log(chalk.cyan(`\n Starting Benchmark: ${method} ${targetUrl} (Reqs: ${totalRequests}, Workers: ${concurrency})\n`));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const results = {
|
|
107
|
+
total: totalRequests,
|
|
108
|
+
success: 0,
|
|
109
|
+
failures: 0,
|
|
110
|
+
times: []
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const startTime = Date.now();
|
|
114
|
+
let completedCount = 0;
|
|
115
|
+
let activeWorkers = 0;
|
|
116
|
+
|
|
117
|
+
// Custom concurrency implementation
|
|
118
|
+
return new Promise((resolve) => {
|
|
119
|
+
const executeNext = async () => {
|
|
120
|
+
if (completedCount >= totalRequests) {
|
|
121
|
+
if (activeWorkers === 0) finalizeBenchmark();
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
activeWorkers++;
|
|
126
|
+
const reqIndex = completedCount++;
|
|
127
|
+
const reqId = reqIndex + 1;
|
|
128
|
+
const reqStartTime = Date.now();
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
const response = await axios({
|
|
132
|
+
method: method.toLowerCase(),
|
|
133
|
+
url: targetUrl,
|
|
134
|
+
headers: headersObj,
|
|
135
|
+
data: requestData,
|
|
136
|
+
validateStatus: () => true, // Don't throw on 4xx/5xx
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const duration = Date.now() - reqStartTime;
|
|
140
|
+
results.times.push(duration);
|
|
141
|
+
|
|
142
|
+
let statusColor = response.status >= 400 ? chalk.red : chalk.green;
|
|
143
|
+
|
|
144
|
+
if (response.status >= 200 && response.status < 400) {
|
|
145
|
+
results.success++;
|
|
146
|
+
} else {
|
|
147
|
+
results.failures++;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (isVerbose) {
|
|
151
|
+
console.log(chalk.gray(` [Req ${reqId}/${totalRequests}] `) + statusColor(`${response.status} ${response.statusText}`) + chalk.gray(` - ${duration}ms`));
|
|
152
|
+
|
|
153
|
+
// Print snippet of data if available
|
|
154
|
+
if (response.data) {
|
|
155
|
+
let dataStr = typeof response.data === 'object' ? JSON.stringify(response.data) : String(response.data);
|
|
156
|
+
if (dataStr.length > 200) dataStr = dataStr.substring(0, 197) + '...';
|
|
157
|
+
console.log(chalk.gray(` β³ Data: `) + chalk.white(dataStr));
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
} catch (error) {
|
|
161
|
+
const duration = Date.now() - reqStartTime;
|
|
162
|
+
results.times.push(duration);
|
|
163
|
+
results.failures++;
|
|
164
|
+
|
|
165
|
+
if (isVerbose) {
|
|
166
|
+
console.log(chalk.gray(` [Req ${reqId}/${totalRequests}] `) + chalk.red(`ERR: ${error.message}`) + chalk.gray(` - ${duration}ms`));
|
|
167
|
+
}
|
|
168
|
+
} finally {
|
|
169
|
+
activeWorkers--;
|
|
170
|
+
// Update spinner if not verbose
|
|
171
|
+
if (!isVerbose) {
|
|
172
|
+
const percent = Math.floor((completedCount / totalRequests) * 100);
|
|
173
|
+
spinner.text = `Benchmarking... ${percent}% [${completedCount}/${totalRequests}]`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
executeNext();
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const finalizeBenchmark = () => {
|
|
181
|
+
const totalTime = Date.now() - startTime;
|
|
182
|
+
if (!isVerbose) spinner.stop();
|
|
183
|
+
|
|
184
|
+
const rps = ((results.total / totalTime) * 1000).toFixed(2);
|
|
185
|
+
|
|
186
|
+
// Calculate min, max, avg
|
|
187
|
+
let min = 0, max = 0, avg = 0;
|
|
188
|
+
if (results.times.length > 0) {
|
|
189
|
+
min = Math.min(...results.times);
|
|
190
|
+
max = Math.max(...results.times);
|
|
191
|
+
avg = Math.round(results.times.reduce((a, b) => a + b, 0) / results.times.length);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
console.log('\n ' + chalk.blue.bold('π Benchmark Results'));
|
|
195
|
+
console.log(' ' + chalk.gray(`${method} ${targetUrl}\n`));
|
|
196
|
+
|
|
197
|
+
const statsTable = new Table({
|
|
198
|
+
colWidths: [20, 15]
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
statsTable.push(
|
|
202
|
+
[chalk.white('Total Requests'), chalk.cyan(results.total)],
|
|
203
|
+
[chalk.white('Concurrency'), chalk.cyan(concurrency)],
|
|
204
|
+
[chalk.white('Success Rate'), results.failures === 0 ? chalk.green('100%') : chalk.yellow(`${((results.success/results.total)*100).toFixed(1)}%`)],
|
|
205
|
+
[chalk.white('Requests/sec'), chalk.magenta(rps)],
|
|
206
|
+
[chalk.gray('---'), chalk.gray('---')],
|
|
207
|
+
[chalk.white('Fastest (Min)'), chalk.green(`${min}ms`)],
|
|
208
|
+
[chalk.white('Slowest (Max)'), chalk.red(`${max}ms`)],
|
|
209
|
+
[chalk.white('Average'), chalk.blue(`${avg}ms`)]
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
console.log(statsTable.toString());
|
|
213
|
+
|
|
214
|
+
if (results.failures > 0) {
|
|
215
|
+
console.log(chalk.red(`\n β οΈ ${results.failures} requests failed (HTTP 4xx/5xx or Network Error).\n`));
|
|
216
|
+
} else {
|
|
217
|
+
console.log(chalk.green(`\n β
All requests completed successfully.\n`));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
resolve();
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
// Bootstrap workers
|
|
224
|
+
const initialWorkers = Math.min(concurrency, totalRequests);
|
|
225
|
+
for (let i = 0; i < initialWorkers; i++) {
|
|
226
|
+
executeNext();
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
}
|
package/src/checker.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Validates a response against a set of rules
|
|
5
|
+
* @param {Object} response - The Axios response object
|
|
6
|
+
* @param {Object} rules - Rules like { status, has: [], match: { key: value } }
|
|
7
|
+
* @returns {Object} { passed: boolean, results: [] }
|
|
8
|
+
*/
|
|
9
|
+
export function validateResponse(response, rules) {
|
|
10
|
+
const results = [];
|
|
11
|
+
let allPassed = true;
|
|
12
|
+
|
|
13
|
+
// 1. Status Check
|
|
14
|
+
if (rules.status) {
|
|
15
|
+
const expected = parseInt(rules.status);
|
|
16
|
+
const actual = response.status;
|
|
17
|
+
const passed = expected === actual;
|
|
18
|
+
results.push({
|
|
19
|
+
label: 'Status Code',
|
|
20
|
+
expected,
|
|
21
|
+
actual,
|
|
22
|
+
passed
|
|
23
|
+
});
|
|
24
|
+
if (!passed) allPassed = false;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// 2. Body Presence Check (has keys)
|
|
28
|
+
if (rules.has && rules.has.length > 0) {
|
|
29
|
+
const body = response.data || {};
|
|
30
|
+
rules.has.forEach(key => {
|
|
31
|
+
let actual = getDeep(body, key);
|
|
32
|
+
let passed = actual !== undefined;
|
|
33
|
+
|
|
34
|
+
// Smart Array Handling:
|
|
35
|
+
// If root is an Array and user checks for 'data' (or any key) that's missing,
|
|
36
|
+
// we check if the array itself belongs to the "presence" check.
|
|
37
|
+
if (!passed && Array.isArray(body)) {
|
|
38
|
+
// If the user is checking for existence on a root array,
|
|
39
|
+
// we'll pass if the array is not empty.
|
|
40
|
+
passed = body.length > 0;
|
|
41
|
+
actual = passed ? `Array(${body.length})` : 'Empty Array';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
results.push({
|
|
45
|
+
label: `Field Presence [${key}]`,
|
|
46
|
+
expected: 'Exists',
|
|
47
|
+
actual: passed ? (actual === undefined ? 'Found' : actual) : 'Missing',
|
|
48
|
+
passed
|
|
49
|
+
});
|
|
50
|
+
if (!passed) allPassed = false;
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 3. Value Match Check
|
|
55
|
+
if (rules.match && Object.keys(rules.match).length > 0) {
|
|
56
|
+
const body = response.data || {};
|
|
57
|
+
for (const [key, expected] of Object.entries(rules.match)) {
|
|
58
|
+
const actual = getDeep(body, key);
|
|
59
|
+
const passed = String(actual) === String(expected);
|
|
60
|
+
results.push({
|
|
61
|
+
label: `Value Match [${key}]`,
|
|
62
|
+
expected,
|
|
63
|
+
actual: actual !== undefined ? actual : 'undefined',
|
|
64
|
+
passed
|
|
65
|
+
});
|
|
66
|
+
if (!passed) allPassed = false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return { passed: allPassed, results };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function getDeep(obj, path) {
|
|
74
|
+
if (!obj) return undefined;
|
|
75
|
+
const keys = path.split(/[.[\]]+/).filter(Boolean);
|
|
76
|
+
return keys.reduce((acc, key) => acc && acc[key], obj);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function showCheckResult(validation) {
|
|
80
|
+
console.log(chalk.cyan('\n π§ͺ Test Results:'));
|
|
81
|
+
|
|
82
|
+
validation.results.forEach(res => {
|
|
83
|
+
const icon = res.passed ? chalk.green('β') : chalk.red('β');
|
|
84
|
+
const color = res.passed ? chalk.white : chalk.red;
|
|
85
|
+
|
|
86
|
+
console.log(` ${icon} ${res.label}`);
|
|
87
|
+
if (!res.passed) {
|
|
88
|
+
console.log(chalk.gray(` Expected: ${res.expected}`));
|
|
89
|
+
console.log(chalk.gray(` Actual: ${res.actual}`));
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
if (validation.passed) {
|
|
94
|
+
console.log(chalk.green.bold('\n β¨ ALL TESTS PASSED! \n'));
|
|
95
|
+
} else {
|
|
96
|
+
console.log(chalk.red.bold('\n β SOME TESTS FAILED \n'));
|
|
97
|
+
process.exit(1);
|
|
98
|
+
}
|
|
99
|
+
}
|
package/src/edit.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, unlinkSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { execSync } from 'child_process';
|
|
4
|
+
import chalk from 'chalk';
|
|
5
|
+
import { loadInteraction } from './storage.js';
|
|
6
|
+
import { replayInteraction } from './replay.js';
|
|
7
|
+
|
|
8
|
+
export async function editInteraction(id) {
|
|
9
|
+
try {
|
|
10
|
+
const interaction = loadInteraction(id);
|
|
11
|
+
if (!interaction) {
|
|
12
|
+
console.log(chalk.red(`\n β Interaction not found: ${id}\n`));
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Step 2: Generate temporary editable JSON
|
|
17
|
+
const tmpFileName = `.kiroo/tmp_edit_${id}.json`;
|
|
18
|
+
const editableData = {
|
|
19
|
+
method: interaction.request.method,
|
|
20
|
+
url: interaction.request.url,
|
|
21
|
+
headers: interaction.request.headers || {},
|
|
22
|
+
data: interaction.request.body || null
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
writeFileSync(tmpFileName, JSON.stringify(editableData, null, 2));
|
|
26
|
+
|
|
27
|
+
// Step 3: Launch Editor
|
|
28
|
+
const editor = process.env.EDITOR || 'code'; // Fallbacks: code, nano, vim, notepad...
|
|
29
|
+
|
|
30
|
+
console.log(chalk.cyan(`\n π Opening interaction in your editor (${editor})...`));
|
|
31
|
+
console.log(chalk.gray(` Save and close the file to automatically replay the request.`));
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
// In windows, 'code -w' waits for VS Code to close.
|
|
35
|
+
// If notepad, just 'notepad' blocks until closed.
|
|
36
|
+
// If using fallback, we try standard blocking call.
|
|
37
|
+
const launchCmd = editor === 'code' ? 'code -w' : editor;
|
|
38
|
+
execSync(`${launchCmd} ${tmpFileName}`, { stdio: 'inherit' });
|
|
39
|
+
} catch (e) {
|
|
40
|
+
console.log(chalk.red(`\n β Failed to open editor '${editor}'.`));
|
|
41
|
+
console.log(chalk.gray(` Please specify a valid editor via EDITOR environment variable (e.g. EDITOR=nano kiroo edit).`));
|
|
42
|
+
try { unlinkSync(tmpFileName); } catch(err){}
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Step 4: Editor closed, read updated data
|
|
47
|
+
let updatedDataStr;
|
|
48
|
+
try {
|
|
49
|
+
updatedDataStr = readFileSync(tmpFileName, 'utf-8');
|
|
50
|
+
} catch (e) {
|
|
51
|
+
console.log(chalk.red('\n β Could not read the edited file. Editing cancelled.\n'));
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let updatedData;
|
|
56
|
+
try {
|
|
57
|
+
updatedData = JSON.parse(updatedDataStr);
|
|
58
|
+
} catch (e) {
|
|
59
|
+
console.log(chalk.red('\n β Invalid JSON syntax in edited file. Editing cancelled.\n'));
|
|
60
|
+
try { unlinkSync(tmpFileName); } catch(err){}
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Step 5: Merge and Rewrite
|
|
65
|
+
interaction.request.method = updatedData.method;
|
|
66
|
+
interaction.request.url = updatedData.url;
|
|
67
|
+
interaction.request.headers = updatedData.headers;
|
|
68
|
+
interaction.request.body = updatedData.data;
|
|
69
|
+
|
|
70
|
+
// Save it over the original file
|
|
71
|
+
const originalPath = join('.kiroo', 'interactions', `${id}.json`);
|
|
72
|
+
writeFileSync(originalPath, JSON.stringify(interaction, null, 2));
|
|
73
|
+
|
|
74
|
+
// Cleanup tmp file
|
|
75
|
+
try { unlinkSync(tmpFileName); } catch(err){}
|
|
76
|
+
|
|
77
|
+
console.log(chalk.green(`\n β
Interaction updated. Replaying now...\n`));
|
|
78
|
+
|
|
79
|
+
// Step 6: Trigger replay
|
|
80
|
+
await replayInteraction(id);
|
|
81
|
+
|
|
82
|
+
} catch (error) {
|
|
83
|
+
console.error(chalk.red(`\n β Edit Failed: ${error.message}\n`));
|
|
84
|
+
}
|
|
85
|
+
}
|
package/src/executor.js
CHANGED
|
@@ -4,7 +4,7 @@ import ora from 'ora';
|
|
|
4
4
|
import { saveInteraction, loadEnv, saveEnv } from './storage.js';
|
|
5
5
|
import { formatResponse } from './formatter.js';
|
|
6
6
|
|
|
7
|
-
function applyEnvReplacements(data, envVars, usedKeys = null) {
|
|
7
|
+
export function applyEnvReplacements(data, envVars, usedKeys = null) {
|
|
8
8
|
if (typeof data === 'string') {
|
|
9
9
|
return data.replace(/\{\{(.+?)\}\}/g, (match, key) => {
|
|
10
10
|
if (envVars[key] !== undefined) {
|
|
@@ -89,6 +89,12 @@ export async function executeRequest(method, url, options = {}) {
|
|
|
89
89
|
// Let's at least strip the drive letter root
|
|
90
90
|
pathPart = url.replace(/^[a-zA-Z]:/, '').replace(/\\/g, '/');
|
|
91
91
|
if (!pathPart.startsWith('/')) pathPart = '/' + pathPart;
|
|
92
|
+
|
|
93
|
+
// Hard fix for Git Bash root expansion "C:/Program Files/Git/" -> "/"
|
|
94
|
+
const lowerPath = pathPart.toLowerCase();
|
|
95
|
+
if (lowerPath === '/program files/git/' || lowerPath === '/program files/git') {
|
|
96
|
+
pathPart = '/';
|
|
97
|
+
}
|
|
92
98
|
}
|
|
93
99
|
}
|
|
94
100
|
// 3. No leading slash but doesn't look like a host (no dots, no protocol)
|
|
@@ -211,6 +217,7 @@ export async function executeRequest(method, url, options = {}) {
|
|
|
211
217
|
|
|
212
218
|
console.log(chalk.gray('\n πΎ Interaction saved:'), chalk.white(interactionId));
|
|
213
219
|
|
|
220
|
+
return response;
|
|
214
221
|
} catch (error) {
|
|
215
222
|
const duration = Date.now() - startTime;
|
|
216
223
|
spinner.fail(chalk.red('Request failed'));
|
package/src/export.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { writeFileSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import { getAllInteractions } from './storage.js';
|
|
5
|
+
|
|
6
|
+
export function exportToPostman(outFileName) {
|
|
7
|
+
try {
|
|
8
|
+
const interactions = getAllInteractions();
|
|
9
|
+
|
|
10
|
+
if (interactions.length === 0) {
|
|
11
|
+
console.log(chalk.yellow('\n β οΈ No interactions found to export.'));
|
|
12
|
+
console.log(chalk.gray(' Run some requests first before exporting.\n'));
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const postmanCollection = {
|
|
17
|
+
info: {
|
|
18
|
+
name: `Kiroo Export - ${new Date().toISOString().split('T')[0]}`,
|
|
19
|
+
schema: "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
|
20
|
+
},
|
|
21
|
+
item: interactions.map(int => {
|
|
22
|
+
// Map Headers
|
|
23
|
+
const headerList = Object.entries(int.request.headers || {}).map(([key, value]) => ({
|
|
24
|
+
key,
|
|
25
|
+
value: value.toString(),
|
|
26
|
+
type: "text"
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
// Format body
|
|
30
|
+
let rawBody = '';
|
|
31
|
+
if (int.request.body) {
|
|
32
|
+
rawBody = typeof int.request.body === 'object'
|
|
33
|
+
? JSON.stringify(int.request.body, null, 2)
|
|
34
|
+
: int.request.body.toString();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Response Body
|
|
38
|
+
let resBodyStr = '';
|
|
39
|
+
if (int.response.body) {
|
|
40
|
+
resBodyStr = typeof int.response.body === 'object'
|
|
41
|
+
? JSON.stringify(int.response.body, null, 2)
|
|
42
|
+
: int.response.body.toString();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
name: `[${int.request.method}] ${int.request.url}`,
|
|
47
|
+
request: {
|
|
48
|
+
method: int.request.method.toUpperCase(),
|
|
49
|
+
header: headerList,
|
|
50
|
+
url: {
|
|
51
|
+
raw: int.request.url
|
|
52
|
+
},
|
|
53
|
+
...(rawBody ? {
|
|
54
|
+
body: {
|
|
55
|
+
mode: "raw",
|
|
56
|
+
raw: rawBody,
|
|
57
|
+
options: {
|
|
58
|
+
raw: { language: "json" }
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
} : {})
|
|
62
|
+
},
|
|
63
|
+
response: [
|
|
64
|
+
{
|
|
65
|
+
name: "Saved Example from Kiroo",
|
|
66
|
+
originalRequest: {
|
|
67
|
+
method: int.request.method.toUpperCase(),
|
|
68
|
+
header: headerList,
|
|
69
|
+
url: { raw: int.request.url }
|
|
70
|
+
},
|
|
71
|
+
status: "Saved Response",
|
|
72
|
+
code: int.response.status,
|
|
73
|
+
_postman_previewlanguage: "json",
|
|
74
|
+
header: [],
|
|
75
|
+
cookie: [],
|
|
76
|
+
body: resBodyStr
|
|
77
|
+
}
|
|
78
|
+
]
|
|
79
|
+
};
|
|
80
|
+
})
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const outputPath = join(process.cwd(), outFileName);
|
|
84
|
+
writeFileSync(outputPath, JSON.stringify(postmanCollection, null, 2));
|
|
85
|
+
|
|
86
|
+
console.log(chalk.green(`\n β
Collection exported successfully!`));
|
|
87
|
+
console.log(chalk.gray(` Saved to: ${outputPath}`));
|
|
88
|
+
console.log(chalk.magenta(` You can now import this file directly into Postman/Insomnia.\n`));
|
|
89
|
+
|
|
90
|
+
} catch (error) {
|
|
91
|
+
console.error(chalk.red('\n β Export failed:'), error.message, '\n');
|
|
92
|
+
}
|
|
93
|
+
}
|
package/src/lingo.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
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
|
+
}
|
package/src/replay.js
CHANGED
|
@@ -5,13 +5,26 @@ import { getAllInteractions, loadInteraction } from './storage.js';
|
|
|
5
5
|
import { formatResponse } from './formatter.js';
|
|
6
6
|
|
|
7
7
|
export async function listInteractions(options) {
|
|
8
|
-
|
|
8
|
+
let interactions = getAllInteractions();
|
|
9
9
|
const limit = parseInt(options.limit) || 10;
|
|
10
10
|
const offset = parseInt(options.offset) || 0;
|
|
11
11
|
|
|
12
|
+
// Apply Filters
|
|
13
|
+
if (options.date) {
|
|
14
|
+
interactions = interactions.filter(int => int.id.startsWith(options.date));
|
|
15
|
+
}
|
|
16
|
+
if (options.url) {
|
|
17
|
+
interactions = interactions.filter(int => int.request.url.toLowerCase().includes(options.url.toLowerCase()));
|
|
18
|
+
}
|
|
19
|
+
if (options.status) {
|
|
20
|
+
interactions = interactions.filter(int => String(int.response.status) === String(options.status));
|
|
21
|
+
}
|
|
22
|
+
|
|
12
23
|
if (interactions.length === 0) {
|
|
13
|
-
console.log(chalk.yellow('\n No interactions found.'));
|
|
14
|
-
|
|
24
|
+
console.log(chalk.yellow('\n No matching interactions found.'));
|
|
25
|
+
if (!options.date && !options.url && !options.status) {
|
|
26
|
+
console.log(chalk.gray(' Run a request first: '), chalk.white('kiroo POST https://api.example.com/endpoint\n'));
|
|
27
|
+
}
|
|
15
28
|
return;
|
|
16
29
|
}
|
|
17
30
|
|
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,12 +50,15 @@ 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
63
|
const results = [];
|
|
60
64
|
let breakingChanges = 0;
|
|
@@ -93,18 +97,62 @@ export async function compareSnapshots(tag1, tag2) {
|
|
|
93
97
|
breakingChanges++;
|
|
94
98
|
}
|
|
95
99
|
|
|
96
|
-
//
|
|
97
|
-
|
|
98
|
-
const
|
|
99
|
-
const keys2 = Object.keys(int2.response.body);
|
|
100
|
+
// Helper for deep structural comparison
|
|
101
|
+
const deepCompare = (val1, val2, path = '') => {
|
|
102
|
+
const changes = [];
|
|
100
103
|
|
|
101
|
-
|
|
102
|
-
if (
|
|
103
|
-
|
|
104
|
-
|
|
104
|
+
// Handle nulls
|
|
105
|
+
if (val1 === null && val2 !== null) return [{ path, msg: `type changed from null to ${typeof val2}`, breaking: false }];
|
|
106
|
+
if (val1 !== null && val2 === null) return [{ path, msg: `type changed from ${typeof val1} to null`, breaking: false }];
|
|
107
|
+
if (val1 === null && val2 === null) return changes;
|
|
108
|
+
|
|
109
|
+
const type1 = Array.isArray(val1) ? 'array' : typeof val1;
|
|
110
|
+
const type2 = Array.isArray(val2) ? 'array' : typeof val2;
|
|
111
|
+
|
|
112
|
+
if (type1 !== type2) {
|
|
113
|
+
changes.push({ path, msg: `type changed from ${chalk.yellow(type1)} to ${chalk.yellow(type2)}`, breaking: true });
|
|
114
|
+
return changes;
|
|
105
115
|
}
|
|
106
|
-
|
|
107
|
-
|
|
116
|
+
|
|
117
|
+
if (type1 === 'object') {
|
|
118
|
+
const keys1 = Object.keys(val1);
|
|
119
|
+
const keys2 = Object.keys(val2);
|
|
120
|
+
|
|
121
|
+
// Check for removed keys (Breaking)
|
|
122
|
+
for (const k of keys1) {
|
|
123
|
+
const currentPath = path ? `${path}.${k}` : k;
|
|
124
|
+
if (!keys2.includes(k)) {
|
|
125
|
+
changes.push({ path: currentPath, msg: `was ${chalk.red('removed')}`, breaking: true });
|
|
126
|
+
} else {
|
|
127
|
+
changes.push(...deepCompare(val1[k], val2[k], currentPath));
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Check for added keys (Non-breaking)
|
|
132
|
+
for (const k of keys2) {
|
|
133
|
+
const currentPath = path ? `${path}.${k}` : k;
|
|
134
|
+
if (!keys1.includes(k)) {
|
|
135
|
+
changes.push({ path: currentPath, msg: `was ${chalk.green('added')}`, breaking: false });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
} else if (type1 === 'array') {
|
|
139
|
+
// Array structure validation (check first item schema only if exists)
|
|
140
|
+
if (val1.length > 0 && val2.length > 0) {
|
|
141
|
+
const itemPath = path ? `${path}[0]` : '[0]';
|
|
142
|
+
changes.push(...deepCompare(val1[0], val2[0], itemPath));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return changes;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
if (int1.response.body !== undefined && int2.response.body !== undefined) {
|
|
150
|
+
const structuralChanges = deepCompare(int1.response.body, int2.response.body);
|
|
151
|
+
for (const change of structuralChanges) {
|
|
152
|
+
diffs.push(`${chalk.cyan(change.path || 'root')} ${change.msg}`);
|
|
153
|
+
if (change.breaking) breakingChanges++;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
108
156
|
if (diffs.length > 0) {
|
|
109
157
|
results.push({
|
|
110
158
|
type: 'CHANGE',
|
|
@@ -116,19 +164,36 @@ export async function compareSnapshots(tag1, tag2) {
|
|
|
116
164
|
});
|
|
117
165
|
|
|
118
166
|
if (results.length === 0) {
|
|
119
|
-
|
|
167
|
+
let finalMsg = 'No differences detected. Your API is stable!';
|
|
168
|
+
if (lang) finalMsg = await translateText(finalMsg, lang);
|
|
169
|
+
console.log(chalk.green(`\n β
${finalMsg}\n`));
|
|
120
170
|
} else {
|
|
121
171
|
console.log('');
|
|
122
|
-
|
|
172
|
+
|
|
173
|
+
for (const res of results) {
|
|
174
|
+
let printMsg = res.msg;
|
|
175
|
+
if (lang) {
|
|
176
|
+
// Basic translation hook for individual diff items (stripping ansi)
|
|
177
|
+
const cleanMsg = printMsg.replace(/\x1B\[[0-9;]*m/g, '');
|
|
178
|
+
const translatedMsg = await translateText(cleanMsg, lang);
|
|
179
|
+
printMsg = chalk.yellow('[Translated] ') + translatedMsg;
|
|
180
|
+
}
|
|
181
|
+
|
|
123
182
|
const symbol = res.type === 'NEW' ? chalk.blue('+') : chalk.yellow('β οΈ');
|
|
124
183
|
console.log(` ${symbol} ${chalk.white(res.method)} ${chalk.gray(res.url)}`);
|
|
125
|
-
console.log(` ${
|
|
126
|
-
}
|
|
184
|
+
console.log(` ${printMsg}`);
|
|
185
|
+
}
|
|
127
186
|
|
|
187
|
+
let alertMsg = breakingChanges > 0
|
|
188
|
+
? `Detected ${breakingChanges} potential breaking changes!`
|
|
189
|
+
: `Non-breaking changes detected.`;
|
|
190
|
+
|
|
191
|
+
if (lang) alertMsg = await translateText(alertMsg, lang);
|
|
192
|
+
|
|
128
193
|
if (breakingChanges > 0) {
|
|
129
|
-
console.log(chalk.red(`\n π¨
|
|
194
|
+
console.log(chalk.red(`\n π¨ ${alertMsg}\n`));
|
|
130
195
|
} else {
|
|
131
|
-
console.log(chalk.blue(
|
|
196
|
+
console.log(chalk.blue(`\n βΉοΈ ${alertMsg}\n`));
|
|
132
197
|
}
|
|
133
198
|
}
|
|
134
199
|
|
package/src/storage.js
CHANGED
|
@@ -19,6 +19,11 @@ export function ensureKirooDir() {
|
|
|
19
19
|
if (!existsSync(ENV_FILE)) {
|
|
20
20
|
writeFileSync(ENV_FILE, JSON.stringify({ current: 'default', environments: { default: {} } }, null, 2));
|
|
21
21
|
}
|
|
22
|
+
|
|
23
|
+
const GITIGNORE_FILE = join(KIROO_DIR, '.gitignore');
|
|
24
|
+
if (!existsSync(GITIGNORE_FILE)) {
|
|
25
|
+
writeFileSync(GITIGNORE_FILE, 'env.json\n');
|
|
26
|
+
}
|
|
22
27
|
}
|
|
23
28
|
|
|
24
29
|
export async function saveInteraction(interaction) {
|