hostfn 0.1.1 → 0.1.2

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