diff --git a/src/commands/run.cr b/src/commands/run.cr
index 2467c1e..7d9dc83 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 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)")
.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,6 +35,11 @@ module Build
Run a one-off command on the Build platform. Useful for migrations,
console sessions, or ad-hoc scripts.
+ 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
+
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:
@@ -43,11 +52,10 @@ module Build
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 stdin redirection or --file to send a local file:
- bld run rails runner -a my-app --file script.rb
bld run rails runner -a my-app < script.rb
+ bld run rails runner -a my-app --file script.rb
HELP
)
.usage("bash -a my-app")
@@ -81,10 +89,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
+ 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}"
@@ -121,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|
@@ -255,7 +273,40 @@ module Build
return ACON::Command::Status::SUCCESS
end
- # Reads output from the SSH channel and writes to STDOUT
+ # 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 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)