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,108 @@
1
+ import { SSHConnection } from './ssh.js';
2
+ import { Logger } from '../utils/logger.js';
3
+
4
+ export interface DeploymentLock {
5
+ pid: number;
6
+ user: string;
7
+ timestamp: number;
8
+ hostname: string;
9
+ }
10
+
11
+ /**
12
+ * Deployment lock manager to prevent concurrent deployments
13
+ */
14
+ export class LockManager {
15
+ private ssh: SSHConnection;
16
+ private lockFile: string;
17
+
18
+ constructor(ssh: SSHConnection, remoteDir: string) {
19
+ this.ssh = ssh;
20
+ this.lockFile = `${remoteDir}/.hostfn-deploy.lock`;
21
+ }
22
+
23
+ /**
24
+ * Acquire deployment lock
25
+ */
26
+ async acquire(timeout: number = 300): Promise<boolean> {
27
+ // Check if lock exists
28
+ const exists = await this.ssh.exists(this.lockFile);
29
+
30
+ if (exists) {
31
+ // Read lock info
32
+ const result = await this.ssh.exec(`cat ${this.lockFile}`);
33
+
34
+ try {
35
+ const lock: DeploymentLock = JSON.parse(result.stdout);
36
+ const age = Date.now() - lock.timestamp;
37
+
38
+ // Check if lock is stale (older than timeout)
39
+ if (age > timeout * 1000) {
40
+ Logger.warn('Found stale lock file, removing...');
41
+ await this.release();
42
+ } else {
43
+ const ageMinutes = Math.round(age / 1000 / 60);
44
+ Logger.error('Deployment is already in progress');
45
+ Logger.info(`Started by: ${lock.user}`);
46
+ Logger.info(`Started at: ${new Date(lock.timestamp).toLocaleString()}`);
47
+ Logger.info(`Age: ${ageMinutes} minute(s)`);
48
+ Logger.br();
49
+ Logger.warn('Wait for the current deployment to finish or:');
50
+ Logger.log(' 1. Wait for lock to expire (5 minutes)');
51
+ Logger.log(' 2. Manually remove: ssh <host> rm ' + this.lockFile);
52
+ return false;
53
+ }
54
+ } catch {
55
+ // Invalid lock file, remove it
56
+ await this.release();
57
+ }
58
+ }
59
+
60
+ // Create lock
61
+ const lock: DeploymentLock = {
62
+ pid: process.pid,
63
+ user: process.env.USER || 'unknown',
64
+ timestamp: Date.now(),
65
+ hostname: process.env.HOSTNAME || 'unknown',
66
+ };
67
+
68
+ await this.ssh.exec(`cat > ${this.lockFile} << 'LOCKEOF'
69
+ ${JSON.stringify(lock, null, 2)}
70
+ LOCKEOF`);
71
+
72
+ return true;
73
+ }
74
+
75
+ /**
76
+ * Release deployment lock
77
+ */
78
+ async release(): Promise<void> {
79
+ await this.ssh.exec(`rm -f ${this.lockFile}`).catch(() => {
80
+ // Ignore errors on cleanup
81
+ });
82
+ }
83
+
84
+ /**
85
+ * Check if deployment is locked
86
+ */
87
+ async isLocked(): Promise<boolean> {
88
+ const exists = await this.ssh.exists(this.lockFile);
89
+
90
+ if (!exists) {
91
+ return false;
92
+ }
93
+
94
+ // Check if lock is stale
95
+ const result = await this.ssh.exec(`cat ${this.lockFile}`);
96
+
97
+ try {
98
+ const lock: DeploymentLock = JSON.parse(result.stdout);
99
+ const age = Date.now() - lock.timestamp;
100
+
101
+ // Lock is stale if older than 5 minutes
102
+ return age < 300 * 1000;
103
+ } catch {
104
+ // Invalid lock file = not locked
105
+ return false;
106
+ }
107
+ }
108
+ }
@@ -0,0 +1,170 @@
1
+ import type { HostfnConfig, ServiceConfig } from '../config/schema.js';
2
+
3
+ export interface NginxServiceConfig {
4
+ name: string;
5
+ port: number;
6
+ exposePath?: string;
7
+ isDefault?: boolean;
8
+ }
9
+
10
+ export interface NginxConfig {
11
+ domain?: string | string[];
12
+ ssl: boolean;
13
+ services: NginxServiceConfig[];
14
+ environment: string;
15
+ }
16
+
17
+ export class NginxConfigGenerator {
18
+ /**
19
+ * Generate nginx configuration for services
20
+ */
21
+ static generate(config: NginxConfig): string {
22
+ const { domain, ssl, services, environment } = config;
23
+ // Handle both single domain string and array of domains
24
+ const domains = domain ? (Array.isArray(domain) ? domain : [domain]) : [];
25
+ const serverName = domains.length > 0 ? domains.join(' ') : '_';
26
+
27
+ // Separate default and path-based services
28
+ const defaultService = services.find(s => s.isDefault);
29
+ const pathServices = services.filter(s => !s.isDefault && s.exposePath);
30
+
31
+ let nginxConfig = '';
32
+
33
+ if (ssl) {
34
+ // HTTPS server block
35
+ nginxConfig += this.generateHttpsBlock(serverName, defaultService, pathServices);
36
+ nginxConfig += '\n\n';
37
+ // HTTP redirect block
38
+ nginxConfig += this.generateHttpRedirectBlock(serverName);
39
+ } else {
40
+ // HTTP only server block
41
+ nginxConfig += this.generateHttpBlock(serverName, defaultService, pathServices);
42
+ }
43
+
44
+ return nginxConfig;
45
+ }
46
+
47
+ /**
48
+ * Generate HTTPS server block
49
+ */
50
+ private static generateHttpsBlock(
51
+ serverName: string,
52
+ defaultService: NginxServiceConfig | undefined,
53
+ pathServices: NginxServiceConfig[]
54
+ ): string {
55
+ // Extract primary domain for certificate path (first domain in the list)
56
+ const primaryDomain = serverName.split(' ')[0];
57
+ let config = `server {
58
+ listen 443 ssl http2;
59
+ listen [::]:443 ssl http2;
60
+ server_name ${serverName};
61
+
62
+ ssl_certificate /etc/letsencrypt/live/${primaryDomain}/fullchain.pem; # managed by Certbot
63
+ ssl_certificate_key /etc/letsencrypt/live/${primaryDomain}/privkey.pem; # managed by Certbot
64
+ include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
65
+ ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
66
+ `;
67
+
68
+ // Add path-based services first (higher priority)
69
+ for (const service of pathServices) {
70
+ config += this.generateLocationBlock(service);
71
+ }
72
+
73
+ // Add default service
74
+ if (defaultService) {
75
+ config += this.generateLocationBlock(defaultService);
76
+ }
77
+
78
+ config += '}\n';
79
+ return config;
80
+ }
81
+
82
+ /**
83
+ * Generate HTTP server block
84
+ */
85
+ private static generateHttpBlock(
86
+ serverName: string,
87
+ defaultService: NginxServiceConfig | undefined,
88
+ pathServices: NginxServiceConfig[]
89
+ ): string {
90
+ let config = `server {
91
+ listen 80;
92
+ listen [::]:80;
93
+ server_name ${serverName};
94
+ `;
95
+
96
+ // Add path-based services first (higher priority)
97
+ for (const service of pathServices) {
98
+ config += this.generateLocationBlock(service);
99
+ }
100
+
101
+ // Add default service
102
+ if (defaultService) {
103
+ config += this.generateLocationBlock(defaultService);
104
+ }
105
+
106
+ config += '}\n';
107
+ return config;
108
+ }
109
+
110
+ /**
111
+ * Generate HTTP to HTTPS redirect block
112
+ */
113
+ private static generateHttpRedirectBlock(serverName: string): string {
114
+ return `server {
115
+ listen 80;
116
+ listen [::]:80;
117
+ server_name ${serverName};
118
+
119
+ location / {
120
+ return 301 https://$host$request_uri;
121
+ }
122
+ }`;
123
+ }
124
+
125
+ /**
126
+ * Generate location block for a service
127
+ */
128
+ private static generateLocationBlock(service: NginxServiceConfig): string {
129
+ const path = service.exposePath || '/';
130
+ const hasTrailingSlash = path !== '/' && path.endsWith('/');
131
+ const proxyPassPath = hasTrailingSlash ? '/' : '';
132
+
133
+ return `
134
+ # ${service.name}
135
+ location ${path} {
136
+ proxy_pass http://localhost:${service.port}${proxyPassPath};
137
+ proxy_http_version 1.1;
138
+ proxy_set_header Upgrade $http_upgrade;
139
+ proxy_set_header Connection 'upgrade';
140
+ proxy_set_header Host $host;
141
+ proxy_set_header X-Real-IP $remote_addr;
142
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
143
+ proxy_set_header X-Forwarded-Proto $scheme;
144
+ proxy_cache_bypass $http_upgrade;
145
+ proxy_read_timeout 60s;
146
+ proxy_connect_timeout 60s;
147
+ }
148
+ `;
149
+ }
150
+
151
+ /**
152
+ * Get nginx config file path based on the system
153
+ */
154
+ static getConfigPath(environment: string, useSitesAvailable: boolean): string {
155
+ if (useSitesAvailable) {
156
+ return `/etc/nginx/sites-available/hostfn-${environment}`;
157
+ }
158
+ return `/etc/nginx/conf.d/hostfn-${environment}.conf`;
159
+ }
160
+
161
+ /**
162
+ * Get command to enable site (for sites-available/sites-enabled systems)
163
+ */
164
+ static getEnableCommand(environment: string, useSitesAvailable: boolean): string | null {
165
+ if (useSitesAvailable) {
166
+ return `ln -sf /etc/nginx/sites-available/hostfn-${environment} /etc/nginx/sites-enabled/hostfn-${environment}`;
167
+ }
168
+ return null;
169
+ }
170
+ }
@@ -0,0 +1,335 @@
1
+ import { Client, ConnectConfig } from 'ssh2';
2
+ import { readFileSync } from 'fs';
3
+ import { homedir } from 'os';
4
+ import { resolve } from 'path';
5
+ import { Logger } from '../utils/logger.js';
6
+
7
+ export interface SSHConnectionOptions {
8
+ host: string;
9
+ port?: number;
10
+ username: string;
11
+ password?: string;
12
+ privateKeyPath?: string;
13
+ passphrase?: string;
14
+ }
15
+
16
+ export interface SSHCommandResult {
17
+ stdout: string;
18
+ stderr: string;
19
+ exitCode: number;
20
+ }
21
+
22
+ /**
23
+ * SSH Connection Manager
24
+ */
25
+ export class SSHConnection {
26
+ private client: Client;
27
+ private config: ConnectConfig;
28
+ private connected: boolean = false;
29
+
30
+ constructor(options: SSHConnectionOptions) {
31
+ this.client = new Client();
32
+
33
+ // Build SSH config
34
+ this.config = {
35
+ host: options.host,
36
+ port: options.port || 22,
37
+ username: options.username,
38
+ };
39
+
40
+ // Add authentication method
41
+ if (options.password) {
42
+ this.config.password = options.password;
43
+ } else if (process.env.HOSTFN_SSH_KEY) {
44
+ // CI/CD mode: Load key from environment variable
45
+ try {
46
+ this.config.privateKey = Buffer.from(
47
+ process.env.HOSTFN_SSH_KEY,
48
+ 'base64'
49
+ );
50
+ if (options.passphrase || process.env.HOSTFN_SSH_PASSPHRASE) {
51
+ this.config.passphrase = options.passphrase || process.env.HOSTFN_SSH_PASSPHRASE;
52
+ }
53
+ } catch (error) {
54
+ throw new Error(
55
+ `Failed to decode HOSTFN_SSH_KEY from environment variable\n` +
56
+ `Make sure it's base64 encoded: cat ~/.ssh/id_rsa | base64`
57
+ );
58
+ }
59
+ } else {
60
+ // Enable SSH agent for passphrase-protected keys
61
+ if (process.env.SSH_AUTH_SOCK) {
62
+ this.config.agent = process.env.SSH_AUTH_SOCK;
63
+ }
64
+
65
+ // Try to load private key from file
66
+ const keyPath = options.privateKeyPath || this.getDefaultKeyPath();
67
+ try {
68
+ this.config.privateKey = readFileSync(keyPath);
69
+ if (options.passphrase) {
70
+ this.config.passphrase = options.passphrase;
71
+ }
72
+ } catch (error) {
73
+ throw new Error(
74
+ `Failed to load SSH key from ${keyPath}\n` +
75
+ `Make sure the file exists or set HOSTFN_SSH_KEY environment variable\n` +
76
+ `For CI/CD: export HOSTFN_SSH_KEY=$(cat ~/.ssh/id_rsa | base64)`
77
+ );
78
+ }
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Get default SSH key path (~/.ssh/id_rsa)
84
+ */
85
+ private getDefaultKeyPath(): string {
86
+ const sshDir = resolve(homedir(), '.ssh');
87
+ // Try common key names
88
+ const keyNames = ['id_rsa', 'id_ed25519', 'id_ecdsa'];
89
+
90
+ for (const keyName of keyNames) {
91
+ const keyPath = resolve(sshDir, keyName);
92
+ try {
93
+ readFileSync(keyPath);
94
+ return keyPath;
95
+ } catch {
96
+ continue;
97
+ }
98
+ }
99
+
100
+ // Default to id_rsa even if it doesn't exist (will error later)
101
+ return resolve(sshDir, 'id_rsa');
102
+ }
103
+
104
+ /**
105
+ * Connect to SSH server
106
+ */
107
+ async connect(): Promise<void> {
108
+ return new Promise((resolve, reject) => {
109
+ this.client
110
+ .on('ready', () => {
111
+ this.connected = true;
112
+ resolve();
113
+ })
114
+ .on('error', (err) => {
115
+ reject(new Error(`SSH connection failed: ${err.message}`));
116
+ })
117
+ .connect(this.config);
118
+ });
119
+ }
120
+
121
+ /**
122
+ * Execute command on remote server
123
+ */
124
+ async exec(command: string, options?: {
125
+ streaming?: boolean;
126
+ cwd?: string;
127
+ skipNvmSetup?: boolean;
128
+ }): Promise<SSHCommandResult> {
129
+ if (!this.connected) {
130
+ throw new Error('Not connected. Call connect() first.');
131
+ }
132
+
133
+ // Source nvm to ensure Node.js/npm are in PATH for non-interactive sessions
134
+ // Skip for setup scripts that install nvm
135
+ const nvmSetup = options?.skipNvmSetup
136
+ ? ''
137
+ : 'export NVM_DIR="$HOME/.nvm" && [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" && ';
138
+
139
+ // Add cd command if cwd provided
140
+ const fullCommand = options?.cwd
141
+ ? `${nvmSetup}cd ${options.cwd} && ${command}`
142
+ : `${nvmSetup}${command}`;
143
+
144
+ return new Promise((resolve, reject) => {
145
+ this.client.exec(fullCommand, (err, stream) => {
146
+ if (err) {
147
+ reject(new Error(`Failed to execute command: ${err.message}`));
148
+ return;
149
+ }
150
+
151
+ let stdout = '';
152
+ let stderr = '';
153
+
154
+ stream
155
+ .on('close', (code: number) => {
156
+ resolve({
157
+ stdout,
158
+ stderr,
159
+ exitCode: code,
160
+ });
161
+ })
162
+ .on('data', (data: Buffer) => {
163
+ const output = data.toString();
164
+ stdout += output;
165
+
166
+ if (options?.streaming) {
167
+ process.stdout.write(output);
168
+ }
169
+ })
170
+ .stderr.on('data', (data: Buffer) => {
171
+ const output = data.toString();
172
+ stderr += output;
173
+
174
+ if (options?.streaming) {
175
+ process.stderr.write(output);
176
+ }
177
+ });
178
+ });
179
+ });
180
+ }
181
+
182
+ /**
183
+ * Upload file to remote server (SCP)
184
+ */
185
+ async uploadFile(
186
+ localPath: string,
187
+ remotePath: string
188
+ ): Promise<void> {
189
+ if (!this.connected) {
190
+ throw new Error('Not connected. Call connect() first.');
191
+ }
192
+
193
+ return new Promise((resolve, reject) => {
194
+ this.client.sftp((err, sftp) => {
195
+ if (err) {
196
+ reject(new Error(`SFTP session failed: ${err.message}`));
197
+ return;
198
+ }
199
+
200
+ sftp.fastPut(localPath, remotePath, (err) => {
201
+ if (err) {
202
+ reject(new Error(`Failed to upload file: ${err.message}`));
203
+ } else {
204
+ resolve();
205
+ }
206
+ });
207
+ });
208
+ });
209
+ }
210
+
211
+ /**
212
+ * Download file from remote server
213
+ */
214
+ async downloadFile(
215
+ remotePath: string,
216
+ localPath: string
217
+ ): Promise<void> {
218
+ if (!this.connected) {
219
+ throw new Error('Not connected. Call connect() first.');
220
+ }
221
+
222
+ return new Promise((resolve, reject) => {
223
+ this.client.sftp((err, sftp) => {
224
+ if (err) {
225
+ reject(new Error(`SFTP session failed: ${err.message}`));
226
+ return;
227
+ }
228
+
229
+ sftp.fastGet(remotePath, localPath, (err) => {
230
+ if (err) {
231
+ reject(new Error(`Failed to download file: ${err.message}`));
232
+ } else {
233
+ resolve();
234
+ }
235
+ });
236
+ });
237
+ });
238
+ }
239
+
240
+ /**
241
+ * Check if file/directory exists on remote
242
+ */
243
+ async exists(remotePath: string): Promise<boolean> {
244
+ try {
245
+ const result = await this.exec(`test -e ${remotePath} && echo "exists"`);
246
+ return result.stdout.trim() === 'exists';
247
+ } catch {
248
+ return false;
249
+ }
250
+ }
251
+
252
+ /**
253
+ * Create directory on remote server
254
+ */
255
+ async mkdir(remotePath: string, recursive: boolean = true): Promise<void> {
256
+ const flag = recursive ? '-p' : '';
257
+ const result = await this.exec(`mkdir ${flag} ${remotePath}`);
258
+
259
+ if (result.exitCode !== 0) {
260
+ throw new Error(`Failed to create directory: ${result.stderr}`);
261
+ }
262
+ }
263
+
264
+ /**
265
+ * Disconnect from server
266
+ */
267
+ disconnect(): void {
268
+ if (this.connected) {
269
+ this.client.end();
270
+ this.connected = false;
271
+ }
272
+ }
273
+
274
+ /**
275
+ * Check if connected
276
+ */
277
+ isConnected(): boolean {
278
+ return this.connected;
279
+ }
280
+ }
281
+
282
+ /**
283
+ * Parse SSH connection string (user@host)
284
+ */
285
+ export function parseSSHConnection(connectionString: string): {
286
+ username: string;
287
+ host: string;
288
+ port?: number;
289
+ } {
290
+ // Support formats:
291
+ // - user@host
292
+ // - user@host:port
293
+ const match = connectionString.match(/^([^@]+)@([^:]+)(?::(\d+))?$/);
294
+
295
+ if (!match) {
296
+ throw new Error(
297
+ `Invalid SSH connection string: ${connectionString}\n` +
298
+ `Expected format: user@host or user@host:port`
299
+ );
300
+ }
301
+
302
+ return {
303
+ username: match[1],
304
+ host: match[2],
305
+ port: match[3] ? parseInt(match[3]) : undefined,
306
+ };
307
+ }
308
+
309
+ /**
310
+ * Create SSH connection from connection string
311
+ */
312
+ export async function createSSHConnection(
313
+ connectionString: string,
314
+ options?: {
315
+ password?: string;
316
+ privateKeyPath?: string;
317
+ passphrase?: string;
318
+ }
319
+ ): Promise<SSHConnection> {
320
+ // Support HOSTFN_HOST env var for CI/CD
321
+ const hostToUse = process.env.HOSTFN_HOST || connectionString;
322
+ const { username, host, port } = parseSSHConnection(hostToUse);
323
+
324
+ const ssh = new SSHConnection({
325
+ host,
326
+ port,
327
+ username,
328
+ password: options?.password || process.env.HOSTFN_SSH_PASSWORD,
329
+ privateKeyPath: options?.privateKeyPath,
330
+ passphrase: options?.passphrase || process.env.HOSTFN_SSH_PASSPHRASE,
331
+ });
332
+
333
+ await ssh.connect();
334
+ return ssh;
335
+ }