hostfn 0.1.0 → 0.1.1

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/package.json CHANGED
@@ -1,8 +1,7 @@
1
1
  {
2
2
  "name": "hostfn",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Universal application deployment CLI",
5
- "license": "Apache-2.0",
6
5
  "workspaces": [
7
6
  "packages/*"
8
7
  ],
@@ -14,13 +13,13 @@
14
13
  "clean": "turbo run clean && rm -rf node_modules"
15
14
  },
16
15
  "devDependencies": {
16
+ "fast-glob": "^3.3.3",
17
17
  "turbo": "^1.11.0",
18
18
  "typescript": "^5.3.0"
19
19
  },
20
- "keywords": [
21
- "deployment",
22
- "vps"
23
- ],
20
+ "engines": {
21
+ "node": ">=18.0.0"
22
+ },
24
23
  "author": "21n",
25
24
  "repository": {
26
25
  "type": "git",
@@ -29,11 +28,9 @@
29
28
  "bugs": {
30
29
  "url": "https://github.com/21nCo/hostfn/issues"
31
30
  },
32
- "engines": {
33
- "node": ">=18.0.0"
34
- },
35
- "packageManager": "npm@10.2.0",
36
- "dependencies": {
37
- "fast-glob": "^3.3.3"
38
- }
31
+ "keywords": [
32
+ "deployment",
33
+ "vps"
34
+ ],
35
+ "packageManager": "npm@10.2.0"
39
36
  }
