Class: Roast::CommandRunner
- Inherits:
-
Object
- Object
- Roast::CommandRunner
- Defined in:
- lib/roast/command_runner.rb
Overview
The canonical way to execute shell commands in Roast.
CommandRunner is the standard command execution interface for Roast and should be used for all command invocations in this project.
Features:
-
Separate stdout/stderr capture (using Async fibers for concurrency)
-
Line-by-line streaming callbacks for custom handling
-
Optional timeout support with automatic process cleanup
-
Direct command execution (no shell by default for safety)
Note: Currently executes commands directly without shell features. Shell support (pipes, redirects, etc.) will be added in a future version.
Defined Under Namespace
Classes: CommandRunnerError, NoCommandProvidedError, TimeoutError
Class Method Summary collapse
-
.execute(args, working_directory: nil, timeout: nil, stdin_content: nil, stdout_handler: nil, stderr_handler: nil) ⇒ Array<String, String, Process::Status>
Execute a command with optional stream handlers.
Class Method Details
.execute(args, working_directory: nil, timeout: nil, stdin_content: nil, stdout_handler: nil, stderr_handler: nil) ⇒ Array<String, String, Process::Status>
Execute a command with optional stream handlers
: ( | Array, | ?working_directory: (Pathname | String)?, | ?timeout: (Integer | Float)?, | ?stdin_content: String?, | ?stdout_handler: (^(String) -> void)?, | ?stderr_handler: (^(String) -> void)?, | ) -> [String, String, Process::Status]
53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 |
# File 'lib/roast/command_runner.rb', line 53 def execute( args, working_directory: nil, timeout: nil, stdin_content: nil, stdout_handler: nil, stderr_handler: nil ) args.compact! raise NoCommandProvidedError if args.blank? env_overrides = ENV.each_with_object({}) do |item, obj| key, value = item obj[key.sub(/^ROAST_CMD_ENV_OVERRIDE__/, "")] = value if key.start_with?("ROAST_CMD_ENV_OVERRIDE__") end.compact stdin, stdout, stderr, wait_thread = Bundler.with_unbundled_env do Open3 #: as untyped .popen3( env_overrides.merge({ "PWD" => working_directory&.to_s }).compact, *args.map(&:to_s), { chdir: working_directory }.compact, ) end stdin.puts stdin_content if stdin_content.present? stdin.close pid = wait_thread.pid # If timeout is specified, start a timer in a separate fiber timeout_task = if timeout Async do |task| task.annotate("CommandRunner Timeout Monitor") sleep(timeout) kill_process(pid) if pid end end # Read stdout and stderr concurrently stdout_content, stderr_content = Sync do |sync_task| sync_task.annotate("CommandRunner Process Handler") stdout_task = Async do |task| task.annotate("CommandRunner Standard Output Reader") buffer = "" #: String stdout.each_line do |line| buffer += line begin stdout_handler&.call(line) rescue => e Event << { warn: "stdout_handler raised: #{e.class} - #{e.}" } end end buffer rescue IOError buffer end stderr_task = Async do |task| task.annotate("CommandRunner Standard Error Reader") buffer = "" #: String stderr.each_line do |line| buffer += line begin stderr_handler&.call(line) rescue => e Event << { warn: "stderr_handler raised: #{e.class} - #{e.}" } end end buffer rescue IOError buffer end [stdout_task.wait, stderr_task.wait] end # Wait for the process to complete status = wait_thread.value # Cancel the timeout task if it's still running timeout_task&.stop # Check if the process was killed due to timeout if timeout && status.signaled? && (status.termsig == 15 || status.termsig == 9) raise TimeoutError, "Command timed out after #{timeout} seconds" end [stdout_content, stderr_content, status] ensure # Clean up resources begin [stdout, stderr].compact.each(&:close) rescue nil end # If we haven't waited for the process yet, kill it if pid && wait_thread&.alive? Async do |task| task.annotate("CommandRunner Process Killer") kill_process(pid) end.wait wait_thread.join(1) # Give it a second to finish end end |