@modelriver/cli 1.0.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.
@@ -0,0 +1,315 @@
1
+ const express = require('express');
2
+ const ApiClient = require('../lib/api-client');
3
+ const CLIWebSocketClient = require('../lib/cli-websocket-client');
4
+ const WebhookVerifier = require('../lib/webhook-verifier');
5
+ const Logger = require('../utils/logger');
6
+ const Formatter = require('../utils/formatter');
7
+
8
+ async function listenCommand(options) {
9
+ let { apiKey, apiUrl, print, port, verbose, forward } = options;
10
+ const forwardOnly = forward === true;
11
+
12
+ const spinner = Logger.spinner('Setting up webhook forwarding...');
13
+ spinner.start();
14
+
15
+ let apiClient;
16
+ let wsClient;
17
+ let localPort = port || 3001;
18
+ let webhookSecret = null;
19
+
20
+ try {
21
+ // Validate API key
22
+ if (!apiKey) {
23
+ const config = require('../lib/config');
24
+ apiKey = config.getApiKey();
25
+ }
26
+
27
+ if (!apiKey) {
28
+ spinner.fail('API key is required');
29
+ Logger.error('API key is not set. Please set MODELRIVER_API_KEY environment variable or use --api-key flag.');
30
+
31
+ // Check if this is localhost for better error message
32
+ const isLocalhost = apiUrl && (
33
+ apiUrl.includes('localhost') ||
34
+ apiUrl.includes('127.0.0.1') ||
35
+ apiUrl.includes('0.0.0.0')
36
+ );
37
+
38
+ if (isLocalhost) {
39
+ Logger.warning('\n> Quick fix for localhost:');
40
+ Logger.warning('> 1. Log in at http://localhost:4000');
41
+ Logger.warning('> 2. Create a project if you don\'t have one');
42
+ Logger.warning('> 3. Go to Project Settings > API Keys and create a new key');
43
+ Logger.warning('> 4. Or run this script to create a test key:');
44
+ Logger.warning('> mix run scripts/create_test_api_key.exs USER_EMAIL');
45
+ Logger.warning('> 5. Then set it:');
46
+ Logger.warning('> export MODELRIVER_API_KEY=your_key_here');
47
+ } else {
48
+ Logger.warning('\n> To fix this:');
49
+ Logger.warning('> 1. Set MODELRIVER_API_KEY environment variable:');
50
+ Logger.warning('> export MODELRIVER_API_KEY=mr_live_YOUR_KEY');
51
+ Logger.warning('> 2. Or use --api-key flag:');
52
+ Logger.warning('> modelriver listen --api-key mr_live_YOUR_KEY');
53
+ Logger.warning('> 3. Get your API key from https://console.modelriver.com');
54
+ }
55
+ process.exit(1);
56
+ }
57
+
58
+ // Validate API key format
59
+ if (!apiKey.startsWith('mr_live_') && !apiKey.startsWith('mr_test_')) {
60
+ spinner.fail('Invalid API key format');
61
+ Logger.error(`API key must start with "mr_live_" or "mr_test_". Got: ${apiKey.substring(0, 20)}...`);
62
+ Logger.warning('> Make sure you copied the full API key from the dashboard');
63
+ process.exit(1);
64
+ }
65
+
66
+ apiClient = new ApiClient(apiKey, apiUrl);
67
+
68
+ // Debug: Show API key info if verbose
69
+ if (verbose) {
70
+ Logger.info(`> API URL: ${apiUrl}`);
71
+ Logger.info(`> API Path: ${apiClient.apiPath}`);
72
+ Logger.info(`> API Key (first 30 chars): ${apiKey.substring(0, 30)}...`);
73
+ }
74
+
75
+ // Step 1: Get CLI WebSocket token
76
+ spinner.text = 'Connecting to ModelRiver...';
77
+
78
+ const connectResponse = await apiClient.connectCLI();
79
+ const { ws_token, websocket_url, user_id, channel } = connectResponse;
80
+
81
+ spinner.text = 'Connecting to WebSocket...';
82
+
83
+ // Step 2: Connect to WebSocket
84
+ wsClient = new CLIWebSocketClient(websocket_url, ws_token, verbose || false);
85
+
86
+ try {
87
+ await wsClient.connect();
88
+ } catch (error) {
89
+ spinner.fail('WebSocket connection failed');
90
+ Logger.error(error.message);
91
+ process.exit(1);
92
+ }
93
+
94
+ spinner.text = 'Joining webhook channel...';
95
+
96
+ // Step 3: Join CLI webhook channel
97
+ try {
98
+ await wsClient.joinChannel(channel);
99
+ } catch (error) {
100
+ spinner.fail('Failed to join channel');
101
+ Logger.error(error.message);
102
+ wsClient.close();
103
+ process.exit(1);
104
+ }
105
+
106
+ // Step 4: Get webhook secret for signature verification
107
+ // We'll use the first webhook's secret if available, or generate a default
108
+ try {
109
+ const webhooks = await apiClient.listWebhooks();
110
+ if (webhooks.length > 0) {
111
+ webhookSecret = webhooks[0].secret;
112
+ }
113
+ } catch (error) {
114
+ // If we can't get webhook secret, we'll skip verification
115
+ Logger.warning('Could not retrieve webhook secret for verification');
116
+ }
117
+
118
+ spinner.succeed('Webhook forwarding active');
119
+
120
+ Logger.success('\n> Ready! ModelRiver webhook forwarding is active');
121
+ Logger.info(`> User ID: ${user_id}`);
122
+ Logger.info(`> Channel: ${channel}`);
123
+ Logger.info(`> Local port: ${localPort}`);
124
+ if (webhookSecret) {
125
+ Logger.warning(`> Webhook secret: ${webhookSecret}`);
126
+ Logger.warning('> Save this secret for webhook-test-server!\n');
127
+ }
128
+ Logger.warning('> Press Ctrl+C to stop forwarding\n');
129
+
130
+ // Step 5: Start local Express server (only if not forwarding to external server)
131
+ let server = null;
132
+ if (!forwardOnly) {
133
+ const app = express();
134
+ app.use(express.json());
135
+
136
+ app.post('*', (req, res) => {
137
+ const signature = req.headers['x-modelriver-signature'];
138
+ const timestamp = req.headers['x-modelriver-timestamp'];
139
+ const webhookIdHeader = req.headers['x-modelriver-webhook-id'];
140
+
141
+ const { channel_id, timestamp: payloadTimestamp, data } = req.body;
142
+
143
+ // Verify signature if we have a secret
144
+ let isValid = null;
145
+ if (webhookSecret && signature && timestamp && data) {
146
+ isValid = WebhookVerifier.verify(
147
+ signature,
148
+ data,
149
+ webhookSecret,
150
+ timestamp
151
+ );
152
+ }
153
+
154
+ if (print) {
155
+ const timestampStr = Formatter.timestamp(new Date());
156
+ Logger.log(`\n[${timestampStr}] Webhook received:`);
157
+ Logger.log(` Channel ID: ${channel_id || 'N/A'}`);
158
+ Logger.log(` Status: ${data?.status || 'N/A'}`);
159
+ if (isValid !== null) {
160
+ Logger.log(` Signature: ${isValid ? '✓ Valid' : '✗ Invalid'}`);
161
+ }
162
+ if (data?.data) {
163
+ Logger.log(` Data: ${Formatter.json(data.data)}`);
164
+ }
165
+ if (data?.meta) {
166
+ Logger.log(` Meta: ${Formatter.json(data.meta)}`);
167
+ }
168
+ }
169
+
170
+ // Always return 200 to acknowledge receipt
171
+ res.status(200).json({ received: true, verified: isValid });
172
+ });
173
+
174
+ server = app.listen(localPort, 'localhost', () => {
175
+ Logger.success(`> Local server listening on http://localhost:${localPort}/webhook`);
176
+ });
177
+ } else {
178
+ Logger.success(`> Forwarding webhooks to http://localhost:${localPort}/webhook`);
179
+ }
180
+
181
+ // Step 6: Listen for webhook events from WebSocket
182
+ wsClient.onWebhook((webhookPayload) => {
183
+ const { channel_id, timestamp, data, signature } = webhookPayload;
184
+
185
+ if (print) {
186
+ const timestampStr = Formatter.timestamp(new Date());
187
+ Logger.log(`\n[${timestampStr}] Webhook received via WebSocket:`);
188
+ Logger.log(` Channel ID: ${channel_id || 'N/A'}`);
189
+ Logger.log(` Status: ${data?.status || 'N/A'}`);
190
+ if (signature && webhookSecret) {
191
+ const isValid = WebhookVerifier.verify(
192
+ signature,
193
+ data,
194
+ webhookSecret,
195
+ timestamp.toString()
196
+ );
197
+ Logger.log(` Signature: ${isValid ? '✓ Valid' : '✗ Invalid'}`);
198
+ }
199
+ if (data?.data) {
200
+ Logger.log(` Data: ${Formatter.json(data.data)}`);
201
+ }
202
+ if (data?.meta) {
203
+ Logger.log(` Meta: ${Formatter.json(data.meta)}`);
204
+ }
205
+ }
206
+
207
+ // Forward to local server
208
+ const http = require('http');
209
+ const postData = JSON.stringify({
210
+ channel_id,
211
+ timestamp,
212
+ data
213
+ });
214
+
215
+ const options = {
216
+ hostname: 'localhost',
217
+ port: localPort,
218
+ path: '/webhook',
219
+ method: 'POST',
220
+ headers: {
221
+ 'Content-Type': 'application/json',
222
+ 'Content-Length': Buffer.byteLength(postData),
223
+ 'X-ModelRiver-Signature': signature || '',
224
+ 'X-ModelRiver-Timestamp': timestamp.toString(),
225
+ 'X-ModelRiver-Webhook-Id': webhookPayload.webhook_id || ''
226
+ }
227
+ };
228
+
229
+ const req = http.request(options, (res) => {
230
+ // Webhook forwarded successfully
231
+ if (print) {
232
+ Logger.info(` → Forwarded to local server (status: ${res.statusCode})`);
233
+ }
234
+ });
235
+
236
+ req.on('error', (error) => {
237
+ Logger.error(`Failed to forward webhook to local server: ${error.message}`);
238
+ });
239
+
240
+ req.write(postData);
241
+ req.end();
242
+ });
243
+
244
+ // Handle WebSocket close
245
+ wsClient.onClose((code, reason) => {
246
+ Logger.warning(`\n> WebSocket connection closed (code: ${code})`);
247
+ if (reason) {
248
+ Logger.warning(`> Reason: ${reason}`);
249
+ }
250
+ });
251
+
252
+ // Cleanup on exit
253
+ const cleanup = async () => {
254
+ Logger.warning('\n\n> Cleaning up...');
255
+ try {
256
+ if (wsClient) {
257
+ wsClient.close();
258
+ Logger.info('> WebSocket connection closed');
259
+ }
260
+ } catch (error) {
261
+ Logger.error(`> Failed to cleanup: ${error.message}`);
262
+ }
263
+ if (server) {
264
+ server.close();
265
+ }
266
+ process.exit(0);
267
+ };
268
+
269
+ process.on('SIGINT', cleanup);
270
+ process.on('SIGTERM', cleanup);
271
+
272
+ } catch (error) {
273
+ spinner.fail('Failed to setup webhook forwarding');
274
+ Logger.error(error.message);
275
+
276
+ // Provide helpful error messages
277
+ if (error.message.includes('API key is required')) {
278
+ Logger.warning('\n> To fix this:');
279
+ Logger.warning('> 1. Set MODELRIVER_API_KEY environment variable:');
280
+ Logger.warning('> export MODELRIVER_API_KEY=mr_live_YOUR_KEY');
281
+ Logger.warning('> 2. Or use --api-key flag:');
282
+ Logger.warning('> modelriver listen --api-key mr_live_YOUR_KEY');
283
+ Logger.warning('> 3. Create an API key in the ModelRiver dashboard if you don\'t have one');
284
+ } else if (error.message.includes('Invalid API key format')) {
285
+ Logger.warning('\n> API keys must start with "mr_live_" or "mr_test_"');
286
+ Logger.warning('> Example: mr_live_abc123...');
287
+ } else if (error.response?.status === 401) {
288
+ Logger.warning('\n> Authentication failed. Possible causes:');
289
+ Logger.warning('> 1. API key is invalid or revoked');
290
+ Logger.warning('> 2. API key doesn\'t exist in the database');
291
+ Logger.warning('> 3. API key format is incorrect');
292
+ Logger.warning('> Check your API key in the ModelRiver dashboard');
293
+ } else if (error.response?.status === 403) {
294
+ Logger.warning('\n> Access forbidden. Possible causes:');
295
+ Logger.warning('> 1. Backend code not deployed (if testing against production)');
296
+ Logger.warning('> 2. Route not available on this server');
297
+ Logger.warning('> 3. Server needs to be restarted to load new routes');
298
+ }
299
+
300
+ if (error.response?.data) {
301
+ Logger.error(`\nServer response: ${JSON.stringify(error.response.data, null, 2)}`);
302
+ }
303
+ if (error.response?.status) {
304
+ Logger.error(`HTTP Status: ${error.response.status}`);
305
+ }
306
+ if (wsClient) {
307
+ wsClient.close();
308
+ }
309
+ process.exit(1);
310
+ }
311
+ }
312
+
313
+ module.exports = { listenCommand };
314
+
315
+
@@ -0,0 +1,176 @@
1
+ const readline = require('readline');
2
+ const config = require('../lib/config');
3
+ const Logger = require('../utils/logger');
4
+ const { normalizeWebhookUrl, isValidApiKeyFormat } = require('../utils/url-helpers');
5
+
6
+ /**
7
+ * Create a readline interface for interactive prompts
8
+ */
9
+ function createReadlineInterface() {
10
+ return readline.createInterface({
11
+ input: process.stdin,
12
+ output: process.stdout
13
+ });
14
+ }
15
+
16
+ /**
17
+ * Prompt user for input
18
+ * @param {readline.Interface} rl - Readline interface
19
+ * @param {string} question - Question to ask
20
+ * @param {string} defaultValue - Default value if user presses enter
21
+ * @param {boolean} maskDefault - Whether to mask the default value in display
22
+ * @param {boolean} showDefault - Whether to show the default value in brackets
23
+ * @returns {Promise<string>} - User input
24
+ */
25
+ function prompt(rl, question, defaultValue = '', maskDefault = false, showDefault = true) {
26
+ return new Promise((resolve) => {
27
+ let displayDefault = defaultValue;
28
+ if (defaultValue && maskDefault) {
29
+ // Show masked version like "mr_live_...a1b2"
30
+ if (defaultValue.length > 10) {
31
+ displayDefault = `${defaultValue.substring(0, 8)}...${defaultValue.substring(defaultValue.length - 4)}`;
32
+ } else {
33
+ displayDefault = '********';
34
+ }
35
+ }
36
+
37
+ let displayQuestion = question + ': ';
38
+
39
+ if (showDefault && defaultValue) {
40
+ displayQuestion = `${question} (${displayDefault}): `;
41
+ }
42
+
43
+ // If the question already contains the default value in brackets (redundancy check), don't append it again
44
+ // Heuristic: check if question ends with the default value inside parens, ignoring whitespace
45
+ if (showDefault && defaultValue && question.trim().endsWith(`(${defaultValue})`)) {
46
+ displayQuestion = `${question}: `;
47
+ }
48
+
49
+ rl.question(displayQuestion, (answer) => {
50
+ resolve(answer.trim() || defaultValue);
51
+ });
52
+ });
53
+ }
54
+
55
+ /**
56
+ * Interactive login command
57
+ * Prompts for API key and forward URL, saves to config
58
+ */
59
+ async function loginCommand(options) {
60
+ const rl = createReadlineInterface();
61
+
62
+ console.log('');
63
+ Logger.section('ModelRiver CLI Login');
64
+ console.log('Configure your ModelRiver CLI credentials.\n');
65
+
66
+ try {
67
+ // Get current values for defaults
68
+ const currentApiKey = config.getApiKey();
69
+ const currentForwardUrl = config.getForwardUrl();
70
+
71
+ // Show current config if exists
72
+ if (currentApiKey || currentForwardUrl) {
73
+ Logger.info('Current configuration:');
74
+ if (currentApiKey) {
75
+ Logger.info(` API Key: ${currentApiKey.substring(0, 8)}...${currentApiKey.substring(currentApiKey.length - 4)}`);
76
+ }
77
+ if (currentForwardUrl) {
78
+ Logger.info(` Forward URL: ${currentForwardUrl}`);
79
+ }
80
+ console.log('');
81
+ }
82
+
83
+ // Check for environment variable shadowing
84
+ if (process.env.MODELRIVER_API_KEY) {
85
+ Logger.warning('WARNING: MODELRIVER_API_KEY environment variable is set.');
86
+ Logger.warning('This environment variable will override any API key you save here.');
87
+ Logger.warning(`Current env value: ${process.env.MODELRIVER_API_KEY.substring(0, 15)}...`);
88
+ Logger.warning('To use the key you are about to save, you must unset this variable:');
89
+ Logger.warning(' unset MODELRIVER_API_KEY');
90
+ console.log('');
91
+ }
92
+
93
+ // Prompt for API key
94
+ let apiKey = await prompt(
95
+ rl,
96
+ 'Enter your ModelRiver API key',
97
+ currentApiKey || '',
98
+ true // maskDefault
99
+ );
100
+
101
+ // Validate API key format
102
+ if (!apiKey) {
103
+ Logger.error('API key is required.');
104
+ Logger.info('Get your API key from https://console.modelriver.com');
105
+ rl.close();
106
+ process.exit(1);
107
+ }
108
+
109
+ if (!isValidApiKeyFormat(apiKey)) {
110
+ Logger.error('Invalid API key format.');
111
+ Logger.warning('API key must start with "mr_live_" or "mr_test_"');
112
+ rl.close();
113
+ process.exit(1);
114
+ }
115
+
116
+ // Prompt for forward URL
117
+ const defaultForwardUrl = currentForwardUrl || 'http://localhost:4000';
118
+ let forwardUrl = await prompt(
119
+ rl,
120
+ 'Enter your webhook forward URL (e.g. http://localhost:4000/webhook/modelriver)',
121
+ defaultForwardUrl,
122
+ false, // maskDefault
123
+ false // showDefault - Hide the (http://...) bracket as requested
124
+ );
125
+
126
+ // Normalize the forward URL
127
+ forwardUrl = normalizeWebhookUrl(forwardUrl);
128
+
129
+ rl.close();
130
+
131
+ // Save configuration
132
+ const spinner = Logger.spinner('Saving configuration...');
133
+ spinner.start();
134
+
135
+ try {
136
+ config.saveConfig({
137
+ api_key: apiKey,
138
+ forward_url: forwardUrl
139
+ });
140
+
141
+ spinner.succeed('Configuration saved!');
142
+ console.log('');
143
+
144
+ // Display saved configuration
145
+ Logger.section('Saved Configuration');
146
+ Logger.success(`API Key: ${apiKey.substring(0, 25)}...`);
147
+ Logger.success(`Forward URL: ${forwardUrl}`);
148
+ Logger.info(`Config file: ${config.getHomeConfigPath()}`);
149
+
150
+ console.log('');
151
+ Logger.section('Quick Start');
152
+ Logger.info('Start forwarding webhooks:');
153
+ console.log(' modelriver forward');
154
+ console.log('');
155
+ Logger.info('Or use listen with print:');
156
+ console.log(' modelriver listen --print');
157
+ console.log('');
158
+ Logger.info('Other useful commands:');
159
+ console.log(' modelriver trigger -w my-workflow -m "Hello"');
160
+ console.log(' modelriver websocket -w my-workflow -m "Test"');
161
+ console.log('');
162
+
163
+ } catch (error) {
164
+ spinner.fail('Failed to save configuration');
165
+ Logger.error(error.message);
166
+ process.exit(1);
167
+ }
168
+
169
+ } catch (error) {
170
+ rl.close();
171
+ Logger.error(`Login failed: ${error.message}`);
172
+ process.exit(1);
173
+ }
174
+ }
175
+
176
+ module.exports = { loginCommand };