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,817 @@
1
+ import ora from 'ora';
2
+ import { tmpdir } from 'os';
3
+ import { join } from 'path';
4
+ import { mkdtempSync, rmSync, cpSync } from 'fs';
5
+ import { Logger } from '../utils/logger.js';
6
+ import { ConfigLoader } from '../config/loader.js';
7
+ import { createSSHConnection, SSHConnection } from '../core/ssh.js';
8
+ import { LocalExecutor } from '../core/local.js';
9
+ import { FileSync } from '../core/sync.js';
10
+ import { HealthCheck } from '../core/health.js';
11
+ import { BackupManager } from '../core/backup.js';
12
+ import { LockManager } from '../core/lock.js';
13
+ import { RuntimeRegistry } from '../runtimes/registry.js';
14
+ import { validateEnvironmentName, validateHttpUrl, validateRemotePath } from '../utils/validation.js';
15
+ import { HostfnConfig, EnvironmentConfig } from '../config/schema.js';
16
+ import { PM2Manager } from '../runtimes/nodejs/pm2.js';
17
+ import { WorkspaceManager } from '../core/workspace.js';
18
+
19
+ interface DeployOptions {
20
+ host?: string;
21
+ ci: boolean;
22
+ local: boolean;
23
+ dryRun: boolean;
24
+ service?: string;
25
+ all?: boolean;
26
+ }
27
+
28
+ export async function deployCommand(
29
+ environment: string,
30
+ options: DeployOptions
31
+ ): Promise<void> {
32
+ const startTime = Date.now();
33
+
34
+ Logger.header('Deploy Application');
35
+
36
+ // Validate environment name
37
+ if (!validateEnvironmentName(environment)) {
38
+ process.exit(1);
39
+ }
40
+
41
+ // Load configuration
42
+ const config = ConfigLoader.load();
43
+ const envConfig = config.environments[environment];
44
+
45
+ if (!envConfig) {
46
+ throw new Error(
47
+ `Environment '${environment}' not found in configuration\n` +
48
+ `Available: ${Object.keys(config.environments).join(', ')}`
49
+ );
50
+ }
51
+
52
+ // Handle multi-service deployment
53
+ if (config.services && Object.keys(config.services).length > 0) {
54
+ await deployMultiService(config, environment, envConfig, options, startTime);
55
+ return;
56
+ }
57
+
58
+ // Single service deployment
59
+ const host = process.env.HOSTFN_HOST || options.host || envConfig.server;
60
+ const remoteDir = `/var/www/${config.name}-${environment}`;
61
+
62
+ Logger.kv('Application', config.name);
63
+ Logger.kv('Runtime', config.runtime);
64
+ Logger.kv('Environment', environment);
65
+ Logger.kv('Server', host);
66
+ Logger.kv('Port', envConfig.port.toString());
67
+ Logger.kv('Remote Directory', remoteDir);
68
+ Logger.br();
69
+
70
+ try {
71
+ await deploySingleService(config, envConfig, environment, options);
72
+
73
+ // Success!
74
+ const duration = Math.round((Date.now() - startTime) / 1000);
75
+ const serviceName = `${config.name}-${environment}`;
76
+ const hostname = host.includes('@') ? host.split('@')[1] : host;
77
+ const healthUrl = `http://${hostname}:${envConfig.port}${config.health?.path || '/health'}`;
78
+
79
+ Logger.br();
80
+ Logger.success('Deployment completed successfully!');
81
+ Logger.br();
82
+ Logger.kv('Environment', environment);
83
+ Logger.kv('Service', serviceName);
84
+ Logger.kv('Duration', `${duration}s`);
85
+ Logger.kv('Health URL', healthUrl);
86
+ Logger.br();
87
+ Logger.info('Next steps:');
88
+ Logger.br();
89
+ Logger.info('1. Configure domain and SSL (if needed):');
90
+ Logger.command(`hostfn expose ${environment}`);
91
+ Logger.br();
92
+ Logger.info('2. View logs:');
93
+ Logger.command(`hostfn logs ${environment}`);
94
+ Logger.br();
95
+ } catch (error) {
96
+ process.exit(1);
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Deploy multiple services in a monorepo
102
+ */
103
+ async function deployMultiService(
104
+ config: HostfnConfig,
105
+ environment: string,
106
+ envConfig: EnvironmentConfig,
107
+ options: DeployOptions,
108
+ startTime: number
109
+ ): Promise<void> {
110
+ const services = config.services!;
111
+ const serviceNames = Object.keys(services);
112
+
113
+ // Determine which services to deploy
114
+ let servicesToDeploy: string[] = [];
115
+
116
+ if (options.service) {
117
+ // Deploy specific service
118
+ if (!services[options.service]) {
119
+ throw new Error(
120
+ `Service '${options.service}' not found in configuration\n` +
121
+ `Available services: ${serviceNames.join(', ')}`
122
+ );
123
+ }
124
+ servicesToDeploy = [options.service];
125
+ } else if (options.all) {
126
+ // Deploy all services
127
+ servicesToDeploy = serviceNames;
128
+ } else {
129
+ // No flag specified - default to all services
130
+ servicesToDeploy = serviceNames;
131
+ Logger.info(`Deploying all ${serviceNames.length} services (use --service <name> to deploy specific service)`);
132
+ Logger.br();
133
+ }
134
+
135
+ Logger.kv('Application', config.name);
136
+ Logger.kv('Environment', environment);
137
+ Logger.kv('Services to deploy', servicesToDeploy.join(', '));
138
+ Logger.br();
139
+
140
+ const results: { service: string; success: boolean; error?: string }[] = [];
141
+
142
+ // Deploy each service
143
+ for (const serviceName of servicesToDeploy) {
144
+ const serviceConfig = services[serviceName];
145
+
146
+ // Determine which server to use: service-specific or environment default
147
+ const serviceServer = serviceConfig.server || envConfig.server;
148
+
149
+ Logger.section(`Deploying Service: ${serviceName}`);
150
+ Logger.kv('Path', serviceConfig.path);
151
+ Logger.kv('Port', serviceConfig.port.toString());
152
+ Logger.kv('Server', serviceServer);
153
+ if (serviceConfig.domain) {
154
+ const domainDisplay = Array.isArray(serviceConfig.domain)
155
+ ? serviceConfig.domain.join(', ')
156
+ : serviceConfig.domain;
157
+ Logger.kv('Domain', domainDisplay);
158
+ }
159
+ if (serviceConfig.instances) {
160
+ Logger.kv('Instances', serviceConfig.instances.toString());
161
+ }
162
+ Logger.br();
163
+
164
+ try {
165
+ // Create a modified config for this specific service
166
+ const serviceSpecificConfig: HostfnConfig = {
167
+ ...config,
168
+ name: `${config.name}-${serviceName}`,
169
+ services: undefined, // Remove services to avoid recursion
170
+ };
171
+
172
+ const serviceEnvConfig: EnvironmentConfig = {
173
+ ...envConfig,
174
+ server: serviceServer, // Use service-specific server or default
175
+ port: serviceConfig.port,
176
+ ...(serviceConfig.instances && { instances: serviceConfig.instances }),
177
+ ...(serviceConfig.domain && { domain: serviceConfig.domain }),
178
+ };
179
+
180
+ // Get the service path
181
+ const servicePath = serviceConfig.path;
182
+ const originalCwd = process.cwd();
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();
202
+ } catch (error) {
203
+ const errorMsg = error instanceof Error ? error.message : String(error);
204
+ results.push({ service: serviceName, success: false, error: errorMsg });
205
+ Logger.error(`Service '${serviceName}' deployment failed: ${errorMsg}`);
206
+ Logger.br();
207
+
208
+ // Continue to next service instead of failing entirely
209
+ if (servicesToDeploy.length > 1) {
210
+ Logger.info('Continuing with remaining services...');
211
+ Logger.br();
212
+ }
213
+ }
214
+ }
215
+
216
+ // Summary
217
+ const duration = Math.round((Date.now() - startTime) / 1000);
218
+ Logger.section('Deployment Summary');
219
+ Logger.br();
220
+
221
+ const successful = results.filter(r => r.success);
222
+ const failed = results.filter(r => !r.success);
223
+
224
+ Logger.kv('Total services', results.length.toString());
225
+ Logger.kv('Successful', successful.length.toString());
226
+ Logger.kv('Failed', failed.length.toString());
227
+ Logger.kv('Duration', `${duration}s`);
228
+ Logger.br();
229
+
230
+ if (successful.length > 0) {
231
+ Logger.info('Successful deployments:');
232
+ successful.forEach(r => Logger.log(` ✓ ${r.service}`));
233
+ Logger.br();
234
+ }
235
+
236
+ if (failed.length > 0) {
237
+ Logger.error('Failed deployments:');
238
+ failed.forEach(r => Logger.log(` ✗ ${r.service}: ${r.error}`));
239
+ Logger.br();
240
+ process.exit(1);
241
+ }
242
+
243
+ Logger.success('All services deployed successfully!');
244
+ Logger.br();
245
+ Logger.info('Next steps:');
246
+ Logger.br();
247
+ Logger.info('Configure domain and SSL (if needed):');
248
+ Logger.command(`hostfn expose ${environment}`);
249
+ Logger.br();
250
+ }
251
+
252
+ /**
253
+ * Deploy a single service (extracted from original deployCommand)
254
+ */
255
+ async function deploySingleService(
256
+ config: HostfnConfig,
257
+ envConfig: EnvironmentConfig,
258
+ environment: string,
259
+ options: DeployOptions,
260
+ servicePath?: string
261
+ ): Promise<void> {
262
+ // Support HOSTFN_HOST override for CI/CD
263
+ const host = process.env.HOSTFN_HOST || options.host || envConfig.server;
264
+ const remoteDir = `/var/www/${config.name}-${environment}`;
265
+
266
+ // Validate remote directory
267
+ if (!validateRemotePath(remoteDir)) {
268
+ process.exit(1);
269
+ }
270
+
271
+ const sourceDir = servicePath || process.cwd();
272
+
273
+ if (options.dryRun) {
274
+ Logger.warn('DRY RUN MODE - No changes will be made');
275
+ Logger.br();
276
+ await dryRunDeploy(config, envConfig, environment, remoteDir, host);
277
+ return;
278
+ }
279
+
280
+ if (options.ci) {
281
+ Logger.info('Running in CI/CD mode');
282
+ Logger.br();
283
+ }
284
+
285
+ if (options.local) {
286
+ Logger.info('Running in LOCAL mode (self-hosted)');
287
+ Logger.br();
288
+ }
289
+
290
+ // Get runtime adapter
291
+ const adapter = RuntimeRegistry.get(config.runtime);
292
+ const pm2 = adapter.getProcessManager();
293
+ const serviceName = `${config.name}-${environment}`;
294
+
295
+ let ssh: SSHConnection | LocalExecutor | null = null;
296
+ let backupPath: string | null = null;
297
+ let lockManager: LockManager | null = null;
298
+ let bundleDir: string | null = null;
299
+
300
+ try {
301
+ // ===== Phase 1: Pre-flight Checks =====
302
+ Logger.section('Pre-flight Checks');
303
+ Logger.br();
304
+
305
+ // For local mode, skip rsync and SSH connection
306
+ if (options.local) {
307
+ const localSpinner = ora('Initializing local deployment...').start();
308
+ ssh = new LocalExecutor();
309
+ localSpinner.succeed('Local mode ready');
310
+ } else {
311
+ // Check rsync availability
312
+ const rsyncSpinner = ora('Checking rsync availability...').start();
313
+ const hasRsync = await FileSync.isRsyncAvailable();
314
+ if (!hasRsync) {
315
+ rsyncSpinner.fail('rsync not installed');
316
+ throw new Error('rsync is required for deployment. Please install it first.');
317
+ }
318
+ rsyncSpinner.succeed('rsync available');
319
+
320
+ // Connect to server
321
+ const connectSpinner = ora('Connecting to server...').start();
322
+ ssh = await createSSHConnection(host);
323
+ connectSpinner.succeed('Connected to server');
324
+ }
325
+
326
+ // Verify Node version and PM2 installation
327
+ const versionCheckSpinner = ora('Checking Node.js version...').start();
328
+ const nodeVersionCheck = await ssh.exec('node --version');
329
+ const currentVersion = nodeVersionCheck.stdout.trim().replace('v', '');
330
+ const requiredVersion = config.version;
331
+
332
+ if (!currentVersion.startsWith(requiredVersion)) {
333
+ versionCheckSpinner.text = `Switching to Node.js v${requiredVersion}...`;
334
+ await ssh.exec(`nvm install ${requiredVersion} && nvm alias default ${requiredVersion}`);
335
+ versionCheckSpinner.succeed(`Node.js v${requiredVersion} activated`);
336
+ } else {
337
+ versionCheckSpinner.succeed(`Node.js v${currentVersion} ready`);
338
+ }
339
+
340
+ // Ensure PM2 is installed
341
+ const pm2Check = await ssh.exec('command -v pm2 || echo "missing"');
342
+ if (pm2Check.stdout.includes('missing')) {
343
+ const pm2Spinner = ora('Installing PM2...').start();
344
+ await ssh.exec('npm install -g pm2');
345
+ pm2Spinner.succeed('PM2 installed');
346
+ }
347
+
348
+ // Check if remote directory exists, create if not
349
+ const remoteDirExists = await ssh.exists(remoteDir);
350
+ if (!remoteDirExists) {
351
+ const mkdirSpinner = ora('Creating remote directory...').start();
352
+ await ssh.mkdir(remoteDir, true);
353
+ mkdirSpinner.succeed('Remote directory created');
354
+ }
355
+
356
+ // Acquire deployment lock
357
+ const lockSpinner = ora('Acquiring deployment lock...').start();
358
+ lockManager = new LockManager(ssh as any, remoteDir);
359
+ const lockAcquired = await lockManager.acquire();
360
+
361
+ if (!lockAcquired) {
362
+ lockSpinner.fail('Could not acquire deployment lock');
363
+ ssh.disconnect();
364
+ process.exit(1);
365
+ }
366
+
367
+ lockSpinner.succeed('Deployment lock acquired');
368
+
369
+ Logger.br();
370
+
371
+ // ===== Phase 1.5: Workspace Bundling =====
372
+ const workspaceManager = new WorkspaceManager();
373
+ const isWorkspace = await workspaceManager.detectWorkspace(sourceDir);
374
+ let actualSourceDir = sourceDir;
375
+
376
+ if (isWorkspace) {
377
+ const workspaceDeps = workspaceManager.getWorkspaceDependencies(sourceDir);
378
+
379
+ if (workspaceDeps.length > 0) {
380
+ Logger.section('Workspace Bundling');
381
+ Logger.br();
382
+ Logger.info(`Detected ${workspaceDeps.length} workspace dependencies: ${workspaceDeps.join(', ')}`);
383
+ Logger.br();
384
+
385
+ const bundleSpinner = ora('Creating deployment bundle...').start();
386
+
387
+ bundleDir = mkdtempSync(join(tmpdir(), 'hostfn-bundle-'));
388
+
389
+ cpSync(sourceDir, bundleDir, {
390
+ recursive: true,
391
+ filter: (src) => {
392
+ return !src.includes('node_modules') && !src.includes('.git');
393
+ },
394
+ });
395
+
396
+ bundleSpinner.text = 'Bundling workspace dependencies...';
397
+ await workspaceManager.bundleWorkspaceDependencies(sourceDir, bundleDir);
398
+
399
+ bundleSpinner.succeed('Workspace dependencies bundled');
400
+ Logger.br();
401
+
402
+ actualSourceDir = bundleDir;
403
+ }
404
+ }
405
+
406
+ // ===== Phase 2: File Sync =====
407
+ Logger.section('Syncing Files');
408
+ Logger.br();
409
+
410
+ if (options.local) {
411
+ // Local mode: copy files directly
412
+ const syncSpinner = ora('Copying files locally...').start();
413
+
414
+ cpSync(actualSourceDir, remoteDir, {
415
+ recursive: true,
416
+ filter: (src) => {
417
+ const relativePath = src.replace(actualSourceDir, '');
418
+ const excludePatterns = config.sync?.exclude || [
419
+ 'node_modules',
420
+ '.git',
421
+ 'dist',
422
+ '.env',
423
+ '*.log',
424
+ ];
425
+ return !excludePatterns.some(pattern => relativePath.includes(pattern));
426
+ },
427
+ });
428
+
429
+ syncSpinner.succeed('Files copied successfully');
430
+
431
+ // Copy workspace dependencies if they exist
432
+ if (bundleDir) {
433
+ const bundledNodeModules = join(bundleDir, 'node_modules');
434
+ const { existsSync } = await import('fs');
435
+
436
+ if (existsSync(bundledNodeModules)) {
437
+ const uploadSpinner = ora('Copying workspace dependencies...').start();
438
+ cpSync(bundledNodeModules, join(remoteDir, 'node_modules'), { recursive: true });
439
+ uploadSpinner.succeed('Workspace dependencies copied');
440
+ }
441
+ }
442
+ } else {
443
+ // Remote mode: use rsync
444
+ const syncSpinner = ora('Syncing files to server...').start();
445
+
446
+ await FileSync.sync(
447
+ actualSourceDir,
448
+ remoteDir,
449
+ host,
450
+ {
451
+ exclude: config.sync?.exclude || [
452
+ 'node_modules',
453
+ '.git',
454
+ 'dist',
455
+ '.env',
456
+ '*.log',
457
+ ],
458
+ verbose: false,
459
+ }
460
+ );
461
+
462
+ syncSpinner.succeed('Files synced successfully');
463
+
464
+ // Upload bundled workspace dependencies if they exist (before npm install)
465
+ if (bundleDir) {
466
+ const bundledNodeModules = join(bundleDir, 'node_modules');
467
+ const { existsSync } = await import('fs');
468
+
469
+ if (existsSync(bundledNodeModules)) {
470
+ Logger.section('Uploading Workspace Dependencies');
471
+ Logger.br();
472
+
473
+ const uploadSpinner = ora('Uploading bundled workspace dependencies...').start();
474
+
475
+ await FileSync.sync(
476
+ bundledNodeModules,
477
+ join(remoteDir, 'node_modules'),
478
+ host,
479
+ {
480
+ exclude: [],
481
+ verbose: false,
482
+ }
483
+ );
484
+
485
+ uploadSpinner.succeed('Workspace dependencies uploaded');
486
+ Logger.br();
487
+ }
488
+ }
489
+ }
490
+
491
+ Logger.br();
492
+
493
+ // ===== Phase 3: Remote Build =====
494
+ Logger.section('Building Application');
495
+ Logger.br();
496
+
497
+ // Install dependencies
498
+ const installSpinner = ora('Installing dependencies...').start();
499
+
500
+ // Check if package-lock.json exists, use npm ci if available, otherwise npm install
501
+ const lockFileCheck = await ssh.exec(
502
+ 'test -f package-lock.json && echo "exists"',
503
+ { cwd: remoteDir, streaming: false }
504
+ );
505
+ const hasLockFile = lockFileCheck.stdout.trim() === 'exists';
506
+
507
+ // If build command exists, install all dependencies (including dev); otherwise production only
508
+ const needsDevDeps = !!config.build?.command;
509
+ const installCmd = hasLockFile
510
+ ? (needsDevDeps ? 'npm ci --install-links' : 'npm ci --production --install-links')
511
+ : (needsDevDeps ? 'npm install --install-links' : 'npm install --production --install-links');
512
+
513
+ const installResult = await ssh.exec(
514
+ installCmd,
515
+ { cwd: remoteDir, streaming: false }
516
+ );
517
+
518
+ if (installResult.exitCode !== 0) {
519
+ installSpinner.fail('Dependency installation failed');
520
+ throw new Error(`${installCmd} failed: ${installResult.stderr}`);
521
+ }
522
+ installSpinner.succeed('Dependencies installed');
523
+
524
+ // Build application
525
+ if (config.build?.command) {
526
+ const buildSpinner = ora('Building application...').start();
527
+ const buildResult = await ssh.exec(
528
+ config.build.command.replace('npm run ', 'npm run '),
529
+ { cwd: remoteDir, streaming: false }
530
+ );
531
+
532
+ if (buildResult.exitCode !== 0) {
533
+ buildSpinner.fail('Build failed');
534
+ const errorOutput = buildResult.stderr || buildResult.stdout;
535
+ throw new Error(`Build failed: ${errorOutput}`);
536
+ }
537
+ buildSpinner.succeed('Build completed');
538
+ }
539
+
540
+ Logger.br();
541
+
542
+ // ===== Phase 4: Backup =====
543
+ Logger.section('Creating Backup');
544
+ Logger.br();
545
+
546
+ const backupManager = new BackupManager(ssh as any, remoteDir);
547
+ const backupSpinner = ora('Creating backup of current deployment...').start();
548
+
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');
554
+ }
555
+
556
+ Logger.br();
557
+
558
+ // ===== Phase 5: PM2 Deployment =====
559
+ Logger.section('Deploying Service');
560
+ Logger.br();
561
+
562
+ // Read .env file from remote server
563
+ const envFileResult = await ssh.exec(`cat ${remoteDir}/.env 2>/dev/null || echo ""`, { cwd: remoteDir, streaming: false });
564
+ const envVars: Record<string, string> = {};
565
+
566
+ if (envFileResult.stdout) {
567
+ // Parse .env file
568
+ envFileResult.stdout.split('\n').forEach(line => {
569
+ line = line.trim();
570
+ if (line && !line.startsWith('#')) {
571
+ const equalIndex = line.indexOf('=');
572
+ if (equalIndex > 0) {
573
+ const key = line.substring(0, equalIndex).trim();
574
+ let value = line.substring(equalIndex + 1).trim();
575
+ // Remove quotes if present
576
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
577
+ value = value.slice(1, -1);
578
+ }
579
+ envVars[key] = value;
580
+ }
581
+ }
582
+ });
583
+ }
584
+
585
+ // Check if service already exists
586
+ const checkPm2 = await ssh.exec('pm2 list | grep "' + serviceName + '" || true');
587
+ const serviceExists = checkPm2.stdout.includes(serviceName);
588
+
589
+ if (serviceExists) {
590
+ const reloadSpinner = ora('Reloading PM2 process (zero-downtime)...').start();
591
+
592
+ // Regenerate PM2 ecosystem file with env vars
593
+ const pm2Manager = pm2 as PM2Manager;
594
+ const ecosystemConfig = pm2Manager.generateEcosystemConfig(
595
+ {
596
+ name: config.name,
597
+ runtime: config.runtime,
598
+ version: config.version,
599
+ start: config.start,
600
+ port: envConfig.port,
601
+ },
602
+ environment,
603
+ envVars
604
+ );
605
+
606
+ await ssh.exec(`cat > ${remoteDir}/ecosystem.config.cjs << 'EOF'
607
+ ${ecosystemConfig}
608
+ EOF`);
609
+
610
+ // Delete and restart the service with the updated ecosystem config
611
+ await ssh.exec(`pm2 delete ${serviceName} || true`, { cwd: remoteDir });
612
+ const startResult = await ssh.exec(
613
+ `pm2 start ${remoteDir}/ecosystem.config.cjs`,
614
+ { cwd: remoteDir }
615
+ );
616
+
617
+ if (startResult.exitCode !== 0) {
618
+ reloadSpinner.fail('PM2 reload failed');
619
+ throw new Error(`PM2 reload failed: ${startResult.stderr}`);
620
+ }
621
+
622
+ // Save PM2 configuration
623
+ await ssh.exec('pm2 save');
624
+
625
+ reloadSpinner.succeed('Service reloaded');
626
+ } else {
627
+ const startSpinner = ora('Starting PM2 process...').start();
628
+
629
+ // Create PM2 ecosystem file
630
+ const pm2Manager = pm2 as PM2Manager;
631
+ const ecosystemConfig = pm2Manager.generateEcosystemConfig(
632
+ {
633
+ name: config.name,
634
+ runtime: config.runtime,
635
+ version: config.version,
636
+ start: config.start,
637
+ port: envConfig.port,
638
+ },
639
+ environment,
640
+ envVars
641
+ );
642
+
643
+ await ssh.exec(`cat > ${remoteDir}/ecosystem.config.cjs << 'EOF'
644
+ ${ecosystemConfig}
645
+ EOF`);
646
+
647
+ const startResult = await ssh.exec(
648
+ `pm2 start ${remoteDir}/ecosystem.config.cjs`,
649
+ { cwd: remoteDir }
650
+ );
651
+
652
+ if (startResult.exitCode !== 0) {
653
+ startSpinner.fail('PM2 start failed');
654
+ throw new Error(`PM2 start failed: ${startResult.stderr}`);
655
+ }
656
+
657
+ // Save PM2 configuration
658
+ await ssh.exec('pm2 save');
659
+
660
+ startSpinner.succeed('Service started');
661
+ }
662
+
663
+ Logger.br();
664
+
665
+ // ===== Phase 6: Health Check =====
666
+ Logger.section('Health Check');
667
+ Logger.br();
668
+
669
+ const healthSpinner = ora('Waiting for service to be ready...').start();
670
+ const healthPath = config.health?.path || '/health';
671
+ const retries = config.health?.retries || 10;
672
+ const interval = config.health?.interval || 3000;
673
+
674
+ let healthy = false;
675
+ for (let i = 0; i < retries; i++) {
676
+ healthSpinner.text = `Health check attempt ${i + 1}/${retries}...`;
677
+
678
+ // Check health via SSH using curl on localhost
679
+ const healthCheckResult = await ssh.exec(
680
+ `curl -sf http://localhost:${envConfig.port}${healthPath}`,
681
+ { cwd: remoteDir, streaming: false }
682
+ );
683
+
684
+ if (healthCheckResult.exitCode === 0) {
685
+ healthy = true;
686
+ healthSpinner.succeed('Health check passed');
687
+ break;
688
+ }
689
+
690
+ if (i < retries - 1) {
691
+ await new Promise(resolve => setTimeout(resolve, interval));
692
+ }
693
+ }
694
+
695
+ if (!healthy) {
696
+ healthSpinner.fail('Health check failed');
697
+ throw new Error('Service is not responding to health checks');
698
+ }
699
+
700
+ Logger.br();
701
+
702
+ // ===== Phase 7: Cleanup =====
703
+ // Cleanup old backups
704
+ await backupManager.cleanup(config.backup?.keep || 5);
705
+
706
+ // Release lock before success message
707
+ if (lockManager) {
708
+ await lockManager.release();
709
+ lockManager = null;
710
+ }
711
+
712
+ } catch (error) {
713
+ Logger.br();
714
+ Logger.error('Deployment failed!');
715
+ Logger.error(error instanceof Error ? error.message : String(error));
716
+ Logger.br();
717
+
718
+ // ===== Auto-Rollback =====
719
+ if (backupPath && ssh) {
720
+ Logger.section('Rolling Back');
721
+ Logger.br();
722
+
723
+ const rollbackSpinner = ora('Restoring previous deployment...').start();
724
+
725
+ try {
726
+ const backupManager = new BackupManager(ssh as any, remoteDir);
727
+ await backupManager.restore(backupPath.split('/').pop()!);
728
+
729
+ // Reload PM2 with old version
730
+ const adapter = RuntimeRegistry.get(config.runtime);
731
+ const pm2 = adapter.getProcessManager();
732
+ await ssh.exec(pm2.generateReloadCommand(serviceName), { cwd: remoteDir });
733
+
734
+ rollbackSpinner.succeed('Rolled back to previous deployment');
735
+ Logger.info('Previous deployment restored successfully');
736
+ } catch (rollbackError) {
737
+ rollbackSpinner.fail('Rollback failed');
738
+ Logger.error('Manual intervention required');
739
+ Logger.error(rollbackError instanceof Error ? rollbackError.message : String(rollbackError));
740
+ }
741
+
742
+ Logger.br();
743
+ }
744
+
745
+ throw error; // Re-throw for multi-service handler
746
+ } finally {
747
+ // Always release lock
748
+ if (lockManager) {
749
+ await lockManager.release();
750
+ }
751
+
752
+ if (ssh) {
753
+ ssh.disconnect();
754
+ }
755
+
756
+ // Cleanup bundle directory
757
+ if (bundleDir) {
758
+ try {
759
+ rmSync(bundleDir, { recursive: true, force: true });
760
+ } catch (error) {
761
+ // Ignore cleanup errors
762
+ }
763
+ }
764
+ }
765
+ }
766
+
767
+ /**
768
+ * Dry run - show what would be deployed
769
+ */
770
+ async function dryRunDeploy(
771
+ config: HostfnConfig,
772
+ envConfig: EnvironmentConfig,
773
+ environment: string,
774
+ remoteDir: string,
775
+ host: string
776
+ ): Promise<void> {
777
+ Logger.info('Deployment Plan:');
778
+ Logger.br();
779
+
780
+ Logger.log('1. Pre-flight Checks');
781
+ Logger.log(' ✓ Check rsync availability');
782
+ Logger.log(' ✓ Connect to server');
783
+ Logger.log(' ✓ Verify remote directory');
784
+ Logger.br();
785
+
786
+ Logger.log('2. File Sync');
787
+ Logger.log(` → Sync ${process.cwd()} to ${remoteDir}`);
788
+ Logger.log(` → Exclude: ${config.sync?.exclude?.join(', ')}`);
789
+ Logger.br();
790
+
791
+ Logger.log('3. Remote Build');
792
+ Logger.log(' → npm ci --production');
793
+ if (config.build?.command) {
794
+ Logger.log(` → ${config.build.command}`);
795
+ }
796
+ Logger.br();
797
+
798
+ Logger.log('4. Backup');
799
+ Logger.log(' → Create timestamped backup of current deployment');
800
+ Logger.br();
801
+
802
+ Logger.log('5. PM2 Deployment');
803
+ Logger.log(` → Check if ${config.name}-${environment} exists`);
804
+ Logger.log(' → Start/Reload PM2 process');
805
+ Logger.br();
806
+
807
+ Logger.log('6. Health Check');
808
+ Logger.log(` → Poll ${config.health?.path || '/health'}`);
809
+ Logger.log(` → Retries: ${config.health?.retries || 10}`);
810
+ Logger.br();
811
+
812
+ Logger.log('7. Cleanup');
813
+ Logger.log(` → Keep last ${config.backup?.keep || 5} backups`);
814
+ Logger.br();
815
+
816
+ Logger.info('Use --no-dry-run to execute deployment');
817
+ }