hostfn 0.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.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1136 -0
  3. package/_conduct/specs/1.v0.spec.md +1041 -0
  4. package/examples/express-api/package.json +22 -0
  5. package/examples/express-api/src/index.ts +16 -0
  6. package/examples/express-api/tsconfig.json +11 -0
  7. package/examples/github-actions-deploy.yml +40 -0
  8. package/examples/monorepo-config.json +76 -0
  9. package/examples/monorepo-multi-server-config.json +74 -0
  10. package/package.json +39 -0
  11. package/packages/cli/package.json +40 -0
  12. package/packages/cli/src/__tests__/core/backup.test.ts +137 -0
  13. package/packages/cli/src/__tests__/core/health.test.ts +125 -0
  14. package/packages/cli/src/__tests__/core/lock.test.ts +173 -0
  15. package/packages/cli/src/__tests__/core/nginx-multi-domain.test.ts +176 -0
  16. package/packages/cli/src/__tests__/runtimes/pm2.test.ts +130 -0
  17. package/packages/cli/src/__tests__/utils/validation.test.ts +164 -0
  18. package/packages/cli/src/commands/deploy.ts +817 -0
  19. package/packages/cli/src/commands/env.ts +391 -0
  20. package/packages/cli/src/commands/expose.ts +438 -0
  21. package/packages/cli/src/commands/init.ts +192 -0
  22. package/packages/cli/src/commands/logs.ts +106 -0
  23. package/packages/cli/src/commands/rollback.ts +142 -0
  24. package/packages/cli/src/commands/server/info.ts +131 -0
  25. package/packages/cli/src/commands/server/setup.ts +200 -0
  26. package/packages/cli/src/commands/status.ts +149 -0
  27. package/packages/cli/src/config/loader.ts +66 -0
  28. package/packages/cli/src/config/schema.ts +140 -0
  29. package/packages/cli/src/core/backup.ts +128 -0
  30. package/packages/cli/src/core/health.ts +116 -0
  31. package/packages/cli/src/core/local.ts +67 -0
  32. package/packages/cli/src/core/lock.ts +108 -0
  33. package/packages/cli/src/core/nginx.ts +170 -0
  34. package/packages/cli/src/core/ssh.ts +335 -0
  35. package/packages/cli/src/core/sync.ts +138 -0
  36. package/packages/cli/src/core/workspace.ts +180 -0
  37. package/packages/cli/src/index.ts +240 -0
  38. package/packages/cli/src/runtimes/base.ts +144 -0
  39. package/packages/cli/src/runtimes/nodejs/detector.ts +157 -0
  40. package/packages/cli/src/runtimes/nodejs/index.ts +228 -0
  41. package/packages/cli/src/runtimes/nodejs/pm2.ts +71 -0
  42. package/packages/cli/src/runtimes/registry.ts +76 -0
  43. package/packages/cli/src/utils/logger.ts +86 -0
  44. package/packages/cli/src/utils/validation.ts +147 -0
  45. package/packages/cli/tsconfig.json +25 -0
  46. package/packages/cli/vitest.config.ts +19 -0
  47. package/turbo.json +24 -0
