Skip to content

Instantly share code, notes, and snippets.

@marianposaceanu
Created August 2, 2026 09:07
Show Gist options
  • Select an option

  • Save marianposaceanu/c7b70a2a6575aff1477b18dd133a4268 to your computer and use it in GitHub Desktop.

Select an option

Save marianposaceanu/c7b70a2a6575aff1477b18dd133a4268 to your computer and use it in GitHub Desktop.
Cute centered deploy animation wrapper for Kamal

Cute Kamal deploy

A tiny Ruby wrapper that replaces interactive kamal deploy output with a centered ASCII cat and a simple, phase-based percentage. It does not modify or monkey-patch Kamal.

Add it to a Ruby/Rails app

Save the Gist's kamal-deploy file as bin/kamal-deploy, then make it executable:

chmod +x bin/kamal-deploy

Run it anywhere you would run Kamal:

bin/kamal-deploy
bin/kamal-deploy --skip-push
bin/kamal-deploy -d staging

The wrapper automatically uses bin/kamal deploy, bundle exec kamal deploy, or a globally installed kamal deploy, in that order. All arguments and the final exit status are preserved.

The percentage is a monotonic estimate based on Kamal's deploy phases, not a byte-accurate measurement. Complete output is saved to a private 0600 log; failures also show the last 40 lines. Pipes and CI receive Kamal's normal output.

Use KAMAL_DEPLOY_PLAIN=1 to disable the animation, or run bin/kamal-deploy --help for all options.