@@ -22,8 +22,8 @@ describe('PM2Manager', () => {
22
22
 
23
23
  expect(result).toContain("name: 'test-app-production'");
24
24
  expect(result).toContain("script: 'dist/index.js'");
25
- expect(result).toContain("env_file: '.env'");
26
- expect(result).toContain("NODE_ENV: 'production'");
25
+ // Implementation uses inline env with double quotes (JSON.stringify)
26
+ expect(result).toContain('NODE_ENV: "production"');
27
27
  expect(result).toContain('PORT: 3000');
28
28
  expect(result).toContain("instances: 'max'");
29
29
  expect(result).toContain("exec_mode: 'cluster'");
@@ -181,24 +181,32 @@ async function deployMultiService(
181
181
  const servicePath = serviceConfig.path;
182
182
  const originalCwd = process.cwd();
183
183
 
184
- // Change to service directory
185
- process.chdir(servicePath);
186
-
187
- // Deploy single service
188
- await deploySingleService(
189
- serviceSpecificConfig,
190
- serviceEnvConfig,
191
- environment,
192
- options,
193
- servicePath
194
- );
195
-
196
- // Restore original directory
197
- process.chdir(originalCwd);
198
-
199
- results.push({ service: serviceName, success: true });
200
- Logger.success(`Service '${serviceName}' deployed successfully`);
201
- Logger.br();
184
+ try {
185
+ // Change to service directory
186
+ process.chdir(servicePath);
187
+
188
+ // Deploy single service
189
+ await deploySingleService(
190
+ serviceSpecificConfig,
191
+ serviceEnvConfig,
192
+ environment,
193
+ options,
194
+ servicePath
195
+ );
196
+
197
+ results.push({ service: serviceName, success: true });
198
+ Logger.success(`Service '${serviceName}' deployed successfully`);
199
+ Logger.br();
200
+ } catch (error) {
201
+ // Restore original directory in case of error
202
+ process.chdir(originalCwd);
203
+ throw error;
204
+ } finally {
205
+ // Restore original directory
206
+ if (process.cwd() !== originalCwd) {
207
+ process.chdir(originalCwd);
208
+ }
209
+ }
202
210
  } catch (error) {
203
211
  const errorMsg = error instanceof Error ? error.message : String(error);
204
212
  results.push({ service: serviceName, success: false, error: errorMsg });
@@ -546,11 +554,19 @@ async function deploySingleService(
546
554
  const backupManager = new BackupManager(ssh as any, remoteDir);
547
555
  const backupSpinner = ora('Creating backup of current deployment...').start();
548
556
 
549
- try {
550
- backupPath = await backupManager.create();
551
- backupSpinner.succeed(`Backup created: ${backupPath.split('/').pop()}`);
552
- } catch (error) {
553
- backupSpinner.warn('No existing deployment to backup');
557
+ // Check if there's anything to backup
558
+ const hasExistingDeployment = await ssh.exists(`${remoteDir}/dist`);
559
+
560
+ if (!hasExistingDeployment) {
561
+ backupSpinner.info('No existing deployment to backup');
562
+ } else {
563
+ try {
564
+ backupPath = await backupManager.create();
565
+ backupSpinner.succeed(`Backup created: ${backupPath.split('/').pop()}`);
566
+ } catch (error) {
567
+ backupSpinner.fail('Failed to create backup');
568
+ throw error;
569
+ }
554
570
  }
555
571
 
556
572
  Logger.br();
@@ -116,6 +116,11 @@ export async function envSetCommand(
116
116
  process.exit(1);
117
117
  }
118
118
 
119
+ // Validate key format
120
+ if (!/^[A-Z_][A-Z0-9_]*$/.test(key)) {
121
+ throw new Error('Invalid key format. Must be uppercase, alphanumeric, and underscores only.');
122
+ }
123
+
119
124
  // Check if key already exists
120
125
  const checkResult = await ssh.exec(
121
126
  `grep -q "^${key}=" ${envFile} 2>/dev/null && echo "exists" || echo "new"`
@@ -127,15 +132,23 @@ export async function envSetCommand(
127
132
  isNew ? 'Adding variable...' : 'Updating variable...'
128
133
  ).start();
129
134
 
135
+ // Use a temporary file strategy to avoid shell injection issues with value
136
+ const tempFile = `/tmp/env_var_${Date.now()}`;
137
+ await ssh.exec(`echo "${value.replace(/"/g, '\\"')}" > ${tempFile}`);
138
+
130
139
  if (isNew) {
131
- // Append new variable
132
- await ssh.exec(`echo "${key}=${value}" >> ${envFile}`);
140
+ // Append new variable safely
141
+ await ssh.exec(`echo "${key}=\\"$(cat ${tempFile})\\"" >> ${envFile}`);
133
142
  } else {
134
- // Update existing variable (use sed for safety)
135
- await ssh.exec(
136
- `sed -i.bak "s|^${key}=.*|${key}=${value}|" ${envFile}`
137
- );
143
+ // Update existing variable safely using temp file content
144
+ // We use a more complex sed or awk command, or just overwrite the line
145
+ // Constructing safe sed is hard, so let's use grep -v to remove old and append new
146
+ await ssh.exec(`grep -v "^${key}=" ${envFile} > ${envFile}.tmp && mv ${envFile}.tmp ${envFile}`);
147
+ await ssh.exec(`echo "${key}=\\"$(cat ${tempFile})\\"" >> ${envFile}`);
138
148
  }
149
+
150
+ // Cleanup temp file
151
+ await ssh.exec(`rm ${tempFile}`);
139
152
 
140
153
  updateSpinner.succeed(isNew ? 'Variable added' : 'Variable updated');
141
154
 
@@ -94,7 +94,8 @@ export async function exposeCommand(environment: string, options: {
94
94
  if (shouldSetupSsl && domains.length > 0) {
95
95
  // Check if certificate exists for the primary domain (first in the list)
96
96
  const primaryDomain = domains[0];
97
- const certCheck = await ssh.exec(`sudo test -d /etc/letsencrypt/live/${primaryDomain} && echo "exists"`);
97
+ const safePrimaryDomain = `'${primaryDomain.replace(/'/g, "'\\''")}'`;
98
+ const certCheck = await ssh.exec(`sudo test -d /etc/letsencrypt/live/${safePrimaryDomain} && echo "exists"`);
98
99
  certsExist = certCheck.stdout.trim() === 'exists';
99
100
  }
100
101
 
@@ -114,10 +115,16 @@ export async function exposeCommand(environment: string, options: {
114
115
 
115
116
  // Only include services on this server
116
117
  if (serviceEnvConfig.server === host) {
118
+ const port = serviceConfig.port;
119
+ // Validate port
120
+ if (!port || isNaN(Number(port)) || Number(port) < 1 || Number(port) > 65535) {
121
+ throw new Error(`Invalid port for service '${serviceName}': ${port}. Must be between 1 and 65535.`);
122
+ }
123
+
117
124
  const isDefault = !serviceConfig.exposePath;
118
125
  services.push({
119
126
  name: `${config.name}-${serviceName}`,
120
- port: serviceConfig.port,
127
+ port: Number(port),
121
128
  exposePath: serviceConfig.exposePath,
122
129
  isDefault,
123
130
  });
@@ -136,9 +143,15 @@ export async function exposeCommand(environment: string, options: {
136
143
  Logger.section('Configuring Single Service');
137
144
  Logger.br();
138
145
 
146
+ const port = envConfig.port;
147
+ // Validate port
148
+ if (!port || isNaN(Number(port)) || Number(port) < 1 || Number(port) > 65535) {
149
+ throw new Error(`Invalid port for environment '${environment}': ${port}. Must be between 1 and 65535.`);
150
+ }
151
+
139
152
  services.push({
140
153
  name: `${config.name}-${environment}`,
141
- port: envConfig.port,
154
+ port: Number(port),
142
155
  isDefault: true,
143
156
  });
144
157
 
@@ -247,7 +260,7 @@ EOF`);
247
260
 
248
261
  // Setup SSL if domains are configured
249
262
  if (shouldSetupSsl && domains.length > 0 && sslEmail) {
250
- await setupSSL(ssh, domains, sslEmail, environment);
263
+ await setupSSL(ssh, host, domains, sslEmail, environment);
251
264
  }
252
265
 
253
266
  // Success summary
@@ -293,6 +306,7 @@ EOF`);
293
306
  */
294
307
  async function setupSSL(
295
308
  ssh: SSHConnection,
309
+ host: string,
296
310
  domains: string[],
297
311
  email: string,
298
312
  environment: string
@@ -371,7 +385,7 @@ async function setupSSL(
371
385
  Logger.warn('SSL expansion failed');
372
386
  Logger.info('You can manually run certbot with --expand:');
373
387
  const manualDomainFlags = domains.map(d => `-d ${d}`).join(' ');
374
- Logger.command(`ssh ${ssh} "sudo certbot --nginx ${manualDomainFlags} --expand"`);
388
+ Logger.command(`ssh ${host} "sudo certbot --nginx ${manualDomainFlags} --expand"`);
375
389
  return;
376
390
  }
377
391
  } else {
@@ -416,7 +430,7 @@ async function setupSSL(
416
430
  Logger.warn('SSL setup failed, but nginx is still configured for HTTP');
417
431
  Logger.info('You can manually run certbot later:');
418
432
  const manualDomainFlags = domains.map(d => `-d ${d}`).join(' ');
419
- Logger.command(`ssh ${ssh} "sudo certbot --nginx ${manualDomainFlags}"`);
433
+ Logger.command(`ssh ${host} "sudo certbot --nginx ${manualDomainFlags}"`);
420
434
  return;
421
435
  }
422
436
  }
@@ -66,9 +66,10 @@ async function fetchLogs(
66
66
  const pm2 = adapter.getProcessManager();
67
67
 
68
68
  const spinner = ora('Connecting to server...').start();
69
+ let ssh: { disconnect: () => void } | null = null;
69
70
 
70
71
  try {
71
- const ssh = await createSSHConnection(server);
72
+ ssh = await createSSHConnection(server);
72
73
  spinner.succeed('Connected');
73
74
 
74
75
  Logger.br();
@@ -82,10 +83,10 @@ async function fetchLogs(
82
83
  }
83
84
 
84
85
  // Execute logs command
85
- const result = await ssh.exec(logsCmd, { streaming: !options.output });
86
+ const result = await (ssh as any).exec(logsCmd, { streaming: !options.output });
86
87
 
87
88
  if (result.exitCode !== 0) {
88
- Logger.error('Failed to fetch logs');
89
+ throw new Error(result.stderr || 'Failed to fetch logs');
89
90
  }
90
91
 
91
92
  // Save to file if requested
@@ -97,10 +98,13 @@ async function fetchLogs(
97
98
  Logger.kv('Size', `${(result.stdout.length / 1024).toFixed(2)} KB`);
98
99
  }
99
100
 
100
- ssh.disconnect();
101
101
  } catch (error) {
102
102
  spinner.fail('Failed to get logs');
103
103
  Logger.error(error instanceof Error ? error.message : String(error));
104
104
  process.exit(1);
105
+ } finally {
106
+ if (ssh) {
107
+ ssh.disconnect();
108
+ }
105
109
  }
106
110
  }
@@ -28,14 +28,15 @@ export async function rollbackCommand(
28
28
  const serviceName = `${config.name}-${environment}`;
29
29
 
30
30
  const spinner = ora('Connecting to server...').start();
31
+ let ssh: { disconnect: () => void } | null = null;
31
32
 
32
33
  try {
33
- const ssh = await createSSHConnection(envConfig.server);
34
+ ssh = await createSSHConnection(envConfig.server);
34
35
  spinner.succeed('Connected');
35
36
 
36
37
  Logger.br();
37
38
 
38
- const backupManager = new BackupManager(ssh, remoteDir);
39
+ const backupManager = new BackupManager(ssh as any, remoteDir);
39
40
 
40
41
  // List available backups
41
42
  const listSpinner = ora('Fetching available backups...').start();
@@ -44,7 +45,6 @@ export async function rollbackCommand(
44
45
 
45
46
  if (backups.length === 0) {
46
47
  Logger.warn('No backups available');
47
- ssh.disconnect();
48
48
  return;
49
49
  }
50
50
 
@@ -96,7 +96,6 @@ export async function rollbackCommand(
96
96
 
97
97
  if (!confirm) {
98
98
  Logger.info('Rollback cancelled');
99
- ssh.disconnect();
100
99
  return;
101
100
  }
102
101
 
@@ -112,7 +111,7 @@ export async function rollbackCommand(
112
111
  const pm2 = adapter.getProcessManager();
113
112
 
114
113
  const reloadSpinner = ora('Reloading service...').start();
115
- const reloadResult = await ssh.exec(
114
+ const reloadResult = await (ssh as any).exec(
116
115
  pm2.generateReloadCommand(serviceName),
117
116
  { cwd: remoteDir }
118
117
  );
@@ -133,10 +132,13 @@ export async function rollbackCommand(
133
132
  Logger.command(`hostfn status ${environment}`);
134
133
  Logger.br();
135
134
 
136
- ssh.disconnect();
137
135
  } catch (error) {
138
136
  spinner.fail('Rollback failed');
139
137
  Logger.error(error instanceof Error ? error.message : String(error));
140
138
  process.exit(1);
139
+ } finally {
140
+ if (ssh) {
141
+ ssh.disconnect();
142
+ }
141
143
  }
142
144
  }
@@ -75,15 +75,16 @@ async function showServiceStatus(
75
75
  displayName?: string
76
76
  ): Promise<void> {
77
77
  const spinner = ora(`Fetching status for ${displayName || serviceName}...`).start();
78
+ let ssh: { disconnect: () => void } | null = null;
78
79
 
79
80
  try {
80
- const ssh = await createSSHConnection(server);
81
+ ssh = await createSSHConnection(server);
81
82
  spinner.succeed('Connected');
82
83
 
83
84
  Logger.br();
84
85
 
85
86
  // Get PM2 service details
86
- const result = await ssh.exec(`pm2 jlist | jq '.[] | select(.name=="${serviceName}")'`);
87
+ const result = await (ssh as any).exec(`pm2 jlist | jq '.[] | select(.name=="${serviceName}")'`);
87
88
 
88
89
  if (result.stdout.trim()) {
89
90
  const service = JSON.parse(result.stdout);
@@ -129,10 +130,13 @@ async function showServiceStatus(
129
130
  Logger.info('Has the service been deployed?');
130
131
  }
131
132
 
132
- ssh.disconnect();
133
133
  } catch (error) {
134
134
  spinner.fail('Failed to get status');
135
135
  throw error;
136
+ } finally {
137
+ if (ssh) {
138
+ ssh.disconnect();
139
+ }
136
140
  }
137
141
  }
138
142