portok 1.0.1 → 1.0.3

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/README.md CHANGED
@@ -15,8 +15,15 @@ A lightweight Node.js "switchboard" proxy that enables zero-downtime deployments
15
15
 
16
16
  ### Installation
17
17
 
18
+ **Global Installation (Recommended):**
19
+
18
20
  ```bash
19
- npm install
21
+ # Install globally
22
+ npm install -g portok
23
+
24
+ # Now you can use portok and portokd commands from anywhere
25
+ portok --help
26
+ portokd --help
20
27
  ```
21
28
 
22
29
  ### Start the Daemon
@@ -28,25 +35,27 @@ export INITIAL_TARGET_PORT=8080
28
35
  export ADMIN_TOKEN=your-secret-token
29
36
 
30
37
  # Start portokd
31
- node portokd.mjs
38
+ node portokd.js
32
39
  ```
33
40
 
34
41
  ### Use the CLI
35
42
 
36
43
  ```bash
37
44
  # Check status
38
- ./portok.mjs status --token your-secret-token
45
+ portok status --token your-secret-token
39
46
 
40
47
  # Switch to new port
41
- ./portok.mjs switch 8081 --token your-secret-token
48
+ portok switch 8081 --token your-secret-token
42
49
 
43
50
  # Check metrics
44
- ./portok.mjs metrics --token your-secret-token
51
+ portok metrics --token your-secret-token
45
52
 
46
53
  # Check health
47
- ./portok.mjs health --token your-secret-token
54
+ portok health --token your-secret-token
48
55
  ```
49
56
 
57
+ > **Note:** If not installed globally, use `./portok.js` instead of `portok`.
58
+
50
59
  ## Configuration
51
60
 
52
61
  All configuration is via environment variables:
@@ -188,8 +197,17 @@ portok <command> [options]
188
197
  Management Commands:
189
198
  init Initialize portok directories (/etc/portok, /var/lib/portok)
190
199
  add <name> Create a new service instance
200
+ remove <name> Remove a service instance (stops, disables, deletes config/state)
191
201
  list List all configured instances and their status
192
202
 
203
+ Service Control Commands:
204
+ start <name> Start a portok service (systemctl start portok@<name>)
205
+ stop <name> Stop a portok service
206
+ restart <name> Restart a portok service
207
+ enable <name> Enable service at boot
208
+ disable <name> Disable service at boot
209
+ logs <name> Show service logs (journalctl)
210
+
193
211
  Operational Commands:
194
212
  status Show current proxy status
195
213
  metrics Show proxy metrics
@@ -208,6 +226,14 @@ Options for 'add' command:
208
226
  --target <port> Target port (default: random 8000-8999)
209
227
  --health <path> Health check path (default: /health)
210
228
  --force Overwrite existing config
229
+
230
+ Options for 'remove' command:
231
+ --force Skip confirmation prompt
232
+ --keep-state Keep state file (/var/lib/portok/<name>.json)
233
+
234
+ Options for 'logs' command:
235
+ --follow, -f Follow log output
236
+ --lines, -n Number of lines to show (default: 50)
211
237
  ```
212
238
 
213
239
  ### Quick Start with CLI
@@ -220,12 +246,15 @@ sudo portok init
220
246
  sudo portok add api --port 3001 --target 8001
221
247
 
222
248
  # 3. Start the service
223
- sudo systemctl start portok@api
249
+ sudo portok start api
250
+
251
+ # 4. Enable at boot
252
+ sudo portok enable api
224
253
 
225
- # 4. Check status
254
+ # 5. Check status
226
255
  portok status --instance api
227
256
 
228
- # 5. List all services
257
+ # 6. List all services
229
258
  portok list
230
259
  ```
231
260
 
@@ -244,6 +273,22 @@ sudo portok init
244
273
  sudo portok add api --port 3001 --target 8001
245
274
  sudo portok add web --port 3002 --target 8002
246
275
 
276
+ # Service management
277
+ sudo portok start api
278
+ sudo portok stop api
279
+ sudo portok restart api
280
+ sudo portok enable api # Enable at boot
281
+ sudo portok disable api # Disable at boot
282
+
283
+ # Remove a service (stops, disables, removes config and state)
284
+ sudo portok remove api --force
285
+ sudo portok remove api --force --keep-state # Keep state file
286
+
287
+ # View logs
288
+ portok logs api
289
+ portok logs api --follow # Follow log output
290
+ portok logs api -n 100 # Show last 100 lines
291
+
247
292
  # List all instances with status
248
293
  portok list
249
294
 
@@ -276,7 +321,7 @@ After=network.target
276
321
  Type=simple
277
322
  User=www-data
278
323
  WorkingDirectory=/opt/portok
279
- ExecStart=/usr/bin/node /opt/portok/portokd.mjs
324
+ ExecStart=/usr/bin/node /opt/portok/portokd.js
280
325
  Restart=always
281
326
  RestartSec=5
282
327
 
@@ -548,10 +593,10 @@ Run the validation benchmark to verify performance:
548
593
 
549
594
  ```bash
