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,108 @@
|
|
|
1
|
+
import { SSHConnection } from './ssh.js';
|
|
2
|
+
import { Logger } from '../utils/logger.js';
|
|
3
|
+
|
|
4
|
+
export interface DeploymentLock {
|
|
5
|
+
pid: number;
|
|
6
|
+
user: string;
|
|
7
|
+
timestamp: number;
|
|
8
|
+
hostname: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Deployment lock manager to prevent concurrent deployments
|
|
13
|
+
*/
|
|
14
|
+
export class LockManager {
|
|
15
|
+
private ssh: SSHConnection;
|
|
16
|
+
private lockFile: string;
|
|
17
|
+
|
|
18
|
+
constructor(ssh: SSHConnection, remoteDir: string) {
|
|
19
|
+
this.ssh = ssh;
|
|
20
|
+
this.lockFile = `${remoteDir}/.hostfn-deploy.lock`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Acquire deployment lock
|
|
25
|
+
*/
|
|
26
|
+
async acquire(timeout: number = 300): Promise<boolean> {
|
|
27
|
+
// Check if lock exists
|
|
28
|
+
const exists = await this.ssh.exists(this.lockFile);
|
|
29
|
+
|
|
30
|
+
if (exists) {
|
|
31
|
+
// Read lock info
|
|
32
|
+
const result = await this.ssh.exec(`cat ${this.lockFile}`);
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const lock: DeploymentLock = JSON.parse(result.stdout);
|
|
36
|
+
const age = Date.now() - lock.timestamp;
|
|
37
|
+
|
|
38
|
+
// Check if lock is stale (older than timeout)
|
|
39
|
+
if (age > timeout * 1000) {
|
|
40
|
+
Logger.warn('Found stale lock file, removing...');
|
|
41
|
+
await this.release();
|
|
42
|
+
} else {
|
|
43
|
+
const ageMinutes = Math.round(age / 1000 / 60);
|
|
44
|
+
Logger.error('Deployment is already in progress');
|
|
45
|
+
Logger.info(`Started by: ${lock.user}`);
|
|
46
|
+
Logger.info(`Started at: ${new Date(lock.timestamp).toLocaleString()}`);
|
|
47
|
+
Logger.info(`Age: ${ageMinutes} minute(s)`);
|
|
48
|
+
Logger.br();
|
|
49
|
+
Logger.warn('Wait for the current deployment to finish or:');
|
|
50
|
+
Logger.log(' 1. Wait for lock to expire (5 minutes)');
|
|
51
|
+
Logger.log(' 2. Manually remove: ssh <host> rm ' + this.lockFile);
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
} catch {
|
|
55
|
+
// Invalid lock file, remove it
|
|
56
|
+
await this.release();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Create lock
|
|
61
|
+
const lock: DeploymentLock = {
|
|
62
|
+
pid: process.pid,
|
|
63
|
+
user: process.env.USER || 'unknown',
|
|
64
|
+
timestamp: Date.now(),
|
|
65
|
+
hostname: process.env.HOSTNAME || 'unknown',
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
await this.ssh.exec(`cat > ${this.lockFile} << 'LOCKEOF'
|
|
69
|
+
${JSON.stringify(lock, null, 2)}
|
|
70
|
+
LOCKEOF`);
|
|
71
|
+
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Release deployment lock
|
|
77
|
+
*/
|
|
78
|
+
async release(): Promise<void> {
|
|
79
|
+
await this.ssh.exec(`rm -f ${this.lockFile}`).catch(() => {
|
|
80
|
+
// Ignore errors on cleanup
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Check if deployment is locked
|
|
86
|
+
*/
|
|
87
|
+
async isLocked(): Promise<boolean> {
|
|
88
|
+
const exists = await this.ssh.exists(this.lockFile);
|
|
89
|
+
|
|
90
|
+
if (!exists) {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Check if lock is stale
|
|
95
|
+
const result = await this.ssh.exec(`cat ${this.lockFile}`);
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
const lock: DeploymentLock = JSON.parse(result.stdout);
|
|
99
|
+
const age = Date.now() - lock.timestamp;
|
|
100
|
+
|
|
101
|
+
// Lock is stale if older than 5 minutes
|
|
102
|
+
return age < 300 * 1000;
|
|
103
|
+
} catch {
|
|
104
|
+
// Invalid lock file = not locked
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import type { HostfnConfig, ServiceConfig } from '../config/schema.js';
|
|
2
|
+
|
|
3
|
+
export interface NginxServiceConfig {
|
|
4
|
+
name: string;
|
|
5
|
+
port: number;
|
|
6
|
+
exposePath?: string;
|
|
7
|
+
isDefault?: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface NginxConfig {
|
|
11
|
+
domain?: string | string[];
|
|
12
|
+
ssl: boolean;
|
|
13
|
+
services: NginxServiceConfig[];
|
|
14
|
+
environment: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class NginxConfigGenerator {
|
|
18
|
+
/**
|
|
19
|
+
* Generate nginx configuration for services
|
|
20
|
+
*/
|
|
21
|
+
static generate(config: NginxConfig): string {
|
|
22
|
+
const { domain, ssl, services, environment } = config;
|
|
23
|
+
// Handle both single domain string and array of domains
|
|
24
|
+
const domains = domain ? (Array.isArray(domain) ? domain : [domain]) : [];
|
|
25
|
+
const serverName = domains.length > 0 ? domains.join(' ') : '_';
|
|
26
|
+
|
|
27
|
+
// Separate default and path-based services
|
|
28
|
+
const defaultService = services.find(s => s.isDefault);
|
|
29
|
+
const pathServices = services.filter(s => !s.isDefault && s.exposePath);
|
|
30
|
+
|
|
31
|
+
let nginxConfig = '';
|
|
32
|
+
|
|
33
|
+
if (ssl) {
|
|
34
|
+
// HTTPS server block
|
|
35
|
+
nginxConfig += this.generateHttpsBlock(serverName, defaultService, pathServices);
|
|
36
|
+
nginxConfig += '\n\n';
|
|
37
|
+
// HTTP redirect block
|
|
38
|
+
nginxConfig += this.generateHttpRedirectBlock(serverName);
|
|
39
|
+
} else {
|
|
40
|
+
// HTTP only server block
|
|
41
|
+
nginxConfig += this.generateHttpBlock(serverName, defaultService, pathServices);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return nginxConfig;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Generate HTTPS server block
|
|
49
|
+
*/
|
|
50
|
+
private static generateHttpsBlock(
|
|
51
|
+
serverName: string,
|
|
52
|
+
defaultService: NginxServiceConfig | undefined,
|
|
53
|
+
pathServices: NginxServiceConfig[]
|
|
54
|
+
): string {
|
|
55
|
+
// Extract primary domain for certificate path (first domain in the list)
|
|
56
|
+
const primaryDomain = serverName.split(' ')[0];
|
|
57
|
+
let config = `server {
|
|
58
|
+
listen 443 ssl http2;
|
|
59
|
+
listen [::]:443 ssl http2;
|
|
60
|
+
server_name ${serverName};
|
|
61
|
+
|
|
62
|
+
ssl_certificate /etc/letsencrypt/live/${primaryDomain}/fullchain.pem; # managed by Certbot
|
|
63
|
+
ssl_certificate_key /etc/letsencrypt/live/${primaryDomain}/privkey.pem; # managed by Certbot
|
|
64
|
+
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
|
|
65
|
+
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot
|
|
66
|
+
`;
|
|
67
|
+
|
|
68
|
+
// Add path-based services first (higher priority)
|
|
69
|
+
for (const service of pathServices) {
|
|
70
|
+
config += this.generateLocationBlock(service);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Add default service
|
|
74
|
+
if (defaultService) {
|
|
75
|
+
config += this.generateLocationBlock(defaultService);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
config += '}\n';
|
|
79
|
+
return config;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Generate HTTP server block
|
|
84
|
+
*/
|
|
85
|
+
private static generateHttpBlock(
|
|
86
|
+
serverName: string,
|
|
87
|
+
defaultService: NginxServiceConfig | undefined,
|
|
88
|
+
pathServices: NginxServiceConfig[]
|
|
89
|
+
): string {
|
|
90
|
+
let config = `server {
|
|
91
|
+
listen 80;
|
|
92
|
+
listen [::]:80;
|
|
93
|
+
server_name ${serverName};
|
|
94
|
+
`;
|
|
95
|
+
|
|
96
|
+
// Add path-based services first (higher priority)
|
|
97
|
+
for (const service of pathServices) {
|
|
98
|
+
config += this.generateLocationBlock(service);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Add default service
|
|
102
|
+
if (defaultService) {
|
|
103
|
+
config += this.generateLocationBlock(defaultService);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
config += '}\n';
|
|
107
|
+
return config;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Generate HTTP to HTTPS redirect block
|
|
112
|
+
*/
|
|
113
|
+
private static generateHttpRedirectBlock(serverName: string): string {
|
|
114
|
+
return `server {
|
|
115
|
+
listen 80;
|
|
116
|
+
listen [::]:80;
|
|
117
|
+
server_name ${serverName};
|
|
118
|
+
|
|
119
|
+
location / {
|
|
120
|
+
return 301 https://$host$request_uri;
|
|
121
|
+
}
|
|
122
|
+
}`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Generate location block for a service
|
|
127
|
+
*/
|
|
128
|
+
private static generateLocationBlock(service: NginxServiceConfig): string {
|
|
129
|
+
const path = service.exposePath || '/';
|
|
130
|
+
const hasTrailingSlash = path !== '/' && path.endsWith('/');
|
|
131
|
+
const proxyPassPath = hasTrailingSlash ? '/' : '';
|
|
132
|
+
|
|
133
|
+
return `
|
|
134
|
+
# ${service.name}
|
|
135
|
+
location ${path} {
|
|
136
|
+
proxy_pass http://localhost:${service.port}${proxyPassPath};
|
|
137
|
+
proxy_http_version 1.1;
|
|
138
|
+
proxy_set_header Upgrade $http_upgrade;
|
|
139
|
+
proxy_set_header Connection 'upgrade';
|
|
140
|
+
proxy_set_header Host $host;
|
|
141
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
142
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
143
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
144
|
+
proxy_cache_bypass $http_upgrade;
|
|
145
|
+
proxy_read_timeout 60s;
|
|
146
|
+
proxy_connect_timeout 60s;
|
|
147
|
+
}
|
|
148
|
+
`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Get nginx config file path based on the system
|
|
153
|
+
*/
|
|
154
|
+
static getConfigPath(environment: string, useSitesAvailable: boolean): string {
|
|
155
|
+
if (useSitesAvailable) {
|
|
156
|
+
return `/etc/nginx/sites-available/hostfn-${environment}`;
|
|
157
|
+
}
|
|
158
|
+
return `/etc/nginx/conf.d/hostfn-${environment}.conf`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Get command to enable site (for sites-available/sites-enabled systems)
|
|
163
|
+
*/
|
|
164
|
+
static getEnableCommand(environment: string, useSitesAvailable: boolean): string | null {
|
|
165
|
+
if (useSitesAvailable) {
|
|
166
|
+
return `ln -sf /etc/nginx/sites-available/hostfn-${environment} /etc/nginx/sites-enabled/hostfn-${environment}`;
|
|
167
|
+
}
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import { Client, ConnectConfig } from 'ssh2';
|
|
2
|
+
import { readFileSync } from 'fs';
|
|
3
|
+
import { homedir } from 'os';
|
|
4
|
+
import { resolve } from 'path';
|
|
5
|
+
import { Logger } from '../utils/logger.js';
|
|
6
|
+
|
|
7
|
+
export interface SSHConnectionOptions {
|
|
8
|
+
host: string;
|
|
9
|
+
port?: number;
|
|
10
|
+
username: string;
|
|
11
|
+
password?: string;
|
|
12
|
+
privateKeyPath?: string;
|
|
13
|
+
passphrase?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface SSHCommandResult {
|
|
17
|
+
stdout: string;
|
|
18
|
+
stderr: string;
|
|
19
|
+
exitCode: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* SSH Connection Manager
|
|
24
|
+
*/
|
|
25
|
+
export class SSHConnection {
|
|
26
|
+
private client: Client;
|
|
27
|
+
private config: ConnectConfig;
|
|
28
|
+
private connected: boolean = false;
|
|
29
|
+
|
|
30
|
+
constructor(options: SSHConnectionOptions) {
|
|
31
|
+
this.client = new Client();
|
|
32
|
+
|
|
33
|
+
// Build SSH config
|
|
34
|
+
this.config = {
|
|
35
|
+
host: options.host,
|
|
36
|
+
port: options.port || 22,
|
|
37
|
+
username: options.username,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// Add authentication method
|
|
41
|
+
if (options.password) {
|
|
42
|
+
this.config.password = options.password;
|
|
43
|
+
} else if (process.env.HOSTFN_SSH_KEY) {
|
|
44
|
+
// CI/CD mode: Load key from environment variable
|
|
45
|
+
try {
|
|
46
|
+
this.config.privateKey = Buffer.from(
|
|
47
|
+
process.env.HOSTFN_SSH_KEY,
|
|
48
|
+
'base64'
|
|
49
|
+
);
|
|
50
|
+
if (options.passphrase || process.env.HOSTFN_SSH_PASSPHRASE) {
|
|
51
|
+
this.config.passphrase = options.passphrase || process.env.HOSTFN_SSH_PASSPHRASE;
|
|
52
|
+
}
|
|
53
|
+
} catch (error) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
`Failed to decode HOSTFN_SSH_KEY from environment variable\n` +
|
|
56
|
+
`Make sure it's base64 encoded: cat ~/.ssh/id_rsa | base64`
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
} else {
|
|
60
|
+
// Enable SSH agent for passphrase-protected keys
|
|
61
|
+
if (process.env.SSH_AUTH_SOCK) {
|
|
62
|
+
this.config.agent = process.env.SSH_AUTH_SOCK;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Try to load private key from file
|
|
66
|
+
const keyPath = options.privateKeyPath || this.getDefaultKeyPath();
|
|
67
|
+
try {
|
|
68
|
+
this.config.privateKey = readFileSync(keyPath);
|
|
69
|
+
if (options.passphrase) {
|
|
70
|
+
this.config.passphrase = options.passphrase;
|
|
71
|
+
}
|
|
72
|
+
} catch (error) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
`Failed to load SSH key from ${keyPath}\n` +
|
|
75
|
+
`Make sure the file exists or set HOSTFN_SSH_KEY environment variable\n` +
|
|
76
|
+
`For CI/CD: export HOSTFN_SSH_KEY=$(cat ~/.ssh/id_rsa | base64)`
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Get default SSH key path (~/.ssh/id_rsa)
|
|
84
|
+
*/
|
|
85
|
+
private getDefaultKeyPath(): string {
|
|
86
|
+
const sshDir = resolve(homedir(), '.ssh');
|
|
87
|
+
// Try common key names
|
|
88
|
+
const keyNames = ['id_rsa', 'id_ed25519', 'id_ecdsa'];
|
|
89
|
+
|
|
90
|
+
for (const keyName of keyNames) {
|
|
91
|
+
const keyPath = resolve(sshDir, keyName);
|
|
92
|
+
try {
|
|
93
|
+
readFileSync(keyPath);
|
|
94
|
+
return keyPath;
|
|
95
|
+
} catch {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Default to id_rsa even if it doesn't exist (will error later)
|
|
101
|
+
return resolve(sshDir, 'id_rsa');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Connect to SSH server
|
|
106
|
+
*/
|
|
107
|
+
async connect(): Promise<void> {
|
|
108
|
+
return new Promise((resolve, reject) => {
|
|
109
|
+
this.client
|
|
110
|
+
.on('ready', () => {
|
|
111
|
+
this.connected = true;
|
|
112
|
+
resolve();
|
|
113
|
+
})
|
|
114
|
+
.on('error', (err) => {
|
|
115
|
+
reject(new Error(`SSH connection failed: ${err.message}`));
|
|
116
|
+
})
|
|
117
|
+
.connect(this.config);
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Execute command on remote server
|
|
123
|
+
*/
|
|
124
|
+
async exec(command: string, options?: {
|
|
125
|
+
streaming?: boolean;
|
|
126
|
+
cwd?: string;
|
|
127
|
+
skipNvmSetup?: boolean;
|
|
128
|
+
}): Promise<SSHCommandResult> {
|
|
129
|
+
if (!this.connected) {
|
|
130
|
+
throw new Error('Not connected. Call connect() first.');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Source nvm to ensure Node.js/npm are in PATH for non-interactive sessions
|
|
134
|
+
// Skip for setup scripts that install nvm
|
|
135
|
+
const nvmSetup = options?.skipNvmSetup
|
|
136
|
+
? ''
|
|
137
|
+
: 'export NVM_DIR="$HOME/.nvm" && [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" && ';
|
|
138
|
+
|
|
139
|
+
// Add cd command if cwd provided
|
|
140
|
+
const fullCommand = options?.cwd
|
|
141
|
+
? `${nvmSetup}cd ${options.cwd} && ${command}`
|
|
142
|
+
: `${nvmSetup}${command}`;
|
|
143
|
+
|
|
144
|
+
return new Promise((resolve, reject) => {
|
|
145
|
+
this.client.exec(fullCommand, (err, stream) => {
|
|
146
|
+
if (err) {
|
|
147
|
+
reject(new Error(`Failed to execute command: ${err.message}`));
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
let stdout = '';
|
|
152
|
+
let stderr = '';
|
|
153
|
+
|
|
154
|
+
stream
|
|
155
|
+
.on('close', (code: number) => {
|
|
156
|
+
resolve({
|
|
157
|
+
stdout,
|
|
158
|
+
stderr,
|
|
159
|
+
exitCode: code,
|
|
160
|
+
});
|
|
161
|
+
})
|
|
162
|
+
.on('data', (data: Buffer) => {
|
|
163
|
+
const output = data.toString();
|
|
164
|
+
stdout += output;
|
|
165
|
+
|
|
166
|
+
if (options?.streaming) {
|
|
167
|
+
process.stdout.write(output);
|
|
168
|
+
}
|
|
169
|
+
})
|
|
170
|
+
.stderr.on('data', (data: Buffer) => {
|
|
171
|
+
const output = data.toString();
|
|
172
|
+
stderr += output;
|
|
173
|
+
|
|
174
|
+
if (options?.streaming) {
|
|
175
|
+
process.stderr.write(output);
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Upload file to remote server (SCP)
|
|
184
|
+
*/
|
|
185
|
+
async uploadFile(
|
|
186
|
+
localPath: string,
|
|
187
|
+
remotePath: string
|
|
188
|
+
): Promise<void> {
|
|
189
|
+
if (!this.connected) {
|
|
190
|
+
throw new Error('Not connected. Call connect() first.');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return new Promise((resolve, reject) => {
|
|
194
|
+
this.client.sftp((err, sftp) => {
|
|
195
|
+
if (err) {
|
|
196
|
+
reject(new Error(`SFTP session failed: ${err.message}`));
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
sftp.fastPut(localPath, remotePath, (err) => {
|
|
201
|
+
if (err) {
|
|
202
|
+
reject(new Error(`Failed to upload file: ${err.message}`));
|
|
203
|
+
} else {
|
|
204
|
+
resolve();
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Download file from remote server
|
|
213
|
+
*/
|
|
214
|
+
async downloadFile(
|
|
215
|
+
remotePath: string,
|
|
216
|
+
localPath: string
|
|
217
|
+
): Promise<void> {
|
|
218
|
+
if (!this.connected) {
|
|
219
|
+
throw new Error('Not connected. Call connect() first.');
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return new Promise((resolve, reject) => {
|
|
223
|
+
this.client.sftp((err, sftp) => {
|
|
224
|
+
if (err) {
|
|
225
|
+
reject(new Error(`SFTP session failed: ${err.message}`));
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
sftp.fastGet(remotePath, localPath, (err) => {
|
|
230
|
+
if (err) {
|
|
231
|
+
reject(new Error(`Failed to download file: ${err.message}`));
|
|
232
|
+
} else {
|
|
233
|
+
resolve();
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Check if file/directory exists on remote
|
|
242
|
+
*/
|
|
243
|
+
async exists(remotePath: string): Promise<boolean> {
|
|
244
|
+
try {
|
|
245
|
+
const result = await this.exec(`test -e ${remotePath} && echo "exists"`);
|
|
246
|
+
return result.stdout.trim() === 'exists';
|
|
247
|
+
} catch {
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Create directory on remote server
|
|
254
|
+
*/
|
|
255
|
+
async mkdir(remotePath: string, recursive: boolean = true): Promise<void> {
|
|
256
|
+
const flag = recursive ? '-p' : '';
|
|
257
|
+
const result = await this.exec(`mkdir ${flag} ${remotePath}`);
|
|
258
|
+
|
|
259
|
+
if (result.exitCode !== 0) {
|
|
260
|
+
throw new Error(`Failed to create directory: ${result.stderr}`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Disconnect from server
|
|
266
|
+
*/
|
|
267
|
+
disconnect(): void {
|
|
268
|
+
if (this.connected) {
|
|
269
|
+
this.client.end();
|
|
270
|
+
this.connected = false;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Check if connected
|
|
276
|
+
*/
|
|
277
|
+
isConnected(): boolean {
|
|
278
|
+
return this.connected;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Parse SSH connection string (user@host)
|
|
284
|
+
*/
|
|
285
|
+
export function parseSSHConnection(connectionString: string): {
|
|
286
|
+
username: string;
|
|
287
|
+
host: string;
|
|
288
|
+
port?: number;
|
|
289
|
+
} {
|
|
290
|
+
// Support formats:
|
|
291
|
+
// - user@host
|
|
292
|
+
// - user@host:port
|
|
293
|
+
const match = connectionString.match(/^([^@]+)@([^:]+)(?::(\d+))?$/);
|
|
294
|
+
|
|
295
|
+
if (!match) {
|
|
296
|
+
throw new Error(
|
|
297
|
+
`Invalid SSH connection string: ${connectionString}\n` +
|
|
298
|
+
`Expected format: user@host or user@host:port`
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return {
|
|
303
|
+
username: match[1],
|
|
304
|
+
host: match[2],
|
|
305
|
+
port: match[3] ? parseInt(match[3]) : undefined,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Create SSH connection from connection string
|
|
311
|
+
*/
|
|
312
|
+
export async function createSSHConnection(
|
|
313
|
+
connectionString: string,
|
|
314
|
+
options?: {
|
|
315
|
+
password?: string;
|
|
316
|
+
privateKeyPath?: string;
|
|
317
|
+
passphrase?: string;
|
|
318
|
+
}
|
|
319
|
+
): Promise<SSHConnection> {
|
|
320
|
+
// Support HOSTFN_HOST env var for CI/CD
|
|
321
|
+
const hostToUse = process.env.HOSTFN_HOST || connectionString;
|
|
322
|
+
const { username, host, port } = parseSSHConnection(hostToUse);
|
|
323
|
+
|
|
324
|
+
const ssh = new SSHConnection({
|
|
325
|
+
host,
|
|
326
|
+
port,
|
|
327
|
+
username,
|
|
328
|
+
password: options?.password || process.env.HOSTFN_SSH_PASSWORD,
|
|
329
|
+
privateKeyPath: options?.privateKeyPath,
|
|
330
|
+
passphrase: options?.passphrase || process.env.HOSTFN_SSH_PASSPHRASE,
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
await ssh.connect();
|
|
334
|
+
return ssh;
|
|
335
|
+
}
|