@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.
@@ -0,0 +1,1185 @@
1
+ import os from 'os';
2
+ import Table from 'cli-table3';
3
+ import net from 'net';
4
+ import chalk from 'chalk';
5
+ import ora from 'ora';
6
+ import axios from 'axios';
7
+ import fs from 'fs';
8
+ import path from 'path';
9
+ import { fileURLToPath } from 'url';
10
+ import { dirname } from 'path';
11
+ import containerManager from './containerManager.js';
12
+ import registryStore from './store/registryStore.js';
13
+ import agentStore from './store/agentStore.js';
14
+ import { docker, formatContainer } from './docker-actions/containers.js';
15
+ import { loadSession, isSessionValid, clearSession } from './auth.js';
16
+ import packageJson from './package.json' with { type: 'json' };
17
+ import {
18
+ formatSize,
19
+ formatCreatedTime,
20
+ formatUptime,
21
+ } from './helper-functions.js';
22
+ import { ensureEnvironmentFiles } from './utils/envSetup.js';
23
+ import dotenv from 'dotenv';
24
+ dotenv.config();
25
+
26
+ const WS_PORT = Number(process.env.WS_PORT) || 3001;
27
+
28
+ // ES module helpers
29
+ const __filename = fileURLToPath(import.meta.url);
30
+ const __dirname = dirname(__filename);
31
+
32
+ /**
33
+ * Check if agent is actually running by trying to connect to its port
34
+ */
35
+ function checkAgentRunning(port = WS_PORT) {
36
+ return new Promise((resolve) => {
37
+ const socket = new net.Socket();
38
+
39
+ socket.setTimeout(1000);
40
+
41
+ socket.on('connect', () => {
42
+ socket.destroy();
43
+ resolve(true);
44
+ });
45
+
46
+ socket.on('timeout', () => {
47
+ socket.destroy();
48
+ resolve(false);
49
+ });
50
+
51
+ socket.on('error', () => {
52
+ resolve(false);
53
+ });
54
+
55
+ socket.connect(port, 'localhost');
56
+ });
57
+ }
58
+
59
+ function setupCLICommands(program, startServerFunction) {
60
+ // Login (create session)
61
+ program
62
+ .command('login')
63
+ .description('authenticate Fenwave agent to backstage')
64
+ .option('-p, --port <port>', 'Port to listen on', String(WS_PORT))
65
+ .action(async (options) => {
66
+ console.log(chalk.blue('🚀 Initializing Fenwave Agent...'));
67
+
68
+ // Show environment file status during login
69
+ ensureEnvironmentFiles(__dirname, true);
70
+
71
+ try {
72
+ const result = await startServerFunction(parseInt(options.port));
73
+
74
+ // Store server instances for graceful shutdown in global scope
75
+ global.serverInstances = result;
76
+ } catch (error) {
77
+ console.error(chalk.red('❌ Failed to start Fenwave Agent:'), error.message);
78
+ if (
79
+ error.message.includes('ECONNREFUSED') ||
80
+ error.message.includes('fetch') ||
81
+ error.message.includes('ENOTFOUND')
82
+ ) {
83
+ // Check if we have a stored session
84
+ const existingSession = loadSession();
85
+ if (existingSession && isSessionValid(existingSession)) {
86
+ // We have a valid session but Backstage is offline - this should be fine for independent operation
87
+ console.error(
88
+ chalk.yellow('⚠️ Warning: Cannot connect to Backstage backend')
89
+ );
90
+ // Don't exit, let it continue with the cached session
91
+ } else {
92
+ // No valid session and Backstage offline - authentication required but impossible
93
+ console.error(chalk.red('❌ Cannot connect to Backstage backend'));
94
+ console.log(
95
+ chalk.yellow(
96
+ '💡 Please ensure Backstage is running and you are properly authenticated, then try again.'
97
+ )
98
+ );
99
+ process.exit(1);
100
+ }
101
+ } else {
102
+ process.exit(1);
103
+ }
104
+ }
105
+ });
106
+
107
+ // Logout (clear session)
108
+ program
109
+ .command('logout')
110
+ .description('clear stored session and logout')
111
+ .action(() => {
112
+ const session = loadSession();
113
+ if (session) {
114
+ clearSession();
115
+ console.log(chalk.green('✅ Agent disconnected successfully'));
116
+ process.exit(0);
117
+ } else {
118
+ console.log(chalk.yellow('ℹ️ No active session found'));
119
+ process.exit(0);
120
+ }
121
+ });
122
+
123
+ // Check Auth session and device registration status
124
+ program
125
+ .command('status')
126
+ .description('check current session status and device registration')
127
+ .action(async () => {
128
+ try {
129
+ const { loadDeviceCredential, isDeviceRegistered } = await import('./store/deviceCredentialStore.js');
130
+ const { hasNpmToken } = await import('./store/npmTokenStore.js');
131
+
132
+ console.log(chalk.bold('\n📊 Fenwave Agent Status\n'));
133
+
134
+ // Session status
135
+ const session = loadSession();
136
+ if (session && isSessionValid(session)) {
137
+ console.log(chalk.green('✅ Session: Active'));
138
+ console.log(chalk.gray(` User: ${session.userEntityRef || 'N/A'}`));
139
+ console.log(chalk.gray(` Expires: ${new Date(session.expiresAt).toLocaleString()}`));
140
+ } else {
141
+ console.log(chalk.yellow('⚠️ Session: Inactive'));
142
+ console.log(chalk.gray(' Run "fenwave login" to authenticate'));
143
+ }
144
+
145
+ console.log('');
146
+
147
+ // Device registration status
148
+ if (isDeviceRegistered()) {
149
+ const deviceCred = loadDeviceCredential();
150
+ console.log(chalk.green('✅ Device: Registered'));
151
+ console.log(chalk.gray(` Device ID: ${deviceCred.deviceId}`));
152
+ console.log(chalk.gray(` Device Name: ${deviceCred.deviceName}`));
153
+ console.log(chalk.gray(` Platform: ${deviceCred.platform}`));
154
+
155
+ let deviceUser;
156
+ if (deviceCred.userEntityRef && deviceCred.userEntityRef !== 'unknown') {
157
+ deviceUser = deviceCred.userEntityRef;
158
+ } else {
159
+ deviceUser = session?.userEntityRef || 'N/A';
160
+ }
161
+
162
+ console.log(chalk.gray(` User: ${deviceUser}`));
163
+ console.log(chalk.gray(` Registered: ${new Date(deviceCred.registeredAt).toLocaleString()}`));
164
+ } else {
165
+ console.log(chalk.yellow('⚠️ Device: Not Registered'));
166
+ console.log(chalk.gray(' Run "fenwave init" or "fenwave register" to register your device'));
167
+ }
168
+
169
+ console.log('');
170
+
171
+ // NPM token status
172
+ if (hasNpmToken()) {
173
+ console.log(chalk.green('✅ NPM Token: Configured'));
174
+ } else {
175
+ console.log(chalk.yellow('⚠️ NPM Token: Not Configured'));
176
+ }
177
+
178
+ console.log('');
179
+
180
+ // Agent running status
181
+ const isRunning = await checkAgentRunning(WS_PORT);
182
+ if (isRunning) {
183
+ console.log(chalk.green(`✅ Agent: Running (port ${WS_PORT})`));
184
+ } else {
185
+ console.log(chalk.yellow('⚠️ Agent: Not Running'));
186
+ console.log(chalk.gray(' Run "fenwave login" to start the agent'));
187
+ }
188
+
189
+ console.log('');
190
+ process.exit(0);
191
+ } catch (error) {
192
+ console.error(chalk.red('❌ Error checking status:'), error.message);
193
+ process.exit(1);
194
+ }
195
+ });
196
+
197
+ // Interactive Setup Wizard
198
+ program
199
+ .command('init')
200
+ .description('interactive setup wizard for Fenwave agent')
201
+ .option('-t, --token <token>', 'Registration token from Backstage')
202
+ .option('--skip-prerequisites', 'Skip prerequisites check')
203
+ .option('--backend-url <url>', 'Backstage backend URL', 'http://localhost:7007')
204
+ .option('--frontend-url <url>', 'Backstage frontend URL', 'http://localhost:3000')
205
+ .option('--aws-region <region>', 'AWS region for ECR', 'eu-west-1')
206
+ .option('--aws-account-id <id>', 'AWS account ID for ECR')
207
+ .action(async (options) => {
208
+ try {
209
+ const { runSetupWizard } = await import('./setup/setupWizard.js');
210
+ await runSetupWizard({
211
+ token: options.token,
212
+ skipPrerequisites: options.skipPrerequisites,
213
+ backendUrl: options.backendUrl,
214
+ frontendUrl: options.frontendUrl,
215
+ awsRegion: options.awsRegion,
216
+ awsAccountId: options.awsAccountId,
217
+ });
218
+ } catch (error) {
219
+ console.error(chalk.red('Setup failed:'), error.message);
220
+ process.exit(1);
221
+ }
222
+ });
223
+
224
+ // Register Device
225
+ program
226
+ .command('register')
227
+ .description('register device with Backstage')
228
+ .option('-t, --token <token>', 'Registration token from Backstage')
229
+ .option('--backend-url <url>', 'Backstage backend URL', 'http://localhost:7007')
230
+ .action(async (options) => {
231
+ const spinner = ora('Registering device...').start();
232
+
233
+ try {
234
+ const { getDeviceMetadata } = await import('./utils/deviceInfo.js');
235
+ const { saveDeviceCredential, isDeviceRegistered } = await import('./store/deviceCredentialStore.js');
236
+ const { saveNpmToken } = await import('./store/npmTokenStore.js');
237
+
238
+ // Check if already registered
239
+ if (isDeviceRegistered()) {
240
+ spinner.warn('Device is already registered');
241
+ const inquirer = (await import('inquirer')).default;
242
+ const { confirmed } = await inquirer.prompt([
243
+ {
244
+ type: 'confirm',
245
+ name: 'confirmed',
246
+ message: 'Re-register (this will replace existing credentials)?',
247
+ default: false,
248
+ },
249
+ ]);
250
+
251
+ if (!confirmed) {
252
+ console.log(chalk.yellow('Registration cancelled'));
253
+ process.exit(0);
254
+ }
255
+ }
256
+
257
+ // Get registration token
258
+ let token = options.token;
259
+ if (!token) {
260
+ const inquirer = (await import('inquirer')).default;
261
+ const answers = await inquirer.prompt([
262
+ {
263
+ type: 'password',
264
+ name: 'token',
265
+ message: 'Enter registration token:',
266
+ mask: '*',
267
+ validate: (input) => input && input.length >= 32 ? true : 'Invalid token format',
268
+ },
269
+ ]);
270
+ token = answers.token;
271
+ }
272
+
273
+ // Collect device info
274
+ const deviceMetadata = await getDeviceMetadata();
275
+
276
+ // Register with backend
277
+ const response = await axios.post(
278
+ `${options.backendUrl}/api/agent-cli/register`,
279
+ {
280
+ installToken: token,
281
+ deviceInfo: {
282
+ deviceName: deviceMetadata.deviceName,
283
+ platform: deviceMetadata.platform,
284
+ osVersion: deviceMetadata.osVersion,
285
+ agentVersion: deviceMetadata.agentVersion,
286
+ metadata: deviceMetadata.metadata,
287
+ },
288
+ },
289
+ { timeout: 10000 }
290
+ );
291
+
292
+ // Save credentials
293
+ saveDeviceCredential({
294
+ deviceId: response.data.deviceId,
295
+ deviceCredential: response.data.deviceCredential,
296
+ userEntityRef: response.data.userEntityRef || 'unknown',
297
+ deviceName: deviceMetadata.deviceName,
298
+ platform: deviceMetadata.platform,
299
+ agentVersion: deviceMetadata.agentVersion,
300
+ });
301
+
302
+ // Save NPM token if provided
303
+ if (response.data.npmToken) {
304
+ saveNpmToken(response.data.npmToken);
305
+ }
306
+
307
+ spinner.succeed('Device registered successfully');
308
+ console.log(chalk.green('\n✅ Registration Complete'));
309
+ console.log(chalk.gray(` Device ID: ${response.data.deviceId}`));
310
+ console.log(chalk.gray(` Device Name: ${deviceMetadata.deviceName}\n`));
311
+
312
+ } catch (error) {
313
+ spinner.fail('Registration failed');
314
+ if (error.response?.status === 401) {
315
+ console.error(chalk.red('❌ Invalid or expired registration token'));
316
+ console.log(chalk.yellow('💡 Get a new token from Backstage at /agent-installer'));
317
+ } else if (error.response?.status === 429) {
318
+ console.error(chalk.red('❌ Rate limit exceeded'));
319
+ console.log(chalk.yellow('💡 Too many attempts. Please wait and try again.'));
320
+ } else {
321
+ console.error(chalk.red('❌ Error:'), error.message);
322
+ }
323
+ process.exit(1);
324
+ }
325
+ });
326
+
327
+ // Rotate Device Credentials
328
+ program
329
+ .command('rotate-credentials')
330
+ .description('rotate device credentials')
331
+ .option('--backend-url <url>', 'Backstage backend URL', 'http://localhost:7007')
332
+ .action(async (options) => {
333
+ const spinner = ora('Rotating credentials...').start();
334
+
335
+ try {
336
+ const { loadDeviceCredential, saveDeviceCredential, isDeviceRegistered } = await import('./store/deviceCredentialStore.js');
337
+
338
+ if (!isDeviceRegistered()) {
339
+ spinner.fail('Device is not registered');
340
+ console.log(chalk.yellow('💡 Run "fenwave register" first'));
341
+ process.exit(1);
342
+ }
343
+
344
+ const deviceCred = loadDeviceCredential();
345
+
346
+ // Rotate credentials with backend
347
+ const response = await axios.post(
348
+ `${options.backendUrl}/api/agent-cli/rotate-credentials`,
349
+ {
350
+ deviceId: deviceCred.deviceId,
351
+ deviceCredential: deviceCred.deviceCredential,
352
+ },
353
+ { timeout: 10000 }
354
+ );
355
+
356
+ // Update stored credentials
357
+ saveDeviceCredential({
358
+ ...deviceCred,
359
+ deviceCredential: response.data.deviceCredential,
360
+ });
361
+
362
+ spinner.succeed('Credentials rotated successfully');
363
+ console.log(chalk.green('✅ New credentials saved securely\n'));
364
+
365
+ } catch (error) {
366
+ spinner.fail('Credential rotation failed');
367
+ if (error.response?.status === 401) {
368
+ console.error(chalk.red('❌ Invalid current credentials'));
369
+ console.log(chalk.yellow('💡 Your device may have been revoked. Run "fenwave register" to re-register.'));
370
+ } else if (error.response?.status === 429) {
371
+ console.error(chalk.red('❌ Rate limit exceeded'));
372
+ console.log(chalk.yellow('💡 Too many rotation attempts. Please wait and try again.'));
373
+ } else {
374
+ console.error(chalk.red('❌ Error:'), error.message);
375
+ }
376
+ process.exit(1);
377
+ }
378
+ });
379
+
380
+ // Uninstall Agent
381
+ program
382
+ .command('uninstall')
383
+ .description('uninstall Fenwave agent and clean up')
384
+ .option('--keep-data', 'Keep configuration and data files')
385
+ .action(async (options) => {
386
+ try {
387
+ const inquirer = (await import('inquirer')).default;
388
+
389
+ // Confirmation
390
+ const { confirmed } = await inquirer.prompt([
391
+ {
392
+ type: 'confirm',
393
+ name: 'confirmed',
394
+ message: 'Are you sure you want to uninstall the Fenwave agent?',
395
+ default: false,
396
+ },
397
+ ]);
398
+
399
+ if (!confirmed) {
400
+ console.log(chalk.yellow('Uninstall cancelled'));
401
+ process.exit(0);
402
+ }
403
+
404
+ const spinner = ora('Uninstalling Fenwave agent...').start();
405
+
406
+ // Stop agent if running
407
+ try {
408
+ const isRunning = await checkAgentRunning(WS_PORT);
409
+ if (isRunning) {
410
+ spinner.text = 'Stopping agent...';
411
+ // Agent will be stopped when process exits
412
+ }
413
+ } catch (error) {
414
+ // Ignore
415
+ }
416
+
417
+ // Clear credentials unless --keep-data
418
+ if (!options.keepData) {
419
+ spinner.text = 'Clearing credentials...';
420
+ const { clearDeviceCredential } = await import('./store/deviceCredentialStore.js');
421
+ const { clearNpmToken } = await import('./store/npmTokenStore.js');
422
+ const { clearSetupState } = await import('./store/setupState.js');
423
+
424
+ clearDeviceCredential();
425
+ clearNpmToken();
426
+ clearSetupState();
427
+ clearSession();
428
+ const fwDir = path.join(os.homedir(), '.fenwave');
429
+
430
+ if (fs.existsSync(fwDir)) {
431
+ fs.rmSync(fwDir, { recursive: true, force: true });
432
+ }
433
+ }
434
+
435
+ spinner.succeed('Agent uninstalled');
436
+ console.log(chalk.green('\n✅ Fenwave agent uninstalled successfully\n'));
437
+
438
+ if (!options.keepData) {
439
+ console.log(chalk.gray('All configuration and data files have been removed.'));
440
+ } else {
441
+ console.log(chalk.gray('Configuration and data files have been preserved.'));
442
+ }
443
+
444
+ console.log(chalk.gray('\nTo reinstall: npm install -g @fenwave/dev-agent\n'));
445
+
446
+ process.exit(0);
447
+ } catch (error) {
448
+ console.error(chalk.red('❌ Uninstall error:'), error.message);
449
+ process.exit(1);
450
+ }
451
+ });
452
+
453
+ // Container commands
454
+ program
455
+ .command('containers')
456
+ .alias('ps')
457
+ .description('list containers')
458
+ .option('-a, --all', 'Show all containers (default shows just running)')
459
+ .action(async (options) => {
460
+ const spinner = ora('Fetching containers...').start();
461
+
462
+ try {
463
+ const containers = await docker.listContainers({ all: options.all });
464
+
465
+ if (containers.length === 0) {
466
+ spinner.succeed('No containers found');
467
+ return;
468
+ }
469
+
470
+ const containerPromises = containers.map((container) =>
471
+ formatContainer(docker.getContainer(container.Id))
472
+ );
473
+
474
+ const formattedContainers = await Promise.all(containerPromises);
475
+
476
+ const containerText =
477
+ containers.length === 1 ? 'container' : 'containers';
478
+ spinner.succeed(`Found ${containers.length} ${containerText}`);
479
+
480
+ // Create a table for display
481
+ const table = new Table({
482
+ head: [
483
+ chalk.blue('ID'),
484
+ chalk.blue('Name'),
485
+ chalk.blue('Image'),
486
+ chalk.blue('Status'),
487
+ chalk.blue('Ports'),
488
+ chalk.blue('CPU %'),
489
+ chalk.blue('MEM %'),
490
+ ],
491
+ colWidths: [15, 25, 30, 10, 20, 10, 10],
492
+ });
493
+
494
+ formattedContainers.forEach((container) => {
495
+ const status =
496
+ container.status === 'running'
497
+ ? chalk.green(container.status)
498
+ : chalk.red(container.status);
499
+
500
+ table.push([
501
+ container.id.substring(0, 12),
502
+ container.name,
503
+ container.image,
504
+ status,
505
+ container.ports.join(', '),
506
+ `${container.cpu}%`,
507
+ `${container.memory}%`,
508
+ ]);
509
+ });
510
+
511
+ console.log(table.toString());
512
+ process.exit(0);
513
+ } catch (error) {
514
+ spinner.fail(`Failed to fetch containers: ${error.message}`);
515
+ process.exit(1);
516
+ }
517
+ });
518
+
519
+ // Start container(s) - supports multiple containers
520
+ program
521
+ .command('start <containerId(s)...>')
522
+ .description('start one or more containers')
523
+ .action(async (containerIds) => {
524
+ const spinner = ora(`Starting ${containerIds.length} ${containerIds.length === 1 ? 'container' : 'containers'}...`).start();
525
+
526
+ try {
527
+ const results = [];
528
+ for (const containerId of containerIds) {
529
+ try {
530
+ const container = docker.getContainer(containerId);
531
+ await container.start();
532
+ results.push({ id: containerId, success: true });
533
+ } catch (error) {
534
+ results.push({ id: containerId, success: false, error: error.message });
535
+ }
536
+ }
537
+
538
+ const succeeded = results.filter(r => r.success);
539
+ const failed = results.filter(r => !r.success);
540
+
541
+ if (failed.length === 0) {
542
+ spinner.succeed(`Successfully started ${succeeded.length} ${succeeded.length === 1 ? 'container' : 'containers'}`);
543
+ } else if (succeeded.length === 0) {
544
+ spinner.fail(`Failed to start all containers`);
545
+ failed.forEach(r => console.log(chalk.red(` ✗ ${r.id}: ${r.error}`)));
546
+ } else {
547
+ spinner.warn(`Started ${succeeded.length}/${containerIds.length} ${succeeded.length === 1 ? 'container' : 'containers'}`);
548
+ failed.forEach(r => console.log(chalk.red(` ✗ ${r.id}: ${r.error}`)));
549
+ }
550
+ process.exit(failed.length > 0 ? 1 : 0);
551
+ } catch (error) {
552
+ spinner.fail(`Failed to start containers: ${error.message}`);
553
+ process.exit(1);
554
+ }
555
+ });
556
+
557
+ // Stop container(s) - supports multiple containers
558
+ program
559
+ .command('stop <containerId(s)...>')
560
+ .description('stop one or more containers')
561
+ .action(async (containerIds) => {
562
+ const spinner = ora(`Stopping ${containerIds.length} ${containerIds.length === 1 ? 'container' : 'containers'}...`).start();
563
+
564
+ try {
565
+ const results = [];
566
+ for (const containerId of containerIds) {
567
+ try {
568
+ const container = docker.getContainer(containerId);
569
+ await container.stop();
570
+ results.push({ id: containerId, success: true });
571
+ } catch (error) {
572
+ results.push({ id: containerId, success: false, error: error.message });
573
+ }
574
+ }
575
+
576
+ const succeeded = results.filter(r => r.success);
577
+ const failed = results.filter(r => !r.success);
578
+
579
+ if (failed.length === 0) {
580
+ spinner.succeed(`Successfully stopped ${succeeded.length} ${succeeded.length === 1 ? 'container' : 'containers'}`);
581
+ } else if (succeeded.length === 0) {
582
+ spinner.fail(`Failed to stop all containers`);
583
+ failed.forEach(r => console.log(chalk.red(` ✗ ${r.id}: ${r.error}`)));
584
+ } else {
585
+ spinner.warn(`Stopped ${succeeded.length}/${containerIds.length} ${succeeded.length === 1 ? 'container' : 'containers'}`);
586
+ failed.forEach(r => console.log(chalk.red(` ✗ ${r.id}: ${r.error}`)));
587
+ }
588
+ process.exit(failed.length > 0 ? 1 : 0);
589
+ } catch (error) {
590
+ spinner.fail(`Failed to stop containers: ${error.message}`);
591
+ process.exit(1);
592
+ }
593
+ });
594
+
595
+ // Restart container(s) - supports multiple containers
596
+ program
597
+ .command('restart <containerId(s)...>')
598
+ .description('restart one or more containers')
599
+ .action(async (containerIds) => {
600
+ const spinner = ora(`Restarting ${containerIds.length} ${containerIds.length === 1 ? 'container' : 'containers'}...`).start();
601
+
602
+ try {
603
+ const results = [];
604
+ for (const containerId of containerIds) {
605
+ try {
606
+ const container = docker.getContainer(containerId);
607
+ await container.restart();
608
+ results.push({ id: containerId, success: true });
609
+ } catch (error) {
610
+ results.push({ id: containerId, success: false, error: error.message });
611
+ }
612
+ }
613
+
614
+ const succeeded = results.filter(r => r.success);
615
+ const failed = results.filter(r => !r.success);
616
+
617
+ if (failed.length === 0) {
618
+ spinner.succeed(`Successfully restarted ${succeeded.length} ${succeeded.length === 1 ? 'container' : 'containers'}`);
619
+ } else if (succeeded.length === 0) {
620
+ spinner.fail(`Failed to restart all containers`);
621
+ failed.forEach(r => console.log(chalk.red(` ✗ ${r.id}: ${r.error}`)));
622
+ } else {
623
+ spinner.warn(`Restarted ${succeeded.length}/${containerIds.length} ${succeeded.length === 1 ? 'container' : 'containers'}`);
624
+ failed.forEach(r => console.log(chalk.red(` ✗ ${r.id}: ${r.error}`)));
625
+ }
626
+ process.exit(failed.length > 0 ? 1 : 0);
627
+ } catch (error) {
628
+ spinner.fail(`Failed to restart containers: ${error.message}`);
629
+ process.exit(1);
630
+ }
631
+ });
632
+
633
+ // Remove container(s) - supports multiple containers
634
+ program
635
+ .command('rm <containerId(s)...>')
636
+ .description('remove one or more containers')
637
+ .option('-f, --force', 'Force remove the container(s)')
638
+ .action(async (containerIds, options) => {
639
+ const spinner = ora(`Removing ${containerIds.length} ${containerIds.length === 1 ? 'container' : 'containers'}...`).start();
640
+
641
+ try {
642
+ const results = [];
643
+ for (const containerId of containerIds) {
644
+ try {
645
+ const container = docker.getContainer(containerId);
646
+ await container.remove({ force: options.force });
647
+ results.push({ id: containerId, success: true });
648
+ } catch (error) {
649
+ results.push({ id: containerId, success: false, error: error.message });
650
+ }
651
+ }
652
+
653
+ const succeeded = results.filter(r => r.success);
654
+ const failed = results.filter(r => !r.success);
655
+
656
+ if (failed.length === 0) {
657
+ spinner.succeed(`Successfully removed ${succeeded.length} ${succeeded.length === 1 ? 'container' : 'containers'}`);
658
+ } else if (succeeded.length === 0) {
659
+ spinner.fail(`Failed to remove all containers`);
660
+ failed.forEach(r => console.log(chalk.red(` ✗ ${r.id}: ${r.error}`)));
661
+ } else {
662
+ spinner.warn(`Removed ${succeeded.length}/${containerIds.length} ${succeeded.length === 1 ? 'container' : 'containers'}`);
663
+ failed.forEach(r => console.log(chalk.red(` ✗ ${r.id}: ${r.error}`)));
664
+ }
665
+ process.exit(failed.length > 0 ? 1 : 0);
666
+ } catch (error) {
667
+ spinner.fail(`Failed to remove containers: ${error.message}`);
668
+ process.exit(1);
669
+ }
670
+ });
671
+
672
+ // Image commands
673
+ program
674
+ .command('images')
675
+ .description('list images')
676
+ .action(async () => {
677
+ const spinner = ora('Fetching images...').start();
678
+
679
+ try {
680
+ const images = await docker.listImages();
681
+
682
+ if (images.length === 0) {
683
+ spinner.succeed('No images found');
684
+ return;
685
+ }
686
+
687
+ spinner.succeed(`Found ${images.length} ${images.length === 1 ? 'image' : 'images'}`);
688
+
689
+ // Create a table for display
690
+ const table = new Table({
691
+ head: [
692
+ chalk.blue('ID'),
693
+ chalk.blue('Repository'),
694
+ chalk.blue('Tag'),
695
+ chalk.blue('Size'),
696
+ chalk.blue('Created'),
697
+ ],
698
+ colWidths: [15, 30, 15, 15, 15],
699
+ });
700
+
701
+ images.forEach((image) => {
702
+ // Extract repository and tag
703
+ let name = '<none>';
704
+ let tag = '<none>';
705
+
706
+ if (image.RepoTags && image.RepoTags.length > 0) {
707
+ const [repoTag] = image.RepoTags;
708
+ const parts = repoTag.split(':');
709
+ name = parts[0];
710
+ tag = parts.length > 1 ? parts[1] : 'latest';
711
+ }
712
+
713
+ table.push([
714
+ image.Id.substring(7, 19),
715
+ name,
716
+ tag,
717
+ formatSize(image.Size),
718
+ formatCreatedTime(image.Created),
719
+ ]);
720
+ });
721
+
722
+ console.log(table.toString());
723
+ process.exit(0);
724
+ } catch (error) {
725
+ spinner.fail(`Failed to fetch images: ${error.message}`);
726
+ process.exit(1);
727
+ }
728
+ });
729
+
730
+ // Pull image(s) - supports multiple images
731
+ program
732
+ .command('pull <imageTags...>')
733
+ .description('pull one or more images')
734
+ .action(async (imageTags) => {
735
+ const spinner = ora(`Pulling ${imageTags.length} ${imageTags.length === 1 ? 'image' : 'images'}...`).start();
736
+
737
+ try {
738
+ const results = [];
739
+ for (const imageTag of imageTags) {
740
+ try {
741
+ // Split image tag into name and tag
742
+ const [name, tag = 'latest'] = imageTag.split(':');
743
+ spinner.text = `Pulling ${imageTag}...`;
744
+
745
+ // Pull the image
746
+ const stream = await docker.pull(`${name}:${tag}`);
747
+
748
+ // Track progress
749
+ await new Promise((resolve, reject) => {
750
+ docker.modem.followProgress(
751
+ stream,
752
+ (err, output) => {
753
+ if (err) {
754
+ reject(err);
755
+ return;
756
+ }
757
+ resolve(output);
758
+ },
759
+ (event) => {
760
+ if (event.progress) {
761
+ spinner.text = `Pulling ${imageTag}: ${event.progress}`;
762
+ } else if (event.status) {
763
+ spinner.text = `Pulling ${imageTag}: ${event.status}`;
764
+ }
765
+ }
766
+ );
767
+ });
768
+
769
+ results.push({ tag: imageTag, success: true });
770
+ } catch (error) {
771
+ results.push({ tag: imageTag, success: false, error: error.message });
772
+ }
773
+ }
774
+
775
+ const succeeded = results.filter(r => r.success);
776
+ const failed = results.filter(r => !r.success);
777
+
778
+ if (failed.length === 0) {
779
+ spinner.succeed(`Successfully pulled ${succeeded.length} ${succeeded.length === 1 ? 'image' : 'images'}`);
780
+ } else if (succeeded.length === 0) {
781
+ spinner.fail(`Failed to pull all images`);
782
+ failed.forEach(r => console.log(chalk.red(` ✗ ${r.tag}: ${r.error}`)));
783
+ } else {
784
+ spinner.warn(`Pulled ${succeeded.length}/${imageTags.length} ${succeeded.length === 1 ? 'image' : 'images'}`);
785
+ failed.forEach(r => console.log(chalk.red(` ✗ ${r.tag}: ${r.error}`)));
786
+ }
787
+ process.exit(failed.length > 0 ? 1 : 0);
788
+ } catch (error) {
789
+ spinner.fail(`Failed to pull images: ${error.message}`);
790
+ process.exit(1);
791
+ }
792
+ });
793
+
794
+ // Remove image(s) - supports multiple images
795
+ program
796
+ .command('rmi <imageIds...>')
797
+ .description('remove one or more images')
798
+ .option('-f, --force', 'Force remove the image(s)')
799
+ .action(async (imageIds, options) => {
800
+ const spinner = ora(`Removing ${imageIds.length} ${imageIds.length === 1 ? 'image' : 'images'}...`).start();
801
+
802
+ try {
803
+ const results = [];
804
+ for (const imageId of imageIds) {
805
+ try {
806
+ const image = docker.getImage(imageId);
807
+ await image.remove({ force: options.force });
808
+ results.push({ id: imageId, success: true });
809
+ } catch (error) {
810
+ results.push({ id: imageId, success: false, error: error.message });
811
+ }
812
+ }
813
+
814
+ const succeeded = results.filter(r => r.success);
815
+ const failed = results.filter(r => !r.success);
816
+
817
+ if (failed.length === 0) {
818
+ spinner.succeed(`Successfully removed ${succeeded.length} ${succeeded.length === 1 ? 'image' : 'images'}`);
819
+ } else if (succeeded.length === 0) {
820
+ spinner.fail(`Failed to remove all images`);
821
+ failed.forEach(r => console.log(chalk.red(` ✗ ${r.id}: ${r.error}`)));
822
+ } else {
823
+ spinner.warn(`Removed ${succeeded.length}/${imageIds.length} ${succeeded.length === 1 ? 'image' : 'images'}`);
824
+ failed.forEach(r => console.log(chalk.red(` ✗ ${r.id}: ${r.error}`)));
825
+ }
826
+ process.exit(failed.length > 0 ? 1 : 0);
827
+ } catch (error) {
828
+ spinner.fail(`Failed to remove images: ${error.message}`);
829
+ process.exit(1);
830
+ }
831
+ });
832
+
833
+ // Volume commands
834
+ program
835
+ .command('volumes')
836
+ .description('list volumes')
837
+ .action(async () => {
838
+ const spinner = ora('Fetching volumes...').start();
839
+
840
+ try {
841
+ const { Volumes } = await docker.listVolumes();
842
+
843
+ if (Volumes.length === 0) {
844
+ spinner.succeed('No volumes found');
845
+ return;
846
+ }
847
+
848
+ spinner.succeed(`Found ${Volumes.length} ${Volumes.length === 1 ? 'volume' : 'volumes'}`);
849
+
850
+ // Create a table for display
851
+ const table = new Table({
852
+ head: [
853
+ chalk.blue('Name'),
854
+ chalk.blue('Driver'),
855
+ chalk.blue('Mountpoint'),
856
+ ],
857
+ colWidths: [30, 15, 50],
858
+ });
859
+
860
+ Volumes.forEach((volume) => {
861
+ table.push([volume.Name, volume.Driver, volume.Mountpoint]);
862
+ });
863
+
864
+ console.log(table.toString());
865
+ process.exit(0);
866
+ } catch (error) {
867
+ spinner.fail(`Failed to fetch volumes: ${error.message}`);
868
+ process.exit(1);
869
+ }
870
+ });
871
+
872
+ // Create volume(s) - supports multiple volumes
873
+ program
874
+ .command('volume-create <names...>')
875
+ .description('create one or more volumes')
876
+ .option('-d, --driver <driver>', 'Volume driver', 'local')
877
+ .action(async (names, options) => {
878
+ const spinner = ora(`Creating ${names.length} ${names.length === 1 ? 'volume' : 'volumes'}...`).start();
879
+
880
+ try {
881
+ const results = [];
882
+ for (const name of names) {
883
+ try {
884
+ await docker.createVolume({
885
+ Name: name,
886
+ Driver: options.driver,
887
+ });
888
+ results.push({ name, success: true });
889
+ } catch (error) {
890
+ results.push({ name, success: false, error: error.message });
891
+ }
892
+ }
893
+
894
+ const succeeded = results.filter(r => r.success);
895
+ const failed = results.filter(r => !r.success);
896
+
897
+ if (failed.length === 0) {
898
+ spinner.succeed(`Successfully created ${succeeded.length} ${succeeded.length === 1 ? 'volume' : 'volumes'}`);
899
+ } else if (succeeded.length === 0) {
900
+ spinner.fail(`Failed to create all volumes`);
901
+ failed.forEach(r => console.log(chalk.red(` ✗ ${r.name}: ${r.error}`)));
902
+ } else {
903
+ spinner.warn(`Created ${succeeded.length}/${names.length} ${succeeded.length === 1 ? 'volume' : 'volumes'}`);
904
+ failed.forEach(r => console.log(chalk.red(` ✗ ${r.name}: ${r.error}`)));
905
+ }
906
+ process.exit(failed.length > 0 ? 1 : 0);
907
+ } catch (error) {
908
+ spinner.fail(`Failed to create volumes: ${error.message}`);
909
+ process.exit(1);
910
+ }
911
+ });
912
+
913
+ // Remove volume(s) - supports multiple volumes
914
+ program
915
+ .command('volume-rm <names...>')
916
+ .description('remove one or more volumes')
917
+ .action(async (names) => {
918
+ const spinner = ora(`Removing ${names.length} ${names.length === 1 ? 'volume' : 'volumes'}...`).start();
919
+
920
+ try {
921
+ const results = [];
922
+ for (const name of names) {
923
+ try {
924
+ const volume = docker.getVolume(name);
925
+ await volume.remove();
926
+ results.push({ name, success: true });
927
+ } catch (error) {
928
+ results.push({ name, success: false, error: error.message });
929
+ }
930
+ }
931
+
932
+ const succeeded = results.filter(r => r.success);
933
+ const failed = results.filter(r => !r.success);
934
+
935
+ if (failed.length === 0) {
936
+ spinner.succeed(`Successfully removed ${succeeded.length} ${succeeded.length === 1 ? 'volume' : 'volumes'}`);
937
+ } else if (succeeded.length === 0) {
938
+ spinner.fail(`Failed to remove all volumes`);
939
+ failed.forEach(r => console.log(chalk.red(` ✗ ${r.name}: ${r.error}`)));
940
+ } else {
941
+ spinner.warn(`Removed ${succeeded.length}/${names.length} ${succeeded.length === 1 ? 'volume' : 'volumes'}`);
942
+ failed.forEach(r => console.log(chalk.red(` ✗ ${r.name}: ${r.error}`)));
943
+ }
944
+ process.exit(failed.length > 0 ? 1 : 0);
945
+ } catch (error) {
946
+ spinner.fail(`Failed to remove volumes: ${error.message}`);
947
+ process.exit(1);
948
+ }
949
+ });
950
+
951
+ // Logs command
952
+ program
953
+ .command('logs <containerId>')
954
+ .description('fetch container logs')
955
+ .option('-f, --follow', 'Follow log output')
956
+ .option('-t, --tail <lines>', 'Number of lines to show from the end', '100')
957
+ .action(async (containerId, options) => {
958
+ try {
959
+ const container = docker.getContainer(containerId);
960
+
961
+ if (options.follow) {
962
+ console.log(
963
+ chalk.blue(`Following logs for container ${containerId}...`)
964
+ );
965
+ console.log(chalk.blue('Press Ctrl+C to exit'));
966
+
967
+ const logStream = await container.logs({
968
+ follow: true,
969
+ stdout: true,
970
+ stderr: true,
971
+ tail: Number.parseInt(options.tail, 10),
972
+ });
973
+
974
+ logStream.on('data', (chunk) => {
975
+ process.stdout.write(chunk.toString());
976
+ });
977
+
978
+ // Handle Ctrl+C
979
+ process.on('SIGINT', () => {
980
+ console.log(chalk.blue('\nStopping log stream...'));
981
+ process.exit(0);
982
+ });
983
+ } else {
984
+ const spinner = ora(
985
+ `Fetching logs for container ${containerId}...`
986
+ ).start();
987
+
988
+ const logs = await container.logs({
989
+ stdout: true,
990
+ stderr: true,
991
+ tail: Number.parseInt(options.tail, 10),
992
+ });
993
+
994
+ spinner.stop();
995
+ console.log(logs.toString());
996
+ process.exit(0);
997
+ }
998
+ } catch (error) {
999
+ console.error(chalk.red(`Failed to fetch logs: ${error.message}`));
1000
+ process.exit(1);
1001
+ }
1002
+ });
1003
+
1004
+ // Local-env app commands
1005
+ program
1006
+ .command('local-env')
1007
+ .description('manage local-env app container')
1008
+ .option('--start', 'start the local-env app')
1009
+ .option('--stop', 'stop the local-env app')
1010
+ .option('--status', 'show local-env app status')
1011
+ .option('--logs', 'show local-env app logs')
1012
+ .option('--logs-follow', 'follow local-env app logs')
1013
+ .action(async (options) => {
1014
+ try {
1015
+ if (options.start) {
1016
+ const spinner = ora('Starting local-env app...').start();
1017
+ try {
1018
+ await containerManager.startContainer();
1019
+ spinner.succeed('Local-env app started successfully');
1020
+ } catch (error) {
1021
+ spinner.fail(`Failed to start local-env app: ${error.message}`);
1022
+ process.exit(1);
1023
+ }
1024
+ } else if (options.stop) {
1025
+ const spinner = ora('Stopping local-env app...').start();
1026
+ try {
1027
+ await containerManager.stopContainerGracefully();
1028
+ spinner.succeed('Local-env app stopped successfully');
1029
+ } catch (error) {
1030
+ spinner.fail(`Failed to stop local-env app: ${error.message}`);
1031
+ process.exit(1);
1032
+ }
1033
+ } else if (options.status) {
1034
+ const status = await containerManager.getStatus();
1035
+ console.log(chalk.bold('\nLocal-env App Status:'));
1036
+ console.log(chalk.blue('Container:'), status.containerName);
1037
+ console.log(
1038
+ chalk.blue('Running:'),
1039
+ status.isRunning ? chalk.green('Yes') : chalk.red('No')
1040
+ );
1041
+ console.log(chalk.blue('Port:'), status.port);
1042
+ console.log(chalk.blue('Data Directory:'), status.dataDirectory);
1043
+ if (status.isRunning) {
1044
+ console.log(
1045
+ chalk.blue('URL:'),
1046
+ chalk.underline(`http://localhost:${status.port}`)
1047
+ );
1048
+ }
1049
+ } else if (options.logs) {
1050
+ console.log(chalk.blue('📋 Showing local-env app logs...'));
1051
+ containerManager.showLogs(false);
1052
+ } else if (options.logsFollow) {
1053
+ console.log(
1054
+ chalk.blue(
1055
+ '📋 Following local-env app logs... (Press Ctrl+C to stop)'
1056
+ )
1057
+ );
1058
+ containerManager.showLogs(true);
1059
+ } else {
1060
+ console.log(
1061
+ chalk.yellow(
1062
+ 'Please specify an action: --start, --stop, --status, --logs, or --logs-follow'
1063
+ )
1064
+ );
1065
+ process.exit(1);
1066
+ }
1067
+ } catch (error) {
1068
+ console.error(chalk.red('❌ Error:'), error.message);
1069
+ process.exit(1);
1070
+ }
1071
+ });
1072
+
1073
+ // Registry commands
1074
+ program
1075
+ .command('registries')
1076
+ .description('list registries')
1077
+ .action(async () => {
1078
+ const spinner = ora('Fetching registries...').start();
1079
+
1080
+ try {
1081
+ await registryStore.initialize();
1082
+ const registries = await registryStore.getAllRegistries();
1083
+
1084
+ if (registries.length === 0) {
1085
+ spinner.succeed('No registries found');
1086
+ return;
1087
+ }
1088
+
1089
+ const registryText =
1090
+ registries.length === 1 ? 'registry' : 'registries';
1091
+ spinner.succeed(`Found ${registries.length} ${registryText}`);
1092
+
1093
+ const table = new Table({
1094
+ head: ['ID', 'Name', 'Type', 'URL', 'Connected'].map((h) =>
1095
+ chalk.cyan(h)
1096
+ ),
1097
+ style: { head: [], border: [] },
1098
+ });
1099
+
1100
+ for (const registry of registries) {
1101
+ table.push([
1102
+ registry.id.substring(0, 12) + '...',
1103
+ registry.name,
1104
+ registry.type,
1105
+ registry.url,
1106
+ registry.connected ? chalk.green('✓') : chalk.red('✗'),
1107
+ ]);
1108
+ }
1109
+
1110
+ console.log(chalk.bold('\nConnected Registries:'));
1111
+ console.log(table.toString());
1112
+ process.exit(0);
1113
+ } catch (error) {
1114
+ spinner.fail(`Failed to fetch registries: ${error.message}`);
1115
+ process.exit(1);
1116
+ }
1117
+ });
1118
+
1119
+ // Agent info command
1120
+ program
1121
+ .command('info')
1122
+ .description('display agent informations')
1123
+ .action(async () => {
1124
+ const spinner = ora('Fetching agent informations...').start();
1125
+
1126
+ try {
1127
+ // Get Docker version
1128
+ const dockerVersion = await docker.version();
1129
+
1130
+ // Display agent informations
1131
+ spinner.stop();
1132
+ const { version } = packageJson;
1133
+
1134
+ console.log(chalk.bold('\nFenwave Agent Informations:'));
1135
+ console.log(chalk.blue('Version:'), version);
1136
+ console.log(chalk.blue('Hostname:'), os.hostname());
1137
+ console.log(chalk.blue('Platform:'), os.platform());
1138
+ console.log(chalk.blue('Architecture:'), os.arch());
1139
+ console.log(chalk.blue('Node.js Version:'), process.version);
1140
+ console.log(chalk.blue('Docker Version:'), dockerVersion.Version);
1141
+ console.log(chalk.blue('CPU Cores:'), os.cpus().length);
1142
+ console.log(
1143
+ chalk.blue('Memory:'),
1144
+ `${Math.round(os.totalmem() / (1024 * 1024 * 1024))} GB`
1145
+ );
1146
+
1147
+ // Check if agent is actually running
1148
+ const isAgentRunning = await checkAgentRunning(WS_PORT);
1149
+
1150
+ if (isAgentRunning) {
1151
+ // Agent is running, try to get uptime from stored data
1152
+ try {
1153
+ const agentStartTime = await agentStore.loadAgentStartTime();
1154
+ if (agentStartTime) {
1155
+ const currentTime = Date.now();
1156
+ const agentUptimeMs = currentTime - agentStartTime.getTime();
1157
+ console.log(chalk.blue('Uptime:'), formatUptime(agentUptimeMs));
1158
+ } else {
1159
+ console.log(chalk.blue('Uptime:'), '0 seconds');
1160
+ }
1161
+ } catch (error) {
1162
+ console.log(chalk.blue('Uptime:'), '0 seconds');
1163
+ }
1164
+ } else {
1165
+ // Agent is not running
1166
+ console.log(chalk.blue('Status:'), chalk.red('Agent Disconnected'));
1167
+
1168
+ // Clean up stale agent info file if it exists
1169
+ try {
1170
+ await agentStore.clearAgentInfo();
1171
+ } catch (error) {
1172
+ // Ignore cleanup errors
1173
+ }
1174
+ }
1175
+ process.exit(0);
1176
+ } catch (error) {
1177
+ spinner.fail(`Failed to fetch agent informations: ${error.message}`);
1178
+ process.exit(1);
1179
+ }
1180
+ });
1181
+ }
1182
+
1183
+ export {
1184
+ setupCLICommands,
1185
+ };