@@ -0,0 +1,173 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { LockManager } from '../../core/lock.js';
3
+
4
+ // Mock SSH connection
5
+ const mockSSH = {
6
+ exec: vi.fn(),
7
+ exists: vi.fn(),
8
+ mkdir: vi.fn(),
9
+ upload: vi.fn(),
10
+ download: vi.fn(),
11
+ disconnect: vi.fn(),
12
+ };
13
+
14
+ // Mock Logger to prevent console output
15
+ vi.mock('../../utils/logger.js', () => ({
16
+ Logger: {
17
+ error: vi.fn(),
18
+ warn: vi.fn(),
19
+ info: vi.fn(),
20
+ log: vi.fn(),
21
+ br: vi.fn(),
22
+ },
23
+ }));
24
+
25
+ describe('LockManager', () => {
26
+ let lockManager: LockManager;
27
+
28
+ beforeEach(() => {
29
+ vi.clearAllMocks();
30
+ lockManager = new LockManager(mockSSH as any, '/var/www/myapp');
31
+ });
32
+
33
+ describe('acquire', () => {
34
+ it('should acquire lock when no lock exists', async () => {
35
+ mockSSH.exists.mockResolvedValue(false);
36
+ mockSSH.exec.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 });
37
+
38
+ const acquired = await lockManager.acquire();
39
+
40
+ expect(acquired).toBe(true);
41
+ expect(mockSSH.exec).toHaveBeenCalledWith(
42
+ expect.stringContaining('.hostfn-deploy.lock')
43
+ );
44
+ });
45
+
46
+ it('should fail when valid lock exists', async () => {
47
+ const recentLock = {
48
+ pid: 12345,
49
+ user: 'developer',
50
+ timestamp: Date.now() - 60000, // 1 minute ago
51
+ hostname: 'laptop',
52
+ };
53
+
54
+ mockSSH.exists.mockResolvedValue(true);
55
+ mockSSH.exec.mockResolvedValue({
56
+ stdout: JSON.stringify(recentLock),
57
+ stderr: '',
58
+ exitCode: 0
59
+ });
60
+
61
+ const acquired = await lockManager.acquire();
62
+
63
+ expect(acquired).toBe(false);
64
+ });
65
+
66
+ it('should acquire lock when stale lock exists', async () => {
67
+ const staleLock = {
68
+ pid: 12345,
69
+ user: 'developer',
70
+ timestamp: Date.now() - 400000, // 6+ minutes ago (stale)
71
+ hostname: 'laptop',
72
+ };
73
+
74
+ mockSSH.exists.mockResolvedValue(true);
75
+ mockSSH.exec
76
+ .mockResolvedValueOnce({
77
+ stdout: JSON.stringify(staleLock),
78
+ stderr: '',
79
+ exitCode: 0
80
+ })
81
+ .mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 }) // release
82
+ .mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 }); // acquire
83
+
84
+ const acquired = await lockManager.acquire();
85
+
86
+ expect(acquired).toBe(true);
87
+ });
88
+
89
+ it('should acquire lock when invalid lock file exists', async () => {
90
+ mockSSH.exists.mockResolvedValue(true);
91
+ mockSSH.exec
92
+ .mockResolvedValueOnce({
93
+ stdout: 'invalid json',
94
+ stderr: '',
95
+ exitCode: 0
96
+ })
97
+ .mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 }) // release
98
+ .mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 }); // acquire
99
+
100
+ const acquired = await lockManager.acquire();
101
+
102
+ expect(acquired).toBe(true);
103
+ });
104
+ });
105
+
106
+ describe('release', () => {
107
+ it('should remove lock file', async () => {
108
+ mockSSH.exec.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 });
109
+
110
+ await lockManager.release();
111
+
112
+ expect(mockSSH.exec).toHaveBeenCalledWith(
113
+ expect.stringContaining('rm -f')
114
+ );
115
+ });
116
+
117
+ it('should not throw on release error', async () => {
118
+ mockSSH.exec.mockRejectedValue(new Error('File not found'));
119
+
120
+ await expect(lockManager.release()).resolves.not.toThrow();
121
+ });
122
+ });
123
+
124
+ describe('isLocked', () => {
125
+ it('should return false when no lock exists', async () => {
126
+ mockSSH.exists.mockResolvedValue(false);
127
+
128
+ const locked = await lockManager.isLocked();
129
+
130
+ expect(locked).toBe(false);
131
+ });
132
+
133
+ it('should return true when valid lock exists', async () => {
134
+ const recentLock = {
135
+ pid: 12345,
136
+ user: 'developer',
137
+ timestamp: Date.now() - 60000, // 1 minute ago
138
+ hostname: 'laptop',
139
+ };
140
+
141
+ mockSSH.exists.mockResolvedValue(true);
142
+ mockSSH.exec.mockResolvedValue({
143
+ stdout: JSON.stringify(recentLock),
144
+ stderr: '',
145
+ exitCode: 0
146
+ });
147
+
148
+ const locked = await lockManager.isLocked();
149
+
150
+ expect(locked).toBe(true);
151
+ });
152
+
153
+ it('should return false when stale lock exists', async () => {
154
+ const staleLock = {
155
+ pid: 12345,
156
+ user: 'developer',
157
+ timestamp: Date.now() - 400000, // 6+ minutes ago (stale)
158
+ hostname: 'laptop',
159
+ };
160
+
161
+ mockSSH.exists.mockResolvedValue(true);
162
+ mockSSH.exec.mockResolvedValue({
163
+ stdout: JSON.stringify(staleLock),
164
+ stderr: '',
165
+ exitCode: 0
166
+ });
167
+
168
+ const locked = await lockManager.isLocked();
169
+
170
+ expect(locked).toBe(false);
171
+ });
172
+ });
173
+ });
@@ -0,0 +1,176 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { NginxConfigGenerator } from '../../core/nginx.js';
3
+
4
+ describe('NginxConfigGenerator - Multi-Domain Support', () => {
5
+ describe('generate with single domain (backward compatibility)', () => {
6
+ it('should generate config with single domain string', () => {
7
+ const config = NginxConfigGenerator.generate({
8
+ domain: 'example.com',
9
+ ssl: false,
10
+ services: [
11
+ {
12
+ name: 'test-app',
13
+ port: 3000,
14
+ isDefault: true,
15
+ },
16
+ ],
17
+ environment: 'production',
18
+ });
19
+
20
+ expect(config).toContain('server_name example.com;');
21
+ expect(config).toContain('proxy_pass http://localhost:3000;');
22
+ });
23
+
24
+ it('should generate SSL config with single domain', () => {
25
+ const config = NginxConfigGenerator.generate({
26
+ domain: 'example.com',
27
+ ssl: true,
28
+ services: [
29
+ {
30
+ name: 'test-app',
31
+ port: 3000,
32
+ isDefault: true,
33
+ },
34
+ ],
35
+ environment: 'production',
36
+ });
37
+
38
+ expect(config).toContain('server_name example.com;');
39
+ expect(config).toContain('listen 443 ssl http2;');
40
+ expect(config).toContain('ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;');
41
+ expect(config).toContain('ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;');
42
+ });
43
+ });
44
+
45
+ describe('generate with multiple domains', () => {
46
+ it('should generate config with multiple domains in server_name', () => {
47
+ const config = NginxConfigGenerator.generate({
48
+ domain: ['example.com', 'www.example.com', 'app.example.com'],
49
+ ssl: false,
50
+ services: [
51
+ {
52
+ name: 'test-app',
53
+ port: 3000,
54
+ isDefault: true,
55
+ },
56
+ ],
57
+ environment: 'production',
58
+ });
59
+
60
+ expect(config).toContain('server_name example.com www.example.com app.example.com;');
61
+ expect(config).toContain('proxy_pass http://localhost:3000;');
62
+ });
63
+
64
+ it('should use primary domain for SSL certificate paths', () => {
65
+ const config = NginxConfigGenerator.generate({
66
+ domain: ['example.com', 'www.example.com', 'api.example.com'],
67
+ ssl: true,
68
+ services: [
69
+ {
70
+ name: 'test-app',
71
+ port: 3000,
72
+ isDefault: true,
73
+ },
74
+ ],
75
+ environment: 'production',
76
+ });
77
+
78
+ // Should have all domains in server_name
79
+ expect(config).toContain('server_name example.com www.example.com api.example.com;');
80
+
81
+ // Should use first domain (primary) for certificate paths
82
+ expect(config).toContain('ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;');
83
+ expect(config).toContain('ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;');
84
+
85
+ // Should NOT use other domains for cert paths
86
+ expect(config).not.toContain('ssl_certificate /etc/letsencrypt/live/www.example.com/');
87
+ expect(config).not.toContain('ssl_certificate /etc/letsencrypt/live/api.example.com/');
88
+ });
89
+
90
+ it('should generate HTTP redirect block for all domains', () => {
91
+ const config = NginxConfigGenerator.generate({
92
+ domain: ['example.com', 'www.example.com'],
93
+ ssl: true,
94
+ services: [
95
+ {
96
+ name: 'test-app',
97
+ port: 3000,
98
+ isDefault: true,
99
+ },
100
+ ],
101
+ environment: 'production',
102
+ });
103
+
104
+ // Should have redirect block
105
+ expect(config).toContain('return 301 https://$host$request_uri;');
106
+
107
+ // Should have two server blocks (HTTPS + redirect)
108
+ const serverBlocks = config.match(/server \{/g);
109
+ expect(serverBlocks).toHaveLength(2);
110
+ });
111
+ });
112
+
113
+ describe('generate without domain', () => {
114
+ it('should use catch-all server_name when no domain provided', () => {
115
+ const config = NginxConfigGenerator.generate({
116
+ ssl: false,
117
+ services: [
118
+ {
119
+ name: 'test-app',
120
+ port: 3000,
121
+ isDefault: true,
122
+ },
123
+ ],
124
+ environment: 'production',
125
+ });
126
+
127
+ expect(config).toContain('server_name _;');
128
+ });
129
+
130
+ it('should use catch-all server_name with empty array', () => {
131
+ const config = NginxConfigGenerator.generate({
132
+ domain: [],
133
+ ssl: false,
134
+ services: [
135
+ {
136
+ name: 'test-app',
137
+ port: 3000,
138
+ isDefault: true,
139
+ },
140
+ ],
141
+ environment: 'production',
142
+ });
143
+
144
+ expect(config).toContain('server_name _;');
145
+ });
146
+ });
147
+
148
+ describe('generate with multiple services and multiple domains', () => {
149
+ it('should handle path-based routing with multiple domains', () => {
150
+ const config = NginxConfigGenerator.generate({
151
+ domain: ['example.com', 'www.example.com'],
152
+ ssl: false,
153
+ services: [
154
+ {
155
+ name: 'api-service',
156
+ port: 3001,
157
+ exposePath: '/api',
158
+ isDefault: false,
159
+ },
160
+ {
161
+ name: 'web-service',
162
+ port: 3000,
163
+ isDefault: true,
164
+ },
165
+ ],
166
+ environment: 'production',
167
+ });
168
+
169
+ expect(config).toContain('server_name example.com www.example.com;');
170
+ expect(config).toContain('location /api {');
171
+ expect(config).toContain('proxy_pass http://localhost:3001;');
172
+ expect(config).toContain('location / {');
173
+ expect(config).toContain('proxy_pass http://localhost:3000;');
174
+ });
175
+ });
176
+ });
@@ -0,0 +1,130 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { PM2Manager } from '../../runtimes/nodejs/pm2.js';
3
+ import { RuntimeConfig } from '../../runtimes/base.js';
4
+
5
+ describe('PM2Manager', () => {
6
+ const pm2Manager = new PM2Manager();
7
+
8
+ describe('generateEcosystemConfig', () => {
9
+ it('should generate valid ecosystem config with env_file', () => {
10
+ const config: RuntimeConfig = {
11
+ name: 'test-app',
12
+ runtime: 'nodejs',
13
+ version: '18',
14
+ start: {
15
+ command: 'npm start',
16
+ entry: 'dist/index.js',
17
+ },
18
+ port: 3000,
19
+ };
20
+
21
+ const result = pm2Manager.generateEcosystemConfig(config, 'production');
22
+
23
+ expect(result).toContain("name: 'test-app-production'");
24
+ expect(result).toContain("script: 'dist/index.js'");
25
+ expect(result).toContain("env_file: '.env'");
26
+ expect(result).toContain("NODE_ENV: 'production'");
27
+ expect(result).toContain('PORT: 3000');
28
+ expect(result).toContain("instances: 'max'");
29
+ expect(result).toContain("exec_mode: 'cluster'");
30
+ });
31
+
32
+ it('should use default entry if not specified', () => {
33
+ const config: RuntimeConfig = {
34
+ name: 'test-app',
35
+ runtime: 'nodejs',
36
+ version: '18',
37
+ start: {
38
+ command: 'npm start',
39
+ },
40
+ port: 3000,
41
+ };
42
+
43
+ const result = pm2Manager.generateEcosystemConfig(config, 'staging');
44
+
45
+ expect(result).toContain("script: 'dist/index.js'");
46
+ expect(result).toContain("name: 'test-app-staging'");
47
+ });
48
+
49
+ it('should include error and output log paths', () => {
50
+ const config: RuntimeConfig = {
51
+ name: 'test-app',
52
+ runtime: 'nodejs',
53
+ version: '18',
54
+ start: {
55
+ command: 'npm start',
56
+ entry: 'dist/server.js',
57
+ },
58
+ port: 8080,
59
+ };
60
+
61
+ const result = pm2Manager.generateEcosystemConfig(config, 'dev');
62
+
63
+ expect(result).toContain("error_file: './logs/err.log'");
64
+ expect(result).toContain("out_file: './logs/out.log'");
65
+ });
66
+
67
+ it('should include auto-restart and memory limits', () => {
68
+ const config: RuntimeConfig = {
69
+ name: 'test-app',
70
+ runtime: 'nodejs',
71
+ version: '18',
72
+ start: {
73
+ command: 'npm start',
74
+ },
75
+ port: 3000,
76
+ };
77
+
78
+ const result = pm2Manager.generateEcosystemConfig(config, 'production');
79
+
80
+ expect(result).toContain('autorestart: true');
81
+ expect(result).toContain('max_restarts: 10');
82
+ expect(result).toContain("min_uptime: '10s'");
83
+ expect(result).toContain("max_memory_restart: '1G'");
84
+ });
85
+ });
86
+
87
+ describe('generateReloadCommand', () => {
88
+ it('should generate reload command with update-env flag', () => {
89
+ const result = pm2Manager.generateReloadCommand('my-app-production');
90
+ expect(result).toBe('pm2 reload my-app-production --update-env');
91
+ });
92
+ });
93
+
94
+ describe('generateStartCommand', () => {
95
+ it('should generate start command', () => {
96
+ const config: RuntimeConfig = {
97
+ name: 'test-app',
98
+ runtime: 'nodejs',
99
+ version: '18',
100
+ start: {
101
+ command: 'npm start',
102
+ entry: 'dist/index.js',
103
+ },
104
+ port: 3000,
105
+ };
106
+
107
+ const result = pm2Manager.generateStartCommand(config, 'production');
108
+ expect(result).toBe('pm2 start dist/index.js --name test-app-production -i max --env production');
109
+ });
110
+ });
111
+
112
+ describe('generateStopCommand', () => {
113
+ it('should generate stop command', () => {
114
+ const result = pm2Manager.generateStopCommand('my-app-production');
115
+ expect(result).toBe('pm2 stop my-app-production');
116
+ });
117
+ });
118
+
119
+ describe('generateLogsCommand', () => {
120
+ it('should generate logs command with default lines', () => {
121
+ const result = pm2Manager.generateLogsCommand('my-app-production');
122
+ expect(result).toBe('pm2 logs my-app-production --lines 100');
123
+ });
124
+
125
+ it('should generate logs command with custom lines', () => {
126
+ const result = pm2Manager.generateLogsCommand('my-app-production', 500);
127
+ expect(result).toBe('pm2 logs my-app-production --lines 500');
128
+ });
129
+ });
130
+ });
@@ -0,0 +1,164 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import {
3
+ validateSSHConnection,
4
+ validateHttpUrl,
5
+ validateEnvironmentName,
6
+ validatePort,
7
+ validateNodeVersion,
8
+ validateAppName,
9
+ validateRemotePath,
10
+ } from '../../utils/validation.js';
11
+
12
+ // Mock Logger to prevent console output during tests
13
+ vi.mock('../../utils/logger.js', () => ({
14
+ Logger: {
15
+ error: vi.fn(),
16
+ warn: vi.fn(),
17
+ info: vi.fn(),
18
+ log: vi.fn(),
19
+ },
20
+ }));
21
+
22
+ describe('Validation Utilities', () => {
23
+ describe('validateSSHConnection', () => {
24
+ it('should accept valid SSH connection strings', () => {
25
+ expect(validateSSHConnection('user@host')).toBe(true);
26
+ expect(validateSSHConnection('ubuntu@123.45.67.89')).toBe(true);
27
+ expect(validateSSHConnection('user@server.com')).toBe(true);
28
+ expect(validateSSHConnection('user@host:2222')).toBe(true);
29
+ });
30
+
31
+ it('should reject invalid SSH connection strings', () => {
32
+ expect(validateSSHConnection('invalid')).toBe(false);
33
+ expect(validateSSHConnection('user')).toBe(false);
34
+ expect(validateSSHConnection('@host')).toBe(false);
35
+ expect(validateSSHConnection('user@')).toBe(false);
36
+ expect(validateSSHConnection('user@host:abc')).toBe(false);
37
+ });
38
+ });
39
+
40
+ describe('validateHttpUrl', () => {
41
+ it('should accept valid HTTP/HTTPS URLs', () => {
42
+ expect(validateHttpUrl('http://localhost:3000')).toBe(true);
43
+ expect(validateHttpUrl('https://example.com')).toBe(true);
44
+ expect(validateHttpUrl('http://123.45.67.89:8080/health')).toBe(true);
45
+ });
46
+
47
+ it('should reject invalid URLs', () => {
48
+ expect(validateHttpUrl('ftp://example.com')).toBe(false);
49
+ expect(validateHttpUrl('not-a-url')).toBe(false);
50
+ expect(validateHttpUrl('example.com')).toBe(false);
51
+ });
52
+ });
53
+
54
+ describe('validateEnvironmentName', () => {
55
+ it('should accept valid environment names', () => {
56
+ expect(validateEnvironmentName('production')).toBe(true);
57
+ expect(validateEnvironmentName('staging')).toBe(true);
58
+ expect(validateEnvironmentName('dev')).toBe(true);
59
+ expect(validateEnvironmentName('prod-eu')).toBe(true);
60
+ expect(validateEnvironmentName('test_env')).toBe(true);
61
+ expect(validateEnvironmentName('env123')).toBe(true);
62
+ });
63
+
64
+ it('should reject invalid environment names', () => {
65
+ expect(validateEnvironmentName('prod space')).toBe(false);
66
+ expect(validateEnvironmentName('prod@env')).toBe(false);
67
+ expect(validateEnvironmentName('prod.env')).toBe(false);
68
+ expect(validateEnvironmentName('prod/env')).toBe(false);
69
+ });
70
+ });
71
+
72
+ describe('validatePort', () => {
73
+ it('should accept valid port numbers', () => {
74
+ expect(validatePort(3000)).toBe(true);
75
+ expect(validatePort(8080)).toBe(true);
76
+ expect(validatePort(65535)).toBe(true);
77
+ });
78
+
79
+ it('should warn about privileged ports', () => {
80
+ expect(validatePort(80)).toBe(true);
81
+ expect(validatePort(443)).toBe(true);
82
+ expect(validatePort(22)).toBe(true);
83
+ });
84
+
85
+ it('should reject invalid port numbers', () => {
86
+ expect(validatePort(0)).toBe(false);
87
+ expect(validatePort(-1)).toBe(false);
88
+ expect(validatePort(65536)).toBe(false);
89
+ expect(validatePort(99999)).toBe(false);
90
+ });
91
+ });
92
+
93
+ describe('validateNodeVersion', () => {
94
+ it('should accept valid Node.js versions', () => {
95
+ expect(validateNodeVersion('18')).toBe(true);
96
+ expect(validateNodeVersion('18.19')).toBe(true);
97
+ expect(validateNodeVersion('18.19.0')).toBe(true);
98
+ expect(validateNodeVersion('20')).toBe(true);
99
+ });
100
+
101
+ it('should warn about old versions', () => {
102
+ expect(validateNodeVersion('12')).toBe(true);
103
+ expect(validateNodeVersion('10.0.0')).toBe(true);
104
+ });
105
+
106
+ it('should reject invalid version formats', () => {
107
+ expect(validateNodeVersion('v18')).toBe(false);
108
+ expect(validateNodeVersion('18.x')).toBe(false);
109
+ expect(validateNodeVersion('latest')).toBe(false);
110
+ expect(validateNodeVersion('18.19.0.1')).toBe(false);
111
+ });
112
+ });
113
+
114
+ describe('validateAppName', () => {
115
+ it('should accept valid application names', () => {
116
+ expect(validateAppName('my-app')).toBe(true);
117
+ expect(validateAppName('api_server')).toBe(true);
118
+ expect(validateAppName('webapp123')).toBe(true);
119
+ expect(validateAppName('MyApp')).toBe(true);
120
+ });
121
+
122
+ it('should reject too short names', () => {
123
+ expect(validateAppName('a')).toBe(false);
124
+ expect(validateAppName('')).toBe(false);
125
+ });
126
+
127
+ it('should reject too long names', () => {
128
+ expect(validateAppName('a'.repeat(51))).toBe(false);
129
+ });
130
+
131
+ it('should reject invalid characters', () => {
132
+ expect(validateAppName('my app')).toBe(false);
133
+ expect(validateAppName('my@app')).toBe(false);
134
+ expect(validateAppName('my.app')).toBe(false);
135
+ });
136
+ });
137
+
138
+ describe('validateRemotePath', () => {
139
+ it('should accept valid remote paths', () => {
140
+ expect(validateRemotePath('/var/www/my-app')).toBe(true);
141
+ expect(validateRemotePath('/opt/apps/webapp')).toBe(true);
142
+ expect(validateRemotePath('/home/user/app')).toBe(true);
143
+ });
144
+
145
+ it('should reject relative paths', () => {
146
+ expect(validateRemotePath('var/www/app')).toBe(false);
147
+ expect(validateRemotePath('./app')).toBe(false);
148
+ expect(validateRemotePath('../app')).toBe(false);
149
+ });
150
+
151
+ it('should reject paths with .. traversal', () => {
152
+ expect(validateRemotePath('/var/www/../app')).toBe(false);
153
+ });
154
+
155
+ it('should reject dangerous system paths', () => {
156
+ expect(validateRemotePath('/')).toBe(false);
157
+ expect(validateRemotePath('/etc')).toBe(false);
158
+ expect(validateRemotePath('/bin')).toBe(false);
159
+ expect(validateRemotePath('/usr')).toBe(false);
160
+ expect(validateRemotePath('/root')).toBe(false);
161
+ expect(validateRemotePath('/home')).toBe(false);
162
+ });
163
+ });
164
+ });