@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,225 @@
1
+ const WebSocket = require('ws');
2
+
3
+ class WebSocketClient {
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
+ }
14
+
15
+ /**
16
+ * Connect to WebSocket
17
+ * @returns {Promise<void>}
18
+ */
19
+ connect() {
20
+ return new Promise((resolve, reject) => {
21
+ const url = `${this.websocketUrl}/websocket?token=${encodeURIComponent(this.token)}`;
22
+
23
+ this.ws = new WebSocket(url);
24
+
25
+ this.ws.on('open', () => {
26
+ // Start sending heartbeats every 30 seconds (Phoenix default is 30s)
27
+ this.startHeartbeat();
28
+ resolve();
29
+ });
30
+
31
+ this.ws.on('error', (error) => {
32
+ this.stopHeartbeat();
33
+ reject(error);
34
+ });
35
+
36
+ this.ws.on('message', (data) => {
37
+ this.handleMessage(data);
38
+ });
39
+
40
+ this.ws.on('close', () => {
41
+ this.stopHeartbeat();
42
+ if (this.listeners.close) {
43
+ this.listeners.close();
44
+ }
45
+ });
46
+ });
47
+ }
48
+
49
+ /**
50
+ * Start sending Phoenix heartbeats
51
+ */
52
+ startHeartbeat() {
53
+ this.heartbeatInterval = setInterval(() => {
54
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
55
+ this.heartbeatRef++;
56
+ const heartbeat = JSON.stringify({
57
+ topic: 'phoenix',
58
+ event: 'heartbeat',
59
+ payload: {},
60
+ ref: `heartbeat-${this.heartbeatRef}`
61
+ });
62
+ this.ws.send(heartbeat);
63
+ }
64
+ }, 30000); // Every 30 seconds
65
+ }
66
+
67
+ /**
68
+ * Stop sending heartbeats
69
+ */
70
+ stopHeartbeat() {
71
+ if (this.heartbeatInterval) {
72
+ clearInterval(this.heartbeatInterval);
73
+ this.heartbeatInterval = null;
74
+ }
75
+ }
76
+
77
+
78
+ /**
79
+ * Join a Phoenix channel
80
+ * @param {string} channelName - Channel name (e.g., "ai_response:project_id:channel_id")
81
+ * @returns {Promise<object>} - Join response
82
+ */
83
+ joinChannel(channelName) {
84
+ return new Promise((resolve, reject) => {
85
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
86
+ reject(new Error('WebSocket is not connected'));
87
+ return;
88
+ }
89
+
90
+ const joinRef = '1';
91
+
92
+ const joinMsg = JSON.stringify({
93
+ topic: channelName,
94
+ event: 'phx_join',
95
+ payload: {},
96
+ ref: joinRef
97
+ });
98
+
99
+ let resolved = false;
100
+
101
+ // Set up a temporary listener for the join response
102
+ // This will process the response and then be removed
103
+ const handleJoinResponse = (data) => {
104
+ if (resolved) return;
105
+
106
+ try {
107
+ const msg = JSON.parse(data.toString());
108
+ // Look for the join response (phx_reply with our ref)
109
+ if (msg.event === 'phx_reply' && msg.ref === joinRef && msg.topic === channelName) {
110
+ resolved = true;
111
+ // Remove this listener - handleMessage() will continue processing other messages
112
+ this.ws.removeListener('message', handleJoinResponse);
113
+
114
+ if (msg.payload.status === 'ok') {
115
+ this.channel = channelName;
116
+ resolve(msg.payload);
117
+ } else {
118
+ reject(new Error(`Failed to join channel: ${msg.payload.response || 'Unknown error'}`));
119
+ }
120
+ }
121
+ } catch (error) {
122
+ // Ignore parse errors, wait for next message
123
+ }
124
+ };
125
+
126
+ // Add listener for join response
127
+ // The handleMessage() listener in connect() will also be called, but won't match
128
+ this.ws.on('message', handleJoinResponse);
129
+
130
+ this.ws.send(joinMsg);
131
+
132
+ // Timeout after 10 seconds
133
+ setTimeout(() => {
134
+ if (!resolved) {
135
+ resolved = true;
136
+ this.ws.removeListener('message', handleJoinResponse);
137
+ reject(new Error('Channel join timeout'));
138
+ }
139
+ }, 10000);
140
+ });
141
+ }
142
+
143
+ /**
144
+ * Handle incoming WebSocket messages
145
+ * @param {Buffer} data - Message data
146
+ */
147
+ handleMessage(data) {
148
+ try {
149
+ const msgStr = data.toString();
150
+ const msg = JSON.parse(msgStr);
151
+
152
+ const Logger = require('../utils/logger'); // Lazy load to avoid circular dependency if any
153
+
154
+ // Log all events for debugging purposes (except heartbeats and phx_reply unless verbose)
155
+ if (this.verbose) {
156
+ // In verbose mode, log ALL messages including raw JSON
157
+ if (msg.event !== 'heartbeat') {
158
+ Logger.info(`\n📨 Received WebSocket message:`);
159
+ Logger.info(` Event: ${msg.event}`);
160
+ Logger.info(` Topic: ${msg.topic}`);
161
+ Logger.info(` Payload keys: ${msg.payload ? Object.keys(msg.payload).join(', ') : 'none'}`);
162
+ if (msg.event === 'response') {
163
+ Logger.info(` ✅ This is a RESPONSE event!`);
164
+ }
165
+ }
166
+ } else {
167
+ // In non-verbose mode, only log non-standard events
168
+ if (msg.event !== 'phx_reply' && msg.event !== 'heartbeat' && msg.event !== 'response') {
169
+ Logger.info(`received event: ${msg.event} on topic: ${msg.topic}`);
170
+ }
171
+ }
172
+
173
+ // Handle response events
174
+ if (msg.event === 'response') {
175
+ if (this.verbose) {
176
+ Logger.info(`\n✅ Processing response event...`);
177
+ }
178
+ if (this.listeners.response) {
179
+ this.listeners.response(msg.payload);
180
+ } else if (this.verbose) {
181
+ Logger.warning(`⚠️ Response event received but no listener registered!`);
182
+ }
183
+ } else if (msg.event === 'phx_reply') {
184
+ // Handle join responses
185
+ if (this.listeners.join) {
186
+ this.listeners.join(msg);
187
+ }
188
+ }
189
+ } catch (error) {
190
+ // Log parse errors in verbose mode
191
+ if (this.verbose) {
192
+ const Logger = require('../utils/logger');
193
+ Logger.warning(`Failed to parse WebSocket message: ${error.message}`);
194
+ }
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Listen for response events
200
+ * @param {Function} callback - Callback function
201
+ */
202
+ onResponse(callback) {
203
+ this.listeners.response = callback;
204
+ }
205
+
206
+ /**
207
+ * Listen for close events
208
+ * @param {Function} callback - Callback function
209
+ */
210
+ onClose(callback) {
211
+ this.listeners.close = callback;
212
+ }
213
+
214
+ /**
215
+ * Close WebSocket connection
216
+ */
217
+ close() {
218
+ if (this.ws) {
219
+ this.ws.close();
220
+ }
221
+ }
222
+ }
223
+
224
+ module.exports = WebSocketClient;
225
+
@@ -0,0 +1,43 @@
1
+ class Formatter {
2
+ static json(data, indent = 2) {
3
+ return JSON.stringify(data, null, indent);
4
+ }
5
+
6
+ static table(headers, rows) {
7
+ // Simple table formatter
8
+ const maxWidths = headers.map((header, i) => {
9
+ const headerWidth = header.length;
10
+ const maxRowWidth = Math.max(
11
+ ...rows.map(row => String(row[i] || '').length)
12
+ );
13
+ return Math.max(headerWidth, maxRowWidth);
14
+ });
15
+
16
+ const formatRow = (row) => {
17
+ return row.map((cell, i) => {
18
+ const str = String(cell || '');
19
+ return str.padEnd(maxWidths[i]);
20
+ }).join(' ');
21
+ };
22
+
23
+ const headerRow = formatRow(headers);
24
+ const separator = headers.map((_, i) => '─'.repeat(maxWidths[i])).join(' ');
25
+ const dataRows = rows.map(formatRow);
26
+
27
+ return [headerRow, separator, ...dataRows].join('\n');
28
+ }
29
+
30
+ static timestamp(date = new Date()) {
31
+ return date.toISOString();
32
+ }
33
+
34
+ static formatDuration(ms) {
35
+ if (ms < 1000) return `${ms}ms`;
36
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
37
+ return `${(ms / 60000).toFixed(1)}m`;
38
+ }
39
+ }
40
+
41
+ module.exports = Formatter;
42
+
43
+
@@ -0,0 +1,84 @@
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
+ });
@@ -0,0 +1,39 @@
1
+ const chalk = require('chalk');
2
+ const ora = require('ora');
3
+
4
+ class Logger {
5
+ static success(message) {
6
+ console.log(chalk.green(`✓ ${message}`));
7
+ }
8
+
9
+ static error(message) {
10
+ console.error(chalk.red(`✗ ${message}`));
11
+ }
12
+
13
+ static info(message) {
14
+ console.log(chalk.blue(`ℹ ${message}`));
15
+ }
16
+
17
+ static warning(message) {
18
+ console.log(chalk.yellow(`⚠ ${message}`));
19
+ }
20
+
21
+ static log(message) {
22
+ console.log(message);
23
+ }
24
+
25
+ static spinner(message) {
26
+ // Handle both CommonJS and ESM exports
27
+ const oraInstance = typeof ora === 'function' ? ora : (ora.default || ora);
28
+ return oraInstance(message);
29
+ }
30
+
31
+ static section(title) {
32
+ console.log(chalk.cyan.bold(`\n${title}`));
33
+ console.log(chalk.gray('─'.repeat(title.length)));
34
+ }
35
+ }
36
+
37
+ module.exports = Logger;
38
+
39
+
@@ -0,0 +1,98 @@
1
+ /**
2
+ * URL helper utilities for ModelRiver CLI
3
+ */
4
+
5
+ /**
6
+ * Normalize a webhook URL to ensure it ends with /webhook/modelriver
7
+ * @param {string} url - The input URL
8
+ * @returns {string} - Normalized URL with /webhook/modelriver path
9
+ */
10
+ function normalizeWebhookUrl(url) {
11
+ if (!url) return url;
12
+
13
+ try {
14
+ const urlObj = new URL(url);
15
+ let pathname = urlObj.pathname;
16
+
17
+ // Remove trailing slash
18
+ pathname = pathname.replace(/\/$/, '');
19
+
20
+ // Normalize the path to end with /webhook/modelriver
21
+ if (!pathname || pathname === '') {
22
+ pathname = '/webhook/modelriver';
23
+ } else if (pathname === '/webhook' || pathname === '/webhook/') {
24
+ pathname = '/webhook/modelriver';
25
+ } else if (!pathname.endsWith('/webhook/modelriver')) {
26
+ // If path doesn't already end with /webhook/modelriver
27
+ if (pathname.endsWith('/modelriver')) {
28
+ // Already has /modelriver, check if it has /webhook before it
29
+ if (!pathname.includes('/webhook/modelriver')) {
30
+ pathname = '/webhook/modelriver';
31
+ }
32
+ } else if (!pathname.includes('/webhook')) {
33
+ // No /webhook in path at all, append /webhook/modelriver
34
+ pathname = pathname + '/webhook/modelriver';
35
+ } else {
36
+ // Has /webhook but not /modelriver, append /modelriver
37
+ if (pathname.endsWith('/webhook')) {
38
+ pathname = pathname + '/modelriver';
39
+ }
40
+ }
41
+ }
42
+
43
+ urlObj.pathname = pathname;
44
+ return urlObj.toString().replace(/\/$/, '');
45
+ } catch (error) {
46
+ // If URL parsing fails, try simple string manipulation
47
+ let result = url.replace(/\/$/, '');
48
+ if (!result.includes('/webhook/modelriver')) {
49
+ if (result.endsWith('/webhook')) {
50
+ result += '/modelriver';
51
+ } else {
52
+ result += '/webhook/modelriver';
53
+ }
54
+ }
55
+ return result;
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Extract port from a URL
61
+ * @param {string} url - The URL to extract port from
62
+ * @returns {number|null} - Port number or null if not found
63
+ */
64
+ function extractPort(url) {
65
+ try {
66
+ const urlObj = new URL(url);
67
+ if (urlObj.port) {
68
+ return parseInt(urlObj.port, 10);
69
+ }
70
+ // Return default port based on protocol
71
+ if (urlObj.protocol === 'https:') return 443;
72
+ if (urlObj.protocol === 'http:') return 80;
73
+ return null;
74
+ } catch (error) {
75
+ // Try regex for URLs without protocol
76
+ const match = url.match(/:(\d+)/);
77
+ if (match) {
78
+ return parseInt(match[1], 10);
79
+ }
80
+ return null;
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Validate API key format
86
+ * @param {string} apiKey - The API key to validate
87
+ * @returns {boolean} - True if valid format
88
+ */
89
+ function isValidApiKeyFormat(apiKey) {
90
+ if (!apiKey || typeof apiKey !== 'string') return false;
91
+ return apiKey.startsWith('mr_live_') || apiKey.startsWith('mr_test_');
92
+ }
93
+
94
+ module.exports = {
95
+ normalizeWebhookUrl,
96
+ extractPort,
97
+ isValidApiKeyFormat
98
+ };
@@ -0,0 +1,84 @@
1
+ // Tests for URL helper utilities
2
+ const { normalizeWebhookUrl, extractPort, isValidApiKeyFormat } = 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
+ });