@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,249 @@
1
+ const WebSocket = require('ws');
2
+
3
+ class CLIWebSocketClient {
4
+ constructor(websocketUrl, token, verbose = false) {
5
+ this.websocketUrl = websocketUrl;
6
+ this.token = token;
7
+ this.verbose = verbose;
8
+ this.ws = null;
9
+ this.channel = null;
10
+ this.listeners = {};
11
+ this.heartbeatInterval = null;
12
+ this.heartbeatRef = 0;
13
+ this.reconnectAttempts = 0;
14
+ this.maxReconnectAttempts = 5;
15
+ this.reconnectDelay = 1000; // Start with 1 second
16
+ }
17
+
18
+ /**
19
+ * Connect to WebSocket
20
+ * @returns {Promise<void>}
21
+ */
22
+ connect() {
23
+ return new Promise((resolve, reject) => {
24
+ // Phoenix WebSocket endpoint requires /websocket suffix for raw WebSocket connections
25
+ let wsUrl = this.websocketUrl;
26
+ if (!wsUrl.endsWith('/websocket')) {
27
+ wsUrl = wsUrl.replace(/\/?$/, '/websocket');
28
+ }
29
+ const url = `${wsUrl}?token=${encodeURIComponent(this.token)}`;
30
+
31
+ if (this.verbose) {
32
+ const Logger = require('../utils/logger');
33
+ Logger.info(`Connecting to WebSocket: ${url.replace(this.token, 'TOKEN')}`);
34
+ }
35
+
36
+ this.ws = new WebSocket(url);
37
+
38
+ this.ws.on('open', () => {
39
+ this.reconnectAttempts = 0;
40
+ this.reconnectDelay = 1000;
41
+ this.startHeartbeat();
42
+ resolve();
43
+ });
44
+
45
+ this.ws.on('error', (error) => {
46
+ this.stopHeartbeat();
47
+ if (this.reconnectAttempts === 0) {
48
+ reject(error);
49
+ }
50
+ });
51
+
52
+ this.ws.on('message', (data) => {
53
+ this.handleMessage(data);
54
+ });
55
+
56
+ this.ws.on('close', (code, reason) => {
57
+ this.stopHeartbeat();
58
+ if (this.listeners.close) {
59
+ this.listeners.close(code, reason);
60
+ }
61
+ // Attempt reconnection if not intentional close
62
+ if (code !== 1000 && this.reconnectAttempts < this.maxReconnectAttempts) {
63
+ this.attemptReconnect();
64
+ }
65
+ });
66
+ });
67
+ }
68
+
69
+ /**
70
+ * Attempt to reconnect to WebSocket
71
+ */
72
+ attemptReconnect() {
73
+ this.reconnectAttempts++;
74
+ const delay = Math.min(this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1), 30000);
75
+
76
+ const Logger = require('../utils/logger');
77
+ Logger.warning(`WebSocket closed. Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})...`);
78
+
79
+ setTimeout(() => {
80
+ if (this.ws && this.ws.readyState === WebSocket.CLOSED) {
81
+ this.connect()
82
+ .then(() => {
83
+ Logger.success('WebSocket reconnected');
84
+ // Rejoin channel if we had one
85
+ if (this.channel) {
86
+ this.joinChannel(this.channel).catch(err => {
87
+ Logger.error(`Failed to rejoin channel: ${err.message}`);
88
+ });
89
+ }
90
+ })
91
+ .catch(err => {
92
+ Logger.error(`Reconnection failed: ${err.message}`);
93
+ if (this.reconnectAttempts < this.maxReconnectAttempts) {
94
+ this.attemptReconnect();
95
+ }
96
+ });
97
+ }
98
+ }, delay);
99
+ }
100
+
101
+ /**
102
+ * Start sending Phoenix heartbeats
103
+ */
104
+ startHeartbeat() {
105
+ this.heartbeatInterval = setInterval(() => {
106
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
107
+ this.heartbeatRef++;
108
+ const heartbeat = JSON.stringify({
109
+ topic: 'phoenix',
110
+ event: 'heartbeat',
111
+ payload: {},
112
+ ref: `heartbeat-${this.heartbeatRef}`
113
+ });
114
+ this.ws.send(heartbeat);
115
+ }
116
+ }, 30000); // Every 30 seconds
117
+ }
118
+
119
+ /**
120
+ * Stop sending heartbeats
121
+ */
122
+ stopHeartbeat() {
123
+ if (this.heartbeatInterval) {
124
+ clearInterval(this.heartbeatInterval);
125
+ this.heartbeatInterval = null;
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Join a Phoenix channel (CLI webhook channel)
131
+ * @param {string} channelName - Channel name (e.g., "cli_webhooks:user_id")
132
+ * @returns {Promise<object>} - Join response
133
+ */
134
+ joinChannel(channelName) {
135
+ return new Promise((resolve, reject) => {
136
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
137
+ reject(new Error('WebSocket is not connected'));
138
+ return;
139
+ }
140
+
141
+ const joinRef = '1';
142
+
143
+ const joinMsg = JSON.stringify({
144
+ topic: channelName,
145
+ event: 'phx_join',
146
+ payload: {},
147
+ ref: joinRef
148
+ });
149
+
150
+ let resolved = false;
151
+
152
+ const handleJoinResponse = (data) => {
153
+ if (resolved) return;
154
+
155
+ try {
156
+ const msg = JSON.parse(data.toString());
157
+ if (msg.event === 'phx_reply' && msg.ref === joinRef && msg.topic === channelName) {
158
+ resolved = true;
159
+ this.ws.removeListener('message', handleJoinResponse);
160
+
161
+ if (msg.payload.status === 'ok') {
162
+ this.channel = channelName;
163
+ resolve(msg.payload);
164
+ } else {
165
+ reject(new Error(`Failed to join channel: ${msg.payload.response || 'Unknown error'}`));
166
+ }
167
+ }
168
+ } catch (error) {
169
+ // Ignore parse errors
170
+ }
171
+ };
172
+
173
+ this.ws.on('message', handleJoinResponse);
174
+ this.ws.send(joinMsg);
175
+
176
+ setTimeout(() => {
177
+ if (!resolved) {
178
+ resolved = true;
179
+ this.ws.removeListener('message', handleJoinResponse);
180
+ reject(new Error('Channel join timeout'));
181
+ }
182
+ }, 10000);
183
+ });
184
+ }
185
+
186
+ /**
187
+ * Handle incoming WebSocket messages
188
+ * @param {Buffer} data - Message data
189
+ */
190
+ handleMessage(data) {
191
+ try {
192
+ const msgStr = data.toString();
193
+ const msg = JSON.parse(msgStr);
194
+
195
+ const Logger = require('../utils/logger');
196
+
197
+ // Handle webhook events
198
+ if (msg.event === 'webhook') {
199
+ if (this.verbose) {
200
+ Logger.info(`\n📨 Webhook event received on channel: ${msg.topic}`);
201
+ }
202
+ if (this.listeners.webhook) {
203
+ this.listeners.webhook(msg.payload);
204
+ }
205
+ } else if (msg.event === 'phx_reply') {
206
+ // Handle join responses
207
+ if (this.listeners.join) {
208
+ this.listeners.join(msg);
209
+ }
210
+ } else if (this.verbose && msg.event !== 'heartbeat') {
211
+ Logger.info(`Received event: ${msg.event} on topic: ${msg.topic}`);
212
+ }
213
+ } catch (error) {
214
+ if (this.verbose) {
215
+ const Logger = require('../utils/logger');
216
+ Logger.warning(`Failed to parse WebSocket message: ${error.message}`);
217
+ }
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Listen for webhook events
223
+ * @param {Function} callback - Callback function
224
+ */
225
+ onWebhook(callback) {
226
+ this.listeners.webhook = callback;
227
+ }
228
+
229
+ /**
230
+ * Listen for close events
231
+ * @param {Function} callback - Callback function
232
+ */
233
+ onClose(callback) {
234
+ this.listeners.close = callback;
235
+ }
236
+
237
+ /**
238
+ * Close WebSocket connection
239
+ */
240
+ close() {
241
+ if (this.ws) {
242
+ this.ws.close(1000, 'CLI shutdown');
243
+ this.stopHeartbeat();
244
+ }
245
+ }
246
+ }
247
+
248
+ module.exports = CLIWebSocketClient;
249
+
@@ -0,0 +1,137 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const os = require('os');
4
+
5
+ class Config {
6
+ constructor() {
7
+ this.configFile = this.findConfigFile();
8
+ this.config = this.loadConfig();
9
+ }
10
+
11
+ findConfigFile() {
12
+ // Check for .modelriverrc in current directory
13
+ const localRc = path.join(process.cwd(), '.modelriverrc');
14
+ if (fs.existsSync(localRc)) {
15
+ return localRc;
16
+ }
17
+
18
+ // Check for ~/.modelriver/config.json
19
+ const homeConfig = path.join(os.homedir(), '.modelriver', 'config.json');
20
+ if (fs.existsSync(homeConfig)) {
21
+ return homeConfig;
22
+ }
23
+
24
+ return null;
25
+ }
26
+
27
+ loadConfig() {
28
+ if (!this.configFile) {
29
+ return {};
30
+ }
31
+
32
+ try {
33
+ const content = fs.readFileSync(this.configFile, 'utf8');
34
+ return JSON.parse(content);
35
+ } catch (error) {
36
+ return {};
37
+ }
38
+ }
39
+
40
+ get(key, defaultValue = null) {
41
+ // Priority: env var > config file > default
42
+ const envKey = `MODELRIVER_${key.toUpperCase().replace(/-/g, '_')}`;
43
+
44
+ if (process.env[envKey]) {
45
+ return process.env[envKey].trim();
46
+ }
47
+
48
+ const configKey = key.replace(/-/g, '_');
49
+ if (this.config[configKey]) {
50
+ return this.config[configKey].trim();
51
+ }
52
+
53
+ return defaultValue;
54
+ }
55
+
56
+ getApiKey() {
57
+ return this.get('api-key') || this.get('api_key');
58
+ }
59
+
60
+ getProjectId() {
61
+ return this.get('project-id') || this.get('project_id');
62
+ }
63
+
64
+ getApiUrl() {
65
+ return this.get('api-url', 'https://api.modelriver.com') || this.get('api_url', 'https://api.modelriver.com');
66
+ }
67
+
68
+ getForwardUrl() {
69
+ return this.get('forward-url') || this.get('forward_url');
70
+ }
71
+
72
+ getWebSocketUrl() {
73
+ const apiUrl = this.getApiUrl();
74
+ // Convert http:// to ws:// and https:// to wss://
75
+ if (apiUrl.startsWith('https://')) {
76
+ return apiUrl.replace('https://', 'wss://');
77
+ }
78
+ return apiUrl.replace('http://', 'ws://');
79
+ }
80
+
81
+ /**
82
+ * Get the path to the home config file
83
+ * @returns {string} - Path to ~/.modelriver/config.json
84
+ */
85
+ getHomeConfigPath() {
86
+ return path.join(os.homedir(), '.modelriver', 'config.json');
87
+ }
88
+
89
+ /**
90
+ * Save configuration to ~/.modelriver/config.json
91
+ * @param {object} configData - Configuration object to save
92
+ */
93
+ saveConfig(configData) {
94
+ const configDir = path.join(os.homedir(), '.modelriver');
95
+ const configPath = this.getHomeConfigPath();
96
+
97
+ // Create directory if it doesn't exist
98
+ if (!fs.existsSync(configDir)) {
99
+ fs.mkdirSync(configDir, { recursive: true });
100
+ }
101
+
102
+ // Load existing config and merge
103
+ let existingConfig = {};
104
+ if (fs.existsSync(configPath)) {
105
+ try {
106
+ const content = fs.readFileSync(configPath, 'utf8');
107
+ existingConfig = JSON.parse(content);
108
+ } catch (error) {
109
+ // Ignore parse errors, start fresh
110
+ }
111
+ }
112
+
113
+ // Merge with new config data
114
+ const mergedConfig = { ...existingConfig, ...configData };
115
+
116
+ // Write to file
117
+ fs.writeFileSync(configPath, JSON.stringify(mergedConfig, null, 2), 'utf8');
118
+
119
+ // Reload config
120
+ this.configFile = configPath;
121
+ this.config = mergedConfig;
122
+ }
123
+
124
+ /**
125
+ * Save a single configuration value
126
+ * @param {string} key - Configuration key
127
+ * @param {string} value - Configuration value
128
+ */
129
+ save(key, value) {
130
+ const configKey = key.replace(/-/g, '_');
131
+ this.saveConfig({ [configKey]: value });
132
+ }
133
+ }
134
+
135
+ module.exports = new Config();
136
+
137
+
@@ -0,0 +1,98 @@
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
+ });
@@ -0,0 +1,56 @@
1
+ const crypto = require('crypto');
2
+
3
+ class WebhookVerifier {
4
+ /**
5
+ * Generate HMAC-SHA256 signature for webhook payload
6
+ * Matches backend implementation in lib/modelriver/workers/webhook_delivery_worker.ex
7
+ */
8
+ static generateSignature(payload, secret, timestamp) {
9
+ // Create signature payload: timestamp.json_payload
10
+ const jsonPayload = JSON.stringify(payload);
11
+ const signaturePayload = `${timestamp}.${jsonPayload}`;
12
+
13
+ // Generate HMAC-SHA256
14
+ const hmac = crypto.createHmac('sha256', secret);
15
+ hmac.update(signaturePayload);
16
+ return hmac.digest('hex');
17
+ }
18
+
19
+ /**
20
+ * Verify webhook signature
21
+ * @param {string} providedSignature - Signature from X-ModelRiver-Signature header
22
+ * @param {object} payload - The payload.data object from webhook body
23
+ * @param {string} secret - Webhook secret
24
+ * @param {string|number} timestamp - Timestamp from X-ModelRiver-Timestamp header
25
+ * @returns {boolean} - True if signature is valid
26
+ */
27
+ static verify(providedSignature, payload, secret, timestamp) {
28
+ const expectedSignature = this.generateSignature(payload, secret, timestamp);
29
+ return this.secureCompare(providedSignature, expectedSignature);
30
+ }
31
+
32
+ /**
33
+ * Constant-time comparison to prevent timing attacks
34
+ * Matches backend implementation
35
+ */
36
+ static secureCompare(left, right) {
37
+ if (typeof left !== 'string' || typeof right !== 'string') {
38
+ return false;
39
+ }
40
+
41
+ if (left.length !== right.length) {
42
+ return false;
43
+ }
44
+
45
+ let result = 0;
46
+ for (let i = 0; i < left.length; i++) {
47
+ result |= left.charCodeAt(i) ^ right.charCodeAt(i);
48
+ }
49
+
50
+ return result === 0;
51
+ }
52
+ }
53
+
54
+ module.exports = WebhookVerifier;
55
+
56
+
@@ -0,0 +1,90 @@
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
+ });