@fenwave/agent 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js ADDED
@@ -0,0 +1,988 @@
1
+ #!/usr/bin/env node
2
+
3
+ import http from "http";
4
+ import fs from "fs";
5
+ import os from "os";
6
+ import axios from "axios";
7
+ import chalk from "chalk";
8
+ import path from "path";
9
+ import { program } from "commander";
10
+ import { v4 as uuidv4 } from "uuid";
11
+ import { fileURLToPath } from "url";
12
+ import { dirname } from "path";
13
+ import dotenv from "dotenv";
14
+
15
+ // ES module helpers
16
+ const __filename = fileURLToPath(import.meta.url);
17
+ const __dirname = dirname(__filename);
18
+
19
+ // Ensure environment files exist
20
+ import { ensureEnvironmentFiles } from "./utils/envSetup.js";
21
+ const agentEnvPath = ensureEnvironmentFiles(__dirname);
22
+
23
+ // Load environment variables from .env.agent
24
+ dotenv.config({ path: agentEnvPath });
25
+
26
+ // Load environment variables
27
+ dotenv.config();
28
+
29
+ // Import ES modules
30
+ import { setupWebSocketServer, readWsToken } from "./websocket-server.js";
31
+ import { setupCLICommands } from "./cli-commands.js";
32
+ import containerManager from "./containerManager.js";
33
+ import registryStore from "./store/registryStore.js";
34
+ import agentStore from "./store/agentStore.js";
35
+ import { loadConfig } from "./store/configStore.js";
36
+ import { initializeAgentStartTime } from "./docker-actions/general.js";
37
+ import { checkAppHasBeenRun } from "./docker-actions/apps.js";
38
+ import {
39
+ loadSession,
40
+ isSessionValid,
41
+ handleSessionExpiry,
42
+ createSession,
43
+ saveSession,
44
+ clearSession,
45
+ setupSessionWatcher,
46
+ loadBackendUrl,
47
+ } from "./auth.js";
48
+
49
+ // Version from package.json
50
+ const packageJson = JSON.parse(
51
+ fs.readFileSync(new URL("./package.json", import.meta.url), "utf8")
52
+ );
53
+ const { version } = packageJson;
54
+
55
+ // Store active connections
56
+ const clients = new Map();
57
+
58
+ // Load configuration from config file (saved during fenwave init) or fall back to env variables/defaults
59
+ const config = loadConfig();
60
+ const BACKEND_URL = config.backendUrl;
61
+ const FRONTEND_URL = config.frontendUrl;
62
+ const AUTH_TIMEOUT_MS = config.authTimeoutMs;
63
+ const WS_PORT = config.wsPort;
64
+ const CONTAINER_PORT = config.containerPort;
65
+
66
+ /**
67
+ * Acknowledge a version_published event after it has been displayed
68
+ */
69
+ async function acknowledgeVersionPublishedEvent(
70
+ sessionToken,
71
+ eventId,
72
+ changeId
73
+ ) {
74
+ try {
75
+ await axios.post(
76
+ `${BACKEND_URL}/api/agent-cli/acknowledge-event`,
77
+ {
78
+ token: sessionToken,
79
+ eventId: eventId,
80
+ changeId: changeId,
81
+ },
82
+ {
83
+ headers: {
84
+ "Content-Type": "application/json",
85
+ },
86
+ timeout: 5000,
87
+ }
88
+ );
89
+ } catch (error) {
90
+ console.log(
91
+ chalk.yellow(
92
+ `⚠️ Failed to acknowledge event ${changeId}: ${error.message}`
93
+ )
94
+ );
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Poll for app update events and broadcast to connected clients
100
+ */
101
+ async function pollAppUpdates(sessionToken, wss) {
102
+ try {
103
+ const response = await axios.post(
104
+ `${BACKEND_URL}/api/agent-cli/poll-events`,
105
+ {
106
+ token: sessionToken,
107
+ },
108
+ {
109
+ headers: {
110
+ "Content-Type": "application/json",
111
+ },
112
+ timeout: 10000,
113
+ }
114
+ );
115
+
116
+ if (
117
+ response.data &&
118
+ response.data.events &&
119
+ response.data.events.length > 0
120
+ ) {
121
+ const events = response.data.events;
122
+
123
+ // Filter and process events
124
+ let broadcastedCount = 0;
125
+
126
+ for (const event of events) {
127
+ const eventType = event.event_type || "blueprint_changed";
128
+
129
+ // For blueprint_changed events, check if the app has ever been run locally
130
+ // If not, skip broadcasting (app modifications before first run are part of initial setup)
131
+ if (eventType === "blueprint_changed") {
132
+ const hasBeenRun = await checkAppHasBeenRun(event.app_name);
133
+
134
+ if (!hasBeenRun) {
135
+ acknowledgeVersionPublishedEvent(
136
+ sessionToken,
137
+ event.id,
138
+ event.change_id
139
+ );
140
+ continue;
141
+ }
142
+ }
143
+
144
+ broadcastedCount++;
145
+
146
+ const message = JSON.stringify({
147
+ type: eventType,
148
+ appId: event.app_id,
149
+ backstageId: event.app_id,
150
+ appName: event.app_name,
151
+ version: event.version,
152
+ changeId: event.change_id,
153
+ timestamp: event.created_at,
154
+ actor: event.actor,
155
+ changedAt: event.created_at,
156
+ });
157
+
158
+ wss.clients.forEach((client) => {
159
+ if (client.readyState === 1) {
160
+ // WebSocket.OPEN
161
+ client.send(message);
162
+ }
163
+ });
164
+
165
+ // Auto-acknowledge version_published events after broadcasting
166
+ // (these are just notifications, not sync requests)
167
+ if (eventType === "version_published") {
168
+ setTimeout(() => {
169
+ acknowledgeVersionPublishedEvent(
170
+ sessionToken,
171
+ event.id,
172
+ event.change_id
173
+ );
174
+ }, 2000); // 2 second delay to ensure devapp receives it
175
+ }
176
+ }
177
+
178
+ if (broadcastedCount > 0) {
179
+ console.log(
180
+ chalk.blue(
181
+ `📢 Received ${broadcastedCount} app update ${
182
+ broadcastedCount === 1 ? "event" : "events"
183
+ }`
184
+ )
185
+ );
186
+ }
187
+ }
188
+ } catch (error) {
189
+ // Silently handle polling errors
190
+ if (error.response?.status !== 401) {
191
+ const isConnRefused =
192
+ (error.message && error.message.includes("ECONNREFUSED")) ||
193
+ error.code === "ECONNREFUSED";
194
+
195
+ if (isConnRefused) {
196
+ console.error(
197
+ chalk.red(
198
+ "❌ App update polling error: Please ensure backstage is running."
199
+ )
200
+ );
201
+ } else {
202
+ console.error(
203
+ chalk.red(`❌ App update polling error: ${error.message}`)
204
+ );
205
+ }
206
+ }
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Start periodic polling for app updates
212
+ */
213
+ function startAppUpdatePolling(sessionToken, wss) {
214
+ // Poll every 30 seconds
215
+ const pollInterval = setInterval(() => {
216
+ pollAppUpdates(sessionToken, wss);
217
+ }, 30000);
218
+
219
+ console.log(
220
+ chalk.blue("🔄 Started polling for app updates (every 30 seconds)")
221
+ );
222
+
223
+ return pollInterval;
224
+ }
225
+
226
+ // Store server instances for graceful shutdown
227
+ let serverInstances = null;
228
+
229
+ // Graceful shutdown handler
230
+ function gracefulShutdown() {
231
+ console.log(chalk.red("\n🔒 Fenwave Agent shutting down gracefully..."));
232
+
233
+ if (serverInstances) {
234
+ // Clear polling interval
235
+ if (serverInstances.pollInterval) {
236
+ clearInterval(serverInstances.pollInterval);
237
+ }
238
+
239
+ // Close session watcher
240
+ if (serverInstances.sessionWatcher) {
241
+ serverInstances.sessionWatcher.close();
242
+ }
243
+
244
+ // Close WebSocket connections
245
+ if (serverInstances.wss) {
246
+ serverInstances.wss.clients.forEach((client) => {
247
+ client.send(
248
+ JSON.stringify({
249
+ type: "agent_shutdown",
250
+ message: "Agent is shutting down",
251
+ })
252
+ );
253
+ client.close();
254
+ });
255
+ serverInstances.wss.close();
256
+ }
257
+
258
+ // Close HTTP server
259
+ if (serverInstances.server) {
260
+ serverInstances.server.close();
261
+ }
262
+ }
263
+
264
+ // Clear agent info from persistent storage
265
+ agentStore.clearAgentInfo().catch((err) => {
266
+ console.error("Failed to clear agent info:", err);
267
+ });
268
+
269
+ // Stop container
270
+ containerManager
271
+ .stopContainerGracefully()
272
+ .catch((error) => console.error("Error stopping container:", error.message))
273
+ .finally(() => process.exit(0));
274
+ process.exit(0);
275
+ }
276
+
277
+ // Handle graceful shutdown signals
278
+ process.on("SIGINT", () => gracefulShutdown("SIGINT"));
279
+ process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
280
+
281
+ // Start the WebSocket server
282
+ async function startServer(port = WS_PORT) {
283
+ return new Promise(async (resolve, reject) => {
284
+ try {
285
+ // Check for existing session first and validate it
286
+ console.log(chalk.blue("🔍 Checking for existing session..."));
287
+ const existingSession = loadSession();
288
+ if (existingSession) {
289
+ if (isSessionValid(existingSession)) {
290
+ console.log(
291
+ chalk.green("✅ Found valid session, using existing credentials")
292
+ );
293
+
294
+ // Initialize registry store
295
+ await registryStore.initialize();
296
+
297
+ // Start local-env container
298
+ console.log(chalk.blue("🐳 Starting container..."));
299
+ try {
300
+ await containerManager.startContainer();
301
+ console.log(chalk.cyan("✅ Container started successfully"));
302
+ } catch (containerError) {
303
+ console.warn(
304
+ chalk.yellow("⚠️ Failed to start container:"),
305
+ containerError.message
306
+ );
307
+ console.log(chalk.blue("📡 Starting agent without container..."));
308
+ }
309
+
310
+ // Start WebSocket server with existing session
311
+ const agentId = uuidv4();
312
+
313
+ // Try to read existing WebSocket token
314
+ const existingWsToken = readWsToken();
315
+
316
+ const server = http.createServer();
317
+
318
+ server.listen(port, async () => {
319
+ // Initialize agent start time
320
+ await initializeAgentStartTime();
321
+ console.log(chalk.blue("📡 Agent is ready to receive connections"));
322
+
323
+ // Display connection information
324
+ console.log(
325
+ chalk.green(
326
+ "\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
327
+ )
328
+ );
329
+ console.log(chalk.green("✅ Fenwave Agent Started Successfully"));
330
+ console.log(
331
+ chalk.green(
332
+ "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
333
+ )
334
+ );
335
+ console.log(chalk.white("🌐 Fenwave DevApp Dashboard:"));
336
+ console.log(chalk.cyan(` http://localhost:${CONTAINER_PORT}\n`));
337
+ console.log(chalk.white("🔌 WebSocket Server:"));
338
+ console.log(chalk.cyan(` ws://localhost:${port}\n`));
339
+ console.log(chalk.white("👤 User:"));
340
+ console.log(chalk.cyan(` ${existingSession.userEntityRef}\n`));
341
+ console.log(
342
+ chalk.green(
343
+ "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
344
+ )
345
+ );
346
+
347
+ try {
348
+ const wss = setupWebSocketServer(
349
+ server,
350
+ clients,
351
+ agentId,
352
+ {
353
+ userEntityRef: existingSession.userEntityRef,
354
+ expiresAt: existingSession.expiresAt,
355
+ },
356
+ existingWsToken
357
+ ); // Pass the existing token to reuse it
358
+
359
+ // Set up file watcher for session monitoring
360
+ const sessionWatcher = setupSessionWatcher(
361
+ handleSessionExpiry,
362
+ server,
363
+ wss
364
+ );
365
+
366
+ // Start app update polling
367
+ const pollInterval = startAppUpdatePolling(
368
+ existingSession.token,
369
+ wss
370
+ );
371
+
372
+ resolve({
373
+ server,
374
+ wss,
375
+ sessionToken: existingSession.token,
376
+ userEntityRef: existingSession.userEntityRef,
377
+ agentId,
378
+ sessionWatcher,
379
+ pollInterval,
380
+ });
381
+ } catch (error) {
382
+ console.error(
383
+ chalk.red("❌ Failed to setup WebSocket server:"),
384
+ error.message
385
+ );
386
+ reject(error);
387
+ }
388
+ });
389
+
390
+ return;
391
+ } else {
392
+ // Session expired, clear it immediately
393
+ clearSession();
394
+ }
395
+ } else {
396
+ console.log(
397
+ chalk.blue(
398
+ "🔑 No existing session found, proceeding with new authentication"
399
+ )
400
+ );
401
+ }
402
+
403
+ // Pre-check: Test Backstage connectivity before opening browser
404
+ try {
405
+ await axios.get(`${BACKEND_URL}`, {
406
+ timeout: 5000,
407
+ validateStatus: () => true, // Accept any HTTP status code (404, 401, etc. - they all mean server is up)
408
+ });
409
+ } catch (connectivityError) {
410
+ console.error(chalk.red("❌ Cannot connect to Backstage backend"));
411
+ console.log(
412
+ chalk.yellow(
413
+ "💡 Please ensure Backstage is running and you are properly authenticated, then try again."
414
+ )
415
+ );
416
+ return reject(new Error("Backstage backend not available"));
417
+ }
418
+
419
+ console.log(
420
+ chalk.blue("🌐 Redirecting to the browser for authentication...")
421
+ );
422
+
423
+ // Set up authentication timeout (1 minute)
424
+ const authTimeout = setTimeout(() => {
425
+ console.log(chalk.red("❌ Authorization timed out"));
426
+ console.log(
427
+ chalk.yellow(
428
+ "💡 Please ensure Backstage is running and you are properly authenticated, then try again."
429
+ )
430
+ );
431
+ process.exit(1);
432
+ }, AUTH_TIMEOUT_MS);
433
+
434
+ // Step 1: Start a local HTTP server to handle the loopback redirect on port 3005
435
+ const loopbackServer = http.createServer(async (req, res) => {
436
+ const url = new URL(req.url, `http://${req.headers.host}`);
437
+ if (url.pathname === "/verify-auth") {
438
+ if (req.method === "POST") {
439
+ // Handle POST request with JSON body
440
+ let body = "";
441
+ req.on("data", (chunk) => {
442
+ body += chunk.toString();
443
+ });
444
+
445
+ req.on("end", async () => {
446
+ try {
447
+ const { jwt, entityRef } = JSON.parse(body);
448
+
449
+ if (!jwt || !entityRef) {
450
+ res.writeHead(400, { "Content-Type": "application/json" });
451
+ res.end(
452
+ JSON.stringify({
453
+ error: "Authentication failed. Missing JWT or user info.",
454
+ })
455
+ );
456
+ setTimeout(() => {
457
+ loopbackServer.close();
458
+ clearTimeout(authTimeout);
459
+ }, 2000);
460
+ return reject(new Error("Authentication failed."));
461
+ }
462
+
463
+ // Clear the authentication timeout since we got a successful response
464
+ clearTimeout(authTimeout);
465
+ console.log(chalk.green("✅ Authentication successful!"));
466
+
467
+ try {
468
+ // Step 2: Create session with Backstage backend
469
+ console.log(
470
+ chalk.blue("🔑 Creating session with Backstage backend...")
471
+ );
472
+ const sessionData = await createSession(jwt, BACKEND_URL);
473
+
474
+ // Step 3: Save session locally
475
+ saveSession(
476
+ sessionData.token,
477
+ sessionData.expiresAt,
478
+ entityRef
479
+ );
480
+
481
+ // Initialize registry store
482
+ await registryStore.initialize();
483
+
484
+ // Start local-env container
485
+ console.log(chalk.cyan("🐳 Starting container..."));
486
+ try {
487
+ await containerManager.startContainer();
488
+ console.log(
489
+ chalk.cyan("✅ Container started successfully")
490
+ );
491
+ } catch (containerError) {
492
+ console.warn(
493
+ chalk.yellow("⚠️ Failed to start container:"),
494
+ containerError.message
495
+ );
496
+ console.log(
497
+ chalk.blue("📡 Starting agent without container...")
498
+ );
499
+ }
500
+
501
+ // Generate WebSocket token NOW (before sending response)
502
+ // Import crypto to generate token
503
+ const crypto = await import("crypto");
504
+ const wsToken = crypto.randomBytes(32).toString("hex");
505
+
506
+ // Save token to file for later use
507
+ const wsTokenPath = path.join(
508
+ os.homedir(),
509
+ process.env.AGENT_ROOT_DIR || ".fenwave",
510
+ "ws-token"
511
+ );
512
+ try {
513
+ fs.writeFileSync(wsTokenPath, wsToken, { mode: 0o600 });
514
+ } catch (error) {
515
+ console.warn("⚠️ Could not save ws-token:", error.message);
516
+ }
517
+
518
+ // Send success response with WebSocket token
519
+ res.writeHead(200, {
520
+ "Content-Type": "application/json",
521
+ "Access-Control-Allow-Origin": "*",
522
+ "Access-Control-Allow-Methods": "POST, OPTIONS",
523
+ "Access-Control-Allow-Headers": "Content-Type",
524
+ });
525
+ res.end(
526
+ JSON.stringify({
527
+ message: "Authentication successful",
528
+ wsToken: wsToken,
529
+ wsPort: port,
530
+ containerPort: CONTAINER_PORT,
531
+ })
532
+ );
533
+
534
+ // Close the loopback server after a short delay to allow the success page redirect
535
+ setTimeout(() => {
536
+ loopbackServer.close();
537
+ }, 2000);
538
+
539
+ // Step 4: Start WebSocket server on the requested port
540
+ const agentId = uuidv4();
541
+ const wsServer = http.createServer();
542
+
543
+ wsServer.listen(port, async () => {
544
+ // Initialize agent start time
545
+ await initializeAgentStartTime();
546
+
547
+ console.log(
548
+ chalk.blue("📡 Agent is ready to receive connections")
549
+ );
550
+
551
+ // Display connection information
552
+ console.log(
553
+ chalk.green(
554
+ "\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
555
+ )
556
+ );
557
+ console.log(
558
+ chalk.green("✅ Fenwave Agent Started Successfully")
559
+ );
560
+ console.log(
561
+ chalk.green(
562
+ "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
563
+ )
564
+ );
565
+ console.log(chalk.white("🌐 Fenwave DevApp Dashboard:"));
566
+ console.log(
567
+ chalk.cyan(` http://localhost:${CONTAINER_PORT}\n`)
568
+ );
569
+ console.log(chalk.white("🔌 WebSocket Server:"));
570
+ console.log(chalk.cyan(` ws://localhost:${port}\n`));
571
+ console.log(chalk.white("👤 User:"));
572
+ console.log(chalk.cyan(` ${entityRef}\n`));
573
+ console.log(
574
+ chalk.green(
575
+ "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
576
+ )
577
+ );
578
+
579
+ try {
580
+ const wss = setupWebSocketServer(
581
+ wsServer,
582
+ clients,
583
+ agentId,
584
+ {
585
+ userEntityRef: entityRef,
586
+ expiresAt: sessionData.expiresAt,
587
+ },
588
+ wsToken // Pass the token we generated earlier
589
+ );
590
+
591
+ // Set up file watcher for session monitoring
592
+ const sessionWatcher = setupSessionWatcher(
593
+ handleSessionExpiry,
594
+ wsServer,
595
+ wss
596
+ );
597
+
598
+ // Start app update polling
599
+ startAppUpdatePolling(sessionData.token, wss);
600
+ resolve({
601
+ server: wsServer,
602
+ wss,
603
+ sessionToken: sessionData.token,
604
+ userEntityRef: entityRef,
605
+ agentId,
606
+ sessionWatcher,
607
+ });
608
+ } catch (error) {
609
+ console.error(
610
+ chalk.red("❌ Failed to setup WebSocket server:"),
611
+ error.message
612
+ );
613
+ reject(error);
614
+ }
615
+ });
616
+ } catch (sessionError) {
617
+ res.writeHead(500, { "Content-Type": "application/json" });
618
+ res.end(
619
+ JSON.stringify({
620
+ error: "Failed to create session with Backstage backend.",
621
+ })
622
+ );
623
+ setTimeout(() => {
624
+ loopbackServer.close();
625
+ clearTimeout(authTimeout);
626
+ }, 2000);
627
+ reject(sessionError);
628
+ }
629
+ } catch (parseError) {
630
+ res.writeHead(400, { "Content-Type": "application/json" });
631
+ res.end(
632
+ JSON.stringify({ error: "Invalid JSON in request body." })
633
+ );
634
+ setTimeout(() => {
635
+ loopbackServer.close();
636
+ clearTimeout(authTimeout);
637
+ }, 2000);
638
+ reject(parseError);
639
+ }
640
+ });
641
+ } else if (req.method === "OPTIONS") {
642
+ // Handle CORS preflight request
643
+ res.writeHead(200, {
644
+ "Access-Control-Allow-Origin": "*",
645
+ "Access-Control-Allow-Methods": "POST, OPTIONS",
646
+ "Access-Control-Allow-Headers": "Content-Type",
647
+ });
648
+ res.end();
649
+ } else {
650
+ res.writeHead(405, { "Content-Type": "application/json" });
651
+ res.end(JSON.stringify({ error: "Method not allowed. Use POST." }));
652
+ }
653
+ } else if (url.pathname === "/auth-success") {
654
+ // Serve the authentication success page
655
+ const filePath = path.join(process.cwd(), "index.html");
656
+ fs.readFile(filePath, (err, data) => {
657
+ if (err) {
658
+ console.error("Failed to read HTML file:", err);
659
+ res.writeHead(500, { "Content-Type": "text/plain" });
660
+ res.end("Internal Server Error");
661
+ return;
662
+ }
663
+ res.writeHead(200, { "Content-Type": "text/html" });
664
+ res.end(data);
665
+ });
666
+ } else {
667
+ res.writeHead(404, { "Content-Type": "text/plain" });
668
+ res.end("Not Found");
669
+ }
670
+ });
671
+
672
+ // Use port 3005 for loopback to avoid conflict with WebSocket server
673
+ loopbackServer.listen(3005, async () => {
674
+ const open = (await import("open")).default;
675
+ // Open the Backstage frontend in the default browser
676
+ const authUrl = `${FRONTEND_URL}/agent-cli`;
677
+ open(authUrl);
678
+ });
679
+ } catch (error) {
680
+ console.error(chalk.red("Failed to start server:"), error);
681
+ reject(error);
682
+ }
683
+ });
684
+ }
685
+
686
+ // Initialize the CLI
687
+ program
688
+ .name("fenwave")
689
+ .description("Fenwave - Developer Platform CLI")
690
+ .version(version);
691
+
692
+ // Setup CLI commands
693
+ setupCLICommands(program, startServer);
694
+
695
+ // Custom unknown command handler
696
+ program.on("command:*", ([cmd]) => {
697
+ console.error(`error: unknown command '${cmd}'`);
698
+
699
+ const available = program.commands.map((c) => c.name());
700
+
701
+ // First, try prefix matches
702
+ let matches = available.filter((c) => c.startsWith(cmd));
703
+
704
+ // If no prefix matches, try bidirectional substring matches
705
+ if (matches.length === 0) {
706
+ matches = available.filter((c) => cmd.includes(c) || c.includes(cmd));
707
+ }
708
+
709
+ if (matches.length > 0) {
710
+ if (matches.length === 1) {
711
+ console.error(`(Did you mean '${matches[0]}'?)`);
712
+ } else {
713
+ console.error(`(Did you mean one of ${matches.join(", ")}?)`);
714
+ }
715
+ }
716
+
717
+ process.exit(1);
718
+ });
719
+
720
+ // Pre-process arguments to handle incomplete options before Commander.js
721
+ function preprocessArguments() {
722
+ const args = process.argv.slice(2);
723
+
724
+ if (args.length >= 2) {
725
+ const commandName = args[0];
726
+ const lastArg = args[args.length - 1];
727
+
728
+ // Check if user typed incomplete option (just - or --)
729
+ if (lastArg === "-" || lastArg === "--") {
730
+ // Find the command
731
+ const command = program.commands.find(
732
+ (cmd) =>
733
+ cmd.name() === commandName || cmd.aliases().includes(commandName)
734
+ );
735
+
736
+ if (command) {
737
+ console.error(`error: unknown option '${lastArg}'`);
738
+
739
+ // Get all options for this command
740
+ const options = command.options || [];
741
+ const availableOptions = [];
742
+
743
+ // Add command-specific options
744
+ options.forEach((option) => {
745
+ if (option.short) availableOptions.push(option.short);
746
+ if (option.long) availableOptions.push(option.long);
747
+ });
748
+
749
+ // Always add help option
750
+ if (!availableOptions.includes("-h")) availableOptions.push("-h");
751
+ if (!availableOptions.includes("--help"))
752
+ availableOptions.push("--help");
753
+
754
+ let suggestions = [];
755
+
756
+ if (lastArg === "--") {
757
+ // User typed just '--', suggest all long options
758
+ suggestions = availableOptions.filter((opt) => opt.startsWith("--"));
759
+ } else if (lastArg === "-") {
760
+ // User typed just '-', suggest all short options with their long equivalents
761
+ const shortOpts = availableOptions.filter(
762
+ (opt) => opt.startsWith("-") && !opt.startsWith("--")
763
+ );
764
+ const longOpts = availableOptions.filter((opt) =>
765
+ opt.startsWith("--")
766
+ );
767
+
768
+ // Pair short and long options
769
+ suggestions = shortOpts.map((shortOpt) => {
770
+ const longEquivalent = longOpts.find(
771
+ (longOpt) =>
772
+ longOpt.substring(2) === shortOpt.substring(1) ||
773
+ (shortOpt === "-h" && longOpt === "--help") ||
774
+ (shortOpt === "-a" && longOpt === "--all") ||
775
+ (shortOpt === "-f" &&
776
+ (longOpt === "--force" || longOpt === "--follow")) ||
777
+ (shortOpt === "-d" && longOpt === "--driver") ||
778
+ (shortOpt === "-p" && longOpt === "--port") ||
779
+ (shortOpt === "-t" && longOpt === "--tail")
780
+ );
781
+ return longEquivalent ? `${shortOpt}, ${longEquivalent}` : shortOpt;
782
+ });
783
+ }
784
+
785
+ if (suggestions.length > 0) {
786
+ const formattedSuggestions = suggestions.map((suggestion) => {
787
+ // Add argument placeholder for options that require arguments
788
+ let displaySuggestion = suggestion;
789
+
790
+ // Find the matching option object to check if it requires an argument
791
+ const option = options.find(
792
+ (opt) =>
793
+ opt.long === suggestion ||
794
+ opt.short === suggestion ||
795
+ (suggestion.includes(opt.long) && opt.long) ||
796
+ (suggestion.includes(opt.short) && opt.short)
797
+ );
798
+
799
+ if (option && option.required) {
800
+ // Use a more descriptive argument name based on the option's own description
801
+ let argName = "<value>";
802
+ if (option.argChoices) {
803
+ argName = `<${option.argChoices.join("|")}>`;
804
+ } else if (option.long) {
805
+ // Map common option names to better argument descriptions
806
+ const optName = option.long.replace("--", "");
807
+ const argNameMap = {
808
+ tail: "lines",
809
+ port: "port",
810
+ driver: "driver",
811
+ "backend-url": "url",
812
+ "frontend-url": "url",
813
+ token: "token",
814
+ "aws-region": "region",
815
+ "aws-account-id": "id",
816
+ };
817
+ argName = `<${argNameMap[optName] || optName}>`;
818
+ }
819
+ displaySuggestion = `${suggestion} ${argName}`;
820
+ }
821
+
822
+ return `'${displaySuggestion}'`;
823
+ });
824
+
825
+ console.error(`(Did you mean ${formattedSuggestions.join(", ")}?)`);
826
+ }
827
+
828
+ process.exit(1);
829
+ }
830
+ }
831
+ }
832
+ }
833
+
834
+ // Run preprocessing before parsing
835
+ preprocessArguments();
836
+
837
+ // Handle unknown options
838
+ program.exitOverride((err) => {
839
+ if (err.code === "commander.unknownOption") {
840
+ const args = process.argv.slice(2);
841
+ const commandName = args[0];
842
+ const invalidOption = err.message.match(/unknown option '([^']+)'/)?.[1];
843
+
844
+ if (invalidOption) {
845
+ // Find the command
846
+ const command = program.commands.find(
847
+ (cmd) =>
848
+ cmd.name() === commandName || cmd.aliases().includes(commandName)
849
+ );
850
+
851
+ if (command) {
852
+ console.error(`error: unknown option '${invalidOption}'`);
853
+
854
+ // Get all options for this command
855
+ const options = command.options || [];
856
+ const availableOptions = [];
857
+
858
+ // Add command-specific options
859
+ options.forEach((option) => {
860
+ if (option.short) availableOptions.push(option.short);
861
+ if (option.long) availableOptions.push(option.long);
862
+ });
863
+
864
+ // Always add help option
865
+ if (!availableOptions.includes("-h")) availableOptions.push("-h");
866
+ if (!availableOptions.includes("--help"))
867
+ availableOptions.push("--help");
868
+
869
+ // Find suggestions for partial/incorrect options
870
+ const suggestions = availableOptions.filter(
871
+ (opt) =>
872
+ opt.startsWith(invalidOption) ||
873
+ opt.includes(invalidOption.replace(/^-+/, "")) ||
874
+ invalidOption.includes(opt.replace(/^-+/, ""))
875
+ );
876
+
877
+ if (suggestions.length > 0) {
878
+ // Group short and long options together
879
+ const pairedSuggestions = [];
880
+ const processed = new Set();
881
+
882
+ suggestions.forEach((suggestion) => {
883
+ if (processed.has(suggestion)) return;
884
+
885
+ const option = options.find(
886
+ (opt) => opt.long === suggestion || opt.short === suggestion
887
+ );
888
+
889
+ if (option) {
890
+ let displaySuggestion = "";
891
+
892
+ // Check if both short and long forms are in suggestions
893
+ const hasShort =
894
+ option.short && suggestions.includes(option.short);
895
+ const hasLong = option.long && suggestions.includes(option.long);
896
+
897
+ if (hasShort && hasLong) {
898
+ displaySuggestion = `${option.short}, ${option.long}`;
899
+ processed.add(option.short);
900
+ processed.add(option.long);
901
+ } else if (hasShort) {
902
+ // If only short form matched, still pair it with long form if available
903
+ displaySuggestion = option.long
904
+ ? `${option.short}, ${option.long}`
905
+ : option.short;
906
+ processed.add(option.short);
907
+ if (option.long) processed.add(option.long);
908
+ } else if (hasLong) {
909
+ // If only long form matched, still pair it with short form if available
910
+ displaySuggestion = option.short
911
+ ? `${option.short}, ${option.long}`
912
+ : option.long;
913
+ if (option.short) processed.add(option.short);
914
+ processed.add(option.long);
915
+ }
916
+
917
+ // Add argument placeholder for options that require arguments
918
+ if (option.required) {
919
+ let argName = "<value>";
920
+ if (option.argChoices) {
921
+ argName = `<${option.argChoices.join("|")}>`;
922
+ } else if (option.long) {
923
+ const optName = option.long.replace("--", "");
924
+ const argNameMap = {
925
+ tail: "lines",
926
+ port: "port",
927
+ driver: "driver",
928
+ "backend-url": "url",
929
+ "frontend-url": "url",
930
+ token: "token",
931
+ "aws-region": "region",
932
+ "aws-account-id": "id",
933
+ };
934
+ argName = `<${argNameMap[optName] || optName}>`;
935
+ }
936
+ displaySuggestion = `${displaySuggestion} ${argName}`;
937
+ }
938
+
939
+ pairedSuggestions.push(`'${displaySuggestion}'`);
940
+ } else {
941
+ // Fallback for options without Option object (like help)
942
+ processed.add(suggestion);
943
+
944
+ // Pair -h with --help
945
+ if (suggestion === "-h" && suggestions.includes("--help")) {
946
+ pairedSuggestions.push(`'${suggestion}, --help'`);
947
+ processed.add("--help");
948
+ } else if (
949
+ suggestion === "--help" &&
950
+ suggestions.includes("-h")
951
+ ) {
952
+ // Skip, already handled
953
+ } else {
954
+ pairedSuggestions.push(`'${suggestion}'`);
955
+ }
956
+ }
957
+ });
958
+
959
+ console.error(`(Did you mean ${pairedSuggestions.join(", ")}?)`);
960
+ }
961
+ }
962
+ }
963
+
964
+ process.exit(1);
965
+ }
966
+
967
+ // For other errors (like help), exit normally with the error's exit code
968
+ process.exit(err.exitCode || 1);
969
+ });
970
+
971
+ // Parse command line arguments
972
+ program.parse(process.argv);
973
+
974
+ // If no arguments, show help
975
+ if (process.argv.length === 2) {
976
+ program.help();
977
+ }
978
+
979
+ // Process error handling
980
+ process.on("uncaughtException", (error) => {
981
+ console.error("Uncaught exception:", error);
982
+ });
983
+
984
+ process.on("unhandledRejection", (reason, promise) => {
985
+ console.error("Unhandled rejection at:", promise, "reason:", reason);
986
+ });
987
+
988
+ export { startServer, clients };