550
595
  # Quick validation (3s)
551
- FAST_PATH=1 node bench/validate.mjs --quick
596
+ FAST_PATH=1 node bench/validate.js --quick
552
597
 
553
598
  # Full validation (10s)
554
- FAST_PATH=1 node bench/validate.mjs
599
+ FAST_PATH=1 node bench/validate.js
555
600
 
556
601
  # Manual autocannon test
557
602
  # Direct:
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  const autocannon = require('autocannon');
7
- const { createMockServer, startDaemon, getFreePort, formatNumber } = require('./run.js');
7
+ const { createMockServer, startDaemon, getFreePort, formatNumber } = require('./helpers.js');
8
8
 
9
9
  async function run({ duration, adminToken }) {
10
10
  const shortDuration = Math.max(2, Math.floor(duration / 2));
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  const autocannon = require('autocannon');
7
- const { createMockServer, startDaemon, getFreePort, formatNumber } = require('./run.js');
7
+ const { createMockServer, startDaemon, getFreePort, formatNumber } = require('./helpers.js');
8
8
 
9
9
  async function run({ duration, adminToken }) {
10
10
  // Setup
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Benchmark Helpers
3
+ * Shared utilities for all benchmark files
4
+ */
5
+
6
+ const { spawn } = require('node:child_process');
7
+ const http = require('node:http');
8
+ const path = require('node:path');
9
+
10
+ const ADMIN_TOKEN = process.env.ADMIN_TOKEN || 'bench-token-12345';
11
+
12
+ function formatNumber(n) {
13
+ return n.toLocaleString('en-US', { maximumFractionDigits: 2 });
14
+ }
15
+
16
+ async function getFreePort() {
17
+ return new Promise((resolve, reject) => {
18
+ const server = http.createServer();
19
+ server.listen(0, '127.0.0.1', () => {
20
+ const port = server.address().port;
21
+ server.close(() => resolve(port));
22
+ });
23
+ server.on('error', reject);
24
+ });
25
+ }
26
+
27
+ async function waitFor(condition, timeout = 10000) {
28
+ const start = Date.now();
29
+ while (Date.now() - start < timeout) {
30
+ if (await condition()) return true;
31
+ await new Promise(r => setTimeout(r, 100));
32
+ }
33
+ throw new Error('Timeout waiting for condition');
34
+ }
35
+
36
+ // =============================================================================
37
+ // Mock Server
38
+ // =============================================================================
39
+
40
+ async function createMockServer(options = {}) {
41
+ const { responseDelay = 0, responseSize = 13 } = options;
42
+ const port = await getFreePort();
43
+
44
+ const responseBody = responseSize <= 13
45
+ ? 'Hello, World!'
46
+ : 'x'.repeat(responseSize);
47
+
48
+ const server = http.createServer((req, res) => {
49
+ if (req.url === '/health') {
50
+ res.writeHead(200, { 'Content-Type': 'application/json' });
51
+ res.end('{"status":"ok"}');
52
+ return;
53
+ }
54
+
55
+ if (responseDelay > 0) {
56
+ setTimeout(() => {
57
+ res.writeHead(200, { 'Content-Type': 'text/plain' });
58
+ res.end(responseBody);
59
+ }, responseDelay);
60
+ } else {
61
+ res.writeHead(200, { 'Content-Type': 'text/plain' });
62
+ res.end(responseBody);
63
+ }
64
+ });
65
+
66
+ return new Promise((resolve, reject) => {
67
+ server.listen(port, '127.0.0.1', () => {
68
+ resolve({
69
+ port,
70
+ close: () => new Promise(r => server.close(r)),
71
+ });
72
+ });
73
+ server.on('error', reject);
74
+ });
75
+ }
76
+
77
+ // =============================================================================
78
+ // Daemon Starter
79
+ // =============================================================================
80
+
81
+ async function startDaemon(proxyPort, targetPort, extraEnv = {}) {
82
+ const daemonPath = path.join(__dirname, '..', 'portokd.js');
83
+
84
+ const env = {
85
+ ...process.env,
86
+ LISTEN_PORT: String(proxyPort),
87
+ INITIAL_TARGET_PORT: String(targetPort),
88
+ ADMIN_TOKEN,
89
+ DRAIN_MS: '100',
90
+ ROLLBACK_WINDOW_MS: '1000',
91
+ ROLLBACK_CHECK_EVERY_MS: '100',
92
+ ROLLBACK_FAIL_THRESHOLD: '2',
93
+ ...extraEnv,
94
+ };
95
+
96
+ const daemon = spawn('node', [daemonPath], {
97
+ env,
98
+ stdio: ['ignore', 'pipe', 'pipe'],
99
+ });
100
+
101
+ // Wait for daemon to be ready
102
+ await waitFor(async () => {
103
+ try {
104
+ const res = await fetch(`http://127.0.0.1:${proxyPort}/__status`, {
105
+ headers: { 'x-admin-token': ADMIN_TOKEN },
106
+ });
107
+ return res.ok;
108
+ } catch {
109
+ return false;
110
+ }
111
+ }, 10000);
112
+
113
+ return daemon;
114
+ }
115
+
116
+ module.exports = {
117
+ createMockServer,
118
+ startDaemon,
119
+ getFreePort,
120
+ waitFor,
121
+ formatNumber,
122
+ ADMIN_TOKEN,
123
+ };
124
+
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  const autocannon = require('autocannon');
7
- const { createMockServer, startDaemon, getFreePort } = require('./run.js');
7
+ const { createMockServer, startDaemon, getFreePort } = require('./helpers.js');
8
8
 
9
9
  async function run({ duration, adminToken }) {
10
10
  // Setup
package/bench/run.js CHANGED
@@ -5,9 +5,6 @@
5
5
  * Orchestrates all benchmarks and outputs formatted results
6
6
  */
7
7
 
8
- const { spawn } = require('node:child_process');
9
- const http = require('node:http');
10
-
11
8
  // =============================================================================
12
9
  // Configuration
13
10
  // =============================================================================
@@ -31,86 +28,6 @@ function log(msg) {
31
28
  }
32
29
  }
33
30
 
34
- function formatNumber(n) {
35
- return n.toLocaleString('en-US', { maximumFractionDigits: 2 });
36
- }
37
-
38
- async function getFreePort() {
39
- return new Promise((resolve, reject) => {
40
- const server = http.createServer();
41
- server.listen(0, '127.0.0.1', () => {
42
- const port = server.address().port;
43
- server.close(() => resolve(port));
44
- });
45
- server.on('error', reject);
46
- });
47
- }
48
-
49
- async function waitFor(condition, timeout = 10000) {
50
- const start = Date.now();
51
- while (Date.now() - start < timeout) {
52
- if (await condition()) return true;
53
- await new Promise(r => setTimeout(r, 100));
54
- }
55
- throw new Error('Timeout waiting for condition');
56
- }
57
-
58
- // =============================================================================
59
- // Mock Server
60
- // =============================================================================
61
-
62
- async function createMockServer(port = 0) {
63
- const server = http.createServer((req, res) => {
64
- if (req.url === '/health') {
65
- res.writeHead(200, { 'Content-Type': 'application/json' });
66
- res.end('{"status":"healthy"}');
67
- return;
68
- }
69
- res.writeHead(200, { 'Content-Type': 'text/plain' });
70
- res.end('OK');
71
- });
72
-
73
- await new Promise(resolve => server.listen(port, '127.0.0.1', resolve));
74
- return {
75
- port: server.address().port,
76
- close: () => new Promise(resolve => server.close(resolve)),
77
- };
78
- }
79
-
80
- // =============================================================================
81
- // Start Daemon
82
- // =============================================================================
83
-
84
- async function startDaemon(listenPort, targetPort) {
85
- const proc = spawn('node', ['portokd.js'], {
86
- env: {
87
- ...process.env,
88
- LISTEN_PORT: String(listenPort),
89
- INITIAL_TARGET_PORT: String(targetPort),
90
- ADMIN_TOKEN,
91
- STATE_FILE: `/tmp/portok-bench-${Date.now()}.json`,
92
- DRAIN_MS: '1000',
93
- ROLLBACK_WINDOW_MS: '60000',
94
- ROLLBACK_CHECK_EVERY_MS: '5000',
95
- ROLLBACK_FAIL_THRESHOLD: '3',
96
- },
97
- stdio: 'pipe',
98
- });
99
-
100
- await waitFor(async () => {
101
- try {
102
- const res = await fetch(`http://127.0.0.1:${listenPort}/__status`, {
103
- headers: { 'x-admin-token': ADMIN_TOKEN },
104
- });
105
- return res.ok;
106
- } catch {
107
- return false;
108
- }
109
- });
110
-
111
- return proc;
112
- }
113
-
114
31
  // =============================================================================
115
32
  // Run Individual Benchmarks
116
33
  // =============================================================================
@@ -206,6 +123,3 @@ main().catch(err => {
206
123
  console.error('Benchmark failed:', err);
207
124
  process.exit(1);
208
125
  });
209
-
210
- module.exports = { createMockServer, startDaemon, getFreePort, waitFor, formatNumber, ADMIN_TOKEN };
211
-
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  const autocannon = require('autocannon');
7
- const { createMockServer, startDaemon, getFreePort, formatNumber, ADMIN_TOKEN } = require('./run.js');
7
+ const { createMockServer, startDaemon, getFreePort, formatNumber, ADMIN_TOKEN } = require('./helpers.js');
8
8
 
9
9
  async function run({ duration, adminToken }) {
10
10
  // Setup two servers
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  const autocannon = require('autocannon');
7
- const { createMockServer, startDaemon, getFreePort, formatNumber } = require('./run.js');
7
+ const { createMockServer, startDaemon, getFreePort, formatNumber } = require('./helpers.js');
8
8
 
9
9
  async function run({ duration, adminToken }) {
10
10
  // Setup
@@ -56,7 +56,7 @@ services:
56
56
  - ROLLBACK_FAIL_THRESHOLD=3
57
57
  ports:
58
58
  - "3000:3000"
59
- command: ["node", "portokd.mjs"]
59
+ command: ["node", "portokd.js"]
60
60
  tmpfs:
61
61
  - /tmp
62
62
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portok",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "Zero-downtime deployment proxy - routes traffic through a stable port to internal app instances with health-gated switching",
5
5
  "main": "portokd.js",
6
6
  "bin": {
package/portok.js CHANGED
@@ -314,6 +314,8 @@ async function cmdSwitch(baseUrl, token, port, options) {
314
314
  async function cmdInit(options) {
315
315
  const configDir = ENV_FILE_DIR;
316
316
  const stateDir = '/var/lib/portok';
317
+ const systemdDir = '/etc/systemd/system';
318
+ const serviceFileName = 'portok@.service';
317
319
 
318
320
  console.log(`${colors.bold}Initializing Portok...${colors.reset}\n`);
319
321
 
@@ -351,6 +353,51 @@ async function cmdInit(options) {
351
353
  results.push({ path: stateDir, status: 'error', error: err.message });
352
354
  }
353
355
 
356
+ // Install systemd template unit
357
+ const systemdDest = path.join(systemdDir, serviceFileName);
358
+ try {
359
+ // Find the service file relative to this script
360
+ const scriptDir = path.dirname(process.argv[1]);
361
+ const possiblePaths = [
362
+ path.join(scriptDir, serviceFileName),
363
+ path.join(scriptDir, '..', serviceFileName),
364
+ '/opt/portok/' + serviceFileName,
365
+ ];
366
+
367
+ let sourceFile = null;
368
+ for (const p of possiblePaths) {
369
+ if (fs.existsSync(p)) {
370
+ sourceFile = p;
371
+ break;
372
+ }
373
+ }
374
+
375
+ if (sourceFile) {
376
+ const content = fs.readFileSync(sourceFile, 'utf-8');
377
+ const existingContent = fs.existsSync(systemdDest) ? fs.readFileSync(systemdDest, 'utf-8') : null;
378
+
379
+ if (existingContent === content) {
380
+ results.push({ path: systemdDest, status: 'exists' });
381
+ } else {
382
+ fs.writeFileSync(systemdDest, content, { mode: 0o644 });
383
+ results.push({ path: systemdDest, status: 'created' });
384
+
385
+ // Reload systemd daemon
386
+ try {
387
+ const { execSync } = require('child_process');
388
+ execSync('systemctl daemon-reload', { stdio: 'pipe' });
389
+ results.push({ path: 'systemctl daemon-reload', status: 'created' });
390
+ } catch (e) {
391
+ // Ignore if systemctl not available (e.g., in Docker)
392
+ }
393
+ }
394
+ } else {
395
+ results.push({ path: systemdDest, status: 'skipped', error: 'Template file not found' });
396
+ }
397
+ } catch (err) {
398
+ results.push({ path: systemdDest, status: 'error', error: err.message });
399
+ }
400
+
354
401
  if (options.json) {
355
402
  console.log(JSON.stringify({ success: true, results }, null, 2));
356
403
  return 0;
@@ -360,9 +407,11 @@ async function cmdInit(options) {
360
407
  for (const r of results) {
361
408
  const icon = r.status === 'created' ? `${colors.green}✓${colors.reset}` :
362
409
  r.status === 'exists' ? `${colors.dim}○${colors.reset}` :
410
+ r.status === 'skipped' ? `${colors.yellow}○${colors.reset}` :
363
411
  `${colors.red}✗${colors.reset}`;
364
412
  const status = r.status === 'created' ? 'Created' :
365
413
  r.status === 'exists' ? 'Already exists' :
414
+ r.status === 'skipped' ? `Skipped: ${r.error}` :
366
415
  `Error: ${r.error}`;
367
416
  console.log(` ${icon} ${r.path} - ${status}`);
368
417
  }
@@ -377,7 +426,8 @@ async function cmdInit(options) {
377
426
  console.log(`\n${colors.green}Portok initialized successfully!${colors.reset}`);
378
427
  console.log(`\nNext steps:`);
379
428
  console.log(` 1. Create a service: ${colors.cyan}portok add <name>${colors.reset}`);
380
- console.log(` 2. Or manually create: ${colors.dim}${configDir}/<name>.env${colors.reset}`);
429
+ console.log(` 2. Start service: ${colors.cyan}portok start <name>${colors.reset}`);
430
+ console.log(` 3. Enable at boot: ${colors.cyan}portok enable <name>${colors.reset}`);
381
431
 
382
432
  return 0;
383
433
  }
@@ -495,6 +545,130 @@ ROLLBACK_FAIL_THRESHOLD=3
495
545
  return 0;
496
546
  }
497
547
 
548
+ async function cmdRemove(name, options) {
549
+ if (!name) {
550
+ console.error(`${colors.red}Error:${colors.reset} Service name is required`);
551
+ console.error('Usage: portok remove <name> [--force] [--keep-state]');
552
+ return 1;
553
+ }
554
+
555
+ const envFilePath = path.join(ENV_FILE_DIR, `${name}.env`);
556
+ const stateFilePath = `/var/lib/portok/${name}.json`;
557
+
558
+ // Check if service exists
559
+ if (!fs.existsSync(envFilePath)) {
560
+ console.error(`${colors.red}Error:${colors.reset} Service '${name}' not found at ${envFilePath}`);
561
+ return 1;
562
+ }
563
+
564
+ // Confirmation (unless --force)
565
+ if (!options.force && !options.json) {
566
+ console.log(`${colors.yellow}Warning:${colors.reset} This will remove service '${name}'`);
567
+ console.log(` Config file: ${envFilePath}`);
568
+ if (fs.existsSync(stateFilePath) && !options['keep-state']) {
569
+ console.log(` State file: ${stateFilePath}`);
570
+ }
571
+ console.log(`\nUse ${colors.cyan}--force${colors.reset} to confirm removal.`);
572
+ return 1;
573
+ }
574
+
575
+ const results = [];
576
+ const { execSync } = require('child_process');
577
+
578
+ // 1. Stop the service if running
579
+ try {
580
+ execSync(`systemctl is-active portok@${name}`, { stdio: 'pipe' });
581
+ // Service is running, stop it
582
+ try {
583
+ execSync(`systemctl stop portok@${name}`, { stdio: 'pipe' });
584
+ results.push({ action: 'stop', status: 'success' });
585
+ } catch (e) {
586
+ results.push({ action: 'stop', status: 'error', error: e.message });
587
+ }
588
+ } catch {
589
+ // Service not running, skip
590
+ results.push({ action: 'stop', status: 'skipped' });
591
+ }
592
+
593
+ // 2. Disable the service
594
+ try {
595
+ execSync(`systemctl is-enabled portok@${name}`, { stdio: 'pipe' });
596
+ // Service is enabled, disable it
597
+ try {
598
+ execSync(`systemctl disable portok@${name}`, { stdio: 'pipe' });
599
+ results.push({ action: 'disable', status: 'success' });
600
+ } catch (e) {
601
+ results.push({ action: 'disable', status: 'error', error: e.message });
602
+ }
603
+ } catch {
604
+ // Service not enabled, skip
605
+ results.push({ action: 'disable', status: 'skipped' });
606
+ }
607
+
608
+ // 3. Remove config file
609
+ try {
610
+ fs.unlinkSync(envFilePath);
611
+ results.push({ action: 'remove-config', status: 'success', path: envFilePath });
612
+ } catch (err) {
613
+ results.push({ action: 'remove-config', status: 'error', error: err.message });
614
+ }
615
+
616
+ // 4. Remove state file (unless --keep-state)
617
+ if (!options['keep-state']) {
618
+ if (fs.existsSync(stateFilePath)) {
619
+ try {
620
+ fs.unlinkSync(stateFilePath);
621
+ results.push({ action: 'remove-state', status: 'success', path: stateFilePath });
622
+ } catch (err) {
623
+ results.push({ action: 'remove-state', status: 'error', error: err.message });
624
+ }
625
+ } else {
626
+ results.push({ action: 'remove-state', status: 'skipped' });
627
+ }
628
+ } else {
629
+ results.push({ action: 'remove-state', status: 'kept' });
630
+ }
631
+
632
+ const hasError = results.some(r => r.status === 'error');
633
+
634
+ if (options.json) {
635
+ console.log(JSON.stringify({ success: !hasError, name, results }, null, 2));
636
+ return hasError ? 1 : 0;
637
+ }
638
+
639
+ // Display results
640
+ console.log(`\n${colors.bold}Removing service '${name}'...${colors.reset}\n`);
641
+
642
+ for (const r of results) {
643
+ const icon = r.status === 'success' ? `${colors.green}✓${colors.reset}` :
644
+ r.status === 'skipped' ? `${colors.dim}○${colors.reset}` :
645
+ r.status === 'kept' ? `${colors.dim}○${colors.reset}` :
646
+ `${colors.red}✗${colors.reset}`;
647
+
648
+ const actionLabel = {
649
+ 'stop': 'Stop service',
650
+ 'disable': 'Disable service',
651
+ 'remove-config': 'Remove config',
652
+ 'remove-state': 'Remove state',
653
+ }[r.action];
654
+
655
+ const statusLabel = r.status === 'success' ? 'Done' :
656
+ r.status === 'skipped' ? 'Skipped (not running/enabled)' :
657
+ r.status === 'kept' ? 'Kept (--keep-state)' :
658
+ `Error: ${r.error}`;
659
+
660
+ console.log(` ${icon} ${actionLabel.padEnd(16)} ${statusLabel}`);
661
+ }
662
+
663
+ if (hasError) {
664
+ console.log(`\n${colors.yellow}Warning:${colors.reset} Some operations failed. Check errors above.`);
665
+ return 1;
666
+ }
667
+
668
+ console.log(`\n${colors.green}✓ Service '${name}' removed successfully!${colors.reset}`);
669
+ return 0;
670
+ }
671
+
498
672
  async function cmdList(options) {
499
673
  const configDir = ENV_FILE_DIR;
500
674
 
@@ -631,6 +805,92 @@ async function cmdHealth(baseUrl, token, options) {
631
805
  }
632
806
  }
633
807
 
808
+ // =============================================================================
809
+ // Systemctl Commands (start, stop, restart, enable, disable)
810
+ // =============================================================================
811
+
812
+ const { execSync } = require('node:child_process');
813
+
814
+ async function cmdSystemctl(action, name, options) {
815
+ if (!name) {
816
+ console.error(`${colors.red}Error:${colors.reset} Instance name is required`);
817
+ console.error(`Usage: portok ${action} <name>`);
818
+ return 1;
819
+ }
820
+
821
+ // Validate name exists
822
+ const envFilePath = path.join(ENV_FILE_DIR, `${name}.env`);
823
+ if (!fs.existsSync(envFilePath)) {
824
+ console.error(`${colors.red}Error:${colors.reset} Instance '${name}' not found`);
825
+ console.error(`Make sure ${envFilePath} exists. Run ${colors.cyan}portok add ${name}${colors.reset} to create it.`);
826
+ return 1;
827
+ }
828
+
829
+ const serviceName = `portok@${name}`;
830
+ const actionVerb = {
831
+ start: 'Starting',
832
+ stop: 'Stopping',
833
+ restart: 'Restarting',
834
+ enable: 'Enabling',
835
+ disable: 'Disabling',
836
+ }[action];
837
+
838
+ console.log(`${colors.cyan}${actionVerb} ${serviceName}...${colors.reset}`);
839
+
840
+ try {
841
+ execSync(`systemctl ${action} ${serviceName}`, { stdio: 'inherit' });
842
+
843
+ const pastVerb = {
844
+ start: 'started',
845
+ stop: 'stopped',
846
+ restart: 'restarted',
847
+ enable: 'enabled',
848
+ disable: 'disabled',
849
+ }[action];
850
+
851
+ console.log(`${colors.green}✓ Service ${serviceName} ${pastVerb}${colors.reset}`);
852
+
853
+ // For start/restart, show status after
854
+ if (action === 'start' || action === 'restart') {
855
+ console.log('');
856
+ try {
857
+ execSync(`systemctl status ${serviceName} --no-pager -l`, { stdio: 'inherit' });
858
+ } catch {
859
+ // Status might return non-zero even when running, ignore
860
+ }
861
+ }
862
+
863
+ return 0;
864
+ } catch (err) {
865
+ console.error(`${colors.red}Failed to ${action} ${serviceName}${colors.reset}`);
866
+ if (err.status === 1) {
867
+ console.error(`${colors.yellow}Hint:${colors.reset} This command may require sudo privileges.`);
868
+ console.error(`Try: ${colors.cyan}sudo portok ${action} ${name}${colors.reset}`);
869
+ }
870
+ return 1;
871
+ }
872
+ }
873
+
874
+ async function cmdLogs(name, options) {
875
+ if (!name) {
876
+ console.error(`${colors.red}Error:${colors.reset} Instance name is required`);
877
+ console.error('Usage: portok logs <name> [--follow]');
878
+ return 1;
879
+ }
880
+
881
+ const serviceName = `portok@${name}`;
882
+ const followFlag = options.follow || options.f ? '-f' : '';
883
+ const lines = options.lines || options.n || '50';
884
+
885
+ try {
886
+ execSync(`journalctl -u ${serviceName} -n ${lines} ${followFlag} --no-pager`, { stdio: 'inherit' });
887
+ return 0;
888
+ } catch (err) {
889
+ console.error(`${colors.red}Failed to get logs for ${serviceName}${colors.reset}`);
890
+ return 1;
891
+ }
892
+ }
893
+
634
894
  function showHelp() {
635
895
  console.log(`
636
896
  ${colors.bold}portok${colors.reset} - CLI for portokd zero-downtime proxy daemon
@@ -642,8 +902,17 @@ ${colors.bold}COMMANDS${colors.reset}
642
902
  ${colors.cyan}Management:${colors.reset}
643
903
  init Initialize portok directories (/etc/portok, /var/lib/portok)
644
904
  add <name> Create a new service instance
905
+ remove <name> Remove a service instance (config + state)
645
906
  list List all configured instances and their status
646
907
 
908
+ ${colors.cyan}Service Control:${colors.reset}
909
+ start <name> Start a portok service (systemctl start portok@<name>)
910
+ stop <name> Stop a portok service
911
+ restart <name> Restart a portok service
912
+ enable <name> Enable service at boot
913
+ disable <name> Disable service at boot
914
+ logs <name> Show service logs (journalctl)
915
+
647
916
  ${colors.cyan}Operations:${colors.reset}
648
917
  status Show current proxy status (activePort, drainUntil, lastSwitch)
649
918
  metrics Show proxy metrics (requests, errors, health, RPS)
@@ -663,17 +932,35 @@ ${colors.bold}OPTIONS${colors.reset}
663
932
  --health <path> Health check path (default: /health)
664
933
  --force Overwrite existing config
665
934
 
935
+ ${colors.dim}For 'remove' command:${colors.reset}
936
+ --force Skip confirmation prompt
937
+ --keep-state Keep state file (/var/lib/portok/<name>.json)
938
+
939
+ ${colors.dim}For 'logs' command:${colors.reset}
940
+ --follow, -f Follow log output
941
+ --lines, -n Number of lines to show (default: 50)
942
+
666
943
  ${colors.bold}EXAMPLES${colors.reset}
667
944
  ${colors.dim}# Initialize portok (run once, requires sudo)${colors.reset}
668
945
  sudo portok init
669
946
 
670
- ${colors.dim}# Create a new service${colors.reset}
947
+ ${colors.dim}# Create and start a new service${colors.reset}
671
948
  sudo portok add api --port 3001 --target 8001
672
- sudo portok add web --port 3002 --target 8002
949
+ sudo portok start api
950
+ sudo portok enable api
673
951
 
674
952
  ${colors.dim}# List all instances${colors.reset}
675
953
  portok list
676
954
 
955
+ ${colors.dim}# Service management${colors.reset}
956
+ sudo portok start api
957
+ sudo portok stop api
958
+ sudo portok restart api
959
+ portok logs api --follow
960
+
961
+ ${colors.dim}# Remove a service${colors.reset}
962
+ sudo portok remove api --force
963
+
677
964
  ${colors.dim}# Check status of an instance${colors.reset}
678
965
  portok status --instance api
679
966
 
@@ -683,9 +970,6 @@ ${colors.bold}EXAMPLES${colors.reset}
683
970
  ${colors.dim}# Get metrics as JSON${colors.reset}
684
971
  portok metrics --instance api --json
685
972
 
686
- ${colors.dim}# Direct URL mode (without instance)${colors.reset}
687
- portok status --url http://127.0.0.1:3000 --token mysecret
688
-
689
973
  ${colors.bold}MULTI-INSTANCE${colors.reset}
690
974
  When using --instance, the CLI reads /etc/portok/<name>.env
691
975
  to resolve LISTEN_PORT and ADMIN_TOKEN for that instance.
@@ -711,7 +995,7 @@ async function main() {
711
995
  }
712
996
 
713
997
  // Management commands (don't require daemon connection)
714
- const managementCommands = ['init', 'add', 'list'];
998
+ const managementCommands = ['init', 'add', 'remove', 'list', 'start', 'stop', 'restart', 'enable', 'disable', 'logs'];
715
999
 
716
1000
  if (managementCommands.includes(args.command)) {
717
1001
  let exitCode = 1;
@@ -722,9 +1006,22 @@ async function main() {
722
1006
  case 'add':
723
1007
  exitCode = await cmdAdd(args.positional[0], args.options);
724
1008
  break;
1009
+ case 'remove':
1010
+ exitCode = await cmdRemove(args.positional[0], args.options);
1011
+ break;
725
1012
  case 'list':
726
1013
  exitCode = await cmdList(args.options);
727
1014
  break;
1015
+ case 'start':
1016
+ case 'stop':
1017
+ case 'restart':
1018
+ case 'enable':
1019
+ case 'disable':
1020
+ exitCode = await cmdSystemctl(args.command, args.positional[0], args.options);
1021
+ break;
1022
+ case 'logs':
1023
+ exitCode = await cmdLogs(args.positional[0], args.options);
1024
+ break;
728
1025
  }
729
1026
  process.exit(exitCode);
730
1027
  }
package/portok@.service CHANGED
@@ -29,7 +29,7 @@ Environment=INSTANCE_NAME=%i
29
29
  WorkingDirectory=/opt/portok
30
30
 
31
31
  # Start the daemon
32
- ExecStart=/usr/bin/node /opt/portok/portokd.mjs
32
+ ExecStart=/usr/bin/node /opt/portok/portokd.js
33
33
 
34
34
  # Restart policy
35
35
  Restart=always
@@ -210,25 +210,34 @@ describe('Connection Draining', () => {
210
210
 
211
211
  await new Promise(resolve => setTimeout(resolve, 100));
212
212
 
213
- // Start continuous traffic
213
+ // Start continuous traffic using a more reliable approach
214
214
  let requestCount = 0;
215
215
  let errorCount = 0;
216
-
217
- const interval = setInterval(async () => {
218
- try {
219
- const res = await fetch(`http://127.0.0.1:${proxyPort}/`);
220
- if (res.ok) {
221
- requestCount++;
222
- } else {
216
+ let running = true;
217
+
218
+ // Use a loop instead of setInterval for more reliable timing
219
+ const trafficLoop = async () => {
220
+ while (running) {
221
+ try {
222
+ const res = await fetch(`http://127.0.0.1:${proxyPort}/`);
223
+ if (res.ok) {
224
+ requestCount++;
225
+ } else {
226
+ errorCount++;
227
+ }
228
+ } catch {
223
229
  errorCount++;
224
230
  }
225
- } catch {
226
- errorCount++;
231
+ // Small delay between requests
232
+ await new Promise(resolve => setTimeout(resolve, 30));
227
233
  }
228
- }, 50);
234
+ };
235
+
236
+ // Start traffic in background
237
+ const trafficPromise = trafficLoop();
229
238
 
230
239
  // Wait a bit, then switch
231
- await new Promise(resolve => setTimeout(resolve, 200));
240
+ await new Promise(resolve => setTimeout(resolve, 150));
232
241
 
233
242
  await fetch(`http://127.0.0.1:${proxyPort}/__switch?port=${mockServer2.port}`, {
234
243
  method: 'POST',
@@ -236,12 +245,15 @@ describe('Connection Draining', () => {
236
245
  });
237
246
 
238
247
  // Continue traffic during switch
239
- await new Promise(resolve => setTimeout(resolve, 500));
248
+ await new Promise(resolve => setTimeout(resolve, 300));
240
249
 
241
- clearInterval(interval);
250
+ // Stop traffic
251
+ running = false;
252
+ await trafficPromise;
242
253
 
243
254
  // Should have processed requests with minimal errors
244
- assert.ok(requestCount > 5, `Expected at least 5 requests, got ${requestCount}`);
255
+ // At least 3 requests is enough to verify no drops during switch
256
+ assert.ok(requestCount >= 3, `Expected at least 3 requests, got ${requestCount}`);
245
257
  assert.strictEqual(errorCount, 0, `Expected 0 errors, got ${errorCount}`);
246
258
  });
247
259
  });