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,106 @@
1
+ import ora from 'ora';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import { Logger } from '../utils/logger.js';
5
+ import { ConfigLoader } from '../config/loader.js';
6
+ import { createSSHConnection } from '../core/ssh.js';
7
+ import { RuntimeRegistry } from '../runtimes/registry.js';
8
+ import { Runtime } from '../config/schema.js';
9
+
10
+ interface LogsOptions {
11
+ lines: string;
12
+ errors: boolean;
13
+ follow?: boolean;
14
+ output?: string;
15
+ service?: string;
16
+ }
17
+
18
+ export async function logsCommand(
19
+ environment: string,
20
+ options: LogsOptions
21
+ ): Promise<void> {
22
+ Logger.header(`Logs - ${environment}`);
23
+
24
+ // Load configuration
25
+ const config = ConfigLoader.load();
26
+ const envConfig = config.environments[environment];
27
+
28
+ if (!envConfig) {
29
+ throw new Error(`Environment '${environment}' not found`);
30
+ }
31
+
32
+ // Handle multi-service logs
33
+ if (config.services && Object.keys(config.services).length > 0) {
34
+ if (!options.service) {
35
+ throw new Error(
36
+ 'Multi-service deployment detected. Please specify --service <name>\n' +
37
+ `Available services: ${Object.keys(config.services).join(', ')}`
38
+ );
39
+ }
40
+
41
+ if (!config.services[options.service]) {
42
+ throw new Error(
43
+ `Service '${options.service}' not found\n` +
44
+ `Available: ${Object.keys(config.services).join(', ')}`
45
+ );
46
+ }
47
+
48
+ const serviceConfig = config.services[options.service];
49
+ const serviceServer = serviceConfig.server || envConfig.server;
50
+ const serviceName = `${config.name}-${options.service}-${environment}`;
51
+ await fetchLogs(serviceName, serviceServer, config.runtime, options);
52
+ return;
53
+ }
54
+
55
+ const serviceName = `${config.name}-${environment}`;
56
+ await fetchLogs(serviceName, envConfig.server, config.runtime, options);
57
+ }
58
+
59
+ async function fetchLogs(
60
+ serviceName: string,
61
+ server: string,
62
+ runtime: Runtime,
63
+ options: LogsOptions
64
+ ): Promise<void> {
65
+ const adapter = RuntimeRegistry.get(runtime);
66
+ const pm2 = adapter.getProcessManager();
67
+
68
+ const spinner = ora('Connecting to server...').start();
69
+
70
+ try {
71
+ const ssh = await createSSHConnection(server);
72
+ spinner.succeed('Connected');
73
+
74
+ Logger.br();
75
+
76
+ // Generate logs command
77
+ let logsCmd = pm2.generateLogsCommand(serviceName, parseInt(options.lines));
78
+
79
+ // Add error filtering if requested
80
+ if (options.errors) {
81
+ logsCmd = `${logsCmd} 2>&1 | grep -E "(error|Error|ERROR|fail|Fail|FAIL|exception|Exception)"`;
82
+ }
83
+
84
+ // Execute logs command
85
+ const result = await ssh.exec(logsCmd, { streaming: !options.output });
86
+
87
+ if (result.exitCode !== 0) {
88
+ Logger.error('Failed to fetch logs');
89
+ }
90
+
91
+ // Save to file if requested
92
+ if (options.output) {
93
+ const outputPath = path.resolve(process.cwd(), options.output);
94
+ fs.writeFileSync(outputPath, result.stdout);
95
+ Logger.success(`Logs saved to: ${outputPath}`);
96
+ Logger.kv('Lines', result.stdout.split('\n').length.toString());
97
+ Logger.kv('Size', `${(result.stdout.length / 1024).toFixed(2)} KB`);
98
+ }
99
+
100
+ ssh.disconnect();
101
+ } catch (error) {
102
+ spinner.fail('Failed to get logs');
103
+ Logger.error(error instanceof Error ? error.message : String(error));
104
+ process.exit(1);
105
+ }
106
+ }
@@ -0,0 +1,142 @@
1
+ import ora from 'ora';
2
+ import inquirer from 'inquirer';
3
+ import { Logger } from '../utils/logger.js';
4
+ import { ConfigLoader } from '../config/loader.js';
5
+ import { createSSHConnection } from '../core/ssh.js';
6
+ import { BackupManager } from '../core/backup.js';
7
+ import { RuntimeRegistry } from '../runtimes/registry.js';
8
+
9
+ interface RollbackOptions {
10
+ to?: string;
11
+ }
12
+
13
+ export async function rollbackCommand(
14
+ environment: string,
15
+ options: RollbackOptions
16
+ ): Promise<void> {
17
+ Logger.header(`Rollback - ${environment}`);
18
+
19
+ // Load configuration
20
+ const config = ConfigLoader.load();
21
+ const envConfig = config.environments[environment];
22
+
23
+ if (!envConfig) {
24
+ throw new Error(`Environment '${environment}' not found`);
25
+ }
26
+
27
+ const remoteDir = `/var/www/${config.name}-${environment}`;
28
+ const serviceName = `${config.name}-${environment}`;
29
+
30
+ const spinner = ora('Connecting to server...').start();
31
+
32
+ try {
33
+ const ssh = await createSSHConnection(envConfig.server);
34
+ spinner.succeed('Connected');
35
+
36
+ Logger.br();
37
+
38
+ const backupManager = new BackupManager(ssh, remoteDir);
39
+
40
+ // List available backups
41
+ const listSpinner = ora('Fetching available backups...').start();
42
+ const backups = await backupManager.list();
43
+ listSpinner.succeed(`Found ${backups.length} backup(s)`);
44
+
45
+ if (backups.length === 0) {
46
+ Logger.warn('No backups available');
47
+ ssh.disconnect();
48
+ return;
49
+ }
50
+
51
+ Logger.br();
52
+
53
+ let backupToRestore: string;
54
+
55
+ if (options.to) {
56
+ // Use specified backup
57
+ if (!backups.includes(options.to)) {
58
+ throw new Error(`Backup not found: ${options.to}`);
59
+ }
60
+ backupToRestore = options.to;
61
+ } else {
62
+ // Interactive selection
63
+ Logger.section('Available Backups');
64
+ backups.slice(0, 10).forEach((backup, idx) => {
65
+ Logger.log(` ${idx + 1}. ${backup}`);
66
+ });
67
+ Logger.br();
68
+
69
+ const { selectedBackup } = await inquirer.prompt([
70
+ {
71
+ type: 'list',
72
+ name: 'selectedBackup',
73
+ message: 'Select backup to restore:',
74
+ choices: backups.slice(0, 10).map((b, idx) => ({
75
+ name: `${idx + 1}. ${b}${idx === 0 ? ' (latest)' : ''}`,
76
+ value: b,
77
+ })),
78
+ },
79
+ ]);
80
+
81
+ backupToRestore = selectedBackup;
82
+ }
83
+
84
+ // Confirm rollback
85
+ Logger.br();
86
+ Logger.warn(`This will rollback to: ${backupToRestore}`);
87
+
88
+ const { confirm } = await inquirer.prompt([
89
+ {
90
+ type: 'confirm',
91
+ name: 'confirm',
92
+ message: 'Are you sure you want to rollback?',
93
+ default: false,
94
+ },
95
+ ]);
96
+
97
+ if (!confirm) {
98
+ Logger.info('Rollback cancelled');
99
+ ssh.disconnect();
100
+ return;
101
+ }
102
+
103
+ Logger.br();
104
+
105
+ // Perform rollback
106
+ const rollbackSpinner = ora('Restoring backup...').start();
107
+ await backupManager.restore(backupToRestore);
108
+ rollbackSpinner.succeed('Backup restored');
109
+
110
+ // Reload PM2
111
+ const adapter = RuntimeRegistry.get(config.runtime);
112
+ const pm2 = adapter.getProcessManager();
113
+
114
+ const reloadSpinner = ora('Reloading service...').start();
115
+ const reloadResult = await ssh.exec(
116
+ pm2.generateReloadCommand(serviceName),
117
+ { cwd: remoteDir }
118
+ );
119
+
120
+ if (reloadResult.exitCode !== 0) {
121
+ reloadSpinner.fail('Service reload failed');
122
+ throw new Error(`PM2 reload failed: ${reloadResult.stderr}`);
123
+ }
124
+ reloadSpinner.succeed('Service reloaded');
125
+
126
+ Logger.br();
127
+ Logger.success('Rollback completed successfully!');
128
+ Logger.br();
129
+ Logger.kv('Restored backup', backupToRestore);
130
+ Logger.kv('Service', serviceName);
131
+ Logger.br();
132
+ Logger.info('Check status:');
133
+ Logger.command(`hostfn status ${environment}`);
134
+ Logger.br();
135
+
136
+ ssh.disconnect();
137
+ } catch (error) {
138
+ spinner.fail('Rollback failed');
139
+ Logger.error(error instanceof Error ? error.message : String(error));
140
+ process.exit(1);
141
+ }
142
+ }
@@ -0,0 +1,131 @@
1
+ import ora from 'ora';
2
+ import chalk from 'chalk';
3
+ import { Logger } from '../../utils/logger.js';
4
+ import { createSSHConnection } from '../../core/ssh.js';
5
+
6
+ export async function serverInfoCommand(host: string): Promise<void> {
7
+ Logger.header('Server Information');
8
+
9
+ Logger.kv('Host', host);
10
+ Logger.br();
11
+
12
+ const spinner = ora('Connecting to server...').start();
13
+
14
+ try {
15
+ const ssh = await createSSHConnection(host);
16
+ spinner.succeed('Connected');
17
+
18
+ Logger.br();
19
+
20
+ // Get system information
21
+ const infoSpinner = ora('Gathering system information...').start();
22
+
23
+ const [
24
+ osInfo,
25
+ nodeVersion,
26
+ pm2Version,
27
+ nginxStatus,
28
+ diskSpace,
29
+ memoryInfo,
30
+ pm2List,
31
+ ] = await Promise.all([
32
+ ssh.exec('cat /etc/os-release | grep PRETTY_NAME | cut -d= -f2 | tr -d \'"\''),
33
+ ssh.exec('source ~/.nvm/nvm.sh && node --version 2>/dev/null || echo "not installed"'),
34
+ ssh.exec('pm2 --version 2>/dev/null || echo "not installed"'),
35
+ ssh.exec('systemctl is-active nginx 2>/dev/null || echo "not running"'),
36
+ ssh.exec('df -h / | tail -1'),
37
+ ssh.exec('free -h'),
38
+ ssh.exec('pm2 jlist 2>/dev/null || echo "[]"'),
39
+ ]);
40
+
41
+ infoSpinner.succeed('Information gathered');
42
+
43
+ Logger.br();
44
+
45
+ // Display OS information
46
+ Logger.section('System');
47
+ Logger.kv('OS', osInfo.stdout.trim() || 'Unknown');
48
+
49
+ // Display disk space
50
+ const diskParts = diskSpace.stdout.trim().split(/\s+/);
51
+ if (diskParts.length >= 5) {
52
+ Logger.kv('Disk', `${diskParts[2]} used / ${diskParts[1]} total (${diskParts[4]} used)`);
53
+ }
54
+
55
+ // Display memory
56
+ const memLines = memoryInfo.stdout.trim().split('\n');
57
+ if (memLines.length >= 2) {
58
+ const memParts = memLines[1].split(/\s+/);
59
+ if (memParts.length >= 3) {
60
+ Logger.kv('Memory', `${memParts[2]} used / ${memParts[1]} total`);
61
+ }
62
+ }
63
+
64
+ Logger.br();
65
+
66
+ // Display runtime information
67
+ Logger.section('Runtime');
68
+ const node = nodeVersion.stdout.trim();
69
+ Logger.kv('Node.js', node === 'not installed' ? chalk.red(node) : chalk.green(node));
70
+
71
+ const pm2 = pm2Version.stdout.trim();
72
+ Logger.kv('PM2', pm2 === 'not installed' ? chalk.red(pm2) : chalk.green(pm2));
73
+
74
+ const nginx = nginxStatus.stdout.trim();
75
+ Logger.kv('Nginx', nginx === 'active' ? chalk.green(nginx) : chalk.yellow(nginx));
76
+
77
+ Logger.br();
78
+
79
+ // Display running services
80
+ Logger.section('Services');
81
+
82
+ try {
83
+ const services = JSON.parse(pm2List.stdout || '[]');
84
+
85
+ if (services.length === 0) {
86
+ Logger.info('No PM2 services running');
87
+ } else {
88
+ for (const service of services) {
89
+ const status = service.pm2_env?.status || 'unknown';
90
+ const memory = service.monit?.memory
91
+ ? `${Math.round(service.monit.memory / 1024 / 1024)}MB`
92
+ : 'N/A';
93
+ const cpu = service.monit?.cpu !== undefined
94
+ ? `${service.monit.cpu}%`
95
+ : 'N/A';
96
+ const uptime = service.pm2_env?.pm_uptime
97
+ ? formatUptime(Date.now() - service.pm2_env.pm_uptime)
98
+ : 'N/A';
99
+
100
+ const statusColor = status === 'online' ? chalk.green : chalk.red;
101
+
102
+ Logger.log(` ${chalk.bold(service.name)}`);
103
+ Logger.log(` Status: ${statusColor(status)}`);
104
+ Logger.log(` Memory: ${memory} CPU: ${cpu} Uptime: ${uptime}`);
105
+ Logger.br();
106
+ }
107
+ }
108
+ } catch {
109
+ Logger.warn('Could not parse PM2 service list');
110
+ }
111
+
112
+ ssh.disconnect();
113
+
114
+ } catch (error) {
115
+ spinner.fail('Failed to get server information');
116
+ Logger.error(error instanceof Error ? error.message : String(error));
117
+ process.exit(1);
118
+ }
119
+ }
120
+
121
+ function formatUptime(ms: number): string {
122
+ const seconds = Math.floor(ms / 1000);
123
+ const minutes = Math.floor(seconds / 60);
124
+ const hours = Math.floor(minutes / 60);
125
+ const days = Math.floor(hours / 24);
126
+
127
+ if (days > 0) return `${days}d ${hours % 24}h`;
128
+ if (hours > 0) return `${hours}h ${minutes % 60}m`;
129
+ if (minutes > 0) return `${minutes}m`;
130
+ return `${seconds}s`;
131
+ }
@@ -0,0 +1,200 @@
1
+ import { writeFileSync } from 'fs';
2
+ import { tmpdir } from 'os';
3
+ import { join } from 'path';
4
+ import ora from 'ora';
5
+ import inquirer from 'inquirer';
6
+ import { Logger } from '../../utils/logger.js';
7
+ import { ConfigLoader } from '../../config/loader.js';
8
+ import { RuntimeRegistry } from '../../runtimes/registry.js';
9
+ import { createSSHConnection } from '../../core/ssh.js';
10
+ import { validateSSHConnection, validatePort, validateNodeVersion } from '../../utils/validation.js';
11
+
12
+ interface SetupOptions {
13
+ env: string;
14
+ nodeVersion: string;
15
+ port: string;
16
+ redis: boolean;
17
+ password?: string;
18
+ execute?: boolean;
19
+ }
20
+
21
+ export async function serverSetupCommand(
22
+ host: string,
23
+ options: SetupOptions
24
+ ): Promise<void> {
25
+ Logger.header('Server Setup');
26
+
27
+ // Validate inputs
28
+ if (!validateSSHConnection(host)) {
29
+ process.exit(1);
30
+ }
31
+
32
+ if (!validatePort(parseInt(options.port))) {
33
+ process.exit(1);
34
+ }
35
+
36
+ if (!validateNodeVersion(options.nodeVersion)) {
37
+ process.exit(1);
38
+ }
39
+
40
+ Logger.kv('Host', host);
41
+ Logger.kv('Environment', options.env);
42
+ Logger.kv('Port', options.port);
43
+ Logger.kv('Redis', options.redis ? 'Yes' : 'No');
44
+ Logger.br();
45
+
46
+ // Load config to detect runtime (or use default Node.js)
47
+ let runtime = 'nodejs';
48
+ try {
49
+ if (ConfigLoader.exists()) {
50
+ const config = ConfigLoader.load();
51
+ runtime = config.runtime;
52
+ }
53
+ } catch {
54
+ // If no config, assume Node.js
55
+ Logger.warn('No config found, assuming Node.js runtime');
56
+ }
57
+
58
+ // Get runtime adapter
59
+ const adapter = RuntimeRegistry.get(runtime as any);
60
+
61
+ // Generate setup script
62
+ const spinner = ora('Generating setup script...').start();
63
+
64
+ const setupScript = adapter.generateSetupScript(options.nodeVersion, {
65
+ port: parseInt(options.port),
66
+ environment: options.env,
67
+ installRedis: options.redis,
68
+ });
69
+
70
+ // Write script to temp file
71
+ const scriptPath = join(tmpdir(), 'hostfn-setup.sh');
72
+ writeFileSync(scriptPath, setupScript, { mode: 0o755 });
73
+
74
+ spinner.succeed('Setup script generated');
75
+
76
+ Logger.br();
77
+
78
+ // Ask if user wants to execute now
79
+ const { executeNow } = await inquirer.prompt([
80
+ {
81
+ type: 'confirm',
82
+ name: 'executeNow',
83
+ message: 'Execute setup on server now?',
84
+ default: true,
85
+ },
86
+ ]);
87
+
88
+ if (!executeNow) {
89
+ Logger.br();
90
+ Logger.info('Setup script saved at: ' + scriptPath);
91
+ Logger.br();
92
+ Logger.info('Run manually with:');
93
+ Logger.command(`scp ${scriptPath} ${host}:~/hostfn-setup.sh`);
94
+ Logger.command(`ssh ${host} 'bash ~/hostfn-setup.sh'`);
95
+ return;
96
+ }
97
+
98
+ Logger.br();
99
+ Logger.section('Executing Setup on Server');
100
+ Logger.br();
101
+
102
+ const connectSpinner = ora('Connecting to server...').start();
103
+
104
+ try {
105
+ // Connect to server
106
+ const ssh = await createSSHConnection(host, {
107
+ password: options.password,
108
+ });
109
+
110
+ connectSpinner.succeed('Connected to server');
111
+
112
+ // Upload setup script
113
+ const uploadSpinner = ora('Uploading setup script...').start();
114
+ await ssh.uploadFile(scriptPath, '/tmp/hostfn-setup.sh');
115
+ uploadSpinner.succeed('Setup script uploaded');
116
+
117
+ // Make script executable
118
+ await ssh.exec('chmod +x /tmp/hostfn-setup.sh');
119
+
120
+ // Execute setup script with streaming output
121
+ Logger.br();
122
+ Logger.info('Executing setup (this may take several minutes)...');
123
+ Logger.br();
124
+
125
+ const result = await ssh.exec('bash /tmp/hostfn-setup.sh', {
126
+ streaming: true,
127
+ skipNvmSetup: true, // Setup script installs nvm, so skip the nvm check
128
+ });
129
+
130
+ Logger.br();
131
+
132
+ if (result.exitCode === 0) {
133
+ Logger.success('Server setup completed successfully!');
134
+ Logger.br();
135
+ Logger.info('Your server is ready for deployments.');
136
+ Logger.br();
137
+ Logger.info('Next steps:');
138
+ Logger.br();
139
+ Logger.info('1. Set environment variables (if needed):');
140
+ Logger.command(`hostfn env push ${options.env} .env`);
141
+ Logger.info(' Or set individual variables:');
142
+ Logger.command(`hostfn env set ${options.env} KEY value`);
143
+ Logger.br();
144
+ Logger.info('2. Deploy your application:');
145
+ Logger.command(`hostfn deploy ${options.env}`);
146
+ } else {
147
+ Logger.error('Setup failed with exit code: ' + result.exitCode);
148
+ Logger.br();
149
+
150
+ // Try to download the log file from the server
151
+ try {
152
+ const localLogPath = join(tmpdir(), `hostfn-setup-error-${Date.now()}.log`);
153
+ await ssh.downloadFile('/tmp/hostfn-setup.log', localLogPath);
154
+ Logger.info('Setup log downloaded to: ' + localLogPath);
155
+ Logger.br();
156
+
157
+ // Read and display the last 50 lines of the log
158
+ const logContent = require('fs').readFileSync(localLogPath, 'utf-8');
159
+ const lines = logContent.split('\n');
160
+ const relevantLines = lines.slice(-50).join('\n');
161
+
162
+ Logger.error('Last 50 lines of setup log:');
163
+ Logger.log(relevantLines);
164
+ } catch (logError) {
165
+ Logger.warn('Could not download setup log from server');
166
+
167
+ if (result.stderr) {
168
+ Logger.br();
169
+ Logger.error('Stderr output:');
170
+ Logger.log(result.stderr);
171
+ }
172
+ if (result.stdout) {
173
+ Logger.br();
174
+ Logger.error('Stdout output (last 1000 chars):');
175
+ Logger.log(result.stdout.slice(-1000));
176
+ }
177
+ }
178
+
179
+ Logger.br();
180
+ Logger.info('To inspect the full log on the server, run:');
181
+ Logger.command(`ssh ${host} 'cat /tmp/hostfn-setup.log'`);
182
+
183
+ process.exit(1);
184
+ }
185
+
186
+ // Cleanup
187
+ await ssh.exec('rm /tmp/hostfn-setup.sh').catch(() => {});
188
+ ssh.disconnect();
189
+
190
+ } catch (error) {
191
+ connectSpinner.fail('Setup failed');
192
+ Logger.error(error instanceof Error ? error.message : String(error));
193
+ Logger.br();
194
+ Logger.info('Setup script saved at: ' + scriptPath);
195
+ Logger.info('You can try running it manually:');
196
+ Logger.command(`scp ${scriptPath} ${host}:~/hostfn-setup.sh`);
197
+ Logger.command(`ssh ${host} 'bash ~/hostfn-setup.sh'`);
198
+ process.exit(1);
199
+ }
200
+ }