UNPKG

betterpack

Version:

A Universal Node.js Package Manager CLI with automated agent capabilities

555 lines (466 loc) 14.7 kB
const { spawn, spawnSync } = require("child_process") const fs = require("fs") const path = require("path") const { executeCommand } = require("../index.cjs") class TaskExecutor { constructor(options = {}) { this.options = { dryRun: options.dryRun || false, maxConcurrent: options.maxConcurrent || 3, timeout: options.timeout || 300000, // 5 minutes retryAttempts: options.retryAttempts || 2, rollbackEnabled: options.rollbackEnabled !== false, safeMode: options.safeMode !== false, ...options, } this.taskQueue = [] this.runningTasks = new Map() this.completedTasks = [] this.failedTasks = [] this.rollbackStack = [] this.isExecuting = false } async executeTask(task) { const taskId = this.generateTaskId() const executionTask = { id: taskId, ...task, status: "pending", startTime: null, endTime: null, duration: 0, attempts: 0, output: "", error: null, rollbackData: null, } console.log(`[Executor] Queuing task: ${task.name || task.action}`) if (this.options.dryRun) { return this.simulateTask(executionTask) } return this.executeTaskInternal(executionTask) } async executeBatch(tasks) { console.log(`[Executor] Executing batch of ${tasks.length} tasks`) const results = [] this.isExecuting = true try { // Add all tasks to queue for (const task of tasks) { const taskId = this.generateTaskId() const executionTask = { id: taskId, ...task, status: "queued", startTime: null, endTime: null, duration: 0, attempts: 0, output: "", error: null, rollbackData: null, } this.taskQueue.push(executionTask) } // Process queue while (this.taskQueue.length > 0 || this.runningTasks.size > 0) { // Start new tasks if we have capacity while (this.runningTasks.size < this.options.maxConcurrent && this.taskQueue.length > 0) { const task = this.taskQueue.shift() this.startTask(task) } // Wait for at least one task to complete if (this.runningTasks.size > 0) { await this.waitForAnyTask() } } results.push(...this.completedTasks) results.push(...this.failedTasks) } catch (error) { console.error("[Executor] Batch execution failed:", error.message) if (this.options.rollbackEnabled) { await this.rollbackAll() } } finally { this.isExecuting = false } return { total: tasks.length, successful: this.completedTasks.length, failed: this.failedTasks.length, results: results, } } async executeTaskInternal(task) { task.status = "running" task.startTime = new Date() task.attempts++ console.log(`[Executor] Executing task: ${task.name || task.action} (Attempt ${task.attempts})`) try { // Pre-execution safety checks if (this.options.safeMode && !this.isSafeTask(task)) { throw new Error("Task blocked by safety checks") } // Create rollback data before execution if (this.options.rollbackEnabled) { task.rollbackData = await this.createRollbackData(task) } // Execute the task based on its type const result = await this.executeByType(task) task.status = "completed" task.endTime = new Date() task.duration = task.endTime - task.startTime task.output = result.output || "" this.completedTasks.push(task) if (this.options.rollbackEnabled) { this.rollbackStack.push(task) } console.log(`[Executor] Task completed: ${task.name || task.action} (${task.duration}ms)`) return task } catch (error) { task.status = "failed" task.endTime = new Date() task.duration = task.endTime - task.startTime task.error = error.message console.error(`[Executor] Task failed: ${task.name || task.action} - ${error.message}`) // Retry logic if (task.attempts < this.options.retryAttempts && this.shouldRetry(task, error)) { console.log(`[Executor] Retrying task: ${task.name || task.action}`) await this.delay(1000 * task.attempts) // Exponential backoff return this.executeTaskInternal(task) } this.failedTasks.push(task) throw error } } async executeByType(task) { switch (task.type) { case "command": return this.executeCommand(task) case "package": return this.executePackageCommand(task) case "file": return this.executeFileOperation(task) case "git": return this.executeGitCommand(task) case "script": return this.executeScript(task) case "custom": return this.executeCustomTask(task) default: // Try to infer type from action return this.executeInferredTask(task) } } async executeCommand(task) { const { command, args = [], options = {} } = task return new Promise((resolve, reject) => { const child = spawn(command, args, { stdio: ["pipe", "pipe", "pipe"], shell: true, timeout: this.options.timeout, ...options, }) let stdout = "" let stderr = "" child.stdout?.on("data", (data) => { stdout += data.toString() }) child.stderr?.on("data", (data) => { stderr += data.toString() }) child.on("close", (code) => { if (code === 0) { resolve({ output: stdout, exitCode: code }) } else { reject(new Error(`Command failed with exit code ${code}: ${stderr}`)) } }) child.on("error", (error) => { reject(error) }) // Handle timeout setTimeout(() => { if (!child.killed) { child.kill("SIGTERM") reject(new Error("Task timeout")) } }, this.options.timeout) }) } async executePackageCommand(task) { const { action, packages = [], flags = [] } = task // Use the existing betterpack CLI functionality const args = [action, ...packages, ...flags] return new Promise((resolve, reject) => { try { // Import and use the existing CLI const { runCli } = require("../index.cjs") const originalArgv = process.argv process.argv = ["node", "bpack", ...args] // Capture output const originalLog = console.log const originalError = console.error let output = "" let errorOutput = "" console.log = (...args) => { output += args.join(" ") + "\n" originalLog(...args) } console.error = (...args) => { errorOutput += args.join(" ") + "\n" originalError(...args) } runCli() // Restore process.argv = originalArgv console.log = originalLog console.error = originalError if (errorOutput && !output) { reject(new Error(errorOutput)) } else { resolve({ output: output || "Command completed successfully" }) } } catch (error) { reject(error) } }) } async executeFileOperation(task) { const { operation, filePath, content, backup = true } = task switch (operation) { case "create": if (backup && fs.existsSync(filePath)) { fs.copyFileSync(filePath, `${filePath}.backup`) } fs.writeFileSync(filePath, content || "") return { output: `Created file: ${filePath}` } case "delete": if (backup && fs.existsSync(filePath)) { fs.copyFileSync(filePath, `${filePath}.backup`) } fs.unlinkSync(filePath) return { output: `Deleted file: ${filePath}` } case "modify": if (backup && fs.existsSync(filePath)) { fs.copyFileSync(filePath, `${filePath}.backup`) } fs.writeFileSync(filePath, content) return { output: `Modified file: ${filePath}` } default: throw new Error(`Unknown file operation: ${operation}`) } } async executeGitCommand(task) { const { gitAction, args = [] } = task return this.executeCommand({ command: "git", args: [gitAction, ...args], }) } async executeScript(task) { const { scriptPath, args = [] } = task const ext = path.extname(scriptPath) let command switch (ext) { case ".js": command = "node" break case ".py": command = "python" break case ".sh": command = "bash" break default: command = scriptPath args.unshift() } return this.executeCommand({ command, args: [scriptPath, ...args], }) } async executeCustomTask(task) { if (typeof task.executor === "function") { return task.executor(task) } throw new Error("Custom task must provide an executor function") } async executeInferredTask(task) { const { action } = task if (!action) { throw new Error("Task must specify an action") } // Try to parse as a command const parts = action.split(" ") const command = parts[0] const args = parts.slice(1) // Check if it's a betterpack command if (command === "bpack") { return this.executePackageCommand({ action: args[0], packages: args.slice(1).filter((arg) => !arg.startsWith("-")), flags: args.slice(1).filter((arg) => arg.startsWith("-")), }) } // Execute as regular command return this.executeCommand({ command, args }) } async simulateTask(task) { console.log(`[Executor] [DRY RUN] Would execute: ${task.name || task.action}`) task.status = "simulated" task.startTime = new Date() task.endTime = new Date() task.duration = 0 task.output = "Dry run - task not actually executed" return task } isSafeTask(task) { const dangerousPatterns = [ /rm\s+-rf/, /del\s+\/[sq]/i, /format\s+[a-z]:/i, /shutdown/i, /reboot/i, /kill\s+-9/, /sudo\s+rm/, /npm\s+uninstall\s+.*--save/, ] const actionStr = task.action || task.command || "" return !dangerousPatterns.some((pattern) => pattern.test(actionStr)) } async createRollbackData(task) { const rollbackData = { taskId: task.id, type: task.type, timestamp: new Date(), } switch (task.type) { case "file": if (task.operation === "create" || task.operation === "modify") { if (fs.existsSync(task.filePath)) { rollbackData.originalContent = fs.readFileSync(task.filePath, "utf8") rollbackData.fileExisted = true } else { rollbackData.fileExisted = false } } break case "package": // Store current package.json state const packageJsonPath = path.join(process.cwd(), "package.json") if (fs.existsSync(packageJsonPath)) { rollbackData.packageJson = fs.readFileSync(packageJsonPath, "utf8") } break } return rollbackData } async rollbackTask(task) { if (!task.rollbackData) { console.log(`[Executor] No rollback data for task: ${task.id}`) return } console.log(`[Executor] Rolling back task: ${task.name || task.action}`) try { switch (task.type) { case "file": if (task.rollbackData.fileExisted) { fs.writeFileSync(task.filePath, task.rollbackData.originalContent) } else { if (fs.existsSync(task.filePath)) { fs.unlinkSync(task.filePath) } } break case "package": if (task.rollbackData.packageJson) { const packageJsonPath = path.join(process.cwd(), "package.json") fs.writeFileSync(packageJsonPath, task.rollbackData.packageJson) } break } console.log(`[Executor] Rollback completed for task: ${task.id}`) } catch (error) { console.error(`[Executor] Rollback failed for task: ${task.id} - ${error.message}`) } } async rollbackAll() { console.log(`[Executor] Rolling back ${this.rollbackStack.length} tasks`) // Rollback in reverse order for (let i = this.rollbackStack.length - 1; i >= 0; i--) { await this.rollbackTask(this.rollbackStack[i]) } this.rollbackStack = [] } shouldRetry(task, error) { // Don't retry certain types of errors const nonRetryableErrors = ["ENOENT", "EACCES", "syntax error", "permission denied"] return !nonRetryableErrors.some((pattern) => error.message.toLowerCase().includes(pattern.toLowerCase())) } async startTask(task) { this.runningTasks.set(task.id, task) try { const result = await this.executeTaskInternal(task) this.runningTasks.delete(task.id) return result } catch (error) { this.runningTasks.delete(task.id) throw error } } async waitForAnyTask() { return new Promise((resolve) => { const checkTasks = () => { for (const [taskId, task] of this.runningTasks) { if (task.status === "completed" || task.status === "failed") { resolve(task) return } } setTimeout(checkTasks, 100) } checkTasks() }) } generateTaskId() { return `task_${Date.now()}_${Math.random().toString(36).substr(2, 9)}` } delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)) } getStatus() { return { isExecuting: this.isExecuting, queuedTasks: this.taskQueue.length, runningTasks: this.runningTasks.size, completedTasks: this.completedTasks.length, failedTasks: this.failedTasks.length, rollbackStackSize: this.rollbackStack.length, options: this.options, } } getTaskHistory() { return { completed: this.completedTasks.map((task) => ({ id: task.id, name: task.name || task.action, duration: task.duration, status: task.status, startTime: task.startTime, endTime: task.endTime, })), failed: this.failedTasks.map((task) => ({ id: task.id, name: task.name || task.action, error: task.error, attempts: task.attempts, status: task.status, startTime: task.startTime, endTime: task.endTime, })), } } } module.exports = { TaskExecutor }