hostfn 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1136 -0
  3. package/_conduct/specs/1.v0.spec.md +1041 -0
  4. package/examples/express-api/package.json +22 -0
  5. package/examples/express-api/src/index.ts +16 -0
  6. package/examples/express-api/tsconfig.json +11 -0
  7. package/examples/github-actions-deploy.yml +40 -0
  8. package/examples/monorepo-config.json +76 -0
  9. package/examples/monorepo-multi-server-config.json +74 -0
  10. package/package.json +39 -0
  11. package/packages/cli/package.json +40 -0
  12. package/packages/cli/src/__tests__/core/backup.test.ts +137 -0
  13. package/packages/cli/src/__tests__/core/health.test.ts +125 -0
  14. package/packages/cli/src/__tests__/core/lock.test.ts +173 -0
  15. package/packages/cli/src/__tests__/core/nginx-multi-domain.test.ts +176 -0
  16. package/packages/cli/src/__tests__/runtimes/pm2.test.ts +130 -0
  17. package/packages/cli/src/__tests__/utils/validation.test.ts +164 -0
  18. package/packages/cli/src/commands/deploy.ts +817 -0
  19. package/packages/cli/src/commands/env.ts +391 -0
  20. package/packages/cli/src/commands/expose.ts +438 -0
  21. package/packages/cli/src/commands/init.ts +192 -0
  22. package/packages/cli/src/commands/logs.ts +106 -0
  23. package/packages/cli/src/commands/rollback.ts +142 -0
  24. package/packages/cli/src/commands/server/info.ts +131 -0
  25. package/packages/cli/src/commands/server/setup.ts +200 -0
  26. package/packages/cli/src/commands/status.ts +149 -0
  27. package/packages/cli/src/config/loader.ts +66 -0
  28. package/packages/cli/src/config/schema.ts +140 -0
  29. package/packages/cli/src/core/backup.ts +128 -0
  30. package/packages/cli/src/core/health.ts +116 -0
  31. package/packages/cli/src/core/local.ts +67 -0
  32. package/packages/cli/src/core/lock.ts +108 -0
  33. package/packages/cli/src/core/nginx.ts +170 -0
  34. package/packages/cli/src/core/ssh.ts +335 -0
  35. package/packages/cli/src/core/sync.ts +138 -0
  36. package/packages/cli/src/core/workspace.ts +180 -0
  37. package/packages/cli/src/index.ts +240 -0
  38. package/packages/cli/src/runtimes/base.ts +144 -0
  39. package/packages/cli/src/runtimes/nodejs/detector.ts +157 -0
  40. package/packages/cli/src/runtimes/nodejs/index.ts +228 -0
  41. package/packages/cli/src/runtimes/nodejs/pm2.ts +71 -0
  42. package/packages/cli/src/runtimes/registry.ts +76 -0
  43. package/packages/cli/src/utils/logger.ts +86 -0
  44. package/packages/cli/src/utils/validation.ts +147 -0
  45. package/packages/cli/tsconfig.json +25 -0
  46. package/packages/cli/vitest.config.ts +19 -0
  47. package/turbo.json +24 -0
@@ -0,0 +1,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
+ }