UNPKG

@xec-sh/cli

Version:

Xec: The Universal Shell for TypeScript

686 lines 29.9 kB
import { z } from 'zod'; import path from 'path'; import chalk from 'chalk'; import { parseTimeout } from '../utils/time.js'; import { validateOptions } from '../utils/validation.js'; import { ScriptLoader } from '../utils/script-loader.js'; import { ConfigAwareCommand } from '../utils/command-base.js'; import { InteractiveHelpers } from '../utils/interactive-helpers.js'; export class OnCommand extends ConfigAwareCommand { constructor() { super({ name: 'on', description: 'Execute commands on SSH hosts', arguments: '<hosts> [command...]', options: [ { flags: '-p, --profile <profile>', description: 'Configuration profile to use', }, { flags: '--task <task>', description: 'Execute a configured task on the hosts', }, { flags: '--repl', description: 'Start a REPL session with $target available', }, { flags: '-t, --timeout <duration>', description: 'Command timeout (e.g., 30s, 5m)', }, { flags: '-e, --env <key=value>', description: 'Environment variables (can be used multiple times)', }, { flags: '-d, --cwd <path>', description: 'Working directory on remote host', }, { flags: '-u, --user <user>', description: 'User to run command as (overrides config)', }, { flags: '--parallel', description: 'Execute on multiple hosts in parallel', }, { flags: '--max-concurrent <n>', description: 'Maximum concurrent executions', defaultValue: '10', }, { flags: '--fail-fast', description: 'Stop on first failure in parallel mode', }, { flags: '-i, --interactive', description: 'Interactive mode for selecting SSH hosts and execution options', }, ], examples: [ { command: 'xec on hosts.web-1 "uptime"', description: 'Execute on configured SSH host', }, { command: 'xec on hosts.web-* "systemctl status nginx" --parallel', description: 'Execute on multiple hosts in parallel', }, { command: 'xec on deploy@server.com "date"', description: 'Execute on direct SSH target', }, { command: 'xec on hosts.db-master ./scripts/backup.ts', description: 'Execute script with $target context', }, { command: 'xec on hosts.* --task deploy --parallel', description: 'Run deploy task on all hosts', }, { command: 'xec on --interactive', description: 'Interactive mode for selecting hosts and commands', }, ], validateOptions: (options) => { const schema = z.object({ profile: z.string().optional(), task: z.string().optional(), repl: z.boolean().optional(), timeout: z.string().optional(), env: z.array(z.string()).optional(), cwd: z.string().optional(), user: z.string().optional(), parallel: z.boolean().optional(), maxConcurrent: z.string().optional(), failFast: z.boolean().optional(), interactive: z.boolean().optional(), verbose: z.boolean().optional(), quiet: z.boolean().optional(), dryRun: z.boolean().optional(), }); validateOptions(options, schema); }, }); } getCommandConfigKey() { return 'on'; } async execute(args) { let [hostPattern, ...commandParts] = args.slice(0, -1); const options = args[args.length - 1]; if (options.interactive) { const interactiveResult = await this.runInteractiveMode(options); if (!interactiveResult) return; hostPattern = interactiveResult.hostPattern; commandParts = interactiveResult.commandParts || []; Object.assign(options, interactiveResult.options); } if (!hostPattern) { throw new Error('Host specification is required'); } await this.initializeConfig(options); const defaults = this.getCommandDefaults(); const mergedOptions = this.applyDefaults(options, defaults); let targets; if (hostPattern.includes('*') || hostPattern.includes('{')) { const pattern = hostPattern.startsWith('hosts.') ? hostPattern : `hosts.${hostPattern}`; targets = await this.findTargets(pattern); if (targets.length === 0) { throw new Error(`No hosts found matching pattern: ${hostPattern}`); } } else if (hostPattern.includes(',')) { const hostIds = hostPattern.split(','); targets = []; for (const hostId of hostIds) { try { const target = await this.resolveTarget(hostId); targets.push(target); } catch { targets.push({ id: `ssh:${hostId}`, type: 'ssh', name: hostId, config: { type: 'ssh', host: hostId, user: mergedOptions.user || process.env['USER'] || 'root', }, source: 'detected' }); } } } else if (hostPattern.includes('@') && !hostPattern.includes('.')) { const [user, host] = hostPattern.split('@'); targets = [{ id: `ssh:${hostPattern}`, type: 'ssh', name: host, config: { type: 'ssh', host, user, }, source: 'detected' }]; } else { const targetSpec = hostPattern.startsWith('hosts.') ? hostPattern : `hosts.${hostPattern}`; try { const target = await this.resolveTarget(targetSpec); targets = [target]; } catch { targets = [{ id: `ssh:${hostPattern}`, type: 'ssh', name: hostPattern, config: { type: 'ssh', host: hostPattern, user: mergedOptions.user || process.env['USER'] || 'root', }, source: 'detected' }]; } } const nonSshTargets = targets.filter(t => t.type !== 'ssh'); if (nonSshTargets.length > 0) { throw new Error(`'on' command only supports SSH hosts. Found: ${nonSshTargets.map(t => t.type).join(', ')}`); } if (mergedOptions.task) { await this.executeTask(targets, mergedOptions.task, mergedOptions); } else if (mergedOptions.repl) { if (targets.length === 0) { throw new Error('No targets found'); } if (targets.length > 1) { throw new Error('REPL mode is only supported for single hosts'); } await this.startRepl(targets[0], mergedOptions); } else if (commandParts.length > 0) { const command = commandParts.join(' '); if (command.endsWith('.ts') || command.endsWith('.js')) { await this.executeScript(targets, command, mergedOptions); } else { await this.executeCommand(targets, command, mergedOptions); } } else { throw new Error('No command, task, or REPL mode specified'); } } async executeCommand(targets, command, options) { if (options.dryRun) { for (const target of targets) { this.log(`[DRY RUN] Would execute on ${this.formatTargetDisplay(target)}: ${chalk.yellow(command)}`, 'info'); } return; } if (options.parallel && targets.length > 1) { await this.executeParallel(targets, command, options); } else { for (const target of targets) { await this.executeSingle(target, command, options); } } } async executeSingle(target, command, options) { const targetDisplay = this.formatTargetDisplay(target); if (!options.quiet) { this.startSpinner(`Executing on ${targetDisplay}...`); } try { const engine = await this.createTargetEngine(target); let execEngine = engine; if (options.env && options.env.length > 0) { const envVars = {}; for (const envVar of options.env) { const [key, value] = envVar.split('='); if (key && value !== undefined) { envVars[key] = value; } } execEngine = execEngine.env(envVars); } if (options.cwd) { execEngine = execEngine.cd(options.cwd); } if (options.timeout) { const timeoutMs = parseTimeout(options.timeout); execEngine = execEngine.timeout(timeoutMs); } const result = await execEngine.raw `${command}`; if (!options.quiet) { this.stopSpinner(); this.log(`${chalk.green('✓')} ${targetDisplay}`, 'success'); if (result.stdout) { console.log(result.stdout.trim()); } if (result.stderr && options.verbose) { console.error(chalk.yellow(result.stderr.trim())); } } } catch (error) { if (!options.quiet) { this.stopSpinner(); } const errorMessage = error instanceof Error ? error.message : String(error); this.log(`${chalk.red('✗')} ${targetDisplay}: ${errorMessage}`, 'error'); throw error; } } async executeParallel(targets, command, options) { const maxConcurrent = parseInt(String(options.maxConcurrent || '10'), 10); this.log(`Executing on ${targets.length} hosts in parallel (max ${maxConcurrent} concurrent)...`, 'info'); let activeCount = 0; const results = []; const queue = [...targets]; const executeNext = async () => { if (queue.length === 0) return; const target = queue.shift(); activeCount++; try { await this.executeSingle(target, command, { ...options, quiet: true }); results.push({ target, success: true }); } catch (error) { results.push({ target, success: false, error }); if (options.failFast) { queue.length = 0; } } finally { activeCount--; } }; const initialBatch = Math.min(maxConcurrent, targets.length); const promises = []; for (let i = 0; i < initialBatch; i++) { promises.push(executeNext()); } while (queue.length > 0 || activeCount > 0) { await Promise.race(promises.filter(p => p)); if (queue.length > 0 && activeCount < maxConcurrent) { promises.push(executeNext()); } } await Promise.all(promises); const successful = results.filter(r => r.success); const failed = results.filter(r => !r.success); if (successful.length > 0) { this.log(`${chalk.green('✓')} Succeeded on ${successful.length} hosts:`, 'success'); for (const result of successful) { this.log(` - ${this.formatTargetDisplay(result.target)}`, 'info'); } } if (failed.length > 0) { this.log(`${chalk.red('✗')} Failed on ${failed.length} hosts:`, 'error'); for (const result of failed) { const errorMessage = result.error instanceof Error ? result.error.message : String(result.error); this.log(` - ${this.formatTargetDisplay(result.target)}: ${errorMessage}`, 'error'); } throw new Error(`Command failed on ${failed.length} hosts`); } } async executeTask(targets, taskName, options) { if (!this.taskManager) { throw new Error('Task manager not initialized'); } const executeTaskOnTarget = async (target) => { const targetDisplay = this.formatTargetDisplay(target); if (!options.quiet) { this.log(`Running task '${taskName}' on ${targetDisplay}...`, 'info'); } const result = await this.taskManager.run(taskName, {}, { target: target.id }); if (result.success) { if (!options.quiet) { this.log(`${chalk.green('✓')} Task completed on ${targetDisplay}`, 'success'); } } else { throw new Error(result.error?.message || 'Task failed'); } }; if (options.parallel && targets.length > 1) { const promises = targets.map(target => executeTaskOnTarget(target) .then(() => ({ target, success: true })) .catch(error => ({ target, error, success: false }))); const results = await Promise.all(promises); const errors = results.filter(r => 'error' in r); if (errors.length > 0) { for (const { target, error } of errors) { const errorMessage = error instanceof Error ? error.message : String(error); this.log(`${chalk.red('✗')} Task failed on ${this.formatTargetDisplay(target)}: ${errorMessage}`, 'error'); } throw new Error(`Task failed on ${errors.length} hosts`); } } else { for (const target of targets) { await executeTaskOnTarget(target); } } } async executeScript(targets, scriptPath, options) { const scriptLoader = new ScriptLoader({ verbose: options.verbose || process.env['XEC_DEBUG'] === 'true', quiet: options.quiet, cache: true, preferredCDN: 'esm.sh' }); const executeScriptOnTarget = async (target) => { const targetDisplay = this.formatTargetDisplay(target); if (!options.quiet) { this.log(`Running script '${scriptPath}' on ${targetDisplay}...`, 'info'); } const engine = await this.createTargetEngine(target); const execOptions = { target, targetEngine: engine, context: { args: process.argv.slice(3), argv: [process.argv[0] || 'node', scriptPath, ...process.argv.slice(3)], __filename: path.resolve(scriptPath), __dirname: path.dirname(path.resolve(scriptPath)) }, verbose: options.verbose, quiet: options.quiet }; const result = await scriptLoader.executeScript(scriptPath, execOptions); if (result.success) { if (!options.quiet) { this.log(`${chalk.green('✓')} Script completed on ${targetDisplay}`, 'success'); } } else { throw result.error || new Error('Script execution failed'); } }; if (options.parallel && targets.length > 1) { const promises = targets.map(target => executeScriptOnTarget(target) .then(() => ({ target, success: true })) .catch(error => ({ target, error, success: false }))); const results = await Promise.all(promises); const errors = results.filter(r => 'error' in r); if (errors.length > 0) { for (const { target, error } of errors) { const errorMessage = error instanceof Error ? error.message : String(error); this.log(`${chalk.red('✗')} Script failed on ${this.formatTargetDisplay(target)}: ${errorMessage}`, 'error'); } throw new Error(`Script failed on ${errors.length} hosts`); } } else { for (const target of targets) { await executeScriptOnTarget(target); } } } async startRepl(target, options) { const targetDisplay = this.formatTargetDisplay(target); this.log(`Starting REPL with $target configured for ${targetDisplay}...`, 'info'); const scriptLoader = new ScriptLoader({ verbose: options.verbose || process.env['XEC_DEBUG'] === 'true', quiet: options.quiet, cache: true, preferredCDN: 'esm.sh' }); const engine = await this.createTargetEngine(target); const execOptions = { target, targetEngine: engine, verbose: options.verbose, quiet: options.quiet }; await scriptLoader.startRepl(execOptions); } async runInteractiveMode(options) { InteractiveHelpers.startInteractiveMode('Interactive SSH Execution Mode'); try { const hosts = await InteractiveHelpers.selectTarget({ message: 'Select SSH hosts to execute on:', type: 'ssh', allowMultiple: true, allowCustom: true, }); if (!hosts) return null; let hostPattern; if (Array.isArray(hosts)) { if (hosts.length === 1) { hostPattern = hosts[0]?.id || ''; } else { const hostIds = hosts.map(h => h.id).filter(Boolean); hostPattern = hostIds.join(','); } } else { hostPattern = hosts.id; } const executionType = await InteractiveHelpers.selectFromList('What do you want to execute?', [ { value: 'command', label: '💻 Command' }, { value: 'script', label: '📜 Script file' }, { value: 'task', label: '⚙️ Configured task' }, { value: 'repl', label: '🔧 REPL session' }, ], (item) => item.label); if (!executionType) return null; const interactiveOptions = {}; let commandParts = []; switch (executionType?.value) { case 'command': { const command = await InteractiveHelpers.inputText('Enter command to execute:', { placeholder: 'uptime', validate: (value) => { if (!value || value.trim().length === 0) { return 'Command cannot be empty'; } return undefined; }, }); if (!command) return null; commandParts = [command]; break; } case 'script': { const scriptPath = await InteractiveHelpers.inputText('Enter script file path:', { placeholder: './deploy.ts or /path/to/script.js', validate: (value) => { if (!value || value.trim().length === 0) { return 'Script path cannot be empty'; } if (!value.endsWith('.ts') && !value.endsWith('.js')) { return 'Script must be a .ts or .js file'; } return undefined; }, }); if (!scriptPath) return null; commandParts = [scriptPath]; break; } case 'task': { const taskName = await InteractiveHelpers.inputText('Enter task name:', { placeholder: 'deploy, backup, etc.', validate: (value) => { if (!value || value.trim().length === 0) { return 'Task name cannot be empty'; } return undefined; }, }); if (!taskName) return null; interactiveOptions.task = taskName; break; } case 'repl': { if (Array.isArray(hosts) && hosts.length > 1) { InteractiveHelpers.showWarning('REPL mode only supports single hosts. Using the first selected host.'); hostPattern = hosts[0]?.id || ''; } interactiveOptions.repl = true; break; } } if (Array.isArray(hosts) && hosts.length > 1 && executionType.value !== 'repl') { const useParallel = await InteractiveHelpers.confirmAction('Execute on hosts in parallel?', true); if (useParallel) { interactiveOptions.parallel = true; const maxConcurrent = await InteractiveHelpers.selectFromList('Maximum concurrent executions:', [ { value: '5', label: '5 hosts' }, { value: '10', label: '10 hosts' }, { value: '20', label: '20 hosts' }, { value: 'custom', label: 'Custom amount...' }, ], (item) => item.label); if (maxConcurrent) { if (maxConcurrent?.value === 'custom') { const customCount = await InteractiveHelpers.inputText('Enter max concurrent executions:', { placeholder: '10', validate: (value) => { const num = parseInt(value, 10); if (isNaN(num) || num <= 0) { return 'Please enter a positive number'; } return undefined; }, }); if (customCount) { interactiveOptions.maxConcurrent = parseInt(customCount, 10); } } else { interactiveOptions.maxConcurrent = parseInt(maxConcurrent?.value || '10', 10); } } const useFailFast = await InteractiveHelpers.confirmAction('Stop on first failure?', false); if (useFailFast) { interactiveOptions.failFast = true; } } } const useTimeout = await InteractiveHelpers.confirmAction('Set command timeout?', false); if (useTimeout) { const timeout = await InteractiveHelpers.selectFromList('Select timeout duration:', [ { value: '30s', label: '30 seconds' }, { value: '1m', label: '1 minute' }, { value: '5m', label: '5 minutes' }, { value: '15m', label: '15 minutes' }, { value: 'custom', label: 'Custom duration...' }, ], (item) => item.label); if (timeout) { if (timeout?.value === 'custom') { const customTimeout = await InteractiveHelpers.inputText('Enter timeout duration:', { placeholder: '10m (10 minutes) or 30s (30 seconds)', validate: (value) => { if (!value || value.trim().length === 0) { return 'Timeout cannot be empty'; } if (!/^\d+[smh]$/.test(value)) { return 'Please use format like 30s, 5m, or 1h'; } return undefined; }, }); if (customTimeout) { interactiveOptions.timeout = customTimeout; } } else { interactiveOptions.timeout = timeout?.value || '30s'; } } } const useEnvVars = await InteractiveHelpers.confirmAction('Set environment variables?', false); if (useEnvVars) { const envVars = []; let addingVars = true; while (addingVars) { const envVar = await InteractiveHelpers.inputText('Enter environment variable (KEY=value):', { placeholder: 'NODE_ENV=production', validate: (value) => { if (!value || value.trim().length === 0) { return 'Environment variable cannot be empty'; } if (!value.includes('=')) { return 'Please use KEY=value format'; } return undefined; }, }); if (envVar) { envVars.push(envVar); } addingVars = await InteractiveHelpers.confirmAction('Add another environment variable?', false); } if (envVars.length > 0) { interactiveOptions.env = envVars; } } const useCwd = await InteractiveHelpers.confirmAction('Set working directory on remote hosts?', false); if (useCwd) { const cwd = await InteractiveHelpers.inputText('Enter working directory:', { placeholder: '/app, /home/user, etc.', validate: (value) => { if (!value || value.trim().length === 0) { return 'Working directory cannot be empty'; } return undefined; }, }); if (cwd) { interactiveOptions.cwd = cwd; } } const useCustomUser = await InteractiveHelpers.confirmAction('Override SSH user for execution?', false); if (useCustomUser) { const user = await InteractiveHelpers.inputText('Enter SSH user:', { placeholder: 'root, deploy, www-data, etc.', validate: (value) => { if (!value || value.trim().length === 0) { return 'User cannot be empty'; } return undefined; }, }); if (user) { interactiveOptions.user = user; } } InteractiveHelpers.endInteractiveMode('SSH execution configuration complete!'); return { hostPattern, commandParts: commandParts.length > 0 ? commandParts : undefined, options: interactiveOptions, }; } catch (error) { InteractiveHelpers.showError(`Interactive mode failed: ${error}`); return null; } } } export default function command(program) { const cmd = new OnCommand(); program.addCommand(cmd.create()); } //# sourceMappingURL=on.js.map