From 73e81c45a2343141add3cff4b8f519f4401c3e70 Mon Sep 17 00:00:00 2001 From: Jonathan Siegel <248302+usiegj00@users.noreply.github.com> Date: Fri, 8 May 2026 19:08:11 +0900 Subject: [PATCH 1/3] Add --detach/-d flag to run command for API-based execution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uses the new POST /api/v1/apps/:id/dynos/run endpoint via the SDK's run_dyno method. Creates a fresh K8s Job with the app's image and env — no SSH or ContainerSSH required. Options: -t/--type (process type), -s/--size (dyno size), --timeout (30-1800s). Also available to addons like CronToGo via their addon access keys. --- src/commands/run.cr | 58 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 54 insertions(+), 4 deletions(-) diff --git a/src/commands/run.cr b/src/commands/run.cr index 2467c1e..9aa0949 100644 --- a/src/commands/run.cr +++ b/src/commands/run.cr @@ -23,6 +23,10 @@ module Build .option("app", "a", :required, "The app to run the command on") .option("debug", nil, :none, "Show verbose debugging information") .option("no-tty", nil, :none, "Force the command to not run in a tty") + .option("detach", "d", :none, "Run via API in a fresh container (no SSH required)") + .option("type", "t", :optional, "Process type for detached run (default: worker)") + .option("size", "s", :optional, "Dyno size for detached run (default: from formation)") + .option("timeout", nil, :optional, "Timeout in seconds for detached run (30-1800, default: 300)") .option("exit-code", "x", :none, "Passthrough the exit code of the remote command") .option("file", "f", :optional, "Send a local file as stdin to the remote command") .option("shell", "c", :none, "Pass the command to the remote shell for interpretation (enables $VAR, globs, pipes, multi-command)") @@ -31,23 +35,26 @@ module Build Run a one-off command on the Build platform. Useful for migrations, console sessions, or ad-hoc scripts. + By default, runs via SSH (interactive, with PTY). Use --detach/-d to + run via API in a fresh container — no SSH or ContainerSSH required. + + bld run -d -a my-app -- rails db:migrate + bld run -d -a my-app -t worker -s Standard-2X -- rake heavy:task + By default each argument is POSIX single-quoted before being sent to the remote side, so parentheses, braces, quotes, $VAR, globs, and other shell metacharacters reach the target process literally: bld run ruby -e 'puts ENV["HOME"]' -a my-app - bld run rake 'test:unit[foo,bar]' -a my-app Use -c/--shell when you actually want the remote shell to interpret metacharacters (variable expansion, pipes, multiple commands): bld run -c 'echo $RAILS_ENV | tee /tmp/env' -a my-app - Use --file (or stdin redirection) to send a local file as stdin and - sidestep quoting entirely: + Use --file (or stdin redirection) to send a local file as stdin: bld run rails runner -a my-app --file script.rb - bld run rails runner -a my-app < script.rb HELP ) .usage("bash -a my-app") @@ -81,10 +88,20 @@ module Build verbose = input.option("debug", type: Bool) no_tty = input.option("no-tty", type: Bool) + detach = input.option("detach", type: Bool) exit_code_mode = input.option("exit-code", type: Bool) command_array = input.argument("cmd", type: Array(String)) rescue [] of String file_path = input.option("file", type: String?) + # --detach: run via API in a fresh container (no SSH) + if detach + if command_array.empty? + output.puts " Command is required for detached mode" + return ACON::Command::Status::FAILURE + end + return run_detached(app_id, command_array, input, output) + end + # Validate --file if specified if file_path && !File.exists?(file_path) output.puts "File not found: #{file_path}" @@ -255,6 +272,39 @@ module Build return ACON::Command::Status::SUCCESS end + # Run via API — creates a fresh K8s Job, waits for output, returns it. + private def run_detached(app_id : String, command_array : Array(String), input : ACON::Input::Interface, output : ACON::Output::Interface) : ACON::Command::Status + command = command_array.join(" ") + type = input.option("type", type: String?) || "worker" + size = input.option("size", type: String?) + timeout = input.option("timeout", type: String?).try(&.to_i) + + spinner = dots_spinner("Running '#{command}' on #{app_id} (#{type})") + + request = Build::DynoRunRequest.new( + command: command, + _type: type, + size: size, + timeout: timeout + ) + + begin + result = api.run_dyno(app_id, request) + rescue ex : Exception + spinner.error + output.puts " #{ex.message}" + return ACON::Command::Status::FAILURE + end + + spinner.success + output.print result.output if result + if result && result.status != "succeeded" + output.puts "Process exited with status: #{result.status}".colorize(:red).to_s + return ACON::Command::Status::FAILURE + end + ACON::Command::Status::SUCCESS + end + # Reads output from the SSH channel and writes to STDOUT # Returns the exit code if exit_code_mode is enabled and sentinel is found private def read_channel_output(channel, output, verbose, exit_code_mode) : Int32? From 28e1e7907bea63e1dc4969f9c1b69c6c118cef55 Mon Sep 17 00:00:00 2001 From: Jonathan Siegel <248302+usiegj00@users.noreply.github.com> Date: Fri, 8 May 2026 22:04:09 +0900 Subject: [PATCH 2/3] Fix help text: remove SSH impl detail, restore examples --- src/commands/run.cr | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/commands/run.cr b/src/commands/run.cr index 9aa0949..e5aa4ba 100644 --- a/src/commands/run.cr +++ b/src/commands/run.cr @@ -35,8 +35,7 @@ module Build Run a one-off command on the Build platform. Useful for migrations, console sessions, or ad-hoc scripts. - By default, runs via SSH (interactive, with PTY). Use --detach/-d to - run via API in a fresh container — no SSH or ContainerSSH required. + Use --detach/-d to run non-interactively in a fresh container: bld run -d -a my-app -- rails db:migrate bld run -d -a my-app -t worker -s Standard-2X -- rake heavy:task @@ -46,14 +45,16 @@ module Build other shell metacharacters reach the target process literally: bld run ruby -e 'puts ENV["HOME"]' -a my-app + bld run rake 'test:unit[foo,bar]' -a my-app Use -c/--shell when you actually want the remote shell to interpret metacharacters (variable expansion, pipes, multiple commands): bld run -c 'echo $RAILS_ENV | tee /tmp/env' -a my-app - Use --file (or stdin redirection) to send a local file as stdin: + Use stdin redirection or --file to send a local file: + bld run rails runner -a my-app < script.rb bld run rails runner -a my-app --file script.rb HELP ) From bdd01c90280fc839461df08493076e93bb229774 Mon Sep 17 00:00:00 2001 From: Jonathan Siegel <248302+usiegj00@users.noreply.github.com> Date: Sat, 9 May 2026 08:50:55 +0900 Subject: [PATCH 3/3] Remove SSH/ContainerSSH references from user-facing strings Debug output now says 'Connection established', 'Authentication successful', etc. instead of mentioning SSH as an implementation detail. --- src/commands/run.cr | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/commands/run.cr b/src/commands/run.cr index e5aa4ba..7d9dc83 100644 --- a/src/commands/run.cr +++ b/src/commands/run.cr @@ -23,7 +23,7 @@ module Build .option("app", "a", :required, "The app to run the command on") .option("debug", nil, :none, "Show verbose debugging information") .option("no-tty", nil, :none, "Force the command to not run in a tty") - .option("detach", "d", :none, "Run via API in a fresh container (no SSH required)") + .option("detach", "d", :none, "Run non-interactively in a fresh container") .option("type", "t", :optional, "Process type for detached run (default: worker)") .option("size", "s", :optional, "Dyno size for detached run (default: from formation)") .option("timeout", nil, :optional, "Timeout in seconds for detached run (30-1800, default: 300)") @@ -94,7 +94,7 @@ module Build command_array = input.argument("cmd", type: Array(String)) rescue [] of String file_path = input.option("file", type: String?) - # --detach: run via API in a fresh container (no SSH) + # --detach: run via API in a fresh container if detach if command_array.empty? output.puts " Command is required for detached mode" @@ -139,26 +139,26 @@ module Build SSH2::Session.open(ssh_host, ssh_port) do |session| spinner.update(status: "Logging in") if verbose - output.puts "SSH connection established" + output.puts "Connection established" output.puts "Authenticating with app: #{app.name}" end begin session.login(app.name, user_token) if verbose - output.puts "SSH authentication successful" + output.puts "Authentication successful" end rescue e : SSH2::SessionError spinner.error("Login failed") if verbose - output.puts "SSH authentication error: #{e.message}" + output.puts "Authentication error: #{e.message}" end exit end spinner.update(status: "Opening channel") if verbose - output.puts "Opening SSH channel" + output.puts "Opening channel" end session.open_session do |channel| @@ -306,7 +306,7 @@ module Build ACON::Command::Status::SUCCESS end - # Reads output from the SSH channel and writes to STDOUT + # Reads output from the channel and writes to STDOUT # Returns the exit code if exit_code_mode is enabled and sentinel is found private def read_channel_output(channel, output, verbose, exit_code_mode) : Int32? buffer = Bytes.new(4096)