#!/usr/bin/env ruby
# frozen_string_literal: true
# A dependency-free, Gist-friendly wrapper around `kamal deploy`.
# It replaces Kamal's interactive output with a small centered animation while
# preserving plain output for pipes and CI. The percentage is a phase estimate,
# not a byte counter. Run with --help for configuration.
require 'fileutils'
require 'io/console'
require 'shellwords'
require 'tmpdir'
class KamalDeployAnimation
ANSI_PATTERN = /\e(?:\[[0-?]*[ -\/]*[@-~]|\][^\a]*(?:\a|\e\\))/.freeze
FRAME_INTERVAL = 0.12
FAILURE_TAIL_LINES = 40
PHASES = [
[ 8, 'Building image', /Build and push app image|Pull app image/i ],
[ 18, 'Building image', /docker buildx build|\[internal\].*(?:dockerfile|metadata|context)/i ],
[ 32, 'Packaging image', /exporting (?:to image|layers|manifest|config)/i ],
[ 42, 'Shipping image', /pushing (?:layers|manifest)|Pulling image/i ],
[ 48, 'Locking deploy', /Acquiring the deploy lock/i ],
[ 55, 'Starting proxy', /Ensure kamal-proxy is running/i ],
[ 64, 'Checking containers', /Detect stale containers/i ],
[ 71, 'Selecting image', /Get most recent version available as an image/i ],
[ 78, 'Starting application', /Start container with version/i ],
[ 86, 'Waiting for healthy application', /healthcheck|health check|kamal-proxy deploy/i ],
[ 91, 'Tidying old images', /Prune old containers and images/i ],
[ 97, 'Releasing deploy lock', /Releasing the deploy lock/i ],
[ 99, 'Finishing deploy', /Finished all in/i ]
].freeze
FRAMES = [
[ ' /\\_/\\', ' ( o.o )', ' > ^ < ~' ],
[ ' /\\_/\\', ' ( o.o )', ' > ^ < ~ ' ],
[ ' /\\_/\\', ' ( -.- )', ' > ^ < ~ ' ],
[ ' /\\_/\\', ' ( o.o )', ' > ^ < ~ ' ]
].freeze
def initialize(command, log_path)
@command = command
@log_path = log_path
@percent = 2
@phase = 'Preparing deploy'
@frame = 0
@scan_buffer = +''
@child_pid = nil
end
def run
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
read_io, write_io = IO.pipe
@child_pid = Process.spawn(*@command, in: $stdin, out: write_io, err: write_io, pgroup: true)
write_io.close
install_signal_forwarders
enter_screen
File.open(@log_path, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |log|
consume_output(read_io, log)
end
_, status = Process.wait2(@child_pid)
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at
if status.success?
@percent = 100
@phase = 'Deploy complete'
draw
sleep 0.25
leave_screen
puts "Deployed successfully in #{format_duration(elapsed)}."
puts "Log: #{@log_path}"
0
else
leave_screen
report_failure(status)
status.exitstatus || 128 + status.termsig
end
rescue Errno::ENOENT => error
leave_screen
warn "Could not start #{Shellwords.join(@command)}: #{error.message}"
127
ensure
read_io&.close unless read_io&.closed?
write_io&.close unless write_io&.closed?
restore_signal_handlers
leave_screen
end
private
def consume_output(read_io, log)
next_frame_at = monotonic_time
loop do
timeout = [ next_frame_at - monotonic_time, 0 ].max
if IO.select([ read_io ], nil, nil, timeout)
chunk = read_io.read_nonblock(16_384, exception: false)
break if chunk.nil?
unless chunk == :wait_readable
log.write(chunk)
log.flush
update_phase(chunk)
end
end
if monotonic_time >= next_frame_at
draw
next_frame_at = monotonic_time + FRAME_INTERVAL
end
end
end
def update_phase(chunk)
text = chunk.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
@scan_buffer << text.gsub(ANSI_PATTERN, '')
records = @scan_buffer.split(/[\r\n]/, -1)
remainder = records.pop.to_s
@scan_buffer = remainder.length > 2_048 ? remainder.slice(-2_048, 2_048) : remainder
records.each do |record|
PHASES.each do |percent, label, pattern|
next unless percent > @percent && record.match?(pattern)
@percent = percent
@phase = label
end
end
end
def draw
rows, columns = terminal_size
art = FRAMES[@frame % FRAMES.length]
@frame += 1
lines = art + [ "Deploying #{@percent}%", @phase ]
top_padding = [ (rows - lines.length) / 2, 0 ].max
output = +"\e[H"
output << "\n" * top_padding
lines.each do |line|
line = truncate(line, columns)
left_padding = [ (columns - line.length) / 2, 0 ].max
output << (' ' * left_padding) << line << "\e[K\n"
end
output << "\e[J"
$stdout.write(output)
$stdout.flush
end
def terminal_size
console = IO.console
return [ 24, 80 ] unless console
rows, columns = console.winsize
rows = 24 unless rows.positive?
columns = 80 unless columns.positive?
[ rows, columns ]
rescue SystemCallError
[ 24, 80 ]
end
def truncate(line, columns)
return '' if columns <= 0
return line if line.length <= columns
return line.slice(0, columns) if columns < 4
"#{line.slice(0, columns - 3)}..."
end
def enter_screen
return if @screen_active
@screen_active = true
$stdout.write("\e[?1049h\e[?25l\e[2J\e[H")
$stdout.flush
end
def leave_screen
return unless @screen_active
@screen_active = false
$stdout.write("\e[?25h\e[?1049l")
$stdout.flush
end
def install_signal_forwarders
@previous_signal_handlers = {}
%w[INT TERM HUP].each do |signal|
@previous_signal_handlers[signal] = Signal.trap(signal) do
begin
Process.kill(signal, -@child_pid) if @child_pid
rescue Errno::ESRCH
nil
end
end
end
end
def restore_signal_handlers
@previous_signal_handlers&.each do |signal, handler|
Signal.trap(signal, handler)
end
end
def report_failure(status)
code = status.exitstatus || "signal #{status.termsig}"
warn "Deploy failed (#{code})."
warn 'Last Kamal output:'
warn failure_tail
warn "Full log: #{@log_path}"
end
def failure_tail
text = File.binread(@log_path)
.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
.gsub(ANSI_PATTERN, '')
.tr("\r", "\n")
text.lines.last(FAILURE_TAIL_LINES).join.rstrip
rescue Errno::ENOENT
'(no output captured)'
end
def format_duration(seconds)
total_seconds = seconds.round
minutes, seconds = total_seconds.divmod(60)
minutes.positive? ? "#{minutes}m #{seconds}s" : "#{seconds}s"
end
def monotonic_time
Process.clock_gettime(Process::CLOCK_MONOTONIC)
end
end
def deploy_command
configured = ENV['KAMAL_DEPLOY_COMMAND']
return Shellwords.split(configured) unless configured.nil? || configured.strip.empty?
if File.executable?('bin/kamal')
[ 'bin/kamal', 'deploy' ]
elsif File.file?('Gemfile')
[ 'bundle', 'exec', 'kamal', 'deploy' ]
else
[ 'kamal', 'deploy' ]
end
end
def print_help
puts <<~HELP
Usage: kamal-deploy [KAMAL DEPLOY OPTIONS]
Shows a centered deploy animation in an interactive terminal and passes every
argument through to `kamal deploy`. Pipes, CI, TERM=dumb, and explicit plain
mode receive Kamal's original output. The monotonic percentage is an estimate
based on Kamal's deploy phases, not bytes or elapsed time.
Environment:
KAMAL_DEPLOY_COMMAND Full base command (default: auto-detected Kamal deploy)
KAMAL_DEPLOY_LOG_DIR Animation log directory (default: system temp directory)
KAMAL_DEPLOY_PLAIN=1 Disable the animation and use Kamal's original output
Examples:
bin/kamal-deploy
bin/kamal-deploy --skip-push
KAMAL_DEPLOY_COMMAND='kamal deploy' bin/kamal-deploy -d staging
HELP
end
if ARGV == [ '--help' ]
print_help
exit 0
end
command = deploy_command + ARGV
plain = !$stdout.tty? || ENV['TERM'] == 'dumb' || ENV['KAMAL_DEPLOY_PLAIN'] == '1'
if plain
exec(*command)
else
log_dir = ENV.fetch('KAMAL_DEPLOY_LOG_DIR', File.join(Dir.tmpdir, 'kamal-deploy'))
FileUtils.mkdir_p(log_dir, mode: 0o700)
timestamp = Time.now.strftime('%Y%m%d-%H%M%S')
log_path = File.join(log_dir, "deploy-#{timestamp}-#{Process.pid}.log")
exit KamalDeployAnimation.new(command, log_path).run
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment