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,22 @@
1
+ {
2
+ "name": "example-express-api",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "scripts": {
6
+ "dev": "tsx watch src/index.ts",
7
+ "build": "tsc",
8
+ "start": "node dist/index.js"
9
+ },
10
+ "dependencies": {
11
+ "express": "^4.18.0"
12
+ },
13
+ "devDependencies": {
14
+ "@types/express": "^4.17.0",
15
+ "@types/node": "^20.0.0",
16
+ "tsx": "^4.0.0",
17
+ "typescript": "^5.0.0"
18
+ },
19
+ "engines": {
20
+ "node": ">=18.0.0"
21
+ }
22
+ }
@@ -0,0 +1,16 @@
1
+ import express from 'express';
2
+
3
+ const app = express();
4
+ const port = process.env.PORT || 3000;
5
+
6
+ app.get('/health', (req, res) => {
7
+ res.json({ status: 'ok' });
8
+ });
9
+
10
+ app.get('/', (req, res) => {
11
+ res.json({ message: 'Hello from Express API!' });
12
+ });
13
+
14
+ app.listen(port, () => {
15
+ console.log(`Server running on port ${port}`);
16
+ });
@@ -0,0 +1,11 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "node",
6
+ "esModuleInterop": true,
7
+ "strict": true,
8
+ "outDir": "./dist",
9
+ "rootDir": "./src"
10
+ }
11
+ }
@@ -0,0 +1,40 @@
1
+ name: Deploy with hostfn
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+ - production
8
+
9
+ jobs:
10
+ deploy:
11
+ runs-on: ubuntu-latest
12
+
13
+ steps:
14
+ - name: Checkout code
15
+ uses: actions/checkout@v4
16
+
17
+ - name: Setup Node.js
18
+ uses: actions/setup-node@v4
19
+ with:
20
+ node-version: '20'
21
+
22
+ - name: Install hostfn
23
+ run: npm install -g hostfn
24
+
25
+ - name: Deploy to production
26
+ env:
27
+ HOSTFN_SSH_KEY: ${{ secrets.HOSTFN_SSH_KEY }}
28
+ HOSTFN_SSH_PASSPHRASE: ${{ secrets.HOSTFN_SSH_PASSPHRASE }}
29
+ run: hostfn deploy production --ci
30
+
31
+ deploy-selfhosted:
32
+ # This job runs on a self-hosted runner on the deployment server
33
+ runs-on: self-hosted
34
+
35
+ steps:
36
+ - name: Checkout code
37
+ uses: actions/checkout@v4
38
+
39
+ - name: Deploy locally (no SSH needed)
40
+ run: hostfn deploy production --local --ci
@@ -0,0 +1,76 @@
1
+ {
2
+ "name": "21n",
3
+ "runtime": "nodejs",
4
+ "version": "18",
5
+ "environments": {
6
+ "production": {
7
+ "server": "ubuntu@prod.example.com",
8
+ "port": 3000,
9
+ "instances": "max"
10
+ },
11
+ "staging": {
12
+ "server": "ubuntu@staging.example.com",
13
+ "port": 3000,
14
+ "instances": 2
15
+ }
16
+ },
17
+ "build": {
18
+ "command": "npm run build",
19
+ "directory": "dist",
20
+ "nodeModules": "production"
21
+ },
22
+ "start": {
23
+ "command": "npm start",
24
+ "entry": "dist/index.js"
25
+ },
26
+ "services": {
27
+ "account": {
28
+ "port": 3001,
29
+ "path": "services/account",
30
+ "domain": "account.example.com",
31
+ "instances": "max"
32
+ },
33
+ "auth": {
34
+ "port": 3002,
35
+ "path": "services/auth",
36
+ "domain": "auth.example.com",
37
+ "instances": 2
38
+ },
39
+ "notification": {
40
+ "port": 3003,
41
+ "path": "services/notification",
42
+ "domain": "notification.example.com"
43
+ },
44
+ "analytics": {
45
+ "port": 3004,
46
+ "path": "services/analytics"
47
+ }
48
+ },
49
+ "health": {
50
+ "path": "/health",
51
+ "timeout": 60,
52
+ "retries": 10,
53
+ "interval": 3
54
+ },
55
+ "env": {
56
+ "required": ["NODE_ENV", "DATABASE_URL"],
57
+ "optional": ["REDIS_URL", "LOG_LEVEL"]
58
+ },
59
+ "sync": {
60
+ "exclude": [
61
+ "node_modules",
62
+ ".git",
63
+ ".github",
64
+ "dist",
65
+ "build",
66
+ ".env",
67
+ ".env.*",
68
+ "*.log",
69
+ ".turbo",
70
+ ".wrangler"
71
+ ]
72
+ },
73
+ "backup": {
74
+ "keep": 5
75
+ }
76
+ }
@@ -0,0 +1,74 @@
1
+ {
2
+ "name": "21n",
3
+ "runtime": "nodejs",
4
+ "version": "18",
5
+ "environments": {
6
+ "production": {
7
+ "server": "ubuntu@shared.example.com",
8
+ "port": 3000,
9
+ "instances": "max"
10
+ }
11
+ },
12
+ "build": {
13
+ "command": "npm run build",
14
+ "directory": "dist",
15
+ "nodeModules": "production"
16
+ },
17
+ "start": {
18
+ "command": "npm start",
19
+ "entry": "dist/index.js"
20
+ },
21
+ "services": {
22
+ "account": {
23
+ "port": 3001,
24
+ "path": "services/account",
25
+ "domain": "account.example.com",
26
+ "server": "ubuntu@account-server.example.com",
27
+ "instances": "max"
28
+ },
29
+ "auth": {
30
+ "port": 3002,
31
+ "path": "services/auth",
32
+ "domain": "auth.example.com",
33
+ "server": "ubuntu@auth-server.example.com",
34
+ "instances": 4
35
+ },
36
+ "notification": {
37
+ "port": 3003,
38
+ "path": "services/notification",
39
+ "domain": "notification.example.com"
40
+ },
41
+ "analytics": {
42
+ "port": 3004,
43
+ "path": "services/analytics",
44
+ "server": "ubuntu@analytics-server.example.com"
45
+ }
46
+ },
47
+ "health": {
48
+ "path": "/health",
49
+ "timeout": 60,
50
+ "retries": 10,
51
+ "interval": 3
52
+ },
53
+ "env": {
54
+ "required": ["NODE_ENV", "DATABASE_URL"],
55
+ "optional": ["REDIS_URL", "LOG_LEVEL"]
56
+ },
57
+ "sync": {
58
+ "exclude": [
59
+ "node_modules",
60
+ ".git",
61
+ ".github",
62
+ "dist",
63
+ "build",
64
+ ".env",
65
+ ".env.*",
66
+ "*.log",
67
+ ".turbo",
68
+ ".wrangler"
69
+ ]
70
+ },
71
+ "backup": {
72
+ "keep": 5
73
+ }
74
+ }
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "hostfn",
3
+ "version": "0.1.0",
4
+ "description": "Universal application deployment CLI",
5
+ "license": "Apache-2.0",
6
+ "workspaces": [
7
+ "packages/*"
8
+ ],
9
+ "scripts": {
10
+ "build": "turbo run build",
11
+ "dev": "turbo run dev",
12
+ "lint": "turbo run lint",
13
+ "test": "turbo run test",
14
+ "clean": "turbo run clean && rm -rf node_modules"
15
+ },
16
+ "devDependencies": {
17
+ "turbo": "^1.11.0",
18
+ "typescript": "^5.3.0"
19
+ },
20
+ "keywords": [
21
+ "deployment",
22
+ "vps"
23
+ ],
24
+ "author": "21n",
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/21nCo/hostfn.git"
28
+ },
29
+ "bugs": {
30
+ "url": "https://github.com/21nCo/hostfn/issues"
31
+ },
32
+ "engines": {
33
+ "node": ">=18.0.0"
34
+ },
35
+ "packageManager": "npm@10.2.0",
36
+ "dependencies": {
37
+ "fast-glob": "^3.3.3"
38
+ }
39
+ }
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "hostfn",
3
+ "version": "0.1.0",
4
+ "description": "Universal application deployment CLI",
5
+ "type": "module",
6
+ "bin": {
7
+ "hostfn": "./dist/index.js"
8
+ },
9
+ "scripts": {
10
+ "dev": "tsx watch src/index.ts",
11
+ "build": "tsc",
12
+ "clean": "rm -rf dist",
13
+ "lint": "tsc --noEmit",
14
+ "test": "vitest",
15
+ "test:watch": "vitest --watch",
16
+ "test:coverage": "vitest --coverage"
17
+ },
18
+ "dependencies": {
19
+ "commander": "^11.1.0",
20
+ "chalk": "^5.3.0",
21
+ "ora": "^7.0.1",
22
+ "inquirer": "^9.2.12",
23
+ "zod": "^3.22.4",
24
+ "ssh2": "^1.15.0",
25
+ "dotenv": "^16.3.1",
26
+ "execa": "^8.0.1"
27
+ },
28
+ "devDependencies": {
29
+ "@types/node": "^20.10.0",
30
+ "@types/ssh2": "^1.15.0",
31
+ "@types/inquirer": "^9.0.7",
32
+ "@vitest/coverage-v8": "^1.0.0",
33
+ "typescript": "^5.3.0",
34
+ "tsx": "^4.7.0",
35
+ "vitest": "^1.0.0"
36
+ },
37
+ "engines": {
38
+ "node": ">=18.0.0"
39
+ }
40
+ }
@@ -0,0 +1,137 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { BackupManager } from '../../core/backup.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
+ describe('BackupManager', () => {
15
+ let backupManager: BackupManager;
16
+
17
+ beforeEach(() => {
18
+ vi.clearAllMocks();
19
+ backupManager = new BackupManager(mockSSH as any, '/var/www/myapp');
20
+ });
21
+
22
+ describe('create', () => {
23
+ it('should create backup with timestamp', async () => {
24
+ mockSSH.mkdir.mockResolvedValue(undefined);
25
+ mockSSH.exists.mockResolvedValue(true); // dist exists
26
+ mockSSH.exec.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 });
27
+
28
+ const backupPath = await backupManager.create();
29
+
30
+ expect(backupPath).toMatch(/\/var\/www\/myapp\/backups\/\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}/);
31
+ expect(mockSSH.mkdir).toHaveBeenCalled();
32
+ expect(mockSSH.exec).toHaveBeenCalled();
33
+ });
34
+
35
+ it('should return path even if source directory does not exist', async () => {
36
+ mockSSH.mkdir.mockResolvedValue(undefined);
37
+ mockSSH.exists.mockResolvedValue(false); // No dist directory
38
+
39
+ const backupPath = await backupManager.create();
40
+
41
+ // Returns backup path but doesn't fail
42
+ expect(backupPath).toMatch(/\/var\/www\/myapp\/backups\/\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}/);
43
+ expect(mockSSH.mkdir).toHaveBeenCalled();
44
+ });
45
+ });
46
+
47
+ describe('list', () => {
48
+ it('should list all backups with timestamps', async () => {
49
+ mockSSH.exists.mockResolvedValue(true);
50
+ mockSSH.exec.mockResolvedValue({
51
+ stdout: 'myapp.backup.20251114_130000\nmyapp.backup.20251114_120000\n', // sorted reverse
52
+ stderr: '',
53
+ exitCode: 0
54
+ });
55
+
56
+ const backups = await backupManager.list();
57
+
58
+ expect(backups).toHaveLength(2);
59
+ expect(backups[0]).toContain('20251114_130000'); // Most recent first
60
+ expect(backups[1]).toContain('20251114_120000');
61
+ });
62
+
63
+ it('should return empty array when no backups exist', async () => {
64
+ mockSSH.exec.mockResolvedValue({
65
+ stdout: '',
66
+ stderr: '',
67
+ exitCode: 0
68
+ });
69
+
70
+ const backups = await backupManager.list();
71
+
72
+ expect(backups).toHaveLength(0);
73
+ });
74
+ });
75
+
76
+ describe('restore', () => {
77
+ it('should restore from backup', async () => {
78
+ mockSSH.exists.mockResolvedValue(true);
79
+ mockSSH.exec.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 });
80
+
81
+ await backupManager.restore('myapp.backup.20251114_120000');
82
+
83
+ expect(mockSSH.exec).toHaveBeenCalledWith(
84
+ expect.stringContaining('cp -r')
85
+ );
86
+ });
87
+ });
88
+
89
+ describe('cleanup', () => {
90
+ it('should keep only specified number of backups', async () => {
91
+ const backupNames = [
92
+ 'myapp.backup.20251114_150000', // Most recent (sorted)
93
+ 'myapp.backup.20251114_140000',
94
+ 'myapp.backup.20251114_130000',
95
+ 'myapp.backup.20251114_120000',
96
+ 'myapp.backup.20251114_110000',
97
+ 'myapp.backup.20251114_100000', // Oldest
98
+ ];
99
+
100
+ mockSSH.exists.mockResolvedValue(true);
101
+ mockSSH.exec
102
+ .mockResolvedValueOnce({
103
+ stdout: backupNames.join('\n'),
104
+ stderr: '',
105
+ exitCode: 0
106
+ })
107
+ .mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 });
108
+
109
+ await backupManager.cleanup(3);
110
+
111
+ // Should remove oldest 3 backups
112
+ expect(mockSSH.exec).toHaveBeenCalledWith(
113
+ expect.stringContaining('rm -rf')
114
+ );
115
+ });
116
+
117
+ it('should not remove backups if under limit', async () => {
118
+ const backupNames = [
119
+ 'myapp.backup.20251114_100000',
120
+ 'myapp.backup.20251114_110000',
121
+ ];
122
+
123
+ mockSSH.exists.mockResolvedValue(true);
124
+ mockSSH.exec.mockResolvedValueOnce({
125
+ stdout: backupNames.join('\n'),
126
+ stderr: '',
127
+ exitCode: 0
128
+ });
129
+
130
+ await backupManager.cleanup(5);
131
+
132
+ // Should only call list, not remove (called twice: exists + ls)
133
+ expect(mockSSH.exec).toHaveBeenCalledTimes(1);
134
+ expect(mockSSH.exec).toHaveBeenCalledWith(expect.stringContaining('ls -1'));
135
+ });
136
+ });
137
+ });
@@ -0,0 +1,125 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { HealthCheck } from '../../core/health.js';
3
+
4
+ // Mock fetch globally
5
+ global.fetch = vi.fn();
6
+
7
+ describe('HealthCheck', () => {
8
+ beforeEach(() => {
9
+ vi.clearAllMocks();
10
+ });
11
+
12
+ describe('check', () => {
13
+ it('should return healthy for successful response', async () => {
14
+ (global.fetch as any).mockResolvedValueOnce({
15
+ ok: true,
16
+ status: 200,
17
+ });
18
+
19
+ const result = await HealthCheck.check({
20
+ url: 'http://localhost:3000/health',
21
+ });
22
+
23
+ expect(result.healthy).toBe(true);
24
+ expect(result.statusCode).toBe(200);
25
+ expect(result.responseTime).toBeGreaterThanOrEqual(0);
26
+ });
27
+
28
+ it('should return unhealthy for failed response', async () => {
29
+ (global.fetch as any).mockResolvedValueOnce({
30
+ ok: false,
31
+ status: 500,
32
+ });
33
+
34
+ const result = await HealthCheck.check({
35
+ url: 'http://localhost:3000/health',
36
+ });
37
+
38
+ expect(result.healthy).toBe(false);
39
+ expect(result.statusCode).toBe(500);
40
+ });
41
+
42
+ it('should return unhealthy for network error', async () => {
43
+ (global.fetch as any).mockRejectedValueOnce(new Error('Network error'));
44
+
45
+ const result = await HealthCheck.check({
46
+ url: 'http://localhost:3000/health',
47
+ });
48
+
49
+ expect(result.healthy).toBe(false);
50
+ expect(result.error).toBeDefined();
51
+ });
52
+
53
+ it('should respect timeout', async () => {
54
+ (global.fetch as any).mockImplementationOnce(() =>
55
+ new Promise((resolve) => setTimeout(resolve, 2000))
56
+ );
57
+
58
+ const result = await HealthCheck.check({
59
+ url: 'http://localhost:3000/health',
60
+ timeout: 100,
61
+ });
62
+
63
+ expect(result.healthy).toBe(false);
64
+ });
65
+ });
66
+
67
+ describe('waitForReady', () => {
68
+ it('should return true when service becomes healthy', async () => {
69
+ let callCount = 0;
70
+ (global.fetch as any).mockImplementation(() => {
71
+ callCount++;
72
+ return Promise.resolve({
73
+ ok: callCount >= 2, // Becomes healthy on second call
74
+ status: callCount >= 2 ? 200 : 503,
75
+ });
76
+ });
77
+
78
+ const result = await HealthCheck.waitForReady({
79
+ url: 'http://localhost:3000/health',
80
+ retries: 5,
81
+ interval: 10,
82
+ });
83
+
84
+ expect(result).toBe(true);
85
+ expect(callCount).toBeGreaterThanOrEqual(2);
86
+ });
87
+
88
+ it('should return false when max retries exceeded', async () => {
89
+ (global.fetch as any).mockResolvedValue({
90
+ ok: false,
91
+ status: 503,
92
+ });
93
+
94
+ const result = await HealthCheck.waitForReady({
95
+ url: 'http://localhost:3000/health',
96
+ retries: 2,
97
+ interval: 10,
98
+ });
99
+
100
+ expect(result).toBe(false);
101
+ });
102
+
103
+ it('should call progress callback on each attempt', async () => {
104
+ (global.fetch as any).mockResolvedValue({
105
+ ok: false,
106
+ status: 503,
107
+ });
108
+
109
+ const progressCalls: number[] = [];
110
+
111
+ await HealthCheck.waitForReady(
112
+ {
113
+ url: 'http://localhost:3000/health',
114
+ retries: 3,
115
+ interval: 10,
116
+ },
117
+ (attempt) => {
118
+ progressCalls.push(attempt);
119
+ }
120
+ );
121
+
122
+ expect(progressCalls).toEqual([1, 2, 3]);
123
+ });
124
+ });
125
+ });