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.
- package/LICENSE +21 -0
- package/README.md +1136 -0
- package/_conduct/specs/1.v0.spec.md +1041 -0
- package/examples/express-api/package.json +22 -0
- package/examples/express-api/src/index.ts +16 -0
- package/examples/express-api/tsconfig.json +11 -0
- package/examples/github-actions-deploy.yml +40 -0
- package/examples/monorepo-config.json +76 -0
- package/examples/monorepo-multi-server-config.json +74 -0
- package/package.json +39 -0
- package/packages/cli/package.json +40 -0
- package/packages/cli/src/__tests__/core/backup.test.ts +137 -0
- package/packages/cli/src/__tests__/core/health.test.ts +125 -0
- package/packages/cli/src/__tests__/core/lock.test.ts +173 -0
- package/packages/cli/src/__tests__/core/nginx-multi-domain.test.ts +176 -0
- package/packages/cli/src/__tests__/runtimes/pm2.test.ts +130 -0
- package/packages/cli/src/__tests__/utils/validation.test.ts +164 -0
- package/packages/cli/src/commands/deploy.ts +817 -0
- package/packages/cli/src/commands/env.ts +391 -0
- package/packages/cli/src/commands/expose.ts +438 -0
- package/packages/cli/src/commands/init.ts +192 -0
- package/packages/cli/src/commands/logs.ts +106 -0
- package/packages/cli/src/commands/rollback.ts +142 -0
- package/packages/cli/src/commands/server/info.ts +131 -0
- package/packages/cli/src/commands/server/setup.ts +200 -0
- package/packages/cli/src/commands/status.ts +149 -0
- package/packages/cli/src/config/loader.ts +66 -0
- package/packages/cli/src/config/schema.ts +140 -0
- package/packages/cli/src/core/backup.ts +128 -0
- package/packages/cli/src/core/health.ts +116 -0
- package/packages/cli/src/core/local.ts +67 -0
- package/packages/cli/src/core/lock.ts +108 -0
- package/packages/cli/src/core/nginx.ts +170 -0
- package/packages/cli/src/core/ssh.ts +335 -0
- package/packages/cli/src/core/sync.ts +138 -0
- package/packages/cli/src/core/workspace.ts +180 -0
- package/packages/cli/src/index.ts +240 -0
- package/packages/cli/src/runtimes/base.ts +144 -0
- package/packages/cli/src/runtimes/nodejs/detector.ts +157 -0
- package/packages/cli/src/runtimes/nodejs/index.ts +228 -0
- package/packages/cli/src/runtimes/nodejs/pm2.ts +71 -0
- package/packages/cli/src/runtimes/registry.ts +76 -0
- package/packages/cli/src/utils/logger.ts +86 -0
- package/packages/cli/src/utils/validation.ts +147 -0
- package/packages/cli/tsconfig.json +25 -0
- package/packages/cli/vitest.config.ts +19 -0
- package/turbo.json +24 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import ora from 'ora';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { Logger } from '../utils/logger.js';
|
|
4
|
+
import { ConfigLoader } from '../config/loader.js';
|
|
5
|
+
import { createSSHConnection } from '../core/ssh.js';
|
|
6
|
+
import { RuntimeRegistry } from '../runtimes/registry.js';
|
|
7
|
+
|
|
8
|
+
interface StatusOptions {
|
|
9
|
+
service?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function statusCommand(
|
|
13
|
+
environment: string,
|
|
14
|
+
options: StatusOptions = {}
|
|
15
|
+
): Promise<void> {
|
|
16
|
+
Logger.header(`Status - ${environment}`);
|
|
17
|
+
|
|
18
|
+
// Load configuration
|
|
19
|
+
const config = ConfigLoader.load();
|
|
20
|
+
const envConfig = config.environments[environment];
|
|
21
|
+
|
|
22
|
+
if (!envConfig) {
|
|
23
|
+
throw new Error(`Environment '${environment}' not found`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Handle multi-service status
|
|
27
|
+
if (config.services && Object.keys(config.services).length > 0) {
|
|
28
|
+
if (options.service) {
|
|
29
|
+
// Show status for specific service
|
|
30
|
+
if (!config.services[options.service]) {
|
|
31
|
+
throw new Error(
|
|
32
|
+
`Service '${options.service}' not found\n` +
|
|
33
|
+
`Available: ${Object.keys(config.services).join(', ')}`
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
const serviceConfig = config.services[options.service];
|
|
37
|
+
const serviceServer = serviceConfig.server || envConfig.server;
|
|
38
|
+
await showServiceStatus(
|
|
39
|
+
`${config.name}-${options.service}-${environment}`,
|
|
40
|
+
serviceServer,
|
|
41
|
+
options.service
|
|
42
|
+
);
|
|
43
|
+
} else {
|
|
44
|
+
// Show status for all services
|
|
45
|
+
Logger.info(`Showing status for all ${Object.keys(config.services).length} services`);
|
|
46
|
+
Logger.br();
|
|
47
|
+
|
|
48
|
+
for (const serviceName of Object.keys(config.services)) {
|
|
49
|
+
const serviceConfig = config.services[serviceName];
|
|
50
|
+
const serviceServer = serviceConfig.server || envConfig.server;
|
|
51
|
+
await showServiceStatus(
|
|
52
|
+
`${config.name}-${serviceName}-${environment}`,
|
|
53
|
+
serviceServer,
|
|
54
|
+
serviceName
|
|
55
|
+
);
|
|
56
|
+
Logger.br();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const serviceName = `${config.name}-${environment}`;
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
await showServiceStatus(serviceName, envConfig.server);
|
|
66
|
+
} catch (error) {
|
|
67
|
+
Logger.error(error instanceof Error ? error.message : String(error));
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function showServiceStatus(
|
|
73
|
+
serviceName: string,
|
|
74
|
+
server: string,
|
|
75
|
+
displayName?: string
|
|
76
|
+
): Promise<void> {
|
|
77
|
+
const spinner = ora(`Fetching status for ${displayName || serviceName}...`).start();
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
const ssh = await createSSHConnection(server);
|
|
81
|
+
spinner.succeed('Connected');
|
|
82
|
+
|
|
83
|
+
Logger.br();
|
|
84
|
+
|
|
85
|
+
// Get PM2 service details
|
|
86
|
+
const result = await ssh.exec(`pm2 jlist | jq '.[] | select(.name=="${serviceName}")'`);
|
|
87
|
+
|
|
88
|
+
if (result.stdout.trim()) {
|
|
89
|
+
const service = JSON.parse(result.stdout);
|
|
90
|
+
|
|
91
|
+
const status = service.pm2_env?.status || 'unknown';
|
|
92
|
+
const statusColor = status === 'online' ? chalk.green : chalk.red;
|
|
93
|
+
|
|
94
|
+
if (displayName) {
|
|
95
|
+
Logger.section(`Service: ${displayName}`);
|
|
96
|
+
} else {
|
|
97
|
+
Logger.section('Service Information');
|
|
98
|
+
}
|
|
99
|
+
Logger.kv('Name', service.name);
|
|
100
|
+
Logger.kv('Status', statusColor(status));
|
|
101
|
+
Logger.kv('PID', service.pid?.toString() || 'N/A');
|
|
102
|
+
Logger.kv('Restarts', service.pm2_env?.restart_time?.toString() || '0');
|
|
103
|
+
Logger.br();
|
|
104
|
+
|
|
105
|
+
Logger.section('Resources');
|
|
106
|
+
const memory = service.monit?.memory
|
|
107
|
+
? `${Math.round(service.monit.memory / 1024 / 1024)}MB`
|
|
108
|
+
: 'N/A';
|
|
109
|
+
const cpu = service.monit?.cpu !== undefined
|
|
110
|
+
? `${service.monit.cpu}%`
|
|
111
|
+
: 'N/A';
|
|
112
|
+
|
|
113
|
+
Logger.kv('Memory', memory);
|
|
114
|
+
Logger.kv('CPU', cpu);
|
|
115
|
+
Logger.br();
|
|
116
|
+
|
|
117
|
+
Logger.section('Timing');
|
|
118
|
+
if (service.pm2_env?.pm_uptime) {
|
|
119
|
+
const uptime = Date.now() - service.pm2_env.pm_uptime;
|
|
120
|
+
Logger.kv('Uptime', formatUptime(uptime));
|
|
121
|
+
}
|
|
122
|
+
if (service.pm2_env?.created_at) {
|
|
123
|
+
Logger.kv('Created', new Date(service.pm2_env.created_at).toLocaleString());
|
|
124
|
+
}
|
|
125
|
+
Logger.br();
|
|
126
|
+
|
|
127
|
+
} else {
|
|
128
|
+
Logger.warn(`Service '${serviceName}' not found`);
|
|
129
|
+
Logger.info('Has the service been deployed?');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
ssh.disconnect();
|
|
133
|
+
} catch (error) {
|
|
134
|
+
spinner.fail('Failed to get status');
|
|
135
|
+
throw error;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function formatUptime(ms: number): string {
|
|
140
|
+
const seconds = Math.floor(ms / 1000);
|
|
141
|
+
const minutes = Math.floor(seconds / 60);
|
|
142
|
+
const hours = Math.floor(minutes / 60);
|
|
143
|
+
const days = Math.floor(hours / 24);
|
|
144
|
+
|
|
145
|
+
if (days > 0) return `${days}d ${hours % 24}h ${minutes % 60}m`;
|
|
146
|
+
if (hours > 0) return `${hours}h ${minutes % 60}m`;
|
|
147
|
+
if (minutes > 0) return `${minutes}m ${seconds % 60}s`;
|
|
148
|
+
return `${seconds}s`;
|
|
149
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'fs';
|
|
2
|
+
import { resolve } from 'path';
|
|
3
|
+
import { HostfnConfig, HostfnConfigSchema } from './schema.js';
|
|
4
|
+
|
|
5
|
+
export class ConfigLoader {
|
|
6
|
+
private static CONFIG_FILENAME = 'hostfn.config.json';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Load configuration from file
|
|
10
|
+
*/
|
|
11
|
+
static load(cwd: string = process.cwd()): HostfnConfig {
|
|
12
|
+
const configPath = resolve(cwd, this.CONFIG_FILENAME);
|
|
13
|
+
|
|
14
|
+
if (!existsSync(configPath)) {
|
|
15
|
+
throw new Error(
|
|
16
|
+
`Configuration file not found: ${configPath}\n` +
|
|
17
|
+
`Run 'hostfn init' to create one.`
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
const content = readFileSync(configPath, 'utf-8');
|
|
23
|
+
const json = JSON.parse(content);
|
|
24
|
+
return this.validate(json);
|
|
25
|
+
} catch (error) {
|
|
26
|
+
if (error instanceof SyntaxError) {
|
|
27
|
+
throw new Error(`Invalid JSON in config file: ${error.message}`);
|
|
28
|
+
}
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Validate configuration against schema
|
|
35
|
+
*/
|
|
36
|
+
static validate(config: unknown): HostfnConfig {
|
|
37
|
+
const result = HostfnConfigSchema.safeParse(config);
|
|
38
|
+
|
|
39
|
+
if (!result.success) {
|
|
40
|
+
const errors = result.error.errors
|
|
41
|
+
.map(err => ` - ${err.path.join('.')}: ${err.message}`)
|
|
42
|
+
.join('\n');
|
|
43
|
+
|
|
44
|
+
throw new Error(
|
|
45
|
+
`Invalid configuration:\n${errors}`
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return result.data;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Check if config file exists
|
|
54
|
+
*/
|
|
55
|
+
static exists(cwd: string = process.cwd()): boolean {
|
|
56
|
+
const configPath = resolve(cwd, this.CONFIG_FILENAME);
|
|
57
|
+
return existsSync(configPath);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Get config file path
|
|
62
|
+
*/
|
|
63
|
+
static getConfigPath(cwd: string = process.cwd()): string {
|
|
64
|
+
return resolve(cwd, this.CONFIG_FILENAME);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
// Runtime types - extensible for future languages
|
|
4
|
+
export const RuntimeSchema = z.enum(['nodejs', 'python', 'go', 'ruby', 'rust', 'docker']);
|
|
5
|
+
export type Runtime = z.infer<typeof RuntimeSchema>;
|
|
6
|
+
|
|
7
|
+
// Environment configuration
|
|
8
|
+
export const EnvironmentConfigSchema = z.object({
|
|
9
|
+
server: z.string().describe('SSH connection string (user@host)'),
|
|
10
|
+
port: z.number().int().positive().describe('Port for the service'),
|
|
11
|
+
instances: z.union([z.number().int().positive(), z.literal('max')]).default(1),
|
|
12
|
+
domain: z.union([z.string(), z.array(z.string())]).optional().describe('Domain name(s) for the service - can be a single domain or array of domains'),
|
|
13
|
+
sslEmail: z.string().email().optional().describe('Email for SSL certificate'),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
export type EnvironmentConfig = z.infer<typeof EnvironmentConfigSchema>;
|
|
17
|
+
|
|
18
|
+
// Build configuration
|
|
19
|
+
export const BuildConfigSchema = z.object({
|
|
20
|
+
command: z.string().describe('Build command to run'),
|
|
21
|
+
directory: z.string().default('dist').describe('Output directory'),
|
|
22
|
+
nodeModules: z.enum(['all', 'production', 'none']).default('production'),
|
|
23
|
+
}).optional();
|
|
24
|
+
|
|
25
|
+
export type BuildConfig = z.infer<typeof BuildConfigSchema>;
|
|
26
|
+
|
|
27
|
+
// Start configuration
|
|
28
|
+
export const StartConfigSchema = z.object({
|
|
29
|
+
command: z.string().describe('Start command'),
|
|
30
|
+
entry: z.string().optional().describe('Entry point file'),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
export type StartConfig = z.infer<typeof StartConfigSchema>;
|
|
34
|
+
|
|
35
|
+
// Health check configuration
|
|
36
|
+
export const HealthCheckConfigSchema = z.object({
|
|
37
|
+
path: z.string().default('/health'),
|
|
38
|
+
timeout: z.number().int().positive().default(60),
|
|
39
|
+
retries: z.number().int().positive().default(10),
|
|
40
|
+
interval: z.number().int().positive().default(3),
|
|
41
|
+
}).optional();
|
|
42
|
+
|
|
43
|
+
export type HealthCheckConfig = z.infer<typeof HealthCheckConfigSchema>;
|
|
44
|
+
|
|
45
|
+
// Environment variables configuration
|
|
46
|
+
export const EnvConfigSchema = z.object({
|
|
47
|
+
required: z.array(z.string()).default([]),
|
|
48
|
+
optional: z.array(z.string()).default([]),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
export type EnvConfig = z.infer<typeof EnvConfigSchema>;
|
|
52
|
+
|
|
53
|
+
// Sync configuration
|
|
54
|
+
export const SyncConfigSchema = z.object({
|
|
55
|
+
exclude: z.array(z.string()).default([
|
|
56
|
+
'node_modules',
|
|
57
|
+
'.git',
|
|
58
|
+
'.github',
|
|
59
|
+
'dist',
|
|
60
|
+
'build',
|
|
61
|
+
'.env',
|
|
62
|
+
'.env.*',
|
|
63
|
+
'*.log',
|
|
64
|
+
'.turbo',
|
|
65
|
+
'.wrangler',
|
|
66
|
+
]),
|
|
67
|
+
include: z.array(z.string()).optional(),
|
|
68
|
+
}).optional();
|
|
69
|
+
|
|
70
|
+
export type SyncConfig = z.infer<typeof SyncConfigSchema>;
|
|
71
|
+
|
|
72
|
+
// Backup configuration
|
|
73
|
+
export const BackupConfigSchema = z.object({
|
|
74
|
+
keep: z.number().int().positive().default(5),
|
|
75
|
+
}).optional();
|
|
76
|
+
|
|
77
|
+
export type BackupConfig = z.infer<typeof BackupConfigSchema>;
|
|
78
|
+
|
|
79
|
+
// Service configuration (for monorepos)
|
|
80
|
+
export const ServiceConfigSchema = z.object({
|
|
81
|
+
port: z.number().int().positive(),
|
|
82
|
+
path: z.string().describe('Path in monorepo'),
|
|
83
|
+
domain: z.union([z.string(), z.array(z.string())]).optional().describe('Domain name(s) for this service - can be a single domain or array of domains'),
|
|
84
|
+
exposePath: z.string().optional().describe('Nginx path prefix (e.g., "/api", "/docs")'),
|
|
85
|
+
server: z.string().optional().describe('Override server for this service (defaults to environment server)'),
|
|
86
|
+
instances: z.union([z.number().int().positive(), z.literal('max')]).optional().describe('PM2 instances for this service'),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
export type ServiceConfig = z.infer<typeof ServiceConfigSchema>;
|
|
90
|
+
|
|
91
|
+
// Main configuration schema
|
|
92
|
+
export const HostfnConfigSchema = z.object({
|
|
93
|
+
name: z.string().describe('Application name'),
|
|
94
|
+
runtime: RuntimeSchema.describe('Runtime/language'),
|
|
95
|
+
version: z.string().describe('Runtime version'),
|
|
96
|
+
|
|
97
|
+
environments: z.record(z.string(), EnvironmentConfigSchema)
|
|
98
|
+
.describe('Environment configurations'),
|
|
99
|
+
|
|
100
|
+
build: BuildConfigSchema,
|
|
101
|
+
start: StartConfigSchema,
|
|
102
|
+
health: HealthCheckConfigSchema,
|
|
103
|
+
env: EnvConfigSchema.default({ required: [], optional: [] }),
|
|
104
|
+
sync: SyncConfigSchema,
|
|
105
|
+
backup: BackupConfigSchema,
|
|
106
|
+
|
|
107
|
+
services: z.record(z.string(), ServiceConfigSchema).optional()
|
|
108
|
+
.describe('Multi-service configuration for monorepos'),
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
export type HostfnConfig = z.infer<typeof HostfnConfigSchema>;
|
|
112
|
+
|
|
113
|
+
// Default configuration for new projects
|
|
114
|
+
export const DEFAULT_CONFIG: Partial<HostfnConfig> = {
|
|
115
|
+
health: {
|
|
116
|
+
path: '/health',
|
|
117
|
+
timeout: 60,
|
|
118
|
+
retries: 10,
|
|
119
|
+
interval: 3,
|
|
120
|
+
},
|
|
121
|
+
env: {
|
|
122
|
+
required: [],
|
|
123
|
+
optional: [],
|
|
124
|
+
},
|
|
125
|
+
sync: {
|
|
126
|
+
exclude: [
|
|
127
|
+
'node_modules',
|
|
128
|
+
'.git',
|
|
129
|
+
'.github',
|
|
130
|
+
'dist',
|
|
131
|
+
'build',
|
|
132
|
+
'.env',
|
|
133
|
+
'.env.*',
|
|
134
|
+
'*.log',
|
|
135
|
+
],
|
|
136
|
+
},
|
|
137
|
+
backup: {
|
|
138
|
+
keep: 5,
|
|
139
|
+
},
|
|
140
|
+
};
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { SSHConnection } from './ssh.js';
|
|
2
|
+
import { Logger } from '../utils/logger.js';
|
|
3
|
+
|
|
4
|
+
export interface BackupOptions {
|
|
5
|
+
keep?: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Backup manager for deployments
|
|
10
|
+
*/
|
|
11
|
+
export class BackupManager {
|
|
12
|
+
private ssh: SSHConnection;
|
|
13
|
+
private appDir: string;
|
|
14
|
+
private backupDir: string;
|
|
15
|
+
|
|
16
|
+
constructor(ssh: SSHConnection, appDir: string) {
|
|
17
|
+
this.ssh = ssh;
|
|
18
|
+
this.appDir = appDir;
|
|
19
|
+
this.backupDir = `${appDir}/backups`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Create backup of current deployment
|
|
24
|
+
*/
|
|
25
|
+
async create(): Promise<string> {
|
|
26
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
27
|
+
const backupPath = `${this.backupDir}/${timestamp}`;
|
|
28
|
+
|
|
29
|
+
// Create backup directory if it doesn't exist
|
|
30
|
+
await this.ssh.mkdir(this.backupDir, true);
|
|
31
|
+
|
|
32
|
+
// Check if there's anything to backup
|
|
33
|
+
const distExists = await this.ssh.exists(`${this.appDir}/dist`);
|
|
34
|
+
|
|
35
|
+
if (!distExists) {
|
|
36
|
+
Logger.warn('No existing deployment to backup');
|
|
37
|
+
return backupPath;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Create backup
|
|
41
|
+
const result = await this.ssh.exec(
|
|
42
|
+
`mkdir -p ${backupPath} && ` +
|
|
43
|
+
`cp -r ${this.appDir}/dist ${backupPath}/ && ` +
|
|
44
|
+
`cp ${this.appDir}/package.json ${backupPath}/ 2>/dev/null || true`
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
if (result.exitCode !== 0) {
|
|
48
|
+
throw new Error(`Failed to create backup: ${result.stderr}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return backupPath;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* List all backups
|
|
56
|
+
*/
|
|
57
|
+
async list(): Promise<string[]> {
|
|
58
|
+
const exists = await this.ssh.exists(this.backupDir);
|
|
59
|
+
|
|
60
|
+
if (!exists) {
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const result = await this.ssh.exec(`ls -1 ${this.backupDir}`);
|
|
65
|
+
|
|
66
|
+
if (result.exitCode !== 0) {
|
|
67
|
+
return [];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return result.stdout
|
|
71
|
+
.trim()
|
|
72
|
+
.split('\n')
|
|
73
|
+
.filter(line => line.length > 0)
|
|
74
|
+
.sort()
|
|
75
|
+
.reverse(); // Most recent first
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Restore from backup
|
|
80
|
+
*/
|
|
81
|
+
async restore(backupName: string): Promise<void> {
|
|
82
|
+
const backupPath = `${this.backupDir}/${backupName}`;
|
|
83
|
+
|
|
84
|
+
// Check if backup exists
|
|
85
|
+
const exists = await this.ssh.exists(backupPath);
|
|
86
|
+
|
|
87
|
+
if (!exists) {
|
|
88
|
+
throw new Error(`Backup not found: ${backupName}`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Restore
|
|
92
|
+
const result = await this.ssh.exec(
|
|
93
|
+
`rm -rf ${this.appDir}/dist && ` +
|
|
94
|
+
`cp -r ${backupPath}/dist ${this.appDir}/`
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
if (result.exitCode !== 0) {
|
|
98
|
+
throw new Error(`Failed to restore backup: ${result.stderr}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Clean up old backups (keep only last N)
|
|
104
|
+
*/
|
|
105
|
+
async cleanup(keep: number = 5): Promise<void> {
|
|
106
|
+
const backups = await this.list();
|
|
107
|
+
|
|
108
|
+
if (backups.length <= keep) {
|
|
109
|
+
return; // Nothing to clean up
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const toDelete = backups.slice(keep);
|
|
113
|
+
|
|
114
|
+
for (const backup of toDelete) {
|
|
115
|
+
await this.ssh.exec(`rm -rf ${this.backupDir}/${backup}`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
Logger.info(`Cleaned up ${toDelete.length} old backup(s)`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Get most recent backup
|
|
123
|
+
*/
|
|
124
|
+
async getLatest(): Promise<string | null> {
|
|
125
|
+
const backups = await this.list();
|
|
126
|
+
return backups.length > 0 ? backups[0] : null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { Logger } from '../utils/logger.js';
|
|
2
|
+
|
|
3
|
+
export interface HealthCheckOptions {
|
|
4
|
+
url: string;
|
|
5
|
+
timeout?: number;
|
|
6
|
+
retries?: number;
|
|
7
|
+
interval?: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface HealthCheckResult {
|
|
11
|
+
healthy: boolean;
|
|
12
|
+
statusCode?: number;
|
|
13
|
+
responseTime?: number;
|
|
14
|
+
error?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Health check utility
|
|
19
|
+
*/
|
|
20
|
+
export class HealthCheck {
|
|
21
|
+
/**
|
|
22
|
+
* Check if service is healthy
|
|
23
|
+
*/
|
|
24
|
+
static async check(options: HealthCheckOptions): Promise<HealthCheckResult> {
|
|
25
|
+
const timeout = options.timeout || 5000;
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
const startTime = Date.now();
|
|
29
|
+
|
|
30
|
+
const controller = new AbortController();
|
|
31
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
32
|
+
|
|
33
|
+
const response = await fetch(options.url, {
|
|
34
|
+
signal: controller.signal,
|
|
35
|
+
headers: {
|
|
36
|
+
'User-Agent': 'hostfn-health-check',
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
clearTimeout(timeoutId);
|
|
41
|
+
|
|
42
|
+
const responseTime = Date.now() - startTime;
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
healthy: response.ok,
|
|
46
|
+
statusCode: response.status,
|
|
47
|
+
responseTime,
|
|
48
|
+
};
|
|
49
|
+
} catch (error) {
|
|
50
|
+
return {
|
|
51
|
+
healthy: false,
|
|
52
|
+
error: error instanceof Error ? error.message : String(error),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Poll health endpoint until healthy or max retries reached
|
|
59
|
+
*/
|
|
60
|
+
static async poll(options: HealthCheckOptions): Promise<boolean> {
|
|
61
|
+
const retries = options.retries || 10;
|
|
62
|
+
const interval = options.interval || 3000;
|
|
63
|
+
|
|
64
|
+
for (let i = 0; i < retries; i++) {
|
|
65
|
+
const result = await this.check(options);
|
|
66
|
+
|
|
67
|
+
if (result.healthy) {
|
|
68
|
+
Logger.success(
|
|
69
|
+
`Health check passed (${result.statusCode}, ${result.responseTime}ms)`
|
|
70
|
+
);
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (i < retries - 1) {
|
|
75
|
+
Logger.warn(
|
|
76
|
+
`Health check failed (attempt ${i + 1}/${retries}), retrying in ${interval / 1000}s...`
|
|
77
|
+
);
|
|
78
|
+
await this.sleep(interval);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
Logger.error(`Health check failed after ${retries} attempts`);
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Wait for service to be ready
|
|
88
|
+
*/
|
|
89
|
+
static async waitForReady(
|
|
90
|
+
options: HealthCheckOptions,
|
|
91
|
+
onProgress?: (attempt: number) => void
|
|
92
|
+
): Promise<boolean> {
|
|
93
|
+
const retries = options.retries || 10;
|
|
94
|
+
const interval = options.interval || 3000;
|
|
95
|
+
|
|
96
|
+
for (let i = 0; i < retries; i++) {
|
|
97
|
+
onProgress?.(i + 1);
|
|
98
|
+
|
|
99
|
+
const result = await this.check(options);
|
|
100
|
+
|
|
101
|
+
if (result.healthy) {
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (i < retries - 1) {
|
|
106
|
+
await this.sleep(interval);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private static sleep(ms: number): Promise<void> {
|
|
114
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { exec } from 'child_process';
|
|
2
|
+
import { promisify } from 'util';
|
|
3
|
+
import { existsSync } from 'fs';
|
|
4
|
+
import { mkdir } from 'fs/promises';
|
|
5
|
+
|
|
6
|
+
const execAsync = promisify(exec);
|
|
7
|
+
|
|
8
|
+
export interface LocalCommandResult {
|
|
9
|
+
stdout: string;
|
|
10
|
+
stderr: string;
|
|
11
|
+
exitCode: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class LocalExecutor {
|
|
15
|
+
async connect(): Promise<void> {
|
|
16
|
+
return Promise.resolve();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async exec(command: string, options?: {
|
|
20
|
+
streaming?: boolean;
|
|
21
|
+
cwd?: string;
|
|
22
|
+
}): Promise<LocalCommandResult> {
|
|
23
|
+
try {
|
|
24
|
+
const { stdout, stderr } = await execAsync(command, {
|
|
25
|
+
cwd: options?.cwd,
|
|
26
|
+
shell: '/bin/bash',
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
stdout,
|
|
31
|
+
stderr,
|
|
32
|
+
exitCode: 0,
|
|
33
|
+
};
|
|
34
|
+
} catch (error: any) {
|
|
35
|
+
return {
|
|
36
|
+
stdout: error.stdout || '',
|
|
37
|
+
stderr: error.stderr || '',
|
|
38
|
+
exitCode: error.code || 1,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async uploadFile(localPath: string, remotePath: string): Promise<void> {
|
|
44
|
+
const { copyFileSync } = await import('fs');
|
|
45
|
+
copyFileSync(localPath, remotePath);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async downloadFile(remotePath: string, localPath: string): Promise<void> {
|
|
49
|
+
const { copyFileSync } = await import('fs');
|
|
50
|
+
copyFileSync(remotePath, localPath);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async exists(path: string): Promise<boolean> {
|
|
54
|
+
return existsSync(path);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async mkdir(path: string, recursive: boolean = true): Promise<void> {
|
|
58
|
+
await mkdir(path, { recursive });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
isConnected(): boolean {
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
disconnect(): void {
|
|
66
|
+
}
|
|
67
|
+
}
|