portok 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.
package/portok.mjs ADDED
@@ -0,0 +1,793 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Portok CLI - Command-line interface for portokd daemon
5
+ * Communicates with the daemon via HTTP to query status, metrics, and trigger switches.
6
+ */
7
+
8
+ // =============================================================================
9
+ // ANSI Colors (only if TTY)
10
+ // =============================================================================
11
+
12
+ const isTTY = process.stdout.isTTY;
13
+
14
+ const colors = {
15
+ reset: isTTY ? '\x1b[0m' : '',
16
+ bold: isTTY ? '\x1b[1m' : '',
17
+ dim: isTTY ? '\x1b[2m' : '',
18
+ red: isTTY ? '\x1b[31m' : '',
19
+ green: isTTY ? '\x1b[32m' : '',
20
+ yellow: isTTY ? '\x1b[33m' : '',
21
+ blue: isTTY ? '\x1b[34m' : '',
22
+ cyan: isTTY ? '\x1b[36m' : '',
23
+ };
24
+
25
+ // =============================================================================
26
+ // Argument Parsing
27
+ // =============================================================================
28
+
29
+ function parseArgs(args) {
30
+ const result = {
31
+ command: null,
32
+ positional: [],
33
+ options: {},
34
+ };
35
+
36
+ for (let i = 0; i < args.length; i++) {
37
+ const arg = args[i];
38
+
39
+ if (arg.startsWith('--')) {
40
+ const key = arg.slice(2);
41
+ const nextArg = args[i + 1];
42
+
43
+ // Check if it's a flag (no value) or has a value
44
+ if (!nextArg || nextArg.startsWith('--')) {
45
+ result.options[key] = true;
46
+ } else {
47
+ result.options[key] = nextArg;
48
+ i++;
49
+ }
50
+ } else if (!result.command) {
51
+ result.command = arg;
52
+ } else {
53
+ result.positional.push(arg);
54
+ }
55
+ }
56
+
57
+ return result;
58
+ }
59
+
60
+ // =============================================================================
61
+ // Env File Parser (for --instance support)
62
+ // =============================================================================
63
+
64
+ import fs from 'node:fs';
65
+ import path from 'node:path';
66
+
67
+ const ENV_FILE_DIR = '/etc/portok';
68
+
69
+ /**
70
+ * Parse an env file (KEY=VALUE format)
71
+ * Ignores comments (#) and blank lines
72
+ */
73
+ function parseEnvFile(filePath) {
74
+ const result = {};
75
+
76
+ try {
77
+ const content = fs.readFileSync(filePath, 'utf-8');
78
+ const lines = content.split('\n');
79
+
80
+ for (const line of lines) {
81
+ const trimmed = line.trim();
82
+
83
+ // Skip empty lines and comments
84
+ if (!trimmed || trimmed.startsWith('#')) continue;
85
+
86
+ // Parse KEY=VALUE
87
+ const eqIndex = trimmed.indexOf('=');
88
+ if (eqIndex === -1) continue;
89
+
90
+ const key = trimmed.slice(0, eqIndex).trim();
91
+ let value = trimmed.slice(eqIndex + 1).trim();
92
+
93
+ // Remove surrounding quotes if present
94
+ if ((value.startsWith('"') && value.endsWith('"')) ||
95
+ (value.startsWith("'") && value.endsWith("'"))) {
96
+ value = value.slice(1, -1);
97
+ }
98
+
99
+ result[key] = value;
100
+ }
101
+ } catch (err) {
102
+ // File doesn't exist or can't be read
103
+ return null;
104
+ }
105
+
106
+ return result;
107
+ }
108
+
109
+ /**
110
+ * Resolve instance configuration from env file
111
+ */
112
+ function resolveInstanceConfig(instanceName) {
113
+ const envFilePath = path.join(ENV_FILE_DIR, `${instanceName}.env`);
114
+ const envVars = parseEnvFile(envFilePath);
115
+
116
+ if (!envVars) {
117
+ return null;
118
+ }
119
+
120
+ const listenPort = envVars.LISTEN_PORT;
121
+ if (!listenPort) {
122
+ return null;
123
+ }
124
+
125
+ return {
126
+ url: `http://127.0.0.1:${listenPort}`,
127
+ token: envVars.ADMIN_TOKEN || null,
128
+ instanceName,
129
+ envFilePath,
130
+ };
131
+ }
132
+
133
+ // =============================================================================
134
+ // HTTP Client
135
+ // =============================================================================
136
+
137
+ async function request(url, options = {}) {
138
+ try {
139
+ const response = await fetch(url, {
140
+ ...options,
141
+ headers: {
142
+ ...options.headers,
143
+ },
144
+ });
145
+
146
+ const text = await response.text();
147
+ let data;
148
+
149
+ try {
150
+ data = JSON.parse(text);
151
+ } catch {
152
+ data = { raw: text };
153
+ }
154
+
155
+ return {
156
+ ok: response.ok,
157
+ status: response.status,
158
+ data,
159
+ };
160
+ } catch (err) {
161
+ return {
162
+ ok: false,
163
+ status: 0,
164
+ data: { error: err.message },
165
+ };
166
+ }
167
+ }
168
+
169
+ // =============================================================================
170
+ // Commands
171
+ // =============================================================================
172
+
173
+ async function cmdStatus(baseUrl, token, options) {
174
+ const res = await request(`${baseUrl}/__status`, {
175
+ headers: { 'x-admin-token': token },
176
+ });
177
+
178
+ if (!res.ok) {
179
+ console.error(`${colors.red}Error:${colors.reset} ${res.data.error || 'Failed to fetch status'}`);
180
+ return 1;
181
+ }
182
+
183
+ const { instanceName, activePort, drainUntil, lastSwitch } = res.data;
184
+
185
+ if (options.json) {
186
+ console.log(JSON.stringify(res.data, null, 2));
187
+ return 0;
188
+ }
189
+
190
+ const title = instanceName && instanceName !== 'default'
191
+ ? `Portok Status [${instanceName}]`
192
+ : 'Portok Status';
193
+ console.log(`${colors.bold}${title}${colors.reset}`);
194
+ console.log(`${'─'.repeat(40)}`);
195
+ if (instanceName && instanceName !== 'default') {
196
+ console.log(`${colors.dim}Instance:${colors.reset} ${instanceName}`);
197
+ }
198
+ console.log(`${colors.cyan}Active Port:${colors.reset} ${colors.bold}${activePort}${colors.reset}`);
199
+
200
+ if (drainUntil) {
201
+ const drainTime = new Date(drainUntil);
202
+ const remaining = Math.max(0, drainTime - Date.now());
203
+ console.log(`${colors.yellow}Drain Until:${colors.reset} ${drainUntil} (${Math.ceil(remaining / 1000)}s remaining)`);
204
+ } else {
205
+ console.log(`${colors.dim}Drain Until:${colors.reset} ${colors.dim}none${colors.reset}`);
206
+ }
207
+
208
+ if (lastSwitch.at) {
209
+ console.log(`\n${colors.bold}Last Switch${colors.reset}`);
210
+ console.log(` From: ${lastSwitch.from}`);
211
+ console.log(` To: ${lastSwitch.to}`);
212
+ console.log(` At: ${lastSwitch.at}`);
213
+ console.log(` Reason: ${lastSwitch.reason}`);
214
+ console.log(` ID: ${colors.dim}${lastSwitch.id}${colors.reset}`);
215
+ }
216
+
217
+ return 0;
218
+ }
219
+
220
+ async function cmdMetrics(baseUrl, token, options) {
221
+ const res = await request(`${baseUrl}/__metrics`, {
222
+ headers: { 'x-admin-token': token },
223
+ });
224
+
225
+ if (!res.ok) {
226
+ console.error(`${colors.red}Error:${colors.reset} ${res.data.error || 'Failed to fetch metrics'}`);
227
+ return 1;
228
+ }
229
+
230
+ if (options.json) {
231
+ console.log(JSON.stringify(res.data, null, 2));
232
+ return 0;
233
+ }
234
+
235
+ const m = res.data;
236
+
237
+ console.log(`${colors.bold}Portok Metrics${colors.reset}`);
238
+ console.log(`${'─'.repeat(50)}`);
239
+
240
+ console.log(`\n${colors.cyan}Traffic${colors.reset}`);
241
+ console.log(` Total Requests: ${m.totalRequests.toLocaleString()}`);
242
+ console.log(` Rolling RPS(60s): ${colors.bold}${m.rollingRps60}${colors.reset}`);
243
+ console.log(` Inflight: ${m.inflight} (max: ${m.inflightMax})`);
244
+
245
+ console.log(`\n${colors.cyan}Status Codes${colors.reset}`);
246
+ console.log(` 2xx: ${colors.green}${m.statusCounters['2xx']}${colors.reset}`);
247
+ console.log(` 3xx: ${colors.blue}${m.statusCounters['3xx']}${colors.reset}`);
248
+ console.log(` 4xx: ${colors.yellow}${m.statusCounters['4xx']}${colors.reset}`);
249
+ console.log(` 5xx: ${colors.red}${m.statusCounters['5xx']}${colors.reset}`);
250
+
251
+ console.log(`\n${colors.cyan}Errors${colors.reset}`);
252
+ console.log(` Proxy Errors: ${m.totalProxyErrors}`);
253
+ if (m.lastProxyError) {
254
+ console.log(` Last Error: ${m.lastProxyError.message} @ ${m.lastProxyError.timestamp}`);
255
+ }
256
+
257
+ console.log(`\n${colors.cyan}Health${colors.reset}`);
258
+ const healthIcon = m.health.activePortOk ? `${colors.green}✓${colors.reset}` : `${colors.red}✗${colors.reset}`;
259
+ console.log(` Active Port OK: ${healthIcon}`);
260
+ console.log(` Last Checked: ${m.health.lastCheckedAt || 'never'}`);
261
+ console.log(` Consecutive Fails: ${m.health.consecutiveFails}`);
262
+
263
+ console.log(`\n${colors.dim}Started: ${m.startedAt}${colors.reset}`);
264
+
265
+ return 0;
266
+ }
267
+
268
+ async function cmdSwitch(baseUrl, token, port, options) {
269
+ if (!port) {
270
+ console.error(`${colors.red}Error:${colors.reset} Port is required`);
271
+ console.error('Usage: portok switch <port>');
272
+ return 1;
273
+ }
274
+
275
+ const portNum = parseInt(port, 10);
276
+ if (isNaN(portNum) || portNum < 1 || portNum > 65535) {
277
+ console.error(`${colors.red}Error:${colors.reset} Invalid port: ${port}`);
278
+ return 1;
279
+ }
280
+
281
+ console.log(`${colors.cyan}Switching to port ${portNum}...${colors.reset}`);
282
+
283
+ const res = await request(`${baseUrl}/__switch?port=${portNum}`, {
284
+ method: 'POST',
285
+ headers: { 'x-admin-token': token },
286
+ });
287
+
288
+ if (options.json) {
289
+ console.log(JSON.stringify(res.data, null, 2));
290
+ return res.ok ? 0 : 1;
291
+ }
292
+
293
+ if (!res.ok) {
294
+ console.error(`${colors.red}Switch failed:${colors.reset} ${res.data.error || res.data.message || 'Unknown error'}`);
295
+ if (res.status === 409) {
296
+ console.error(`${colors.yellow}Hint:${colors.reset} The target port failed the health check.`);
297
+ console.error(` Make sure your app is running and healthy on port ${portNum}.`);
298
+ }
299
+ return 1;
300
+ }
301
+
302
+ console.log(`${colors.green}✓ Switch successful!${colors.reset}`);
303
+ console.log(` From: ${res.data.switch.from} → To: ${res.data.switch.to}`);
304
+ console.log(` Switch ID: ${colors.dim}${res.data.switch.id}${colors.reset}`);
305
+
306
+ return 0;
307
+ }
308
+
309
+ // =============================================================================
310
+ // Management Commands (init, add, list)
311
+ // =============================================================================
312
+
313
+ async function cmdInit(options) {
314
+ const configDir = ENV_FILE_DIR;
315
+ const stateDir = '/var/lib/portok';
316
+
317
+ console.log(`${colors.bold}Initializing Portok...${colors.reset}\n`);
318
+
319
+ // Check if running as root (needed for /etc and /var/lib)
320
+ const isRoot = process.getuid && process.getuid() === 0;
321
+
322
+ if (!isRoot && !options.force) {
323
+ console.log(`${colors.yellow}Warning:${colors.reset} This command typically requires root privileges.`);
324
+ console.log(`Run with sudo or use --force to attempt anyway.\n`);
325
+ }
326
+
327
+ const results = [];
328
+
329
+ // Create config directory
330
+ try {
331
+ if (!fs.existsSync(configDir)) {
332
+ fs.mkdirSync(configDir, { recursive: true, mode: 0o755 });
333
+ results.push({ path: configDir, status: 'created' });
334
+ } else {
335
+ results.push({ path: configDir, status: 'exists' });
336
+ }
337
+ } catch (err) {
338
+ results.push({ path: configDir, status: 'error', error: err.message });
339
+ }
340
+
341
+ // Create state directory
342
+ try {
343
+ if (!fs.existsSync(stateDir)) {
344
+ fs.mkdirSync(stateDir, { recursive: true, mode: 0o755 });
345
+ results.push({ path: stateDir, status: 'created' });
346
+ } else {
347
+ results.push({ path: stateDir, status: 'exists' });
348
+ }
349
+ } catch (err) {
350
+ results.push({ path: stateDir, status: 'error', error: err.message });
351
+ }
352
+
353
+ if (options.json) {
354
+ console.log(JSON.stringify({ success: true, results }, null, 2));
355
+ return 0;
356
+ }
357
+
358
+ // Display results
359
+ for (const r of results) {
360
+ const icon = r.status === 'created' ? `${colors.green}✓${colors.reset}` :
361
+ r.status === 'exists' ? `${colors.dim}○${colors.reset}` :
362
+ `${colors.red}✗${colors.reset}`;
363
+ const status = r.status === 'created' ? 'Created' :
364
+ r.status === 'exists' ? 'Already exists' :
365
+ `Error: ${r.error}`;
366
+ console.log(` ${icon} ${r.path} - ${status}`);
367
+ }
368
+
369
+ const hasError = results.some(r => r.status === 'error');
370
+ if (hasError) {
371
+ console.log(`\n${colors.red}Some directories could not be created.${colors.reset}`);
372
+ console.log('Try running with: sudo portok init');
373
+ return 1;
374
+ }
375
+
376
+ console.log(`\n${colors.green}Portok initialized successfully!${colors.reset}`);
377
+ console.log(`\nNext steps:`);
378
+ console.log(` 1. Create a service: ${colors.cyan}portok add <name>${colors.reset}`);
379
+ console.log(` 2. Or manually create: ${colors.dim}${configDir}/<name>.env${colors.reset}`);
380
+
381
+ return 0;
382
+ }
383
+
384
+ function generateToken(length = 32) {
385
+ const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
386
+ let token = '';
387
+ const randomBytes = require('crypto').randomBytes(length);
388
+ for (let i = 0; i < length; i++) {
389
+ token += chars[randomBytes[i] % chars.length];
390
+ }
391
+ return token;
392
+ }
393
+
394
+ async function cmdAdd(name, options) {
395
+ if (!name) {
396
+ console.error(`${colors.red}Error:${colors.reset} Service name is required`);
397
+ console.error('Usage: portok add <name> [--port <listen_port>] [--target <target_port>]');
398
+ return 1;
399
+ }
400
+
401
+ // Validate name (alphanumeric, dash, underscore)
402
+ if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(name)) {
403
+ console.error(`${colors.red}Error:${colors.reset} Invalid service name '${name}'`);
404
+ console.error('Name must start with a letter and contain only letters, numbers, dashes, underscores.');
405
+ return 1;
406
+ }
407
+
408
+ const envFilePath = path.join(ENV_FILE_DIR, `${name}.env`);
409
+
410
+ // Check if already exists
411
+ if (fs.existsSync(envFilePath) && !options.force) {
412
+ console.error(`${colors.red}Error:${colors.reset} Service '${name}' already exists at ${envFilePath}`);
413
+ console.error('Use --force to overwrite.');
414
+ return 1;
415
+ }
416
+
417
+ // Get or generate configuration
418
+ const listenPort = options.port || (3000 + Math.floor(Math.random() * 1000));
419
+ const targetPort = options.target || (8000 + Math.floor(Math.random() * 1000));
420
+ const adminToken = options.token || generateToken();
421
+ const healthPath = options.health || '/health';
422
+
423
+ const envContent = `# Portok instance configuration for "${name}"
424
+ # Generated by: portok add ${name}
425
+ # Date: ${new Date().toISOString()}
426
+
427
+ # Required: Port the proxy listens on
428
+ LISTEN_PORT=${listenPort}
429
+
430
+ # Required: Initial target port (your app's port)
431
+ INITIAL_TARGET_PORT=${targetPort}
432
+
433
+ # Required: Admin token for API authentication
434
+ ADMIN_TOKEN=${adminToken}
435
+
436
+ # Optional: Health check configuration
437
+ HEALTH_PATH=${healthPath}
438
+ HEALTH_TIMEOUT_MS=5000
439
+
440
+ # Optional: Drain configuration (30 seconds)
441
+ DRAIN_MS=30000
442
+
443
+ # Optional: Rollback configuration
444
+ ROLLBACK_WINDOW_MS=60000
445
+ ROLLBACK_CHECK_EVERY_MS=5000
446
+ ROLLBACK_FAIL_THRESHOLD=3
447
+ `;
448
+
449
+ try {
450
+ // Check if directory exists
451
+ if (!fs.existsSync(ENV_FILE_DIR)) {
452
+ console.error(`${colors.red}Error:${colors.reset} Config directory ${ENV_FILE_DIR} does not exist.`);
453
+ console.error(`Run ${colors.cyan}sudo portok init${colors.reset} first.`);
454
+ return 1;
455
+ }
456
+
457
+ fs.writeFileSync(envFilePath, envContent, { mode: 0o600 });
458
+ } catch (err) {
459
+ console.error(`${colors.red}Error:${colors.reset} Could not create ${envFilePath}`);
460
+ console.error(err.message);
461
+ console.error(`Try running with: sudo portok add ${name}`);
462
+ return 1;
463
+ }
464
+
465
+ if (options.json) {
466
+ console.log(JSON.stringify({
467
+ success: true,
468
+ name,
469
+ envFile: envFilePath,
470
+ listenPort,
471
+ targetPort,
472
+ adminToken,
473
+ }, null, 2));
474
+ return 0;
475
+ }
476
+
477
+ console.log(`${colors.green}✓ Service '${name}' created successfully!${colors.reset}\n`);
478
+ console.log(`${colors.bold}Configuration:${colors.reset}`);
479
+ console.log(` File: ${envFilePath}`);
480
+ console.log(` Listen Port: ${colors.cyan}${listenPort}${colors.reset}`);
481
+ console.log(` Target Port: ${targetPort}`);
482
+ console.log(` Admin Token: ${colors.dim}${adminToken}${colors.reset}`);
483
+ console.log(` Health Path: ${healthPath}`);
484
+
485
+ console.log(`\n${colors.bold}Next steps:${colors.reset}`);
486
+ console.log(` 1. Start your app on port ${targetPort} with ${healthPath} endpoint`);
487
+ console.log(` 2. Start the service:`);
488
+ console.log(` ${colors.cyan}sudo systemctl start portok@${name}${colors.reset}`);
489
+ console.log(` 3. Enable at boot:`);
490
+ console.log(` ${colors.cyan}sudo systemctl enable portok@${name}${colors.reset}`);
491
+ console.log(` 4. Check status:`);
492
+ console.log(` ${colors.cyan}portok status --instance ${name}${colors.reset}`);
493
+
494
+ return 0;
495
+ }
496
+
497
+ async function cmdList(options) {
498
+ const configDir = ENV_FILE_DIR;
499
+
500
+ if (!fs.existsSync(configDir)) {
501
+ if (options.json) {
502
+ console.log(JSON.stringify({ instances: [], error: 'Config directory not found' }, null, 2));
503
+ } else {
504
+ console.log(`${colors.yellow}No instances configured.${colors.reset}`);
505
+ console.log(`Run ${colors.cyan}sudo portok init${colors.reset} to initialize.`);
506
+ }
507
+ return 0;
508
+ }
509
+
510
+ // Find all .env files
511
+ let files;
512
+ try {
513
+ files = fs.readdirSync(configDir).filter(f => f.endsWith('.env'));
514
+ } catch (err) {
515
+ console.error(`${colors.red}Error:${colors.reset} Could not read ${configDir}: ${err.message}`);
516
+ return 1;
517
+ }
518
+
519
+ if (files.length === 0) {
520
+ if (options.json) {
521
+ console.log(JSON.stringify({ instances: [] }, null, 2));
522
+ } else {
523
+ console.log(`${colors.yellow}No instances configured.${colors.reset}`);
524
+ console.log(`Run ${colors.cyan}portok add <name>${colors.reset} to create one.`);
525
+ }
526
+ return 0;
527
+ }
528
+
529
+ const instances = [];
530
+
531
+ for (const file of files) {
532
+ const name = file.replace('.env', '');
533
+ const envFilePath = path.join(configDir, file);
534
+ const config = parseEnvFile(envFilePath);
535
+
536
+ if (!config) continue;
537
+
538
+ const instance = {
539
+ name,
540
+ envFile: envFilePath,
541
+ listenPort: config.LISTEN_PORT ? parseInt(config.LISTEN_PORT) : null,
542
+ targetPort: config.INITIAL_TARGET_PORT ? parseInt(config.INITIAL_TARGET_PORT) : null,
543
+ healthPath: config.HEALTH_PATH || '/health',
544
+ status: 'unknown',
545
+ };
546
+
547
+ // Check if daemon is running (try to connect)
548
+ if (instance.listenPort && config.ADMIN_TOKEN) {
549
+ try {
550
+ const res = await request(`http://127.0.0.1:${instance.listenPort}/__status`, {
551
+ headers: { 'x-admin-token': config.ADMIN_TOKEN },
552
+ });
553
+ if (res.ok) {
554
+ instance.status = 'running';
555
+ instance.activePort = res.data.activePort;
556
+ } else {
557
+ instance.status = 'stopped';
558
+ }
559
+ } catch {
560
+ instance.status = 'stopped';
561
+ }
562
+ }
563
+
564
+ instances.push(instance);
565
+ }
566
+
567
+ if (options.json) {
568
+ console.log(JSON.stringify({ instances }, null, 2));
569
+ return 0;
570
+ }
571
+
572
+ console.log(`${colors.bold}Portok Instances${colors.reset}`);
573
+ console.log(`${'─'.repeat(70)}`);
574
+
575
+ if (instances.length === 0) {
576
+ console.log(`${colors.dim}No instances found.${colors.reset}`);
577
+ return 0;
578
+ }
579
+
580
+ // Table header
581
+ console.log(
582
+ `${'Name'.padEnd(15)} ` +
583
+ `${'Listen'.padEnd(8)} ` +
584
+ `${'Target'.padEnd(8)} ` +
585
+ `${'Active'.padEnd(8)} ` +
586
+ `${'Status'.padEnd(10)}`
587
+ );
588
+ console.log(`${'─'.repeat(70)}`);
589
+
590
+ for (const inst of instances) {
591
+ const statusColor = inst.status === 'running' ? colors.green :
592
+ inst.status === 'stopped' ? colors.red : colors.dim;
593
+ const statusIcon = inst.status === 'running' ? '●' :
594
+ inst.status === 'stopped' ? '○' : '?';
595
+
596
+ console.log(
597
+ `${inst.name.padEnd(15)} ` +
598
+ `${String(inst.listenPort || '-').padEnd(8)} ` +
599
+ `${String(inst.targetPort || '-').padEnd(8)} ` +
600
+ `${String(inst.activePort || '-').padEnd(8)} ` +
601
+ `${statusColor}${statusIcon} ${inst.status}${colors.reset}`
602
+ );
603
+ }
604
+
605
+ console.log(`\n${colors.dim}Total: ${instances.length} instance(s)${colors.reset}`);
606
+
607
+ return 0;
608
+ }
609
+
610
+ async function cmdHealth(baseUrl, token, options) {
611
+ const res = await request(`${baseUrl}/__health`, {
612
+ headers: { 'x-admin-token': token },
613
+ });
614
+
615
+ if (options.json) {
616
+ console.log(JSON.stringify(res.data, null, 2));
617
+ return res.ok ? 0 : 1;
618
+ }
619
+
620
+ if (res.ok && res.data.healthy) {
621
+ console.log(`${colors.green}✓ Healthy${colors.reset}`);
622
+ console.log(` Active Port: ${res.data.activePort}`);
623
+ console.log(` Checked At: ${res.data.checkedAt}`);
624
+ return 0;
625
+ } else {
626
+ console.error(`${colors.red}✗ Unhealthy${colors.reset}`);
627
+ console.error(` Active Port: ${res.data.activePort || 'unknown'}`);
628
+ console.error(` Checked At: ${res.data.checkedAt || 'unknown'}`);
629
+ return 1;
630
+ }
631
+ }
632
+
633
+ function showHelp() {
634
+ console.log(`
635
+ ${colors.bold}portok${colors.reset} - CLI for portokd zero-downtime proxy daemon
636
+
637
+ ${colors.bold}USAGE${colors.reset}
638
+ portok <command> [options]
639
+
640
+ ${colors.bold}COMMANDS${colors.reset}
641
+ ${colors.cyan}Management:${colors.reset}
642
+ init Initialize portok directories (/etc/portok, /var/lib/portok)
643
+ add <name> Create a new service instance
644
+ list List all configured instances and their status
645
+
646
+ ${colors.cyan}Operations:${colors.reset}
647
+ status Show current proxy status (activePort, drainUntil, lastSwitch)
648
+ metrics Show proxy metrics (requests, errors, health, RPS)
649
+ switch <port> Switch to a new target port (with health check)
650
+ health Check health of the current active port
651
+
652
+ ${colors.bold}OPTIONS${colors.reset}
653
+ --url <url> Daemon URL (default: http://127.0.0.1:3000)
654
+ --instance <name> Target instance by name (reads /etc/portok/<name>.env)
655
+ --token <token> Admin token for authentication
656
+ --json Output as JSON (for scripting)
657
+ --help Show this help message
658
+
659
+ ${colors.dim}For 'add' command:${colors.reset}
660
+ --port <port> Listen port (default: random 3000-3999)
661
+ --target <port> Target port (default: random 8000-8999)
662
+ --health <path> Health check path (default: /health)
663
+ --force Overwrite existing config
664
+
665
+ ${colors.bold}EXAMPLES${colors.reset}
666
+ ${colors.dim}# Initialize portok (run once, requires sudo)${colors.reset}
667
+ sudo portok init
668
+
669
+ ${colors.dim}# Create a new service${colors.reset}
670
+ sudo portok add api --port 3001 --target 8001
671
+ sudo portok add web --port 3002 --target 8002
672
+
673
+ ${colors.dim}# List all instances${colors.reset}
674
+ portok list
675
+
676
+ ${colors.dim}# Check status of an instance${colors.reset}
677
+ portok status --instance api
678
+
679
+ ${colors.dim}# Switch to new port${colors.reset}
680
+ portok switch 8081 --instance api
681
+
682
+ ${colors.dim}# Get metrics as JSON${colors.reset}
683
+ portok metrics --instance api --json
684
+
685
+ ${colors.dim}# Direct URL mode (without instance)${colors.reset}
686
+ portok status --url http://127.0.0.1:3000 --token mysecret
687
+
688
+ ${colors.bold}MULTI-INSTANCE${colors.reset}
689
+ When using --instance, the CLI reads /etc/portok/<name>.env
690
+ to resolve LISTEN_PORT and ADMIN_TOKEN for that instance.
691
+ Explicit --url and --token flags override env file values.
692
+
693
+ ${colors.bold}EXIT CODES${colors.reset}
694
+ 0 Success
695
+ 1 Failure (error or unhealthy)
696
+ `);
697
+ }
698
+
699
+ // =============================================================================
700
+ // Main
701
+ // =============================================================================
702
+
703
+ async function main() {
704
+ const args = parseArgs(process.argv.slice(2));
705
+
706
+ // Help flag
707
+ if (args.options.help || args.command === 'help') {
708
+ showHelp();
709
+ process.exit(0);
710
+ }
711
+
712
+ // Management commands (don't require daemon connection)
713
+ const managementCommands = ['init', 'add', 'list'];
714
+
715
+ if (managementCommands.includes(args.command)) {
716
+ let exitCode = 1;
717
+ switch (args.command) {
718
+ case 'init':
719
+ exitCode = await cmdInit(args.options);
720
+ break;
721
+ case 'add':
722
+ exitCode = await cmdAdd(args.positional[0], args.options);
723
+ break;
724
+ case 'list':
725
+ exitCode = await cmdList(args.options);
726
+ break;
727
+ }
728
+ process.exit(exitCode);
729
+ }
730
+
731
+ // Operational commands require authentication
732
+ // Get configuration - support --instance flag
733
+ let baseUrl = args.options.url || process.env.PORTOK_URL;
734
+ let token = args.options.token || process.env.PORTOK_TOKEN;
735
+
736
+ // If --instance is provided, resolve from env file
737
+ if (args.options.instance) {
738
+ const instanceConfig = resolveInstanceConfig(args.options.instance);
739
+
740
+ if (!instanceConfig) {
741
+ console.error(`${colors.red}Error:${colors.reset} Could not resolve instance '${args.options.instance}'`);
742
+ console.error(`Make sure ${ENV_FILE_DIR}/${args.options.instance}.env exists and contains LISTEN_PORT`);
743
+ process.exit(1);
744
+ }
745
+
746
+ // Env file values are defaults; explicit flags override
747
+ if (!baseUrl) baseUrl = instanceConfig.url;
748
+ if (!token) token = instanceConfig.token;
749
+ }
750
+
751
+ // Apply final defaults
752
+ baseUrl = baseUrl || 'http://127.0.0.1:3000';
753
+
754
+ if (!token) {
755
+ console.error(`${colors.red}Error:${colors.reset} Admin token is required`);
756
+ console.error('Set --token flag, PORTOK_TOKEN env var, or use --instance with ADMIN_TOKEN in env file');
757
+ process.exit(1);
758
+ }
759
+
760
+ // Route to operational command
761
+ let exitCode = 1;
762
+
763
+ switch (args.command) {
764
+ case 'status':
765
+ exitCode = await cmdStatus(baseUrl, token, args.options);
766
+ break;
767
+ case 'metrics':
768
+ exitCode = await cmdMetrics(baseUrl, token, args.options);
769
+ break;
770
+ case 'switch':
771
+ exitCode = await cmdSwitch(baseUrl, token, args.positional[0], args.options);
772
+ break;
773
+ case 'health':
774
+ exitCode = await cmdHealth(baseUrl, token, args.options);
775
+ break;
776
+ default:
777
+ if (args.command) {
778
+ console.error(`${colors.red}Unknown command:${colors.reset} ${args.command}`);
779
+ } else {
780
+ console.error(`${colors.red}No command specified${colors.reset}`);
781
+ }
782
+ showHelp();
783
+ exitCode = 1;
784
+ }
785
+
786
+ process.exit(exitCode);
787
+ }
788
+
789
+ main().catch((err) => {
790
+ console.error(`${colors.red}Fatal error:${colors.reset} ${err.message}`);
791
+ process.exit(1);
792
+ });
793
+