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.
- package/dist/__tests__/core/backup.test.d.ts +2 -0
- package/dist/__tests__/core/backup.test.d.ts.map +1 -0
- package/dist/__tests__/core/backup.test.js +108 -0
- package/dist/__tests__/core/backup.test.js.map +1 -0
- package/dist/__tests__/core/health.test.d.ts +2 -0
- package/dist/__tests__/core/health.test.d.ts.map +1 -0
- package/dist/__tests__/core/health.test.js +97 -0
- package/dist/__tests__/core/health.test.js.map +1 -0
- package/dist/__tests__/core/lock.test.d.ts +2 -0
- package/dist/__tests__/core/lock.test.d.ts.map +1 -0
- package/dist/__tests__/core/lock.test.js +136 -0
- package/dist/__tests__/core/lock.test.js.map +1 -0
- package/dist/__tests__/core/nginx-multi-domain.test.d.ts +2 -0
- package/dist/__tests__/core/nginx-multi-domain.test.d.ts.map +1 -0
- package/dist/__tests__/core/nginx-multi-domain.test.js +158 -0
- package/dist/__tests__/core/nginx-multi-domain.test.js.map +1 -0
- package/dist/__tests__/runtimes/pm2.test.d.ts +2 -0
- package/dist/__tests__/runtimes/pm2.test.d.ts.map +1 -0
- package/dist/__tests__/runtimes/pm2.test.js +111 -0
- package/dist/__tests__/runtimes/pm2.test.js.map +1 -0
- package/dist/__tests__/utils/validation.test.d.ts +2 -0
- package/dist/__tests__/utils/validation.test.d.ts.map +1 -0
- package/dist/__tests__/utils/validation.test.js +136 -0
- package/dist/__tests__/utils/validation.test.js.map +1 -0
- package/dist/commands/deploy.d.ts +11 -0
- package/dist/commands/deploy.d.ts.map +1 -0
- package/dist/commands/deploy.js +636 -0
- package/dist/commands/deploy.js.map +1 -0
- package/dist/commands/env.d.ts +21 -0
- package/dist/commands/env.d.ts.map +1 -0
- package/dist/commands/env.js +317 -0
- package/dist/commands/env.js.map +1 -0
- package/dist/commands/expose.d.ts +6 -0
- package/dist/commands/expose.d.ts.map +1 -0
- package/dist/commands/expose.js +379 -0
- package/dist/commands/expose.js.map +1 -0
- package/dist/commands/init.d.ts +2 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +175 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/logs.d.ts +10 -0
- package/dist/commands/logs.d.ts.map +1 -0
- package/dist/commands/logs.js +75 -0
- package/dist/commands/logs.js.map +1 -0
- package/dist/commands/rollback.d.ts +6 -0
- package/dist/commands/rollback.d.ts.map +1 -0
- package/dist/commands/rollback.js +113 -0
- package/dist/commands/rollback.js.map +1 -0
- package/dist/commands/server/info.d.ts +2 -0
- package/dist/commands/server/info.d.ts.map +1 -0
- package/dist/commands/server/info.js +104 -0
- package/dist/commands/server/info.js.map +1 -0
- package/dist/commands/server/setup.d.ts +11 -0
- package/dist/commands/server/setup.d.ts.map +1 -0
- package/dist/commands/server/setup.js +161 -0
- package/dist/commands/server/setup.js.map +1 -0
- package/dist/commands/status.d.ts +6 -0
- package/dist/commands/status.d.ts.map +1 -0
- package/dist/commands/status.js +120 -0
- package/dist/commands/status.js.map +1 -0
- package/dist/config/loader.d.ts +21 -0
- package/dist/config/loader.d.ts.map +1 -0
- package/dist/config/loader.js +54 -0
- package/dist/config/loader.js.map +1 -0
- package/dist/config/schema.d.ts +323 -0
- package/dist/config/schema.d.ts.map +1 -0
- package/dist/config/schema.js +108 -0
- package/dist/config/schema.js.map +1 -0
- package/dist/core/backup.d.ts +34 -0
- package/dist/core/backup.d.ts.map +1 -0
- package/dist/core/backup.js +95 -0
- package/dist/core/backup.js.map +1 -0
- package/dist/core/health.d.ts +31 -0
- package/dist/core/health.d.ts.map +1 -0
- package/dist/core/health.js +78 -0
- package/dist/core/health.js.map +1 -0
- package/dist/core/local.d.ts +19 -0
- package/dist/core/local.d.ts.map +1 -0
- package/dist/core/local.js +50 -0
- package/dist/core/local.js.map +1 -0
- package/dist/core/lock.d.ts +28 -0
- package/dist/core/lock.d.ts.map +1 -0
- package/dist/core/lock.js +89 -0
- package/dist/core/lock.js.map +1 -0
- package/dist/core/nginx.d.ts +43 -0
- package/dist/core/nginx.d.ts.map +1 -0
- package/dist/core/nginx.js +131 -0
- package/dist/core/nginx.js.map +1 -0
- package/dist/core/ssh.d.ts +79 -0
- package/dist/core/ssh.d.ts.map +1 -0
- package/dist/core/ssh.js +264 -0
- package/dist/core/ssh.js.map +1 -0
- package/dist/core/sync.d.ts +25 -0
- package/dist/core/sync.d.ts.map +1 -0
- package/dist/core/sync.js +117 -0
- package/dist/core/sync.js.map +1 -0
- package/dist/core/workspace.d.ts +13 -0
- package/dist/core/workspace.d.ts.map +1 -0
- package/dist/core/workspace.js +141 -0
- package/dist/core/workspace.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +232 -0
- package/dist/index.js.map +1 -0
- package/dist/runtimes/base.d.ts +115 -0
- package/dist/runtimes/base.d.ts.map +1 -0
- package/dist/runtimes/base.js +16 -0
- package/dist/runtimes/base.js.map +1 -0
- package/dist/runtimes/nodejs/detector.d.ts +47 -0
- package/dist/runtimes/nodejs/detector.d.ts.map +1 -0
- package/dist/runtimes/nodejs/detector.js +143 -0
- package/dist/runtimes/nodejs/detector.js.map +1 -0
- package/dist/runtimes/nodejs/index.d.ts +14 -0
- package/dist/runtimes/nodejs/index.d.ts.map +1 -0
- package/dist/runtimes/nodejs/index.js +213 -0
- package/dist/runtimes/nodejs/index.js.map +1 -0
- package/dist/runtimes/nodejs/pm2.d.ts +17 -0
- package/dist/runtimes/nodejs/pm2.d.ts.map +1 -0
- package/dist/runtimes/nodejs/pm2.js +60 -0
- package/dist/runtimes/nodejs/pm2.js.map +1 -0
- package/dist/runtimes/registry.d.ts +34 -0
- package/dist/runtimes/registry.d.ts.map +1 -0
- package/dist/runtimes/registry.js +58 -0
- package/dist/runtimes/registry.js.map +1 -0
- package/dist/utils/logger.d.ts +47 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +76 -0
- package/dist/utils/logger.js.map +1 -0
- package/dist/utils/validation.d.ts +32 -0
- package/dist/utils/validation.d.ts.map +1 -0
- package/dist/utils/validation.js +125 -0
- package/dist/utils/validation.js.map +1 -0
- package/package.json +33 -16
- package/LICENSE +0 -21
- package/README.md +0 -1136
- package/_conduct/specs/1.v0.spec.md +0 -1041
- package/examples/express-api/package.json +0 -22
- package/examples/express-api/src/index.ts +0 -16
- package/examples/express-api/tsconfig.json +0 -11
- package/examples/github-actions-deploy.yml +0 -40
- package/examples/monorepo-config.json +0 -76
- package/examples/monorepo-multi-server-config.json +0 -74
- package/packages/cli/package.json +0 -40
- package/turbo.json +0 -24
- /package/{packages/cli/src → src}/__tests__/core/backup.test.ts +0 -0
- /package/{packages/cli/src → src}/__tests__/core/health.test.ts +0 -0
- /package/{packages/cli/src → src}/__tests__/core/lock.test.ts +0 -0
- /package/{packages/cli/src → src}/__tests__/core/nginx-multi-domain.test.ts +0 -0
- /package/{packages/cli/src → src}/__tests__/runtimes/pm2.test.ts +0 -0
- /package/{packages/cli/src → src}/__tests__/utils/validation.test.ts +0 -0
- /package/{packages/cli/src → src}/commands/deploy.ts +0 -0
- /package/{packages/cli/src → src}/commands/env.ts +0 -0
- /package/{packages/cli/src → src}/commands/expose.ts +0 -0
- /package/{packages/cli/src → src}/commands/init.ts +0 -0
- /package/{packages/cli/src → src}/commands/logs.ts +0 -0
- /package/{packages/cli/src → src}/commands/rollback.ts +0 -0
- /package/{packages/cli/src → src}/commands/server/info.ts +0 -0
- /package/{packages/cli/src → src}/commands/server/setup.ts +0 -0
- /package/{packages/cli/src → src}/commands/status.ts +0 -0
- /package/{packages/cli/src → src}/config/loader.ts +0 -0
- /package/{packages/cli/src → src}/config/schema.ts +0 -0
- /package/{packages/cli/src → src}/core/backup.ts +0 -0
- /package/{packages/cli/src → src}/core/health.ts +0 -0
- /package/{packages/cli/src → src}/core/local.ts +0 -0
- /package/{packages/cli/src → src}/core/lock.ts +0 -0
- /package/{packages/cli/src → src}/core/nginx.ts +0 -0
- /package/{packages/cli/src → src}/core/ssh.ts +0 -0
- /package/{packages/cli/src → src}/core/sync.ts +0 -0
- /package/{packages/cli/src → src}/core/workspace.ts +0 -0
- /package/{packages/cli/src → src}/index.ts +0 -0
- /package/{packages/cli/src → src}/runtimes/base.ts +0 -0
- /package/{packages/cli/src → src}/runtimes/nodejs/detector.ts +0 -0
- /package/{packages/cli/src → src}/runtimes/nodejs/index.ts +0 -0
- /package/{packages/cli/src → src}/runtimes/nodejs/pm2.ts +0 -0
- /package/{packages/cli/src → src}/runtimes/registry.ts +0 -0
- /package/{packages/cli/src → src}/utils/logger.ts +0 -0
- /package/{packages/cli/src → src}/utils/validation.ts +0 -0
- /package/{packages/cli/tsconfig.json → tsconfig.json} +0 -0
- /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
|