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)