/** * Custom command execution module * * Executes user-configured commands in project directories */ import type { CommandConfig } from "../types/index.ts"; import { validatePathForShell, expandEnvVarsSafe, detectCommandEscapeAttempt, } from "../utils/shell-sanitize.ts"; export interface CommandResult { success: boolean; command: string; projectPath: string; output: string; error?: string; duration: number; } /** * Execute a custom command in a project directory */ export async function executeCommand( command: CommandConfig, projectPath: string, options: { background?: boolean } = {} ): Promise { const startTime = Date.now(); const { background = command.background } = options; try { // Validate the project path for security const pathValidation = validatePathForShell(projectPath); if (!pathValidation.valid) { return { success: false, command: command.name, projectPath, output: "", error: `Invalid path: ${pathValidation.error}`, duration: Date.now() - startTime, }; } // Check for suspicious command patterns const escapeIssues = detectCommandEscapeAttempt(command.command); if (escapeIssues.length > 0) { // Log warnings but don't block - let user-configured commands run // The warnings help with auditing console.warn(`Command security warnings for "${command.name}":`, escapeIssues); } // Expand environment variables safely (only whitelisted vars) const { command: expandedCommand, warnings } = expandEnvVarsSafe(command.command, projectPath); if (warnings.length > 0) { console.warn(`Command expansion warnings for "${command.name}":`, warnings); } if (background) { // Run in background - don't wait for completion Bun.spawn(["sh", "-c", expandedCommand], { cwd: projectPath, stdout: "ignore", stderr: "ignore", }); return { success: true, command: command.name, projectPath, output: "Started in background", duration: Date.now() - startTime, }; } // Run and wait for completion const result = await Bun.$`sh -c ${expandedCommand}`.cwd(projectPath).quiet().nothrow(); const output = result.stdout.toString().trim(); const stderr = result.stderr.toString().trim(); if (result.exitCode !== 0) { return { success: false, command: command.name, projectPath, output, error: stderr || `Command exited with code ${result.exitCode}`, duration: Date.now() - startTime, }; } return { success: true, command: command.name, projectPath, output, duration: Date.now() - startTime, }; } catch (error) { return { success: false, command: command.name, projectPath, output: "", error: error instanceof Error ? error.message : String(error), duration: Date.now() - startTime, }; } } /** * Find a command by its key from the config */ export function findCommandByKey( commands: CommandConfig[], key: string ): CommandConfig | undefined { if (!commands || !Array.isArray(commands)) { return undefined; } return commands.find((cmd) => cmd.key === key); } /** * Execute commands on multiple projects */ export async function batchExecuteCommand( command: CommandConfig, projectPaths: string[], options: { concurrency?: number; onProgress?: (current: number, total: number) => void; } = {} ): Promise { const { concurrency: rawConcurrency = 5, onProgress } = options; // Guard against concurrency<=0 — i += 0 would loop forever. const concurrency = Number.isFinite(rawConcurrency) && rawConcurrency >= 1 ? Math.floor(rawConcurrency) : 1; const results: CommandResult[] = []; // Process in batches for (let i = 0; i < projectPaths.length; i += concurrency) { const batch = projectPaths.slice(i, i + concurrency); const batchResults = await Promise.all( batch.map((path) => executeCommand(command, path)) ); results.push(...batchResults); onProgress?.(Math.min(i + concurrency, projectPaths.length), projectPaths.length); } return results; }