@xec-sh/cli
Version:
Xec: The Universal Shell for TypeScript
725 lines • 31.4 kB
JavaScript
import { z } from 'zod';
import * as fs from 'fs';
import chalk from 'chalk';
import * as path from 'path';
import { $ } from '@xec-sh/core';
import * as chokidar from 'chokidar';
import { validateOptions } from '../utils/validation.js';
import { getUnifiedScriptLoader } from '../utils/script-loader.js';
import { ConfigAwareCommand } from '../utils/command-base.js';
import { InteractiveHelpers } from '../utils/interactive-helpers.js';
export class WatchCommand extends ConfigAwareCommand {
constructor() {
super({
name: 'watch',
description: 'Watch files for changes and execute commands',
arguments: '<target> [paths...]',
options: [
{
flags: '-p, --profile <profile>',
description: 'Configuration profile to use',
},
{
flags: '--pattern <pattern>',
description: 'File patterns to watch (can be used multiple times)',
},
{
flags: '--exclude <pattern>',
description: 'Patterns to exclude (can be used multiple times)',
},
{
flags: '--command <command>',
description: 'Command to execute on change',
},
{
flags: '--task <task>',
description: 'Task to run on change',
},
{
flags: '--script <script>',
description: 'Script file to execute on change',
},
{
flags: '-d, --debounce <ms>',
description: 'Debounce interval in milliseconds',
defaultValue: '300',
},
{
flags: '--initial',
description: 'Run command immediately on start',
},
{
flags: '--poll',
description: 'Use polling instead of native watchers',
},
{
flags: '--interval <ms>',
description: 'Polling interval (when --poll is used)',
defaultValue: '1000',
},
{
flags: '-i, --interactive',
description: 'Interactive mode for configuring watch settings',
},
],
examples: [
{
command: 'xec watch --interactive',
description: 'Interactive mode to configure file watching',
},
{
command: 'xec watch local "src/**/*.ts" --command "npm test"',
description: 'Watch TypeScript files and run tests',
},
{
command: 'xec watch hosts.dev /app --task deploy',
description: 'Watch remote directory and run deploy task',
},
{
command: 'xec watch containers.app /src --pattern "*.js" --command "npm run build"',
description: 'Watch JavaScript files in container',
},
{
command: 'xec watch pods.frontend /app --exclude "node_modules" --task reload',
description: 'Watch pod files excluding node_modules',
},
{
command: 'xec watch local "src/**/*.ts" --script ./scripts/build.js',
description: 'Watch TypeScript files and run build script',
},
],
validateOptions: (options) => {
const schema = z.object({
profile: z.string().optional(),
pattern: z.array(z.string()).optional(),
exclude: z.array(z.string()).optional(),
command: z.string().optional(),
task: z.string().optional(),
script: z.string().optional(),
debounce: z.string().optional(),
initial: z.boolean().optional(),
poll: z.boolean().optional(),
interval: z.string().optional(),
interactive: z.boolean().optional(),
verbose: z.boolean().optional(),
quiet: z.boolean().optional(),
dryRun: z.boolean().optional(),
});
validateOptions(options, schema);
},
});
this.sessions = new Map();
this.running = true;
}
getCommandConfigKey() {
return 'watch';
}
async execute(args) {
const [targetSpec, ...paths] = args.slice(0, -1);
const options = args[args.length - 1];
if (options.interactive) {
return await this.runInteractiveMode();
}
if (!targetSpec) {
throw new Error('Target specification is required');
}
if (!options.command && !options.task && !options.script) {
throw new Error('Either --command, --task, or --script must be specified');
}
await this.initializeConfig(options);
const defaults = this.getCommandDefaults();
const mergedOptions = this.applyDefaults(options, defaults);
const target = await this.resolveTarget(targetSpec);
const watchPaths = paths.length > 0 ? paths : ['.'];
if (mergedOptions.dryRun) {
this.log('[DRY RUN] Would watch:', 'info');
this.log(` Target: ${this.formatTargetDisplay(target)}`, 'info');
this.log(` Paths: ${watchPaths.join(', ')}`, 'info');
if (mergedOptions.pattern) {
this.log(` Patterns: ${mergedOptions.pattern.join(', ')}`, 'info');
}
if (mergedOptions.exclude) {
this.log(` Exclude: ${mergedOptions.exclude.join(', ')}`, 'info');
}
const action = mergedOptions.command
? `command: ${mergedOptions.command}`
: mergedOptions.task
? `task: ${mergedOptions.task}`
: `script: ${mergedOptions.script}`;
this.log(` Action: ${action}`, 'info');
return;
}
this.setupCleanupHandlers();
await this.startWatching(target, watchPaths, mergedOptions);
if (mergedOptions.initial) {
await this.executeAction(target, 'initial', mergedOptions);
}
if (!mergedOptions.quiet) {
this.log('Watching for changes. Press Ctrl+C to stop...', 'info');
}
await new Promise(() => { });
}
async startWatching(target, paths, options) {
const sessionId = target.id;
if (this.sessions.has(sessionId)) {
throw new Error(`Already watching target: ${sessionId}`);
}
const targetDisplay = this.formatTargetDisplay(target);
if (!options.quiet) {
this.log(`Setting up watch on ${targetDisplay}...`, 'info');
}
try {
let session;
switch (target.type) {
case 'local':
session = await this.watchLocal(target, paths, options);
break;
case 'ssh':
session = await this.watchSSH(target, paths, options);
break;
case 'docker':
session = await this.watchDocker(target, paths, options);
break;
case 'k8s':
session = await this.watchKubernetes(target, paths, options);
break;
default:
throw new Error(`Watch not supported for target type: ${target.type}`);
}
this.sessions.set(sessionId, session);
if (!options.quiet) {
this.log(`${chalk.green('✓')} Watching ${targetDisplay} for changes`, 'success');
this.log(` Paths: ${paths.join(', ')}`, 'info');
if (options.pattern) {
this.log(` Patterns: ${options.pattern.join(', ')}`, 'info');
}
}
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
this.log(`${chalk.red('✗')} Failed to start watching: ${errorMessage}`, 'error');
throw error;
}
}
async watchLocal(target, paths, options) {
const watcherOptions = {
persistent: true,
ignoreInitial: true,
usePolling: options.poll,
interval: parseInt(options.interval || '1000', 10),
};
if (options.exclude && options.exclude.length > 0) {
watcherOptions.ignored = options.exclude;
}
const watcher = chokidar.watch(paths, watcherOptions);
const handleChange = (filePath) => {
if (this.shouldIgnoreFile(filePath, options)) {
return;
}
this.scheduleExecution(target, filePath, options);
};
watcher.on('change', handleChange);
watcher.on('add', handleChange);
watcher.on('unlink', handleChange);
watcher.on('error', (error) => {
this.log(`Watch error: ${error}`, 'error');
});
return {
target,
watcher,
};
}
async watchSSH(target, paths, options) {
const config = target.config;
const sshEngine = await this.createTargetEngine(target);
const watchCommand = this.buildRemoteWatchCommand(paths, options);
const watchProcess = sshEngine.raw `${watchCommand}`.nothrow();
watchProcess.child?.stdout?.on('data', (data) => {
const lines = data.toString().trim().split('\n');
for (const line of lines) {
if (line.trim()) {
const filePath = this.parseWatchOutput(line);
if (filePath && !this.shouldIgnoreFile(filePath, options)) {
this.scheduleExecution(target, filePath, options);
}
}
}
});
watchProcess.child?.stderr?.on('data', (data) => {
if (options.verbose) {
this.log(`Watch stderr: ${data.toString().trim()}`, 'warn');
}
});
return {
target,
watcher: watchProcess,
};
}
async watchDocker(target, paths, options) {
const config = target.config;
const container = config.container || target.name;
const watchCommand = this.buildRemoteWatchCommand(paths, options);
const watchProcess = $.local() `docker exec ${container} sh -c "${watchCommand}"`.nothrow();
if (watchProcess.child?.stdout) {
watchProcess.child.stdout.on('data', (data) => {
const lines = data.toString().trim().split('\n');
for (const line of lines) {
if (line.trim()) {
const filePath = this.parseWatchOutput(line);
if (filePath && !this.shouldIgnoreFile(filePath, options)) {
this.scheduleExecution(target, filePath, options);
}
}
}
});
}
return {
target,
watcher: watchProcess,
};
}
async watchKubernetes(target, paths, options) {
const config = target.config;
const namespace = config.namespace || 'default';
const pod = config.pod || target.name;
const containerFlag = config.container ? `-c ${config.container}` : '';
const watchCommand = this.buildRemoteWatchCommand(paths, options);
const watchProcess = $.local() `kubectl exec -n ${namespace} ${containerFlag} ${pod} -- sh -c "${watchCommand}"`.nothrow();
if (watchProcess.child?.stdout) {
watchProcess.child.stdout.on('data', (data) => {
const lines = data.toString().trim().split('\n');
for (const line of lines) {
if (line.trim()) {
const filePath = this.parseWatchOutput(line);
if (filePath && !this.shouldIgnoreFile(filePath, options)) {
this.scheduleExecution(target, filePath, options);
}
}
}
});
}
return {
target,
watcher: watchProcess,
};
}
buildRemoteWatchCommand(paths, options) {
const events = 'modify,create,delete,move';
const excludePatterns = options.exclude?.map(p => `--exclude '${p}'`).join(' ') || '';
const pathsStr = paths.join(' ');
const inotifyCommand = options.pattern && options.pattern.length > 0
? `while true; do find ${pathsStr} \\( ${options.pattern.map(p => `-name "${p}"`).join(' -o ')} \\) -print0 | xargs -0 inotifywait -e ${events} ${excludePatterns} --format '%w%f' 2>/dev/null || sleep 1; done`
: `inotifywait -mr -e ${events} ${excludePatterns} --format '%w%f' ${pathsStr} 2>/dev/null`;
const fallbackCommand = `
last_mtime=""
while true; do
current_mtime=$(find ${pathsStr} -type f -exec stat -c '%Y' {} \\; 2>/dev/null | sort -n | tail -1)
if [ ! -z "$current_mtime" ] && [ "$current_mtime" != "$last_mtime" ]; then
echo "${pathsStr} MODIFY"
last_mtime="$current_mtime"
fi
sleep 1
done
`.trim().replace(/\n\s*/g, ' ');
return `command -v inotifywait >/dev/null 2>&1 && (${inotifyCommand}) || (${fallbackCommand})`;
}
parseWatchOutput(line) {
const trimmed = line.trim();
if (!trimmed)
return undefined;
const parts = trimmed.split(' ');
if (parts.length > 0) {
return parts[0];
}
return undefined;
}
shouldIgnoreFile(filePath, options) {
if (options.pattern && options.pattern.length > 0) {
const basename = path.basename(filePath);
const matches = options.pattern.some(pattern => {
const regex = pattern
.replace(/\*/g, '.*')
.replace(/\?/g, '.');
return new RegExp(`^${regex}$`).test(basename);
});
if (!matches) {
return true;
}
}
return false;
}
scheduleExecution(target, changedFile, options) {
const session = this.sessions.get(target.id);
if (!session)
return;
const debounceMs = parseInt(options.debounce || '300', 10);
if (session.debounceTimer) {
clearTimeout(session.debounceTimer);
}
session.debounceTimer = setTimeout(async () => {
await this.executeAction(target, changedFile, options);
session.lastRun = new Date();
}, debounceMs);
}
async executeAction(target, changedFile, options) {
const targetDisplay = this.formatTargetDisplay(target);
const timestamp = new Date().toLocaleTimeString();
if (!options.quiet) {
this.log(`\n[${timestamp}] Change detected: ${changedFile}`, 'info');
this.startSpinner(`Executing on ${targetDisplay}...`);
}
try {
const engine = await this.createTargetEngine(target);
if (options.command) {
const result = await engine.raw `${options.command}`.nothrow();
if (!options.quiet) {
this.stopSpinner();
if (result.exitCode === 0) {
this.log(`${chalk.green('✓')} Command executed successfully`, 'success');
if (result.stdout && options.verbose) {
console.log(result.stdout.trim());
}
}
else {
throw new Error(`Command failed with exit code ${result.exitCode}`);
}
}
else if (result.exitCode !== 0) {
throw new Error(`Command failed with exit code ${result.exitCode}`);
}
}
else if (options.script) {
const scriptLoader = getUnifiedScriptLoader({
verbose: options.verbose,
quiet: options.quiet,
});
const result = await scriptLoader.executeScript(options.script, {
target,
targetEngine: engine,
context: {
args: [],
argv: [process.argv[0] || 'node', options.script],
__filename: options.script,
__dirname: path.dirname(options.script),
},
quiet: options.quiet,
});
if (!options.quiet) {
this.stopSpinner();
if (result.success) {
this.log(`${chalk.green('✓')} Script executed successfully`, 'success');
}
else {
throw new Error(result.error?.message || 'Script execution failed');
}
}
else if (!result.success) {
throw new Error(result.error?.message || 'Script execution failed');
}
}
else if (options.task && this.taskManager) {
const result = await this.taskManager.run(options.task, {}, {
target: target.id
});
if (!options.quiet) {
this.stopSpinner();
if (result.success) {
this.log(`${chalk.green('✓')} Task '${options.task}' completed`, 'success');
}
else {
throw new Error(result.error?.message || 'Task failed');
}
}
}
}
catch (error) {
if (!options.quiet) {
this.stopSpinner();
}
const errorMessage = error instanceof Error ? error.message : String(error);
this.log(`${chalk.red('✗')} Execution failed: ${errorMessage}`, 'error');
}
}
setupCleanupHandlers() {
const cleanup = async () => {
this.running = false;
this.log('\nStopping watchers...', 'info');
for (const [sessionId, session] of Array.from(this.sessions.entries())) {
try {
if (session.debounceTimer) {
clearTimeout(session.debounceTimer);
}
if (session.watcher) {
if (typeof session.watcher.close === 'function') {
await session.watcher.close();
}
else if (typeof session.watcher.kill === 'function') {
session.watcher.kill();
}
}
this.log(`Stopped watching ${sessionId}`, 'info');
}
catch (error) {
this.log(`Failed to cleanup ${sessionId}: ${error}`, 'error');
}
}
this.sessions.clear();
process.exit(0);
};
process.once('SIGINT', cleanup);
process.once('SIGTERM', cleanup);
}
async runInteractiveMode() {
InteractiveHelpers.startInteractiveMode('Watch Configuration');
try {
const target = await InteractiveHelpers.selectTarget({
message: 'Select target to watch:',
type: 'all',
allowCustom: true,
});
if (!target) {
InteractiveHelpers.endInteractiveMode('Cancelled');
return;
}
const pathsInput = await InteractiveHelpers.inputText('Enter paths to watch (comma-separated):', {
placeholder: './src, ./config, ./app',
initialValue: '.',
validate: (value) => {
if (!value?.trim()) {
return 'At least one path is required';
}
return undefined;
},
});
if (!pathsInput) {
InteractiveHelpers.endInteractiveMode('Cancelled');
return;
}
const watchPaths = pathsInput.split(',').map(p => p.trim()).filter(Boolean);
const usePatterns = await InteractiveHelpers.confirmAction('Do you want to specify file patterns to watch?', false);
let patterns;
if (usePatterns) {
const patternsInput = await InteractiveHelpers.inputText('Enter file patterns (comma-separated):', {
placeholder: '*.ts, *.js, *.json',
});
if (patternsInput) {
patterns = patternsInput.split(',').map(p => p.trim()).filter(Boolean);
}
}
const useExcludes = await InteractiveHelpers.confirmAction('Do you want to exclude any patterns?', false);
let excludes;
if (useExcludes) {
const excludesInput = await InteractiveHelpers.inputText('Enter exclude patterns (comma-separated):', {
placeholder: 'node_modules, .git, *.log',
});
if (excludesInput) {
excludes = excludesInput.split(',').map(p => p.trim()).filter(Boolean);
}
}
const actionType = await InteractiveHelpers.selectFromList('What should run when files change?', ['command', 'task', 'script'], (type) => {
switch (type) {
case 'command': return '🔧 Execute a shell command';
case 'task': return '📋 Run a configured task';
case 'script': return '📜 Execute a script file';
default: return type;
}
});
if (!actionType) {
InteractiveHelpers.endInteractiveMode('Cancelled');
return;
}
let command;
let task;
let script;
if (actionType === 'command') {
const commandInput = await InteractiveHelpers.inputText('Enter command to execute on change:', {
placeholder: 'npm test, npm run build, etc.',
validate: (value) => {
if (!value?.trim()) {
return 'Command cannot be empty';
}
return undefined;
},
});
command = commandInput || undefined;
if (!command) {
InteractiveHelpers.endInteractiveMode('Cancelled');
return;
}
}
else if (actionType === 'script') {
const scriptInput = await InteractiveHelpers.inputText('Enter script file path:', {
placeholder: './scripts/build.js, ./tasks/deploy.ts',
validate: (value) => {
if (!value?.trim()) {
return 'Script path cannot be empty';
}
if (!fs.existsSync(value.trim())) {
return 'Script file not found';
}
return undefined;
},
});
script = scriptInput || undefined;
if (!script) {
InteractiveHelpers.endInteractiveMode('Cancelled');
return;
}
}
else {
await this.initializeConfig({});
const taskInfos = this.taskManager ? await this.taskManager.list() : [];
const availableTasks = taskInfos.map(info => info.name);
if (availableTasks.length === 0) {
InteractiveHelpers.showWarning('No tasks found in configuration. You can still enter a task name.');
const taskInput = await InteractiveHelpers.inputText('Enter task name:', {
placeholder: 'deploy, build, test',
validate: (value) => {
if (!value?.trim()) {
return 'Task name cannot be empty';
}
return undefined;
},
});
task = taskInput || undefined;
}
else {
const selectedTask = await InteractiveHelpers.selectFromList('Select task to run:', availableTasks, (taskName) => {
const info = taskInfos.find(t => t.name === taskName);
const description = info?.description ? ` - ${info.description}` : '';
return `📋 ${taskName}${description}`;
}, true);
if (!selectedTask) {
InteractiveHelpers.endInteractiveMode('Cancelled');
return;
}
if (selectedTask.custom) {
const customTaskInput = await InteractiveHelpers.inputText('Enter custom task name:', {
validate: (value) => {
if (!value?.trim()) {
return 'Task name cannot be empty';
}
return undefined;
},
});
task = customTaskInput || undefined;
}
else {
task = selectedTask;
}
}
if (!task) {
InteractiveHelpers.endInteractiveMode('Cancelled');
return;
}
}
const configureAdvanced = await InteractiveHelpers.confirmAction('Configure advanced options (debounce, polling, etc.)?', false);
let debounce = '300';
let poll = false;
let interval = '1000';
let initial = false;
if (configureAdvanced) {
const debounceInput = await InteractiveHelpers.inputText('Debounce interval (ms):', {
initialValue: '300',
validate: (value) => {
const num = parseInt(value, 10);
if (isNaN(num) || num < 0) {
return 'Must be a positive number';
}
return undefined;
},
});
if (debounceInput) {
debounce = debounceInput;
}
poll = await InteractiveHelpers.confirmAction('Use polling instead of native file watchers?', false);
if (poll) {
const intervalInput = await InteractiveHelpers.inputText('Polling interval (ms):', {
initialValue: '1000',
validate: (value) => {
const num = parseInt(value, 10);
if (isNaN(num) || num < 100) {
return 'Must be at least 100ms';
}
return undefined;
},
});
if (intervalInput) {
interval = intervalInput;
}
}
initial = await InteractiveHelpers.confirmAction('Run action immediately on start?', false);
}
InteractiveHelpers.showInfo('\nWatch Configuration Summary:');
console.log(` Target: ${InteractiveHelpers.getTargetIcon(target.type)} ${target.id}`);
console.log(` Paths: ${watchPaths.join(', ')}`);
if (patterns) {
console.log(` Patterns: ${patterns.join(', ')}`);
}
if (excludes) {
console.log(` Exclude: ${excludes.join(', ')}`);
}
if (command) {
console.log(` Command: ${command}`);
}
if (task) {
console.log(` Task: ${task}`);
}
if (script) {
console.log(` Script: ${script}`);
}
console.log(` Debounce: ${debounce}ms`);
if (poll) {
console.log(` Polling: ${interval}ms`);
}
if (initial) {
console.log(` Initial run: Yes`);
}
console.log('');
const proceed = await InteractiveHelpers.confirmAction('Start watching with these settings?', true);
if (!proceed) {
InteractiveHelpers.endInteractiveMode('Cancelled');
return;
}
InteractiveHelpers.endInteractiveMode('Starting watch...');
const watchOptions = {
pattern: patterns,
exclude: excludes,
command,
task,
script,
debounce,
initial,
poll,
interval,
quiet: false,
verbose: false,
dryRun: false,
};
if (!this.taskManager) {
await this.initializeConfig(watchOptions);
}
const defaults = this.getCommandDefaults();
const mergedOptions = this.applyDefaults(watchOptions, defaults);
this.setupCleanupHandlers();
await this.startWatching(target, watchPaths, mergedOptions);
if (mergedOptions.initial) {
await this.executeAction(target, 'initial', mergedOptions);
}
console.log('\n' + chalk.green('✓') + ` Watching ${InteractiveHelpers.getTargetIcon(target.type)} ${target.id} for changes...`);
console.log(chalk.gray('Press Ctrl+C to stop watching'));
await new Promise(() => { });
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
InteractiveHelpers.showError(`Configuration failed: ${errorMessage}`);
InteractiveHelpers.endInteractiveMode('Failed');
throw error;
}
}
}
export default function command(program) {
const cmd = new WatchCommand();
program.addCommand(cmd.create());
}
//# sourceMappingURL=watch.js.map