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,438 @@
1
+ import ora from 'ora';
2
+ import { Logger } from '../utils/logger.js';
3
+ import { ConfigLoader } from '../config/loader.js';
4
+ import { createSSHConnection } from '../core/ssh.js';
5
+ import { NginxConfigGenerator, type NginxServiceConfig } from '../core/nginx.js';
6
+ import type { SSHConnection } from '../core/ssh.js';
7
+
8
+ export async function exposeCommand(environment: string, options: {
9
+ host?: string;
10
+ skipSsl?: boolean;
11
+ force?: boolean;
12
+ }) {
13
+ Logger.header('Expose Services via Nginx');
14
+
15
+ // Load configuration
16
+ const config = ConfigLoader.load();
17
+ const envConfig = config.environments[environment];
18
+
19
+ if (!envConfig) {
20
+ throw new Error(
21
+ `Environment '${environment}' not found in configuration\n` +
22
+ `Available: ${Object.keys(config.environments).join(', ')}`
23
+ );
24
+ }
25
+
26
+ const host = options.host || envConfig.server;
27
+ const domainConfig = envConfig.domain;
28
+ // Handle both single domain string and array of domains
29
+ const domains = domainConfig ? (Array.isArray(domainConfig) ? domainConfig : [domainConfig]) : [];
30
+ const sslEmail = envConfig.sslEmail;
31
+ const shouldSetupSsl = !options.skipSsl && domains.length > 0 && !!sslEmail;
32
+
33
+ Logger.kv('Environment', environment);
34
+ Logger.kv('Server', host);
35
+ Logger.kv('Domain(s)', domains.length > 0 ? domains.join(', ') : 'None (using IP)');
36
+ Logger.kv('SSL', shouldSetupSsl ? `Yes (${sslEmail})` : 'No');
37
+ Logger.br();
38
+
39
+ let ssh: SSHConnection | null = null;
40
+
41
+ try {
42
+ // Connect to server
43
+ const connectSpinner = ora('Connecting to server...').start();
44
+ ssh = await createSSHConnection(host);
45
+ connectSpinner.succeed('Connected to server');
46
+ Logger.br();
47
+
48
+ // Check if nginx is installed
49
+ Logger.section('Checking Prerequisites');
50
+ Logger.br();
51
+
52
+ const nginxCheck = await ssh.exec('command -v nginx || echo "missing"');
53
+ if (nginxCheck.stdout.includes('missing')) {
54
+ throw new Error(
55
+ 'Nginx is not installed on the server.\n' +
56
+ 'Run: hostfn server setup <host> --env ' + environment
57
+ );
58
+ }
59
+
60
+ const nginxSpinner = ora('Checking nginx installation...').start();
61
+ nginxSpinner.succeed('Nginx is installed');
62
+
63
+ if (shouldSetupSsl) {
64
+ const certbotCheck = await ssh.exec('command -v certbot || echo "missing"');
65
+ if (certbotCheck.stdout.includes('missing')) {
66
+ throw new Error(
67
+ 'Certbot is not installed on the server.\n' +
68
+ 'Run: hostfn server setup <host> --env ' + environment
69
+ );
70
+ }
71
+ const certbotSpinner = ora('Checking certbot installation...').start();
72
+ certbotSpinner.succeed('Certbot is installed');
73
+ }
74
+
75
+ Logger.br();
76
+
77
+ // Determine nginx config system (sites-available vs conf.d)
78
+ const sitesAvailableCheck = await ssh.exec('test -d /etc/nginx/sites-available && echo "exists"');
79
+ const useSitesAvailable = sitesAvailableCheck.stdout.trim() === 'exists';
80
+
81
+ // Disable default site if using sites-available and domain is configured
82
+ // This prevents conflicts when certbot modifies the default site
83
+ if (useSitesAvailable && domains.length > 0) {
84
+ const defaultSiteCheck = await ssh.exec('test -L /etc/nginx/sites-enabled/default && echo "exists"');
85
+ if (defaultSiteCheck.stdout.trim() === 'exists') {
86
+ const disableSpinner = ora('Disabling default nginx site...').start();
87
+ await ssh.exec('sudo unlink /etc/nginx/sites-enabled/default');
88
+ disableSpinner.succeed('Default site disabled');
89
+ }
90
+ }
91
+
92
+ // Check if SSL certificates already exist for all domains
93
+ let certsExist = false;
94
+ if (shouldSetupSsl && domains.length > 0) {
95
+ // Check if certificate exists for the primary domain (first in the list)
96
+ const primaryDomain = domains[0];
97
+ const certCheck = await ssh.exec(`sudo test -d /etc/letsencrypt/live/${primaryDomain} && echo "exists"`);
98
+ certsExist = certCheck.stdout.trim() === 'exists';
99
+ }
100
+
101
+ // Prepare services configuration
102
+ const services: NginxServiceConfig[] = [];
103
+
104
+ if (config.services && Object.keys(config.services).length > 0) {
105
+ // Multiple services (monorepo)
106
+ Logger.section('Configuring Multiple Services');
107
+ Logger.br();
108
+
109
+ for (const [serviceName, serviceConfig] of Object.entries(config.services)) {
110
+ const serviceEnvConfig = {
111
+ ...envConfig,
112
+ server: serviceConfig.server || envConfig.server,
113
+ };
114
+
115
+ // Only include services on this server
116
+ if (serviceEnvConfig.server === host) {
117
+ const isDefault = !serviceConfig.exposePath;
118
+ services.push({
119
+ name: `${config.name}-${serviceName}`,
120
+ port: serviceConfig.port,
121
+ exposePath: serviceConfig.exposePath,
122
+ isDefault,
123
+ });
124
+
125
+ Logger.kv(
126
+ serviceName,
127
+ serviceConfig.exposePath
128
+ ? `Port ${serviceConfig.port} → ${serviceConfig.exposePath}`
129
+ : `Port ${serviceConfig.port} → / (default)`
130
+ );
131
+ }
132
+ }
133
+ Logger.br();
134
+ } else {
135
+ // Single service
136
+ Logger.section('Configuring Single Service');
137
+ Logger.br();
138
+
139
+ services.push({
140
+ name: `${config.name}-${environment}`,
141
+ port: envConfig.port,
142
+ isDefault: true,
143
+ });
144
+
145
+ Logger.kv('Service', `${config.name}-${environment}`);
146
+ Logger.kv('Port', envConfig.port.toString());
147
+ Logger.br();
148
+ }
149
+
150
+ if (services.length === 0) {
151
+ Logger.warn('No services found to expose on this server');
152
+ return;
153
+ }
154
+
155
+ // Generate nginx configuration
156
+ Logger.section('Generating Nginx Configuration');
157
+ Logger.br();
158
+
159
+ // Only enable SSL in nginx config if certificates already exist
160
+ // Certbot will update the config to add SSL when obtaining new certificates
161
+ const enableSslInConfig = shouldSetupSsl && certsExist;
162
+
163
+ if (shouldSetupSsl && !certsExist) {
164
+ Logger.info('SSL certificates not found - will configure after obtaining certificates');
165
+ Logger.br();
166
+ }
167
+
168
+ const nginxConfig = NginxConfigGenerator.generate({
169
+ domain: domains.length > 0 ? domains : undefined,
170
+ ssl: enableSslInConfig,
171
+ services,
172
+ environment,
173
+ });
174
+
175
+ const configPath = NginxConfigGenerator.getConfigPath(environment, useSitesAvailable);
176
+
177
+ Logger.info('Configuration preview:');
178
+ Logger.br();
179
+ Logger.log(nginxConfig);
180
+ Logger.br();
181
+
182
+ // Check if config already exists
183
+ const existsCheck = await ssh.exec(`test -f ${configPath} && echo "exists"`);
184
+ const configExists = existsCheck.stdout.trim() === 'exists';
185
+
186
+ let shouldWrite = !configExists || options.force;
187
+
188
+ // If config exists, check if domain has changed
189
+ if (configExists && !options.force && domains.length > 0) {
190
+ const currentConfigResult = await ssh.exec(`cat ${configPath}`);
191
+ const currentConfig = currentConfigResult.stdout;
192
+
193
+ // Check if the current config has a different server_name or uses catch-all
194
+ const serverNameMatch = currentConfig.match(/server_name\s+([^;]+);/);
195
+ if (serverNameMatch) {
196
+ const currentServerName = serverNameMatch[1].trim();
197
+ const newServerName = domains.join(' ');
198
+ // If current is catch-all (_) or different domain(s), force update
199
+ if (currentServerName === '_' || currentServerName !== newServerName) {
200
+ Logger.info(`Updating server_name from "${currentServerName}" to "${newServerName}"`);
201
+ Logger.br();
202
+ shouldWrite = true;
203
+ }
204
+ }
205
+ }
206
+
207
+ if (configExists && !shouldWrite) {
208
+ Logger.warn(`Configuration already exists at ${configPath}`);
209
+ Logger.info('Use --force to overwrite');
210
+ Logger.br();
211
+ Logger.info('Skipping to SSL setup...');
212
+ Logger.br();
213
+ } else {
214
+ const writeSpinner = ora('Writing nginx configuration...').start();
215
+
216
+ // Write nginx config
217
+ await ssh.exec(`sudo tee ${configPath} > /dev/null << 'EOF'
218
+ ${nginxConfig}
219
+ EOF`);
220
+
221
+ // Enable site if using sites-available
222
+ const enableCmd = NginxConfigGenerator.getEnableCommand(environment, useSitesAvailable);
223
+ if (enableCmd) {
224
+ await ssh.exec(`sudo ${enableCmd}`);
225
+ }
226
+
227
+ writeSpinner.succeed(`Configuration written to ${configPath}`);
228
+ }
229
+
230
+ // Test nginx configuration
231
+ const testSpinner = ora('Testing nginx configuration...').start();
232
+ const testResult = await ssh.exec('sudo nginx -t');
233
+
234
+ if (testResult.exitCode !== 0) {
235
+ testSpinner.fail('Nginx configuration test failed');
236
+ Logger.error(testResult.stderr);
237
+ throw new Error('Invalid nginx configuration');
238
+ }
239
+ testSpinner.succeed('Nginx configuration is valid');
240
+
241
+ // Reload nginx
242
+ const reloadSpinner = ora('Reloading nginx...').start();
243
+ await ssh.exec('sudo systemctl reload nginx');
244
+ reloadSpinner.succeed('Nginx reloaded');
245
+
246
+ Logger.br();
247
+
248
+ // Setup SSL if domains are configured
249
+ if (shouldSetupSsl && domains.length > 0 && sslEmail) {
250
+ await setupSSL(ssh, domains, sslEmail, environment);
251
+ }
252
+
253
+ // Success summary
254
+ Logger.br();
255
+ Logger.success('Services exposed successfully!');
256
+ Logger.br();
257
+
258
+ if (shouldSetupSsl && domains.length > 0) {
259
+ for (const domain of domains) {
260
+ Logger.kv('URL', `https://${domain}`);
261
+ }
262
+ } else if (domains.length > 0) {
263
+ for (const domain of domains) {
264
+ Logger.kv('URL', `http://${domain}`);
265
+ }
266
+ } else {
267
+ const hostname = host.includes('@') ? host.split('@')[1] : host;
268
+ Logger.kv('URL', `http://${hostname}`);
269
+ }
270
+
271
+ Logger.br();
272
+ Logger.info('Service endpoints:');
273
+ for (const service of services) {
274
+ const path = service.exposePath || '/';
275
+ Logger.log(` ${service.name}: ${path}`);
276
+ }
277
+ Logger.br();
278
+
279
+ } catch (error) {
280
+ Logger.br();
281
+ Logger.error('Failed to expose services');
282
+ Logger.error(error instanceof Error ? error.message : String(error));
283
+ process.exit(1);
284
+ } finally {
285
+ if (ssh) {
286
+ ssh.disconnect();
287
+ }
288
+ }
289
+ }
290
+
291
+ /**
292
+ * Setup SSL certificate using certbot
293
+ */
294
+ async function setupSSL(
295
+ ssh: SSHConnection,
296
+ domains: string[],
297
+ email: string,
298
+ environment: string
299
+ ): Promise<void> {
300
+ Logger.section('Setting up SSL Certificates');
301
+ Logger.br();
302
+
303
+ // Use the primary domain (first in the list) for certificate name
304
+ const primaryDomain = domains[0];
305
+ Logger.info(`Primary domain: ${primaryDomain}`);
306
+ if (domains.length > 1) {
307
+ Logger.info(`Additional domains: ${domains.slice(1).join(', ')}`);
308
+ }
309
+ Logger.br();
310
+
311
+ // Check if certificate already exists for primary domain
312
+ const certCheckSpinner = ora('Checking for existing certificate...').start();
313
+ const certCheck = await ssh.exec(`sudo test -d /etc/letsencrypt/live/${primaryDomain} && echo "exists"`);
314
+ const certExists = certCheck.stdout.trim() === 'exists';
315
+
316
+ if (certExists) {
317
+ certCheckSpinner.succeed('Certificate already exists');
318
+
319
+ // Check if certificate contains all required domains
320
+ const checkDomainsSpinner = ora('Checking certificate domains...').start();
321
+ const certInfoResult = await ssh.exec(`sudo certbot certificates --cert-name ${primaryDomain} 2>/dev/null | grep "Domains:" || echo ""`);
322
+ const certDomainsLine = certInfoResult.stdout.trim();
323
+
324
+ let needsExpansion = false;
325
+ if (certDomainsLine) {
326
+ // Extract domains from "Domains: domain1 domain2 domain3"
327
+ const existingDomains = certDomainsLine
328
+ .replace(/^.*Domains:\s*/, '')
329
+ .split(/\s+/)
330
+ .filter(d => d.length > 0);
331
+
332
+ // Check if all required domains are in the certificate
333
+ needsExpansion = domains.some(domain => !existingDomains.includes(domain));
334
+
335
+ if (needsExpansion) {
336
+ checkDomainsSpinner.info(`Certificate missing domains: ${domains.filter(d => !existingDomains.includes(d)).join(', ')}`);
337
+ } else {
338
+ checkDomainsSpinner.succeed('Certificate contains all required domains');
339
+ }
340
+ } else {
341
+ checkDomainsSpinner.warn('Could not verify certificate domains, will attempt expansion');
342
+ needsExpansion = true;
343
+ }
344
+
345
+ if (needsExpansion) {
346
+ // Expand certificate to include all domains
347
+ const expandSpinner = ora(`Expanding certificate to include ${domains.length} domain(s)...`).start();
348
+ expandSpinner.text = 'This may take a minute...';
349
+
350
+ const domainFlags = domains.map(d => `-d ${d}`).join(' ');
351
+ const expandCmd = [
352
+ 'sudo certbot --nginx',
353
+ domainFlags,
354
+ `--email ${email}`,
355
+ '--non-interactive',
356
+ '--agree-tos',
357
+ '--redirect',
358
+ '--expand',
359
+ ].join(' ');
360
+
361
+ const expandResult = await ssh.exec(expandCmd, { streaming: false });
362
+
363
+ if (expandResult.exitCode === 0) {
364
+ expandSpinner.succeed(`Certificate expanded successfully for all ${domains.length} domain(s)`);
365
+ } else {
366
+ expandSpinner.fail('Failed to expand SSL certificate');
367
+ Logger.br();
368
+ Logger.error('Certbot error:');
369
+ Logger.log(expandResult.stderr || expandResult.stdout);
370
+ Logger.br();
371
+ Logger.warn('SSL expansion failed');
372
+ Logger.info('You can manually run certbot with --expand:');
373
+ const manualDomainFlags = domains.map(d => `-d ${d}`).join(' ');
374
+ Logger.command(`ssh ${ssh} "sudo certbot --nginx ${manualDomainFlags} --expand"`);
375
+ return;
376
+ }
377
+ } else {
378
+ // Just renew the existing certificate
379
+ const renewSpinner = ora('Renewing certificate...').start();
380
+ const renewResult = await ssh.exec(`sudo certbot renew --cert-name ${primaryDomain} --nginx --non-interactive`);
381
+
382
+ if (renewResult.exitCode === 0) {
383
+ renewSpinner.succeed('Certificate renewed (or still valid)');
384
+ } else {
385
+ renewSpinner.warn('Certificate renewal skipped (likely still valid)');
386
+ }
387
+ }
388
+ } else {
389
+ certCheckSpinner.succeed('No existing certificate found');
390
+
391
+ // Obtain new certificate for all domains
392
+ const obtainSpinner = ora(`Obtaining SSL certificate for ${domains.length} domain(s)...`).start();
393
+ obtainSpinner.text = 'This may take a minute...';
394
+
395
+ // Build certbot command with all domains
396
+ const domainFlags = domains.map(d => `-d ${d}`).join(' ');
397
+ const certbotCmd = [
398
+ 'sudo certbot --nginx',
399
+ domainFlags,
400
+ `--email ${email}`,
401
+ '--non-interactive',
402
+ '--agree-tos',
403
+ '--redirect',
404
+ ].join(' ');
405
+
406
+ const certbotResult = await ssh.exec(certbotCmd, { streaming: false });
407
+
408
+ if (certbotResult.exitCode === 0) {
409
+ obtainSpinner.succeed(`SSL certificate obtained successfully for all ${domains.length} domain(s)`);
410
+ } else {
411
+ obtainSpinner.fail('Failed to obtain SSL certificate');
412
+ Logger.br();
413
+ Logger.error('Certbot error:');
414
+ Logger.log(certbotResult.stderr || certbotResult.stdout);
415
+ Logger.br();
416
+ Logger.warn('SSL setup failed, but nginx is still configured for HTTP');
417
+ Logger.info('You can manually run certbot later:');
418
+ const manualDomainFlags = domains.map(d => `-d ${d}`).join(' ');
419
+ Logger.command(`ssh ${ssh} "sudo certbot --nginx ${manualDomainFlags}"`);
420
+ return;
421
+ }
422
+ }
423
+
424
+ // Verify SSL is working
425
+ const verifySpinner = ora('Verifying SSL configuration...').start();
426
+ const sslCheck = await ssh.exec(`sudo nginx -t`);
427
+
428
+ if (sslCheck.exitCode === 0) {
429
+ verifySpinner.succeed('SSL configuration verified');
430
+ } else {
431
+ verifySpinner.warn('SSL verification had warnings (may still work)');
432
+ }
433
+
434
+ Logger.br();
435
+ Logger.success('SSL certificate configured!');
436
+ Logger.info('Auto-renewal is enabled via certbot');
437
+ Logger.br();
438
+ }
@@ -0,0 +1,192 @@
1
+ import { writeFileSync } from 'fs';
2
+ import inquirer from 'inquirer';
3
+ import ora from 'ora';
4
+ import { Logger } from '../utils/logger.js';
5
+ import { ConfigLoader } from '../config/loader.js';
6
+ import { RuntimeRegistry } from '../runtimes/registry.js';
7
+ import { HostfnConfig, DEFAULT_CONFIG } from '../config/schema.js';
8
+
9
+ export async function initCommand(): Promise<void> {
10
+ Logger.header('Initialize hostfn');
11
+
12
+ // Check if config already exists
13
+ if (ConfigLoader.exists()) {
14
+ const { overwrite } = await inquirer.prompt([
15
+ {
16
+ type: 'confirm',
17
+ name: 'overwrite',
18
+ message: 'Configuration file already exists. Overwrite?',
19
+ default: false,
20
+ },
21
+ ]);
22
+
23
+ if (!overwrite) {
24
+ Logger.info('Initialization cancelled');
25
+ return;
26
+ }
27
+ }
28
+
29
+ // Detect runtime
30
+ const spinner = ora('Detecting project runtime...').start();
31
+ const detection = await RuntimeRegistry.detect(process.cwd());
32
+
33
+ if (!detection) {
34
+ spinner.fail('Could not detect project runtime');
35
+ Logger.error('No supported runtime detected in this directory');
36
+ Logger.info('Supported runtimes: nodejs, python, go, ruby, rust');
37
+ Logger.info('Make sure your project has the appropriate configuration files:');
38
+ Logger.info(' - Node.js: package.json');
39
+ Logger.info(' - Python: requirements.txt or pyproject.toml');
40
+ Logger.info(' - Go: go.mod');
41
+ return;
42
+ }
43
+
44
+ spinner.succeed(
45
+ `Detected ${detection.runtime} (confidence: ${detection.confidence}%)`
46
+ );
47
+
48
+ if (detection.confidence < 80) {
49
+ Logger.warn('Low confidence detection. Please verify the generated config.');
50
+ }
51
+
52
+ // Get default config from adapter
53
+ const adapter = detection.adapter;
54
+ const defaults = await adapter.getDefaultConfig(process.cwd());
55
+
56
+ // Prompt for configuration
57
+ Logger.br();
58
+ Logger.section('Project Configuration');
59
+
60
+ const answers = await inquirer.prompt([
61
+ {
62
+ type: 'input',
63
+ name: 'name',
64
+ message: 'Application name:',
65
+ default: defaults.name,
66
+ validate: (input: string) => input.length > 0 || 'Name is required',
67
+ },
68
+ {
69
+ type: 'input',
70
+ name: 'version',
71
+ message: `${detection.runtime} version:`,
72
+ default: defaults.version || '18',
73
+ },
74
+ ]);
75
+
76
+ Logger.br();
77
+ Logger.section('Environment Configuration');
78
+
79
+ const envAnswers = await inquirer.prompt([
80
+ {
81
+ type: 'input',
82
+ name: 'server',
83
+ message: 'Production server (user@host):',
84
+ validate: (input: string) => {
85
+ if (!input) return 'Server is required';
86
+ if (!input.includes('@')) return 'Format: user@host';
87
+ return true;
88
+ },
89
+ },
90
+ {
91
+ type: 'number',
92
+ name: 'port',
93
+ message: 'Production port:',
94
+ default: 3000,
95
+ },
96
+ {
97
+ type: 'input',
98
+ name: 'domain',
99
+ message: 'Domain (optional):',
100
+ },
101
+ ]);
102
+
103
+ Logger.br();
104
+ Logger.section('Build & Start Configuration');
105
+
106
+ const buildAnswers = await inquirer.prompt([
107
+ {
108
+ type: 'input',
109
+ name: 'buildCommand',
110
+ message: 'Build command:',
111
+ default: defaults.build?.command || 'npm run build',
112
+ },
113
+ {
114
+ type: 'input',
115
+ name: 'startCommand',
116
+ message: 'Start command:',
117
+ default: defaults.start?.command || 'npm start',
118
+ },
119
+ ]);
120
+
121
+ // Generate configuration
122
+ const config: HostfnConfig = {
123
+ name: answers.name,
124
+ runtime: detection.runtime,
125
+ version: answers.version,
126
+ environments: {
127
+ production: {
128
+ server: envAnswers.server,
129
+ port: envAnswers.port,
130
+ instances: 'max',
131
+ ...(envAnswers.domain && { domain: envAnswers.domain }),
132
+ },
133
+ },
134
+ build: {
135
+ command: buildAnswers.buildCommand,
136
+ directory: 'dist',
137
+ nodeModules: 'production',
138
+ },
139
+ start: {
140
+ command: buildAnswers.startCommand,
141
+ entry: defaults.start?.entry,
142
+ },
143
+ env: {
144
+ required: [],
145
+ optional: [],
146
+ },
147
+ health: {
148
+ path: '/health',
149
+ timeout: 60,
150
+ retries: 10,
151
+ interval: 3,
152
+ },
153
+ sync: {
154
+ exclude: [
155
+ 'node_modules',
156
+ '.git',
157
+ '.github',
158
+ 'dist',
159
+ 'build',
160
+ '.env',
161
+ '.env.*',
162
+ '*.log',
163
+ ],
164
+ },
165
+ backup: {
166
+ keep: 5,
167
+ },
168
+ };
169
+
170
+ // Write configuration file
171
+ const configPath = ConfigLoader.getConfigPath();
172
+ const spinner2 = ora('Writing configuration...').start();
173
+
174
+ try {
175
+ writeFileSync(configPath, JSON.stringify(config, null, 2));
176
+ spinner2.succeed('Configuration created');
177
+
178
+ Logger.br();
179
+ Logger.success('hostfn initialized successfully!');
180
+ Logger.br();
181
+ Logger.kv('Config file', configPath);
182
+ Logger.kv('Runtime', detection.runtime);
183
+ Logger.kv('Environment', 'production');
184
+ Logger.br();
185
+ Logger.info('Next steps:');
186
+ Logger.command('hostfn server setup ' + envAnswers.server);
187
+ Logger.command('hostfn deploy production');
188
+ } catch (error) {
189
+ spinner2.fail('Failed to write configuration');
190
+ throw error;
191
+ }
192
+ }