@modelriver/cli 1.2.3 → 1.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +71 -7
- package/package.json +4 -2
- package/src/commands/callback.js +81 -0
- package/src/commands/listen.js +59 -36
- package/src/commands/login.js +3 -2
- package/src/commands/trigger.js +12 -0
- package/src/commands/websocket.js +59 -5
- package/src/index.js +24 -5
- package/src/lib/api-client.js +22 -0
- package/src/lib/websocket-client.js +3 -3
- package/src/utils/webhook-payload.js +160 -0
- package/src/lib/api-client.test.js +0 -101
- package/src/lib/config.test.js +0 -98
- package/src/lib/webhook-verifier.test.js +0 -90
- package/src/utils/formatter.test.js +0 -84
- package/src/utils/url-helpers.test.js +0 -144
package/README.md
CHANGED
|
@@ -30,9 +30,10 @@ modelriver login
|
|
|
30
30
|
|
|
31
31
|
You'll be prompted for:
|
|
32
32
|
1. **API Key**: Your ModelRiver API key (starts with `mr_live_` or `mr_test_`)
|
|
33
|
-
2. **Forward URL**: Where to forward webhooks (e.g., `http://localhost:
|
|
33
|
+
2. **Forward URL**: Where to forward webhooks on your machine (e.g., `http://localhost:3001/webhook/modelriver`)
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
This is your local webhook receiver (CLI default port **3001**), not the ModelRiver API port (**4000**).
|
|
36
|
+
The URL will be automatically normalized to include `/webhook/modelriver` if needed.
|
|
36
37
|
|
|
37
38
|
### Step 2: Start Forwarding
|
|
38
39
|
|
|
@@ -54,6 +55,7 @@ For convenience, the CLI supports short aliases:
|
|
|
54
55
|
| `forward` | `f` | Forward using saved config |
|
|
55
56
|
| `trigger` | `t` | Send async request |
|
|
56
57
|
| `websocket` | `ws` | Test WebSocket connection |
|
|
58
|
+
| `callback` | - | Complete event-driven callback |
|
|
57
59
|
| `test-webhook` | - | Test webhook delivery end-to-end |
|
|
58
60
|
|
|
59
61
|
Example:
|
|
@@ -90,7 +92,7 @@ Create a config file at `~/.modelriver/config.json` or `.modelriverrc` in your p
|
|
|
90
92
|
{
|
|
91
93
|
"api_key": "mr_live_YOUR_API_KEY",
|
|
92
94
|
"api_url": "https://api.modelriver.com",
|
|
93
|
-
"forward_url": "http://localhost:
|
|
95
|
+
"forward_url": "http://localhost:3001/webhook/modelriver"
|
|
94
96
|
}
|
|
95
97
|
```
|
|
96
98
|
|
|
@@ -113,14 +115,14 @@ ModelRiver CLI Login
|
|
|
113
115
|
Configure your ModelRiver CLI credentials.
|
|
114
116
|
|
|
115
117
|
Enter your ModelRiver API key: mr_live_abc123...
|
|
116
|
-
Enter your webhook forward URL (e.g. http://localhost:
|
|
118
|
+
Enter your webhook forward URL (e.g. http://localhost:3001/webhook/modelriver): http://localhost:3001/webhook/modelriver
|
|
117
119
|
|
|
118
120
|
✓ Configuration saved!
|
|
119
121
|
|
|
120
122
|
Saved Configuration
|
|
121
123
|
───────────────────
|
|
122
124
|
✓ API Key: mr_live_abc123...
|
|
123
|
-
✓ Forward URL: http://localhost:
|
|
125
|
+
✓ Forward URL: http://localhost:3001/webhook/modelriver
|
|
124
126
|
```
|
|
125
127
|
|
|
126
128
|
### `modelriver forward` - Quick Webhook Forwarding
|
|
@@ -183,6 +185,38 @@ modelriver listen --port 3002 --forward --print
|
|
|
183
185
|
Data: {"result": "..."}
|
|
184
186
|
```
|
|
185
187
|
|
|
188
|
+
**Event-driven example:**
|
|
189
|
+
```
|
|
190
|
+
[timestamp] Webhook received via WebSocket:
|
|
191
|
+
Channel ID: abc-123-def
|
|
192
|
+
Status: ai_generated (callback required)
|
|
193
|
+
Type: task.ai_generated
|
|
194
|
+
Event: schema_result_ready
|
|
195
|
+
Callback URL: https://api.modelriver.com/v1/callback/abc-123-def
|
|
196
|
+
Customer data: { "ticket_id": "TCK-10042" }
|
|
197
|
+
AI response: { ... }
|
|
198
|
+
|
|
199
|
+
> Event-driven webhook — complete the flow with a callback:
|
|
200
|
+
> modelriver callback --channel-id abc-123-def --data '{"summary":"..."}'
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
### `modelriver callback` - Complete Event-driven Flows
|
|
204
|
+
|
|
205
|
+
After `listen` receives a `task.ai_generated` webhook, complete the flow:
|
|
206
|
+
|
|
207
|
+
```bash
|
|
208
|
+
# Using channel ID
|
|
209
|
+
modelriver callback \
|
|
210
|
+
--channel-id abc-123-def \
|
|
211
|
+
--data '{"summary":"Escalated to auth","priority":"high"}' \
|
|
212
|
+
--task-id TCK-10042
|
|
213
|
+
|
|
214
|
+
# Using full callback URL from the webhook
|
|
215
|
+
modelriver callback \
|
|
216
|
+
--callback-url "https://api.modelriver.com/v1/callback/abc-123-def" \
|
|
217
|
+
--data '{"summary":"Escalated to auth"}'
|
|
218
|
+
```
|
|
219
|
+
|
|
186
220
|
### `modelriver websocket` - Test WebSocket Connection
|
|
187
221
|
|
|
188
222
|
Test WebSocket connections to production and receive real-time responses.
|
|
@@ -231,8 +265,8 @@ Send a test async request and get channel details.
|
|
|
231
265
|
# Basic trigger
|
|
232
266
|
modelriver trigger --workflow my-workflow --message "Test message"
|
|
233
267
|
|
|
234
|
-
# With custom payload
|
|
235
|
-
modelriver trigger --workflow my-workflow --payload '{"messages": [...]}'
|
|
268
|
+
# With custom payload (workflow can be in --payload or via --workflow)
|
|
269
|
+
modelriver trigger --workflow my-workflow --payload '{"messages": [...], "metadata": {"ticket_id":"TCK-1"}}'
|
|
236
270
|
|
|
237
271
|
# Create webhook to receive response
|
|
238
272
|
modelriver trigger --workflow my-workflow --message "Test" --webhook-url https://webhook.site/your-id
|
|
@@ -241,6 +275,17 @@ modelriver trigger --workflow my-workflow --message "Test" --webhook-url https:/
|
|
|
241
275
|
modelriver trigger --workflow my-workflow --message "Test" --print-channel
|
|
242
276
|
```
|
|
243
277
|
|
|
278
|
+
For **event-driven** workflows, after triggering:
|
|
279
|
+
|
|
280
|
+
```bash
|
|
281
|
+
# Terminal 1
|
|
282
|
+
modelriver listen --print
|
|
283
|
+
|
|
284
|
+
# Terminal 2
|
|
285
|
+
modelriver trigger --workflow my-event-workflow --message "Test" --print-channel
|
|
286
|
+
modelriver callback --channel-id CHANNEL_ID --data '{"enriched":true}'
|
|
287
|
+
```
|
|
288
|
+
|
|
244
289
|
**Example Output:**
|
|
245
290
|
```
|
|
246
291
|
✓ Async request created
|
|
@@ -344,6 +389,23 @@ export MODELRIVER_API_KEY=mr_live_YOUR_KEY
|
|
|
344
389
|
modelriver trigger --workflow my-workflow --message "Test" --print-channel
|
|
345
390
|
```
|
|
346
391
|
|
|
392
|
+
### Example 4: Event-driven Workflow End-to-End
|
|
393
|
+
|
|
394
|
+
```bash
|
|
395
|
+
# Terminal 1: Listen for webhooks
|
|
396
|
+
export MODELRIVER_API_KEY=mr_live_YOUR_KEY
|
|
397
|
+
modelriver listen --print
|
|
398
|
+
|
|
399
|
+
# Terminal 2: Trigger + callback
|
|
400
|
+
modelriver trigger \
|
|
401
|
+
--workflow my-event-workflow \
|
|
402
|
+
--payload '{"messages":[{"role":"user","content":"Test"}],"metadata":{"ticket_id":"TCK-1"}}' \
|
|
403
|
+
--print-channel
|
|
404
|
+
|
|
405
|
+
# After Terminal 1 shows task.ai_generated:
|
|
406
|
+
modelriver callback --channel-id CHANNEL_ID --data '{"summary":"Done"}'
|
|
407
|
+
```
|
|
408
|
+
|
|
347
409
|
## Webhook Payload Format
|
|
348
410
|
|
|
349
411
|
Webhooks are sent as HTTP POST requests with the following format:
|
|
@@ -410,6 +472,7 @@ The CLI uses these ModelRiver API endpoints:
|
|
|
410
472
|
- `POST /v1/webhooks` - Create a new webhook
|
|
411
473
|
- `DELETE /v1/webhooks/:id` - Delete a webhook
|
|
412
474
|
- `POST /v1/cli/connect` - Connect CLI and get WebSocket token
|
|
475
|
+
- `POST /v1/callback/:channel_id` - Complete event-driven callback
|
|
413
476
|
|
|
414
477
|
**Development** (localhost or custom API URL):
|
|
415
478
|
- Same endpoints but with `/api/v1` prefix instead of `/v1`
|
|
@@ -429,6 +492,7 @@ Tests cover critical components including:
|
|
|
429
492
|
- Configuration loading
|
|
430
493
|
- API client validation
|
|
431
494
|
- Output formatting
|
|
495
|
+
- Event-driven webhook payload helpers
|
|
432
496
|
|
|
433
497
|
|
|
434
498
|
## License
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@modelriver/cli",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.5",
|
|
4
4
|
"description": "ModelRiver CLI for testing webhooks and WebSockets from production",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -47,6 +47,8 @@
|
|
|
47
47
|
"files": [
|
|
48
48
|
"bin/",
|
|
49
49
|
"src/",
|
|
50
|
-
"README.md"
|
|
50
|
+
"README.md",
|
|
51
|
+
"!src/**/*.test.js",
|
|
52
|
+
"!**/*.test.js"
|
|
51
53
|
]
|
|
52
54
|
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
const ApiClient = require('../lib/api-client');
|
|
2
|
+
const Logger = require('../utils/logger');
|
|
3
|
+
const Formatter = require('../utils/formatter');
|
|
4
|
+
|
|
5
|
+
async function callbackCommand(options) {
|
|
6
|
+
const {
|
|
7
|
+
channelId,
|
|
8
|
+
callbackUrl,
|
|
9
|
+
data,
|
|
10
|
+
taskId,
|
|
11
|
+
metadata,
|
|
12
|
+
payload,
|
|
13
|
+
apiKey,
|
|
14
|
+
apiUrl,
|
|
15
|
+
} = options;
|
|
16
|
+
|
|
17
|
+
if (!callbackUrl && !channelId) {
|
|
18
|
+
Logger.error('Error: --channel-id or --callback-url is required');
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let body;
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
if (payload) {
|
|
26
|
+
body = JSON.parse(payload);
|
|
27
|
+
} else {
|
|
28
|
+
body = {};
|
|
29
|
+
|
|
30
|
+
if (data) {
|
|
31
|
+
body.data = typeof data === 'string' ? JSON.parse(data) : data;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (taskId) {
|
|
35
|
+
body.task_id = taskId;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (metadata) {
|
|
39
|
+
body.metadata =
|
|
40
|
+
typeof metadata === 'string' ? JSON.parse(metadata) : metadata;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
} catch (error) {
|
|
44
|
+
Logger.error(`Invalid JSON: ${error.message}`);
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (body.data !== undefined && (typeof body.data !== 'object' || Array.isArray(body.data))) {
|
|
49
|
+
Logger.error('Error: "data" must be a JSON object');
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const apiClient = new ApiClient(apiKey, apiUrl);
|
|
55
|
+
const targetUrl =
|
|
56
|
+
callbackUrl ||
|
|
57
|
+
`${apiClient.apiUrl}${apiClient.apiPath}/callback/${channelId}`;
|
|
58
|
+
|
|
59
|
+
const spinner = Logger.spinner(`Sending callback to ${targetUrl}...`);
|
|
60
|
+
spinner.start();
|
|
61
|
+
|
|
62
|
+
const response = await apiClient.sendCallback(targetUrl, body);
|
|
63
|
+
|
|
64
|
+
if (response.status >= 400) {
|
|
65
|
+
spinner.fail('Callback failed');
|
|
66
|
+
Logger.error(response.data?.error || response.data?.message || `HTTP ${response.status}`);
|
|
67
|
+
if (response.data) {
|
|
68
|
+
console.log(Formatter.json(response.data));
|
|
69
|
+
}
|
|
70
|
+
process.exit(1);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
spinner.succeed('Callback accepted');
|
|
74
|
+
console.log(Formatter.json(response.data));
|
|
75
|
+
} catch (error) {
|
|
76
|
+
Logger.error(error.message);
|
|
77
|
+
process.exit(1);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
module.exports = { callbackCommand };
|
package/src/commands/listen.js
CHANGED
|
@@ -5,6 +5,10 @@ const WebhookVerifier = require('../lib/webhook-verifier');
|
|
|
5
5
|
const Logger = require('../utils/logger');
|
|
6
6
|
const Formatter = require('../utils/formatter');
|
|
7
7
|
const { parseForwardUrl } = require('../utils/url-helpers');
|
|
8
|
+
const {
|
|
9
|
+
printWebhookDetails,
|
|
10
|
+
resolveCallbackUrl,
|
|
11
|
+
} = require('../utils/webhook-payload');
|
|
8
12
|
|
|
9
13
|
async function listenCommand(options) {
|
|
10
14
|
let { apiKey, apiUrl, print, port, verbose, forward, forwardUrl } = options;
|
|
@@ -68,7 +72,7 @@ async function listenCommand(options) {
|
|
|
68
72
|
|
|
69
73
|
// Debug: Show API key info if verbose
|
|
70
74
|
if (verbose) {
|
|
71
|
-
Logger.info(`> API URL: ${apiUrl}`);
|
|
75
|
+
Logger.info(`> API URL: ${apiClient.apiUrl}`);
|
|
72
76
|
Logger.info(`> API Path: ${apiClient.apiPath}`);
|
|
73
77
|
Logger.info(`> API Key (first 30 chars): ${apiKey.substring(0, 30)}...`);
|
|
74
78
|
}
|
|
@@ -152,20 +156,27 @@ async function listenCommand(options) {
|
|
|
152
156
|
);
|
|
153
157
|
}
|
|
154
158
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
159
|
+
// Skip printing when this request came from our own WebSocket forwarder
|
|
160
|
+
// (already printed in the WebSocket handler). Still print external POSTs.
|
|
161
|
+
const isInternalForward = req.headers['x-modelriver-cli-internal'] === '1';
|
|
162
|
+
if (print && !isInternalForward) {
|
|
163
|
+
printWebhookDetails(
|
|
164
|
+
{
|
|
165
|
+
channel_id,
|
|
166
|
+
data,
|
|
167
|
+
callback_url: req.body.callback_url || resolveCallbackUrl(req.body),
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
Logger,
|
|
171
|
+
Formatter,
|
|
172
|
+
WebhookVerifier,
|
|
173
|
+
webhookSecret,
|
|
174
|
+
signature,
|
|
175
|
+
timestamp: payloadTimestamp || timestamp,
|
|
176
|
+
apiKey,
|
|
177
|
+
label: 'Webhook received (local server)',
|
|
178
|
+
}
|
|
179
|
+
);
|
|
169
180
|
}
|
|
170
181
|
|
|
171
182
|
// Always return 200 to acknowledge receipt
|
|
@@ -175,6 +186,25 @@ async function listenCommand(options) {
|
|
|
175
186
|
server = app.listen(localPort, 'localhost', () => {
|
|
176
187
|
Logger.success(`> Local server listening on http://localhost:${localPort}/webhook`);
|
|
177
188
|
});
|
|
189
|
+
|
|
190
|
+
server.on('error', (error) => {
|
|
191
|
+
if (error.code === 'EADDRINUSE') {
|
|
192
|
+
spinner.fail(`Port ${localPort} is already in use`);
|
|
193
|
+
Logger.error(`Another process is listening on port ${localPort}.`);
|
|
194
|
+
Logger.warning(`> Use a different port: modelriver listen --print --port ${Number(localPort) + 1}`);
|
|
195
|
+
Logger.warning('> Or stop the other process and try again');
|
|
196
|
+
if (wsClient) {
|
|
197
|
+
wsClient.close();
|
|
198
|
+
}
|
|
199
|
+
process.exit(1);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
Logger.error(`Local server error: ${error.message}`);
|
|
203
|
+
if (wsClient) {
|
|
204
|
+
wsClient.close();
|
|
205
|
+
}
|
|
206
|
+
process.exit(1);
|
|
207
|
+
});
|
|
178
208
|
} else {
|
|
179
209
|
Logger.success(`> Forwarding webhooks to http://localhost:${localPort}/webhook`);
|
|
180
210
|
}
|
|
@@ -184,25 +214,16 @@ async function listenCommand(options) {
|
|
|
184
214
|
const { channel_id, timestamp, data, signature } = webhookPayload;
|
|
185
215
|
|
|
186
216
|
if (print) {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
);
|
|
198
|
-
Logger.log(` Signature: ${isValid ? '✓ Valid' : '✗ Invalid'}`);
|
|
199
|
-
}
|
|
200
|
-
if (data?.data) {
|
|
201
|
-
Logger.log(` Data: ${Formatter.json(data.data)}`);
|
|
202
|
-
}
|
|
203
|
-
if (data?.meta) {
|
|
204
|
-
Logger.log(` Meta: ${Formatter.json(data.meta)}`);
|
|
205
|
-
}
|
|
217
|
+
printWebhookDetails(webhookPayload, {
|
|
218
|
+
Logger,
|
|
219
|
+
Formatter,
|
|
220
|
+
WebhookVerifier,
|
|
221
|
+
webhookSecret,
|
|
222
|
+
signature,
|
|
223
|
+
timestamp,
|
|
224
|
+
apiKey,
|
|
225
|
+
label: 'Webhook received via WebSocket',
|
|
226
|
+
});
|
|
206
227
|
}
|
|
207
228
|
|
|
208
229
|
// Forward to local server
|
|
@@ -211,7 +232,8 @@ async function listenCommand(options) {
|
|
|
211
232
|
const postData = JSON.stringify({
|
|
212
233
|
channel_id,
|
|
213
234
|
timestamp,
|
|
214
|
-
|
|
235
|
+
callback_url: resolveCallbackUrl(webhookPayload),
|
|
236
|
+
data,
|
|
215
237
|
});
|
|
216
238
|
|
|
217
239
|
// Parse the forward URL or use localhost defaults
|
|
@@ -232,7 +254,8 @@ async function listenCommand(options) {
|
|
|
232
254
|
'Content-Length': Buffer.byteLength(postData),
|
|
233
255
|
'X-ModelRiver-Signature': signature || '',
|
|
234
256
|
'X-ModelRiver-Timestamp': timestamp.toString(),
|
|
235
|
-
'X-ModelRiver-Webhook-Id': webhookPayload.webhook_id || ''
|
|
257
|
+
'X-ModelRiver-Webhook-Id': webhookPayload.webhook_id || '',
|
|
258
|
+
'X-ModelRiver-CLI-Internal': '1',
|
|
236
259
|
}
|
|
237
260
|
};
|
|
238
261
|
|
package/src/commands/login.js
CHANGED
|
@@ -114,10 +114,11 @@ async function loginCommand(options) {
|
|
|
114
114
|
}
|
|
115
115
|
|
|
116
116
|
// Prompt for forward URL
|
|
117
|
-
|
|
117
|
+
// Default to the CLI local receiver port (3001), not ModelRiver API (4000).
|
|
118
|
+
const defaultForwardUrl = currentForwardUrl || 'http://localhost:3001/webhook/modelriver';
|
|
118
119
|
let forwardUrl = await prompt(
|
|
119
120
|
rl,
|
|
120
|
-
'Enter your webhook forward URL (e.g. http://localhost:
|
|
121
|
+
'Enter your webhook forward URL (e.g. http://localhost:3001/webhook/modelriver)',
|
|
121
122
|
defaultForwardUrl,
|
|
122
123
|
false, // maskDefault
|
|
123
124
|
false // showDefault - Hide the (http://...) bracket as requested
|
package/src/commands/trigger.js
CHANGED
|
@@ -23,6 +23,17 @@ async function triggerCommand(options) {
|
|
|
23
23
|
messages: [{ role: 'user', content: message || 'Test from CLI' }]
|
|
24
24
|
};
|
|
25
25
|
|
|
26
|
+
// Allow --workflow with --payload when payload omits workflow
|
|
27
|
+
if (workflow && !requestPayload.workflow) {
|
|
28
|
+
requestPayload.workflow = workflow;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (!requestPayload.workflow) {
|
|
32
|
+
spinner.fail('Workflow required');
|
|
33
|
+
Logger.error('Provide --workflow or include "workflow" in --payload JSON');
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
|
|
26
37
|
const response = await apiClient.createAsyncRequest(requestPayload);
|
|
27
38
|
|
|
28
39
|
spinner.succeed('Async request created');
|
|
@@ -61,6 +72,7 @@ async function triggerCommand(options) {
|
|
|
61
72
|
} else {
|
|
62
73
|
Logger.info('\n> Use --webhook-url to automatically receive responses via webhook');
|
|
63
74
|
Logger.info('> Or use "modelriver websocket" to connect and receive responses');
|
|
75
|
+
Logger.info('> For event-driven workflows, complete with: modelriver callback --channel-id <id> --data \'{...}\'');
|
|
64
76
|
}
|
|
65
77
|
|
|
66
78
|
} catch (error) {
|
|
@@ -3,6 +3,11 @@ const WebSocketClient = require('../lib/websocket-client');
|
|
|
3
3
|
const config = require('../lib/config');
|
|
4
4
|
const Logger = require('../utils/logger');
|
|
5
5
|
const Formatter = require('../utils/formatter');
|
|
6
|
+
const {
|
|
7
|
+
buildCallbackHint,
|
|
8
|
+
isFinalWebSocketStatus,
|
|
9
|
+
isIntermediateWebSocketStatus,
|
|
10
|
+
} = require('../utils/webhook-payload');
|
|
6
11
|
|
|
7
12
|
/**
|
|
8
13
|
* Normalize WebSocket URL for production environments
|
|
@@ -74,9 +79,20 @@ async function websocketCommand(options) {
|
|
|
74
79
|
messages: [{ role: 'user', content: message || 'Test from CLI' }]
|
|
75
80
|
};
|
|
76
81
|
|
|
82
|
+
// Allow --workflow with --payload when payload omits workflow
|
|
83
|
+
if (workflow && !requestPayload.workflow) {
|
|
84
|
+
requestPayload.workflow = workflow;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (!requestPayload.workflow) {
|
|
88
|
+
spinner.fail('Workflow required');
|
|
89
|
+
Logger.error('Provide --workflow or include "workflow" in --payload JSON');
|
|
90
|
+
process.exit(1);
|
|
91
|
+
}
|
|
92
|
+
|
|
77
93
|
if (verbose) {
|
|
78
94
|
Logger.info(`\n> API URL: ${apiClient.apiUrl}`);
|
|
79
|
-
Logger.info(`> Workflow: ${workflow}`);
|
|
95
|
+
Logger.info(`> Workflow: ${requestPayload.workflow}`);
|
|
80
96
|
Logger.info(`> Payload: ${JSON.stringify(requestPayload, null, 2)}`);
|
|
81
97
|
}
|
|
82
98
|
|
|
@@ -194,21 +210,59 @@ async function websocketCommand(options) {
|
|
|
194
210
|
|
|
195
211
|
// Set up response listener BEFORE waiting (to catch responses that arrive quickly)
|
|
196
212
|
let responseReceived = false;
|
|
213
|
+
let sawIntermediateEvent = false;
|
|
197
214
|
|
|
198
215
|
// Timeout after 5 minutes
|
|
199
216
|
const timeoutId = setTimeout(() => {
|
|
200
217
|
if (!responseReceived) {
|
|
201
218
|
Logger.error('\n❌ Timeout: No response received after 5 minutes');
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
219
|
+
if (sawIntermediateEvent) {
|
|
220
|
+
Logger.error(' Event-driven flow is waiting for a backend callback.');
|
|
221
|
+
Logger.error(` Complete it with: modelriver callback --channel-id ${channelIdToUse} --data '{"summary":"..."}'`);
|
|
222
|
+
} else {
|
|
223
|
+
Logger.error(' Possible causes:');
|
|
224
|
+
Logger.error(' 1. Workflow is still processing (try waiting longer)');
|
|
225
|
+
Logger.error(' 2. Workflow failed on the server (check server logs)');
|
|
226
|
+
Logger.error(' 3. Server did not broadcast the response');
|
|
227
|
+
}
|
|
206
228
|
wsClient.close();
|
|
207
229
|
process.exit(1);
|
|
208
230
|
}
|
|
209
231
|
}, 5 * 60 * 1000);
|
|
210
232
|
|
|
211
233
|
wsClient.onResponse((payload) => {
|
|
234
|
+
const status = payload?.status;
|
|
235
|
+
|
|
236
|
+
if (isIntermediateWebSocketStatus(status)) {
|
|
237
|
+
sawIntermediateEvent = true;
|
|
238
|
+
Logger.warning(`\n⏳ Event-driven update (${status})`);
|
|
239
|
+
Logger.info(`> Channel ID: ${payload.channel_id || channelIdToUse}`);
|
|
240
|
+
if (payload.event_name) {
|
|
241
|
+
Logger.info(`> Event: ${payload.event_name}`);
|
|
242
|
+
}
|
|
243
|
+
if (payload.message) {
|
|
244
|
+
Logger.info(`> Message: ${payload.message}`);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const callbackUrl = `${apiClient.apiUrl}${apiClient.apiPath}/callback/${channelIdToUse}`;
|
|
248
|
+
const hint = buildCallbackHint(callbackUrl, channelIdToUse, apiClient.apiKey);
|
|
249
|
+
if (hint) {
|
|
250
|
+
Logger.warning(`\n${hint}\n`);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (verbose) {
|
|
254
|
+
Logger.info(`Intermediate payload: ${Formatter.json(payload)}`);
|
|
255
|
+
}
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (!isFinalWebSocketStatus(status) && status) {
|
|
260
|
+
if (verbose) {
|
|
261
|
+
Logger.info(`Received non-final status "${status}", waiting for completion...`);
|
|
262
|
+
}
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
|
|
212
266
|
responseReceived = true;
|
|
213
267
|
clearTimeout(timeoutId);
|
|
214
268
|
Logger.success('\n✅ Response received:\n');
|
package/src/index.js
CHANGED
|
@@ -6,6 +6,7 @@ const { listenCommand } = require('./commands/listen');
|
|
|
6
6
|
const { forwardCommand } = require('./commands/forward');
|
|
7
7
|
const { websocketCommand } = require('./commands/websocket');
|
|
8
8
|
const { triggerCommand } = require('./commands/trigger');
|
|
9
|
+
const { callbackCommand } = require('./commands/callback');
|
|
9
10
|
const { testWebhookCommand } = require('./commands/test-webhook');
|
|
10
11
|
const { listWebhooks, verifyWebhook } = require('./commands/webhook');
|
|
11
12
|
const config = require('./lib/config');
|
|
@@ -48,7 +49,7 @@ program
|
|
|
48
49
|
listenCommand({
|
|
49
50
|
...options,
|
|
50
51
|
apiKey: options.apiKey || program.opts().apiKey,
|
|
51
|
-
apiUrl: options.apiUrl || program.opts().apiUrl
|
|
52
|
+
apiUrl: options.apiUrl || program.opts().apiUrl || config.getApiUrl(),
|
|
52
53
|
});
|
|
53
54
|
});
|
|
54
55
|
|
|
@@ -77,11 +78,29 @@ program
|
|
|
77
78
|
websocketCommand({
|
|
78
79
|
...options,
|
|
79
80
|
apiKey: options.apiKey || program.opts().apiKey,
|
|
80
|
-
apiUrl: options.apiUrl || program.opts().apiUrl,
|
|
81
|
+
apiUrl: options.apiUrl || program.opts().apiUrl || config.getApiUrl(),
|
|
81
82
|
projectId: options.projectId || program.opts().projectId
|
|
82
83
|
});
|
|
83
84
|
});
|
|
84
85
|
|
|
86
|
+
// Callback command - complete event-driven flows
|
|
87
|
+
program
|
|
88
|
+
.command('callback')
|
|
89
|
+
.description('Send an event-driven callback to complete a pending channel')
|
|
90
|
+
.option('-c, --channel-id <id>', 'Channel ID from trigger/listen output')
|
|
91
|
+
.option('--callback-url <url>', 'Full callback URL from webhook payload')
|
|
92
|
+
.option('-d, --data <json>', 'Callback data object JSON')
|
|
93
|
+
.option('-P, --payload <json>', 'Full callback payload JSON')
|
|
94
|
+
.option('--task-id <id>', 'Optional task ID')
|
|
95
|
+
.option('--metadata <json>', 'Optional metadata object JSON')
|
|
96
|
+
.action((options) => {
|
|
97
|
+
callbackCommand({
|
|
98
|
+
...options,
|
|
99
|
+
apiKey: options.apiKey || program.opts().apiKey,
|
|
100
|
+
apiUrl: options.apiUrl || program.opts().apiUrl || config.getApiUrl(),
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
85
104
|
// Test Webhook command
|
|
86
105
|
program
|
|
87
106
|
.command('test-webhook')
|
|
@@ -97,7 +116,7 @@ program
|
|
|
97
116
|
testWebhookCommand({
|
|
98
117
|
...options,
|
|
99
118
|
apiKey: options.apiKey || program.opts().apiKey,
|
|
100
|
-
apiUrl: options.apiUrl || program.opts().apiUrl
|
|
119
|
+
apiUrl: options.apiUrl || program.opts().apiUrl || config.getApiUrl(),
|
|
101
120
|
});
|
|
102
121
|
});
|
|
103
122
|
|
|
@@ -115,7 +134,7 @@ program
|
|
|
115
134
|
triggerCommand({
|
|
116
135
|
...options,
|
|
117
136
|
apiKey: options.apiKey || program.opts().apiKey,
|
|
118
|
-
apiUrl: options.apiUrl || program.opts().apiUrl
|
|
137
|
+
apiUrl: options.apiUrl || program.opts().apiUrl || config.getApiUrl()
|
|
119
138
|
});
|
|
120
139
|
});
|
|
121
140
|
|
|
@@ -132,7 +151,7 @@ webhookCmd
|
|
|
132
151
|
listWebhooks({
|
|
133
152
|
...options,
|
|
134
153
|
apiKey: options.apiKey || program.opts().apiKey,
|
|
135
|
-
apiUrl: options.apiUrl || program.opts().apiUrl
|
|
154
|
+
apiUrl: options.apiUrl || program.opts().apiUrl || config.getApiUrl(),
|
|
136
155
|
});
|
|
137
156
|
});
|
|
138
157
|
|
package/src/lib/api-client.js
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
const axios = require('axios');
|
|
2
2
|
const config = require('./config');
|
|
3
3
|
|
|
4
|
+
function createAuthHeaders(apiKey) {
|
|
5
|
+
return {
|
|
6
|
+
Authorization: `Bearer ${apiKey}`,
|
|
7
|
+
'Content-Type': 'application/json',
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
|
|
4
11
|
class ApiClient {
|
|
5
12
|
constructor(apiKey = null, apiUrl = null) {
|
|
6
13
|
this.apiKey = apiKey || config.getApiKey();
|
|
@@ -166,6 +173,21 @@ class ApiClient {
|
|
|
166
173
|
}
|
|
167
174
|
}
|
|
168
175
|
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Send an event-driven callback to complete a pending channel.
|
|
179
|
+
* @param {string} callbackUrl - Full callback URL from webhook payload
|
|
180
|
+
* @param {object} body - Callback payload { data?, task_id?, metadata? }
|
|
181
|
+
* @returns {Promise<object>} - Axios-like response { status, data }
|
|
182
|
+
*/
|
|
183
|
+
async sendCallback(callbackUrl, body) {
|
|
184
|
+
const response = await axios.post(callbackUrl, body, {
|
|
185
|
+
headers: createAuthHeaders(this.apiKey),
|
|
186
|
+
validateStatus: (status) => status < 500,
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
return response;
|
|
190
|
+
}
|
|
169
191
|
}
|
|
170
192
|
|
|
171
193
|
module.exports = ApiClient;
|
|
@@ -88,7 +88,7 @@ class WebSocketClient {
|
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
const joinRef = '1';
|
|
91
|
-
|
|
91
|
+
|
|
92
92
|
const joinMsg = JSON.stringify({
|
|
93
93
|
topic: channelName,
|
|
94
94
|
event: 'phx_join',
|
|
@@ -110,7 +110,7 @@ class WebSocketClient {
|
|
|
110
110
|
resolved = true;
|
|
111
111
|
// Remove this listener - handleMessage() will continue processing other messages
|
|
112
112
|
this.ws.removeListener('message', handleJoinResponse);
|
|
113
|
-
|
|
113
|
+
|
|
114
114
|
if (msg.payload.status === 'ok') {
|
|
115
115
|
this.channel = channelName;
|
|
116
116
|
resolve(msg.payload);
|
|
@@ -126,7 +126,7 @@ class WebSocketClient {
|
|
|
126
126
|
// Add listener for join response
|
|
127
127
|
// The handleMessage() listener in connect() will also be called, but won't match
|
|
128
128
|
this.ws.on('message', handleJoinResponse);
|
|
129
|
-
|
|
129
|
+
|
|
130
130
|
this.ws.send(joinMsg);
|
|
131
131
|
|
|
132
132
|
// Timeout after 10 seconds
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers for standard vs event-driven webhook payloads.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
function isEventDrivenWebhook(data) {
|
|
6
|
+
return Boolean(data && data.type === 'task.ai_generated');
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function resolveCallbackUrl(webhookPayload) {
|
|
10
|
+
if (!webhookPayload) {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const { data, callback_url: topLevelCallbackUrl } = webhookPayload;
|
|
15
|
+
|
|
16
|
+
return topLevelCallbackUrl || data?.callback_url || null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function getWebhookStatusLabel(data) {
|
|
20
|
+
if (!data) {
|
|
21
|
+
return 'N/A';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (isEventDrivenWebhook(data)) {
|
|
25
|
+
return 'ai_generated (callback required)';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return data.status || 'N/A';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function getCustomerData(data) {
|
|
32
|
+
if (!data || typeof data !== 'object') {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const customerData = data.customer_data || data.ai_response?.customer_data;
|
|
37
|
+
if (!customerData || typeof customerData !== 'object') {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return Object.keys(customerData).length > 0 ? customerData : null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function buildCallbackHint(callbackUrl, channelId, apiKey) {
|
|
45
|
+
if (!callbackUrl && !channelId) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const keyHint = apiKey
|
|
50
|
+
? `${apiKey.substring(0, 12)}...`
|
|
51
|
+
: '$MODELRIVER_API_KEY';
|
|
52
|
+
|
|
53
|
+
const lines = [
|
|
54
|
+
'> Event-driven webhook — complete the flow with a callback:',
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
if (channelId) {
|
|
58
|
+
lines.push(
|
|
59
|
+
`> modelriver callback --channel-id ${channelId} --data '{"summary":"..."}'`
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (callbackUrl) {
|
|
64
|
+
if (channelId) {
|
|
65
|
+
lines.push('> Or:');
|
|
66
|
+
}
|
|
67
|
+
lines.push(
|
|
68
|
+
`> modelriver callback --callback-url "${callbackUrl}" --data '{"summary":"..."}'`
|
|
69
|
+
);
|
|
70
|
+
lines.push(
|
|
71
|
+
`> curl -X POST "${callbackUrl}" \\`,
|
|
72
|
+
`> -H "Authorization: Bearer ${keyHint}" \\`,
|
|
73
|
+
`> -H "Content-Type: application/json" \\`,
|
|
74
|
+
`> -d '{"data":{"enriched":true},"task_id":"your-task-id"}'`
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return lines.join('\n');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function printWebhookDetails(webhookPayload, options = {}) {
|
|
82
|
+
const {
|
|
83
|
+
Logger,
|
|
84
|
+
Formatter,
|
|
85
|
+
WebhookVerifier,
|
|
86
|
+
webhookSecret,
|
|
87
|
+
signature,
|
|
88
|
+
timestamp,
|
|
89
|
+
apiKey,
|
|
90
|
+
label = 'Webhook received',
|
|
91
|
+
} = options;
|
|
92
|
+
|
|
93
|
+
const { channel_id, data } = webhookPayload;
|
|
94
|
+
const callbackUrl = resolveCallbackUrl(webhookPayload);
|
|
95
|
+
const timestampStr = Formatter.timestamp(new Date());
|
|
96
|
+
|
|
97
|
+
Logger.log(`\n[${timestampStr}] ${label}:`);
|
|
98
|
+
Logger.log(` Channel ID: ${channel_id || 'N/A'}`);
|
|
99
|
+
Logger.log(` Status: ${getWebhookStatusLabel(data)}`);
|
|
100
|
+
|
|
101
|
+
if (isEventDrivenWebhook(data)) {
|
|
102
|
+
Logger.log(` Type: ${data.type}`);
|
|
103
|
+
Logger.log(` Event: ${data.event || 'N/A'}`);
|
|
104
|
+
|
|
105
|
+
if (callbackUrl) {
|
|
106
|
+
Logger.log(` Callback URL: ${callbackUrl}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const customerData = getCustomerData(data);
|
|
110
|
+
if (customerData) {
|
|
111
|
+
Logger.log(` Customer data: ${Formatter.json(customerData)}`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (data.ai_response) {
|
|
115
|
+
Logger.log(` AI response: ${Formatter.json(data.ai_response)}`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const hint = buildCallbackHint(callbackUrl, channel_id, apiKey);
|
|
119
|
+
if (hint) {
|
|
120
|
+
Logger.warning(`\n${hint}\n`);
|
|
121
|
+
}
|
|
122
|
+
} else {
|
|
123
|
+
if (signature && webhookSecret && data) {
|
|
124
|
+
const isValid = WebhookVerifier.verify(
|
|
125
|
+
signature,
|
|
126
|
+
data,
|
|
127
|
+
webhookSecret,
|
|
128
|
+
String(timestamp)
|
|
129
|
+
);
|
|
130
|
+
Logger.log(` Signature: ${isValid ? '✓ Valid' : '✗ Invalid'}`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (data?.data) {
|
|
134
|
+
Logger.log(` Data: ${Formatter.json(data.data)}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (data?.meta) {
|
|
138
|
+
Logger.log(` Meta: ${Formatter.json(data.meta)}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function isIntermediateWebSocketStatus(status) {
|
|
144
|
+
return status === 'ai_generated' || status === 'step_received' || status === 'pending';
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function isFinalWebSocketStatus(status) {
|
|
148
|
+
return status === 'completed' || status === 'success' || status === 'error';
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
module.exports = {
|
|
152
|
+
isEventDrivenWebhook,
|
|
153
|
+
resolveCallbackUrl,
|
|
154
|
+
getCustomerData,
|
|
155
|
+
getWebhookStatusLabel,
|
|
156
|
+
buildCallbackHint,
|
|
157
|
+
printWebhookDetails,
|
|
158
|
+
isIntermediateWebSocketStatus,
|
|
159
|
+
isFinalWebSocketStatus,
|
|
160
|
+
};
|
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
// Mock fs to prevent reading from actual ~/.modelriver/config.json
|
|
2
|
-
jest.mock('fs', () => ({
|
|
3
|
-
existsSync: jest.fn(() => false),
|
|
4
|
-
readFileSync: jest.fn(() => '{}'),
|
|
5
|
-
writeFileSync: jest.fn(),
|
|
6
|
-
mkdirSync: jest.fn(),
|
|
7
|
-
}));
|
|
8
|
-
|
|
9
|
-
describe('ApiClient', () => {
|
|
10
|
-
const originalEnv = process.env;
|
|
11
|
-
|
|
12
|
-
beforeEach(() => {
|
|
13
|
-
jest.resetModules();
|
|
14
|
-
// Re-apply the mock after resetModules
|
|
15
|
-
jest.doMock('fs', () => ({
|
|
16
|
-
existsSync: jest.fn(() => false),
|
|
17
|
-
readFileSync: jest.fn(() => '{}'),
|
|
18
|
-
writeFileSync: jest.fn(),
|
|
19
|
-
mkdirSync: jest.fn(),
|
|
20
|
-
}));
|
|
21
|
-
process.env = { ...originalEnv };
|
|
22
|
-
// Clear any config env vars first
|
|
23
|
-
delete process.env.MODELRIVER_API_KEY;
|
|
24
|
-
// Then set a valid API key for tests that need it
|
|
25
|
-
process.env.MODELRIVER_API_KEY = 'mr_live_validtestkey123';
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
afterAll(() => {
|
|
29
|
-
process.env = originalEnv;
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
describe('constructor validation', () => {
|
|
33
|
-
it('should throw error when API key is missing', () => {
|
|
34
|
-
delete process.env.MODELRIVER_API_KEY;
|
|
35
|
-
const ApiClient = require('./api-client');
|
|
36
|
-
|
|
37
|
-
expect(() => new ApiClient(null, 'https://api.modelriver.com'))
|
|
38
|
-
.toThrow('API key is required');
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
it('should throw error for invalid API key format', () => {
|
|
42
|
-
const ApiClient = require('./api-client');
|
|
43
|
-
|
|
44
|
-
expect(() => new ApiClient('invalid-key-format', 'https://api.modelriver.com'))
|
|
45
|
-
.toThrow('Invalid API key format');
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
it('should accept mr_live_ prefixed keys', () => {
|
|
49
|
-
const ApiClient = require('./api-client');
|
|
50
|
-
|
|
51
|
-
expect(() => new ApiClient('mr_live_abc123', 'https://api.modelriver.com'))
|
|
52
|
-
.not.toThrow();
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
it('should accept mr_test_ prefixed keys', () => {
|
|
56
|
-
const ApiClient = require('./api-client');
|
|
57
|
-
|
|
58
|
-
expect(() => new ApiClient('mr_test_abc123', 'https://api.modelriver.com'))
|
|
59
|
-
.not.toThrow();
|
|
60
|
-
});
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
describe('apiPath detection', () => {
|
|
64
|
-
it('should use /v1 for production URLs', () => {
|
|
65
|
-
const ApiClient = require('./api-client');
|
|
66
|
-
const client = new ApiClient('mr_live_test123', 'https://api.modelriver.com');
|
|
67
|
-
|
|
68
|
-
expect(client.apiPath).toBe('/v1');
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
it('should use /v1 for *.modelriver.com subdomains', () => {
|
|
72
|
-
const ApiClient = require('./api-client');
|
|
73
|
-
const client = new ApiClient('mr_live_test123', 'https://staging.modelriver.com');
|
|
74
|
-
|
|
75
|
-
expect(client.apiPath).toBe('/v1');
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
it('should use /api/v1 for localhost', () => {
|
|
79
|
-
const ApiClient = require('./api-client');
|
|
80
|
-
const client = new ApiClient('mr_live_test123', 'http://localhost:4000');
|
|
81
|
-
|
|
82
|
-
expect(client.apiPath).toBe('/api/v1');
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
it('should use /api/v1 for non-production URLs', () => {
|
|
86
|
-
const ApiClient = require('./api-client');
|
|
87
|
-
const client = new ApiClient('mr_live_test123', 'http://my-dev-server.local');
|
|
88
|
-
|
|
89
|
-
expect(client.apiPath).toBe('/api/v1');
|
|
90
|
-
});
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
describe('URL normalization', () => {
|
|
94
|
-
it('should strip trailing slashes from API URL', () => {
|
|
95
|
-
const ApiClient = require('./api-client');
|
|
96
|
-
const client = new ApiClient('mr_live_test123', 'https://api.modelriver.com/');
|
|
97
|
-
|
|
98
|
-
expect(client.apiUrl).toBe('https://api.modelriver.com');
|
|
99
|
-
});
|
|
100
|
-
});
|
|
101
|
-
});
|
package/src/lib/config.test.js
DELETED
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
// We need to test the Config class, but it's exported as a singleton instance.
|
|
2
|
-
// We'll test the behavior through environment variables.
|
|
3
|
-
|
|
4
|
-
// Mock fs to prevent reading from actual ~/.modelriver/config.json
|
|
5
|
-
jest.mock('fs', () => ({
|
|
6
|
-
existsSync: jest.fn(() => false),
|
|
7
|
-
readFileSync: jest.fn(() => '{}'),
|
|
8
|
-
writeFileSync: jest.fn(),
|
|
9
|
-
mkdirSync: jest.fn(),
|
|
10
|
-
}));
|
|
11
|
-
|
|
12
|
-
describe('Config', () => {
|
|
13
|
-
const originalEnv = process.env;
|
|
14
|
-
|
|
15
|
-
beforeEach(() => {
|
|
16
|
-
// Reset modules to get fresh Config instance
|
|
17
|
-
jest.resetModules();
|
|
18
|
-
// Re-apply the mock after resetModules
|
|
19
|
-
jest.doMock('fs', () => ({
|
|
20
|
-
existsSync: jest.fn(() => false),
|
|
21
|
-
readFileSync: jest.fn(() => '{}'),
|
|
22
|
-
writeFileSync: jest.fn(),
|
|
23
|
-
mkdirSync: jest.fn(),
|
|
24
|
-
}));
|
|
25
|
-
process.env = { ...originalEnv };
|
|
26
|
-
// Clear any config-related env vars
|
|
27
|
-
delete process.env.MODELRIVER_API_KEY;
|
|
28
|
-
delete process.env.MODELRIVER_API_URL;
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
afterAll(() => {
|
|
32
|
-
process.env = originalEnv;
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
describe('getApiKey', () => {
|
|
36
|
-
it('should read API key from MODELRIVER_API_KEY env var', () => {
|
|
37
|
-
process.env.MODELRIVER_API_KEY = 'mr_live_test123';
|
|
38
|
-
const config = require('./config');
|
|
39
|
-
|
|
40
|
-
expect(config.getApiKey()).toBe('mr_live_test123');
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
it('should return undefined when API key not set', () => {
|
|
44
|
-
delete process.env.MODELRIVER_API_KEY;
|
|
45
|
-
const config = require('./config');
|
|
46
|
-
|
|
47
|
-
expect(config.getApiKey()).toBeFalsy();
|
|
48
|
-
});
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
describe('getApiUrl', () => {
|
|
52
|
-
it('should return default URL when not set', () => {
|
|
53
|
-
delete process.env.MODELRIVER_API_URL;
|
|
54
|
-
const config = require('./config');
|
|
55
|
-
|
|
56
|
-
expect(config.getApiUrl()).toBe('https://api.modelriver.com');
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
it('should read API URL from MODELRIVER_API_URL env var', () => {
|
|
60
|
-
process.env.MODELRIVER_API_URL = 'http://localhost:4000';
|
|
61
|
-
const config = require('./config');
|
|
62
|
-
|
|
63
|
-
expect(config.getApiUrl()).toBe('http://localhost:4000');
|
|
64
|
-
});
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
describe('getWebSocketUrl', () => {
|
|
68
|
-
it('should convert https:// to wss://', () => {
|
|
69
|
-
process.env.MODELRIVER_API_URL = 'https://api.modelriver.com';
|
|
70
|
-
const config = require('./config');
|
|
71
|
-
|
|
72
|
-
expect(config.getWebSocketUrl()).toBe('wss://api.modelriver.com');
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
it('should convert http:// to ws://', () => {
|
|
76
|
-
process.env.MODELRIVER_API_URL = 'http://localhost:4000';
|
|
77
|
-
const config = require('./config');
|
|
78
|
-
|
|
79
|
-
expect(config.getWebSocketUrl()).toBe('ws://localhost:4000');
|
|
80
|
-
});
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
describe('get', () => {
|
|
84
|
-
it('should prioritize env vars over defaults', () => {
|
|
85
|
-
process.env.MODELRIVER_CUSTOM_KEY = 'env-value';
|
|
86
|
-
const config = require('./config');
|
|
87
|
-
|
|
88
|
-
expect(config.get('custom-key', 'default-value')).toBe('env-value');
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
it('should return default when env var not set', () => {
|
|
92
|
-
delete process.env.MODELRIVER_MISSING_KEY;
|
|
93
|
-
const config = require('./config');
|
|
94
|
-
|
|
95
|
-
expect(config.get('missing-key', 'fallback')).toBe('fallback');
|
|
96
|
-
});
|
|
97
|
-
});
|
|
98
|
-
});
|
|
@@ -1,90 +0,0 @@
|
|
|
1
|
-
const WebhookVerifier = require('./webhook-verifier');
|
|
2
|
-
|
|
3
|
-
describe('WebhookVerifier', () => {
|
|
4
|
-
const secret = 'test-secret-key-12345';
|
|
5
|
-
const timestamp = '1704825600';
|
|
6
|
-
const payload = { status: 'success', name: 'Test User' };
|
|
7
|
-
|
|
8
|
-
describe('generateSignature', () => {
|
|
9
|
-
it('should generate a consistent HMAC-SHA256 signature', () => {
|
|
10
|
-
const sig1 = WebhookVerifier.generateSignature(payload, secret, timestamp);
|
|
11
|
-
const sig2 = WebhookVerifier.generateSignature(payload, secret, timestamp);
|
|
12
|
-
|
|
13
|
-
expect(sig1).toBe(sig2);
|
|
14
|
-
expect(typeof sig1).toBe('string');
|
|
15
|
-
expect(sig1.length).toBe(64); // SHA256 hex is 64 chars
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
it('should produce different signatures for different payloads', () => {
|
|
19
|
-
const sig1 = WebhookVerifier.generateSignature(payload, secret, timestamp);
|
|
20
|
-
const sig2 = WebhookVerifier.generateSignature({ different: 'data' }, secret, timestamp);
|
|
21
|
-
|
|
22
|
-
expect(sig1).not.toBe(sig2);
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
it('should produce different signatures for different secrets', () => {
|
|
26
|
-
const sig1 = WebhookVerifier.generateSignature(payload, secret, timestamp);
|
|
27
|
-
const sig2 = WebhookVerifier.generateSignature(payload, 'different-secret', timestamp);
|
|
28
|
-
|
|
29
|
-
expect(sig1).not.toBe(sig2);
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
it('should produce different signatures for different timestamps', () => {
|
|
33
|
-
const sig1 = WebhookVerifier.generateSignature(payload, secret, timestamp);
|
|
34
|
-
const sig2 = WebhookVerifier.generateSignature(payload, secret, '1704825601');
|
|
35
|
-
|
|
36
|
-
expect(sig1).not.toBe(sig2);
|
|
37
|
-
});
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
describe('verify', () => {
|
|
41
|
-
it('should return true for valid signatures', () => {
|
|
42
|
-
const signature = WebhookVerifier.generateSignature(payload, secret, timestamp);
|
|
43
|
-
const isValid = WebhookVerifier.verify(signature, payload, secret, timestamp);
|
|
44
|
-
|
|
45
|
-
expect(isValid).toBe(true);
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
it('should return false for tampered payloads', () => {
|
|
49
|
-
const signature = WebhookVerifier.generateSignature(payload, secret, timestamp);
|
|
50
|
-
const tamperedPayload = { ...payload, name: 'Hacker' };
|
|
51
|
-
const isValid = WebhookVerifier.verify(signature, tamperedPayload, secret, timestamp);
|
|
52
|
-
|
|
53
|
-
expect(isValid).toBe(false);
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
it('should return false for wrong secret', () => {
|
|
57
|
-
const signature = WebhookVerifier.generateSignature(payload, secret, timestamp);
|
|
58
|
-
const isValid = WebhookVerifier.verify(signature, payload, 'wrong-secret', timestamp);
|
|
59
|
-
|
|
60
|
-
expect(isValid).toBe(false);
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
it('should return false for wrong timestamp', () => {
|
|
64
|
-
const signature = WebhookVerifier.generateSignature(payload, secret, timestamp);
|
|
65
|
-
const isValid = WebhookVerifier.verify(signature, payload, secret, 'wrong-timestamp');
|
|
66
|
-
|
|
67
|
-
expect(isValid).toBe(false);
|
|
68
|
-
});
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
describe('secureCompare', () => {
|
|
72
|
-
it('should return true for equal strings', () => {
|
|
73
|
-
expect(WebhookVerifier.secureCompare('abc123', 'abc123')).toBe(true);
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
it('should return false for different strings', () => {
|
|
77
|
-
expect(WebhookVerifier.secureCompare('abc123', 'xyz789')).toBe(false);
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
it('should return false for different length strings', () => {
|
|
81
|
-
expect(WebhookVerifier.secureCompare('short', 'muchlongerstring')).toBe(false);
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
it('should return false for non-string inputs', () => {
|
|
85
|
-
expect(WebhookVerifier.secureCompare(null, 'string')).toBe(false);
|
|
86
|
-
expect(WebhookVerifier.secureCompare('string', undefined)).toBe(false);
|
|
87
|
-
expect(WebhookVerifier.secureCompare(123, 456)).toBe(false);
|
|
88
|
-
});
|
|
89
|
-
});
|
|
90
|
-
});
|
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
const Formatter = require('./formatter');
|
|
2
|
-
|
|
3
|
-
describe('Formatter', () => {
|
|
4
|
-
describe('json', () => {
|
|
5
|
-
it('should format objects with default 2-space indentation', () => {
|
|
6
|
-
const obj = { name: 'test', value: 123 };
|
|
7
|
-
const result = Formatter.json(obj);
|
|
8
|
-
|
|
9
|
-
expect(result).toBe('{\n "name": "test",\n "value": 123\n}');
|
|
10
|
-
});
|
|
11
|
-
|
|
12
|
-
it('should format with custom indentation', () => {
|
|
13
|
-
const obj = { key: 'value' };
|
|
14
|
-
const result = Formatter.json(obj, 4);
|
|
15
|
-
|
|
16
|
-
expect(result).toBe('{\n "key": "value"\n}');
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
it('should handle arrays', () => {
|
|
20
|
-
const arr = [1, 2, 3];
|
|
21
|
-
const result = Formatter.json(arr);
|
|
22
|
-
|
|
23
|
-
expect(result).toBe('[\n 1,\n 2,\n 3\n]');
|
|
24
|
-
});
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
describe('timestamp', () => {
|
|
28
|
-
it('should return ISO format string', () => {
|
|
29
|
-
const date = new Date('2024-01-09T12:00:00Z');
|
|
30
|
-
const result = Formatter.timestamp(date);
|
|
31
|
-
|
|
32
|
-
expect(result).toBe('2024-01-09T12:00:00.000Z');
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
it('should use current date when no argument provided', () => {
|
|
36
|
-
const result = Formatter.timestamp();
|
|
37
|
-
|
|
38
|
-
expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
|
|
39
|
-
});
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
describe('formatDuration', () => {
|
|
43
|
-
it('should format milliseconds', () => {
|
|
44
|
-
expect(Formatter.formatDuration(500)).toBe('500ms');
|
|
45
|
-
expect(Formatter.formatDuration(999)).toBe('999ms');
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
it('should format seconds', () => {
|
|
49
|
-
expect(Formatter.formatDuration(1000)).toBe('1.0s');
|
|
50
|
-
expect(Formatter.formatDuration(5500)).toBe('5.5s');
|
|
51
|
-
expect(Formatter.formatDuration(59999)).toBe('60.0s');
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
it('should format minutes', () => {
|
|
55
|
-
expect(Formatter.formatDuration(60000)).toBe('1.0m');
|
|
56
|
-
expect(Formatter.formatDuration(90000)).toBe('1.5m');
|
|
57
|
-
expect(Formatter.formatDuration(300000)).toBe('5.0m');
|
|
58
|
-
});
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
describe('table', () => {
|
|
62
|
-
it('should format a simple table', () => {
|
|
63
|
-
const headers = ['Name', 'Age'];
|
|
64
|
-
const rows = [
|
|
65
|
-
['Alice', 30],
|
|
66
|
-
['Bob', 25]
|
|
67
|
-
];
|
|
68
|
-
const result = Formatter.table(headers, rows);
|
|
69
|
-
|
|
70
|
-
expect(result).toContain('Name');
|
|
71
|
-
expect(result).toContain('Age');
|
|
72
|
-
expect(result).toContain('Alice');
|
|
73
|
-
expect(result).toContain('Bob');
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
it('should handle empty cells', () => {
|
|
77
|
-
const headers = ['A', 'B'];
|
|
78
|
-
const rows = [['value', null]];
|
|
79
|
-
const result = Formatter.table(headers, rows);
|
|
80
|
-
|
|
81
|
-
expect(result).toContain('value');
|
|
82
|
-
});
|
|
83
|
-
});
|
|
84
|
-
});
|
|
@@ -1,144 +0,0 @@
|
|
|
1
|
-
// Tests for URL helper utilities
|
|
2
|
-
const { normalizeWebhookUrl, extractPort, isValidApiKeyFormat, parseForwardUrl } = require('./url-helpers');
|
|
3
|
-
|
|
4
|
-
describe('URL Helpers', () => {
|
|
5
|
-
describe('normalizeWebhookUrl', () => {
|
|
6
|
-
it('should add /webhook/modelriver to base URL', () => {
|
|
7
|
-
expect(normalizeWebhookUrl('http://localhost:4000'))
|
|
8
|
-
.toBe('http://localhost:4000/webhook/modelriver');
|
|
9
|
-
});
|
|
10
|
-
|
|
11
|
-
it('should add /modelriver to URL ending with /webhook', () => {
|
|
12
|
-
expect(normalizeWebhookUrl('http://localhost:4000/webhook'))
|
|
13
|
-
.toBe('http://localhost:4000/webhook/modelriver');
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
it('should not modify URL already ending with /webhook/modelriver', () => {
|
|
17
|
-
expect(normalizeWebhookUrl('http://localhost:4000/webhook/modelriver'))
|
|
18
|
-
.toBe('http://localhost:4000/webhook/modelriver');
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
it('should handle trailing slashes', () => {
|
|
22
|
-
expect(normalizeWebhookUrl('http://localhost:4000/'))
|
|
23
|
-
.toBe('http://localhost:4000/webhook/modelriver');
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
it('should handle URLs with port', () => {
|
|
27
|
-
expect(normalizeWebhookUrl('http://localhost:3001'))
|
|
28
|
-
.toBe('http://localhost:3001/webhook/modelriver');
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
it('should handle https URLs', () => {
|
|
32
|
-
expect(normalizeWebhookUrl('https://myserver.com'))
|
|
33
|
-
.toBe('https://myserver.com/webhook/modelriver');
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
it('should return empty for falsy input', () => {
|
|
37
|
-
expect(normalizeWebhookUrl('')).toBeFalsy();
|
|
38
|
-
expect(normalizeWebhookUrl(null)).toBeFalsy();
|
|
39
|
-
expect(normalizeWebhookUrl(undefined)).toBeFalsy();
|
|
40
|
-
});
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
describe('extractPort', () => {
|
|
44
|
-
it('should extract port from URL with explicit port', () => {
|
|
45
|
-
expect(extractPort('http://localhost:4000')).toBe(4000);
|
|
46
|
-
expect(extractPort('http://localhost:3001/webhook')).toBe(3001);
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
it('should return 80 for http without port', () => {
|
|
50
|
-
expect(extractPort('http://example.com')).toBe(80);
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it('should return 443 for https without port', () => {
|
|
54
|
-
expect(extractPort('https://example.com')).toBe(443);
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
it('should return null for invalid URLs', () => {
|
|
58
|
-
expect(extractPort('not-a-url')).toBeNull();
|
|
59
|
-
expect(extractPort('')).toBeNull();
|
|
60
|
-
});
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
describe('isValidApiKeyFormat', () => {
|
|
64
|
-
it('should accept mr_live_ prefixed keys', () => {
|
|
65
|
-
expect(isValidApiKeyFormat('mr_live_abc123xyz')).toBe(true);
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
it('should accept mr_test_ prefixed keys', () => {
|
|
69
|
-
expect(isValidApiKeyFormat('mr_test_abc123xyz')).toBe(true);
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
it('should reject keys without valid prefix', () => {
|
|
73
|
-
expect(isValidApiKeyFormat('invalid_key')).toBe(false);
|
|
74
|
-
expect(isValidApiKeyFormat('mr_invalid_key')).toBe(false);
|
|
75
|
-
expect(isValidApiKeyFormat('abc123')).toBe(false);
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
it('should reject empty or falsy keys', () => {
|
|
79
|
-
expect(isValidApiKeyFormat('')).toBe(false);
|
|
80
|
-
expect(isValidApiKeyFormat(null)).toBe(false);
|
|
81
|
-
expect(isValidApiKeyFormat(undefined)).toBe(false);
|
|
82
|
-
});
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
describe('parseForwardUrl', () => {
|
|
86
|
-
it('should parse localhost URL with port', () => {
|
|
87
|
-
const result = parseForwardUrl('http://localhost:4000/webhook/modelriver');
|
|
88
|
-
expect(result).toEqual({
|
|
89
|
-
hostname: 'localhost',
|
|
90
|
-
port: 4000,
|
|
91
|
-
path: '/webhook/modelriver',
|
|
92
|
-
protocol: 'http'
|
|
93
|
-
});
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
it('should parse external URL with port', () => {
|
|
97
|
-
const result = parseForwardUrl('http://myserver.com:3001/webhook/modelriver');
|
|
98
|
-
expect(result).toEqual({
|
|
99
|
-
hostname: 'myserver.com',
|
|
100
|
-
port: 3001,
|
|
101
|
-
path: '/webhook/modelriver',
|
|
102
|
-
protocol: 'http'
|
|
103
|
-
});
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
it('should default to port 80 for http without explicit port', () => {
|
|
107
|
-
const result = parseForwardUrl('http://example.com/webhook/modelriver');
|
|
108
|
-
expect(result.port).toBe(80);
|
|
109
|
-
expect(result.hostname).toBe('example.com');
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
it('should default to port 443 for https without explicit port', () => {
|
|
113
|
-
const result = parseForwardUrl('https://example.com/webhook/modelriver');
|
|
114
|
-
expect(result.port).toBe(443);
|
|
115
|
-
expect(result.protocol).toBe('https');
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
it('should handle URLs with different paths', () => {
|
|
119
|
-
const result = parseForwardUrl('http://localhost:5000/custom/path');
|
|
120
|
-
expect(result.path).toBe('/custom/path');
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
it('should return null for empty or falsy input', () => {
|
|
124
|
-
expect(parseForwardUrl('')).toBeNull();
|
|
125
|
-
expect(parseForwardUrl(null)).toBeNull();
|
|
126
|
-
expect(parseForwardUrl(undefined)).toBeNull();
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
it('should handle URLs without protocol by defaulting to http', () => {
|
|
130
|
-
const result = parseForwardUrl('myserver:4000/webhook/modelriver');
|
|
131
|
-
expect(result).toEqual({
|
|
132
|
-
hostname: 'myserver',
|
|
133
|
-
port: 4000,
|
|
134
|
-
path: '/webhook/modelriver',
|
|
135
|
-
protocol: 'http'
|
|
136
|
-
});
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
it('should return null for invalid URLs', () => {
|
|
140
|
-
expect(parseForwardUrl('http://invalid url with spaces')).toBeNull();
|
|
141
|
-
expect(parseForwardUrl('just text with spaces')).toBeNull();
|
|
142
|
-
});
|
|
143
|
-
});
|
|
144
|
-
});
|