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 +10 -13
- package/packages/cli/src/__tests__/runtimes/pm2.test.ts +2 -2
- package/packages/cli/src/commands/deploy.ts +39 -23
- package/packages/cli/src/commands/env.ts +19 -6
- package/packages/cli/src/commands/expose.ts +20 -6
- package/packages/cli/src/commands/logs.ts +8 -4
- package/packages/cli/src/commands/rollback.ts +8 -6
- package/packages/cli/src/commands/status.ts +7 -3
package/package.json
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hostfn",
|
|
3
|
-
"version": "0.1.
|
|
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
|
-
"
|
|
21
|
-
"
|
|
22
|
-
|
|
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
|
-
"
|
|
33
|
-
"
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
"
|
|
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
|
-
|
|
26
|
-
expect(result).toContain(
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
backupSpinner.
|
|
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}
|
|
140
|
+
// Append new variable safely
|
|
141
|
+
await ssh.exec(`echo "${key}=\\"$(cat ${tempFile})\\"" >> ${envFile}`);
|
|
133
142
|
} else {
|
|
134
|
-
// Update existing variable
|
|
135
|
-
|
|
136
|
-
|
|
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
|
|
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:
|
|
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:
|
|
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 ${
|
|
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 ${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|