portok 1.0.5 → 1.0.6

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
@@ -195,9 +195,10 @@ curl -H "x-admin-token: your-token" http://127.0.0.1:3000/__health
195
195
  portok <command> [options]
196
196
 
197
197
  Management Commands:
198
- init Initialize portok directories (/etc/portok, /var/lib/portok)
198
+ init Initialize portok (creates dirs, installs systemd service)
199
199
  add <name> Create a new service instance
200
200
  remove <name> Remove a service instance (stops, disables, deletes config/state)
201
+ clean Remove ALL portok data (configs, states, systemd service)
201
202
  list List all configured instances and their status
202
203
 
203
204
  Service Control Commands:
@@ -231,6 +232,9 @@ Options for 'remove' command:
231
232
  --force Skip confirmation prompt
232
233
  --keep-state Keep state file (/var/lib/portok/<name>.json)
233
234
 
235
+ Options for 'clean' command:
236
+ --force Skip confirmation prompt (required)
237
+
234
238
  Options for 'logs' command:
235
239
  --follow, -f Follow log output
236
240
  --lines, -n Number of lines to show (default: 50)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portok",
3
- "version": "1.0.5",
3
+ "version": "1.0.6",
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
@@ -316,12 +316,26 @@ async function cmdInit(options) {
316
316
  const stateDir = '/var/lib/portok';
317
317
  const systemdDir = '/etc/systemd/system';
318
318
  const serviceFileName = 'portok@.service';
319
+ const { execSync } = require('child_process');
319
320
 
320
- console.log(`${colors.bold}Initializing Portok...${colors.reset}\n`);
321
+ // Detect current user for service configuration
322
+ let currentUser = 'root';
323
+ let currentGroup = 'root';
324
+
325
+ try {
326
+ currentUser = execSync('whoami', { encoding: 'utf-8' }).trim();
327
+ currentGroup = execSync(`id -gn ${currentUser}`, { encoding: 'utf-8' }).trim();
328
+ } catch (e) {
329
+ // Fallback to root if detection fails
330
+ }
321
331
 
322
- // Check if running as root (needed for /etc and /var/lib)
323
332
  const isRoot = process.getuid && process.getuid() === 0;
324
333
 
334
+ console.log(`${colors.bold}Initializing Portok...${colors.reset}\n`);
335
+ console.log(` User: ${colors.cyan}${currentUser}${colors.reset}`);
336
+ console.log(` Group: ${colors.cyan}${currentGroup}${colors.reset}`);
337
+ console.log(` Node: ${colors.dim}${process.execPath}${colors.reset}\n`);
338
+
325
339
  if (!isRoot && !options.force) {
326
340
  console.log(`${colors.yellow}Warning:${colors.reset} This command typically requires root privileges.`);
327
341
  console.log(`Run with sudo or use --force to attempt anyway.\n`);
@@ -394,6 +408,10 @@ async function cmdInit(options) {
394
408
  serviceContent = serviceContent.replace(/WorkingDirectory=\/opt\/portok/g, `WorkingDirectory=${portokDir}`);
395
409
  serviceContent = serviceContent.replace(/\/opt\/portok\/portokd\.js/g, `${portokDir}/portokd.js`);
396
410
 
411
+ // Replace user/group with detected user (important for nvm/fnm compatibility)
412
+ serviceContent = serviceContent.replace(/User=www-data/g, `User=${currentUser}`);
413
+ serviceContent = serviceContent.replace(/Group=www-data/g, `Group=${currentGroup}`);
414
+
397
415
  const existingContent = fs.existsSync(systemdDest) ? fs.readFileSync(systemdDest, 'utf-8') : null;
398
416
 
399
417
  if (existingContent && existingContent.trim() === serviceContent.trim()) {
@@ -404,7 +422,6 @@ async function cmdInit(options) {
404
422
 
405
423
  // Reload systemd daemon
406
424
  try {
407
- const { execSync } = require('child_process');
408
425
  execSync('systemctl daemon-reload', { stdio: 'pipe' });
409
426
  results.push({ path: 'systemctl daemon-reload', status: 'created' });
410
427
  } catch (e) {
@@ -687,6 +704,125 @@ async function cmdRemove(name, options) {
687
704
  return 0;
688
705
  }
689
706
 
707
+ async function cmdClean(options) {
708
+ const configDir = ENV_FILE_DIR;
709
+ const stateDir = '/var/lib/portok';
710
+ const systemdDir = '/etc/systemd/system';
711
+ const serviceFileName = 'portok@.service';
712
+ const systemdDest = path.join(systemdDir, serviceFileName);
713
+
714
+ // Check if running as root
715
+ const isRoot = process.getuid && process.getuid() === 0;
716
+
717
+ if (!isRoot && !options.force) {
718
+ console.error(`${colors.red}Error:${colors.reset} This command requires root privileges.`);
719
+ console.error('Run with: sudo portok clean');
720
+ return 1;
721
+ }
722
+
723
+ // Confirmation (unless --force)
724
+ if (!options.force && !options.json) {
725
+ console.log(`${colors.yellow}Warning:${colors.reset} This will remove ALL portok data:\n`);
726
+ console.log(` • ${configDir}/ (all service configs)`);
727
+ console.log(` • ${stateDir}/ (all state files)`);
728
+ console.log(` • ${systemdDest}`);
729
+ console.log(`\nUse ${colors.cyan}--force${colors.reset} to confirm.\n`);
730
+ return 1;
731
+ }
732
+
733
+ const { execSync } = require('child_process');
734
+ const results = [];
735
+
736
+ // 1. Stop and disable all running portok services
737
+ try {
738
+ const output = execSync('systemctl list-units --type=service --all | grep "portok@" | awk \'{print $1}\'', { encoding: 'utf-8' });
739
+ const services = output.trim().split('\n').filter(s => s);
740
+
741
+ for (const service of services) {
742
+ try {
743
+ execSync(`systemctl stop ${service}`, { stdio: 'pipe' });
744
+ execSync(`systemctl disable ${service}`, { stdio: 'pipe' });
745
+ results.push({ action: `stop ${service}`, status: 'success' });
746
+ } catch (e) {
747
+ results.push({ action: `stop ${service}`, status: 'skipped' });
748
+ }
749
+ }
750
+ } catch (e) {
751
+ // No services found, that's ok
752
+ }
753
+
754
+ // 2. Remove systemd service file
755
+ if (fs.existsSync(systemdDest)) {
756
+ try {
757
+ fs.unlinkSync(systemdDest);
758
+ results.push({ action: 'remove systemd service', status: 'success', path: systemdDest });
759
+
760
+ // Reload systemd
761
+ try {
762
+ execSync('systemctl daemon-reload', { stdio: 'pipe' });
763
+ } catch (e) {}
764
+ } catch (err) {
765
+ results.push({ action: 'remove systemd service', status: 'error', error: err.message });
766
+ }
767
+ } else {
768
+ results.push({ action: 'remove systemd service', status: 'skipped' });
769
+ }
770
+
771
+ // 3. Remove config directory
772
+ if (fs.existsSync(configDir)) {
773
+ try {
774
+ fs.rmSync(configDir, { recursive: true, force: true });
775
+ results.push({ action: 'remove config dir', status: 'success', path: configDir });
776
+ } catch (err) {
777
+ results.push({ action: 'remove config dir', status: 'error', error: err.message });
778
+ }
779
+ } else {
780
+ results.push({ action: 'remove config dir', status: 'skipped' });
781
+ }
782
+
783
+ // 4. Remove state directory
784
+ if (fs.existsSync(stateDir)) {
785
+ try {
786
+ fs.rmSync(stateDir, { recursive: true, force: true });
787
+ results.push({ action: 'remove state dir', status: 'success', path: stateDir });
788
+ } catch (err) {
789
+ results.push({ action: 'remove state dir', status: 'error', error: err.message });
790
+ }
791
+ } else {
792
+ results.push({ action: 'remove state dir', status: 'skipped' });
793
+ }
794
+
795
+ const hasError = results.some(r => r.status === 'error');
796
+
797
+ if (options.json) {
798
+ console.log(JSON.stringify({ success: !hasError, results }, null, 2));
799
+ return hasError ? 1 : 0;
800
+ }
801
+
802
+ // Display results
803
+ console.log(`\n${colors.bold}Cleaning Portok...${colors.reset}\n`);
804
+
805
+ for (const r of results) {
806
+ const icon = r.status === 'success' ? `${colors.green}✓${colors.reset}` :
807
+ r.status === 'skipped' ? `${colors.dim}○${colors.reset}` :
808
+ `${colors.red}✗${colors.reset}`;
809
+
810
+ const statusLabel = r.status === 'success' ? (r.path ? `Removed ${r.path}` : 'Done') :
811
+ r.status === 'skipped' ? 'Skipped (not found)' :
812
+ `Error: ${r.error}`;
813
+
814
+ console.log(` ${icon} ${r.action.padEnd(24)} ${statusLabel}`);
815
+ }
816
+
817
+ if (hasError) {
818
+ console.log(`\n${colors.yellow}Warning:${colors.reset} Some operations failed.`);
819
+ return 1;
820
+ }
821
+
822
+ console.log(`\n${colors.green}✓ Portok cleaned successfully!${colors.reset}`);
823
+ return 0;
824
+ }
825
+
690
826
  async function cmdList(options) {
691
827
  const configDir = ENV_FILE_DIR;
692
828
 
@@ -918,9 +1054,10 @@ ${colors.bold}USAGE${colors.reset}
918
1054
 
919
1055
  ${colors.bold}COMMANDS${colors.reset}
920
1056
  ${colors.cyan}Management:${colors.reset}
921
- init Initialize portok directories (/etc/portok, /var/lib/portok)
1057
+ init Initialize portok (creates dirs, installs systemd service)
922
1058
  add <name> Create a new service instance
923
1059
  remove <name> Remove a service instance (config + state)
1060
+ clean Remove ALL portok data (configs, states, systemd service)
924
1061
  list List all configured instances and their status
925
1062
 
926
1063
  ${colors.cyan}Service Control:${colors.reset}
@@ -954,6 +1091,9 @@ ${colors.bold}OPTIONS${colors.reset}
954
1091
  --force Skip confirmation prompt
955
1092
  --keep-state Keep state file (/var/lib/portok/<name>.json)
956
1093
 
1094
+ ${colors.dim}For 'clean' command:${colors.reset}
1095
+ --force Skip confirmation prompt (required)
1096
+
957
1097
  ${colors.dim}For 'logs' command:${colors.reset}
958
1098
  --follow, -f Follow log output
959
1099
  --lines, -n Number of lines to show (default: 50)
@@ -1013,7 +1153,7 @@ async function main() {
1013
1153
  }
1014
1154
 
1015
1155
  // Management commands (don't require daemon connection)
1016
- const managementCommands = ['init', 'add', 'remove', 'list', 'start', 'stop', 'restart', 'enable', 'disable', 'logs'];
1156
+ const managementCommands = ['init', 'add', 'remove', 'clean', 'list', 'start', 'stop', 'restart', 'enable', 'disable', 'logs'];
1017
1157
 
1018
1158
  if (managementCommands.includes(args.command)) {
1019
1159
  let exitCode = 1;
@@ -1027,6 +1167,9 @@ async function main() {
1027
1167
  case 'remove':
1028
1168
  exitCode = await cmdRemove(args.positional[0], args.options);
1029
1169
  break;
1170
+ case 'clean':
1171
+ exitCode = await cmdClean(args.options);
1172
+ break;
1030
1173
  case 'list':
1031
1174
  exitCode = await cmdList(args.options);
1032
1175
  break;
@@ -0,0 +1,269 @@
1
+ /**
2
+ * Init Command Tests
3
+ * Tests user detection, node path detection, and service template replacement
4
+ */
5
+
6
+ const { describe, it, before, after, beforeEach, afterEach } = require('node:test');
7
+ const assert = require('node:assert');
8
+ const { spawn, execSync } = require('node:child_process');
9
+ const fs = require('node:fs');
10
+ const path = require('node:path');
11
+ const os = require('node:os');
12
+
13
+ // Test directory for temporary files
14
+ const TEST_DIR = path.join(os.tmpdir(), `portok-init-test-${Date.now()}`);
15
+ const TEST_CONFIG_DIR = path.join(TEST_DIR, 'etc', 'portok');
16
+ const TEST_STATE_DIR = path.join(TEST_DIR, 'var', 'lib', 'portok');
17
+ const TEST_SYSTEMD_DIR = path.join(TEST_DIR, 'etc', 'systemd', 'system');
18
+
19
+ describe('Init Command', () => {
20
+ before(() => {
21
+ // Create test directories
22
+ fs.mkdirSync(TEST_SYSTEMD_DIR, { recursive: true });
23
+ });
24
+
25
+ after(() => {
26
+ // Cleanup test directories
27
+ try {
28
+ fs.rmSync(TEST_DIR, { recursive: true, force: true });
29
+ } catch (e) {
30
+ // Ignore cleanup errors
31
+ }
32
+ });
33
+
34
+ describe('User Detection', () => {
35
+ it('should detect current user via whoami', () => {
36
+ const whoami = execSync('whoami', { encoding: 'utf-8' }).trim();
37
+ assert.ok(whoami.length > 0, 'whoami should return a user');
38
+
39
+ // In Docker Alpine, this is typically 'root'
40
+ console.log(` Detected user: ${whoami}`);
41
+ });
42
+
43
+ it('should detect current user group via id -gn', () => {
44
+ const user = execSync('whoami', { encoding: 'utf-8' }).trim();
45
+ const group = execSync(`id -gn ${user}`, { encoding: 'utf-8' }).trim();
46
+ assert.ok(group.length > 0, 'id -gn should return a group');
47
+
48
+ console.log(` Detected group: ${group}`);
49
+ });
50
+
51
+ it('should have process.getuid() available', () => {
52
+ assert.ok(typeof process.getuid === 'function', 'process.getuid should be a function');
53
+ const uid = process.getuid();
54
+ assert.ok(typeof uid === 'number', 'getuid should return a number');
55
+
56
+ console.log(` UID: ${uid}`);
57
+ });
58
+ });
59
+
60
+ describe('Node Path Detection', () => {
61
+ it('should detect node executable path via process.execPath', () => {
62
+ const nodePath = process.execPath;
63
+ assert.ok(nodePath.length > 0, 'process.execPath should not be empty');
64
+ assert.ok(fs.existsSync(nodePath), 'node executable should exist');
65
+
66
+ console.log(` Node path: ${nodePath}`);
67
+ });
68
+
69
+ it('should work with nvm-style paths', () => {
70
+ const nodePath = process.execPath;
71
+
72
+ // Node path should be a valid executable
73
+ const version = execSync(`"${nodePath}" --version`, { encoding: 'utf-8' }).trim();
74
+ assert.ok(version.startsWith('v'), 'Should be able to get node version');
75
+
76
+ console.log(` Node version: ${version}`);
77
+ });
78
+
79
+ it('should not be hardcoded /usr/bin/node when using nvm', () => {
80
+ const nodePath = process.execPath;
81
+
82
+ // In a proper setup, this could be /usr/bin/node, /usr/local/bin/node,
83
+ // or ~/.nvm/versions/node/vX.X.X/bin/node
84
+ // We just verify it's a valid path
85
+ assert.ok(
86
+ nodePath.includes('node'),
87
+ 'Path should contain "node"'
88
+ );
89
+ });
90
+ });
91
+
92
+ describe('Service Template Replacement', () => {
93
+ it('should read portok@.service template', () => {
94
+ const serviceFile = path.join(__dirname, '..', 'portok@.service');
95
+ assert.ok(fs.existsSync(serviceFile), 'portok@.service should exist');
96
+
97
+ const content = fs.readFileSync(serviceFile, 'utf-8');
98
+ assert.ok(content.includes('[Unit]'), 'Should be a valid systemd unit');
99
+ assert.ok(content.includes('[Service]'), 'Should have Service section');
100
+ assert.ok(content.includes('[Install]'), 'Should have Install section');
101
+ });
102
+
103
+ it('should have replaceable placeholders in template', () => {
104
+ const serviceFile = path.join(__dirname, '..', 'portok@.service');
105
+ const content = fs.readFileSync(serviceFile, 'utf-8');
106
+
107
+ // Check for placeholder patterns that will be replaced
108
+ assert.ok(content.includes('/usr/bin/node'), 'Should have node path placeholder');
109
+ assert.ok(content.includes('/opt/portok'), 'Should have portok dir placeholder');
110
+ assert.ok(content.includes('User=www-data'), 'Should have user placeholder');
111
+ assert.ok(content.includes('Group=www-data'), 'Should have group placeholder');
112
+ });
113
+
114
+ it('should correctly replace node path', () => {
115
+ const serviceFile = path.join(__dirname, '..', 'portok@.service');
116
+ let content = fs.readFileSync(serviceFile, 'utf-8');
117
+
118
+ const nodePath = process.execPath;
119
+ content = content.replace(/\/usr\/bin\/node/g, nodePath);
120
+
121
+ assert.ok(content.includes(nodePath), 'Node path should be replaced');
122
+ assert.ok(!content.includes('/usr/bin/node'), 'Old node path should be gone');
123
+ });
124
+
125
+ it('should correctly replace working directory', () => {
126
+ const serviceFile = path.join(__dirname, '..', 'portok@.service');
127
+ let content = fs.readFileSync(serviceFile, 'utf-8');
128
+
129
+ const portokDir = path.dirname(serviceFile);
130
+ content = content.replace(/WorkingDirectory=\/opt\/portok/g, `WorkingDirectory=${portokDir}`);
131
+ content = content.replace(/\/opt\/portok\/portokd\.js/g, `${portokDir}/portokd.js`);
132
+
133
+ assert.ok(content.includes(portokDir), 'Portok dir should be replaced');
134
+ });
135
+
136
+ it('should correctly replace user and group', () => {
137
+ const serviceFile = path.join(__dirname, '..', 'portok@.service');
138
+ let content = fs.readFileSync(serviceFile, 'utf-8');
139
+
140
+ const user = execSync('whoami', { encoding: 'utf-8' }).trim();
141
+ const group = execSync(`id -gn ${user}`, { encoding: 'utf-8' }).trim();
142
+
143
+ content = content.replace(/User=www-data/g, `User=${user}`);
144
+ content = content.replace(/Group=www-data/g, `Group=${group}`);
145
+
146
+ assert.ok(content.includes(`User=${user}`), 'User should be replaced');
147
+ assert.ok(content.includes(`Group=${group}`), 'Group should be replaced');
148
+ assert.ok(!content.includes('www-data'), 'www-data should be gone');
149
+ });
150
+
151
+ it('should produce valid systemd unit after all replacements', () => {
152
+ const serviceFile = path.join(__dirname, '..', 'portok@.service');
153
+ let content = fs.readFileSync(serviceFile, 'utf-8');
154
+
155
+ const nodePath = process.execPath;
156
+ const portokDir = path.dirname(serviceFile);
157
+ const user = execSync('whoami', { encoding: 'utf-8' }).trim();
158
+ const group = execSync(`id -gn ${user}`, { encoding: 'utf-8' }).trim();
159
+
160
+ // Apply all replacements
161
+ content = content.replace(/\/usr\/bin\/node/g, nodePath);
162
+ content = content.replace(/WorkingDirectory=\/opt\/portok/g, `WorkingDirectory=${portokDir}`);
163
+ content = content.replace(/\/opt\/portok\/portokd\.js/g, `${portokDir}/portokd.js`);
164
+ content = content.replace(/User=www-data/g, `User=${user}`);
165
+ content = content.replace(/Group=www-data/g, `Group=${group}`);
166
+
167
+ // Verify structure
168
+ assert.ok(content.includes('[Unit]'), 'Should have Unit section');
169
+ assert.ok(content.includes('[Service]'), 'Should have Service section');
170
+ assert.ok(content.includes('[Install]'), 'Should have Install section');
171
+ assert.ok(content.includes(`ExecStart=${nodePath}`), 'Should have correct ExecStart');
172
+ assert.ok(content.includes(`User=${user}`), 'Should have correct User');
173
+ assert.ok(content.includes(`Group=${group}`), 'Should have correct Group');
174
+
175
+ // Write to test file for inspection
176
+ const testServiceFile = path.join(TEST_SYSTEMD_DIR, 'portok@.service');
177
+ fs.writeFileSync(testServiceFile, content);
178
+
179
+ console.log(` Generated service file: ${testServiceFile}`);
180
+ console.log(` ExecStart: ${nodePath} ${portokDir}/portokd.js`);
181
+ console.log(` User: ${user}, Group: ${group}`);
182
+ });
183
+ });
184
+
185
+ describe('Root User Handling', () => {
186
+ it('should detect if running as root', () => {
187
+ const isRoot = process.getuid && process.getuid() === 0;
188
+ console.log(` Running as root: ${isRoot}`);
189
+
190
+ // In Docker, we typically run as root
191
+ if (process.env.CI || process.env.DOCKER) {
192
+ assert.strictEqual(isRoot, true, 'Should be root in Docker/CI');
193
+ }
194
+ });
195
+
196
+ it('should set User=root when running as root', () => {
197
+ const isRoot = process.getuid && process.getuid() === 0;
198
+
199
+ if (isRoot) {
200
+ const user = execSync('whoami', { encoding: 'utf-8' }).trim();
201
+ assert.strictEqual(user, 'root', 'whoami should return root');
202
+ }
203
+ });
204
+
205
+ it('should handle nvm paths in root home directory', () => {
206
+ const nodePath = process.execPath;
207
+ const isRoot = process.getuid && process.getuid() === 0;
208
+
209
+ // NVM typically installs to ~/.nvm which for root is /root/.nvm
210
+ // But in Docker Alpine, it's usually /usr/local/bin/node
211
+ console.log(` Node in root home: ${nodePath.startsWith('/root/')}`);
212
+ console.log(` Node path: ${nodePath}`);
213
+
214
+ // The important thing is that the path is accessible
215
+ assert.ok(fs.existsSync(nodePath), 'Node path should be accessible');
216
+ });
217
+ });
218
+
219
+ describe('Init Output Format', () => {
220
+ it('should display user info in init output', async () => {
221
+ // Run portok.js with a mock to capture output
222
+ const portokPath = path.join(__dirname, '..', 'portok.js');
223
+
224
+ // Just verify the script exists and is executable
225
+ assert.ok(fs.existsSync(portokPath), 'portok.js should exist');
226
+
227
+ const stats = fs.statSync(portokPath);
228
+ assert.ok(stats.mode & 0o111, 'portok.js should be executable');
229
+ });
230
+ });
231
+ });
232
+
233
+ describe('Clean Command', () => {
234
+ const CLEAN_TEST_DIR = path.join(os.tmpdir(), `portok-clean-test-${Date.now()}`);
235
+ const CLEAN_CONFIG_DIR = path.join(CLEAN_TEST_DIR, 'etc', 'portok');
236
+ const CLEAN_STATE_DIR = path.join(CLEAN_TEST_DIR, 'var', 'lib', 'portok');
237
+
238
+ before(() => {
239
+ // Create test directories with some files
240
+ fs.mkdirSync(CLEAN_CONFIG_DIR, { recursive: true });
241
+ fs.mkdirSync(CLEAN_STATE_DIR, { recursive: true });
242
+ fs.writeFileSync(path.join(CLEAN_CONFIG_DIR, 'test.env'), 'TEST=1');
243
+ fs.writeFileSync(path.join(CLEAN_STATE_DIR, 'test.json'), '{}');
244
+ });
245
+
246
+ after(() => {
247
+ try {
248
+ fs.rmSync(CLEAN_TEST_DIR, { recursive: true, force: true });
249
+ } catch (e) {}
250
+ });
251
+
252
+ it('should require --force flag', () => {
253
+ // The clean command should require --force
254
+ // This is just a logical test - actual command testing would need sudo
255
+ assert.ok(true, 'Clean command requires --force flag');
256
+ });
257
+
258
+ it('should be able to remove directories recursively', () => {
259
+ // Test fs.rmSync works
260
+ const testDir = path.join(CLEAN_TEST_DIR, 'rmtest');
261
+ fs.mkdirSync(testDir, { recursive: true });
262
+ fs.writeFileSync(path.join(testDir, 'file.txt'), 'test');
263
+
264
+ fs.rmSync(testDir, { recursive: true, force: true });
265
+
266
+ assert.ok(!fs.existsSync(testDir), 'Directory should be removed');
267
+ });
268
+ });
269
+