From aea640cd2c4f03f45429e543a1a5f2a8b153fd16 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 19 Feb 2026 21:27:52 -0800 Subject: [PATCH 1/4] Support external commands in .kamal/bin and on PATH Unknown commands are resolved as external executables before falling through to aliases or the error handler. Lookup order: 1. .kamal/bin/ (project-local) 2. kamal- on PATH (system-wide) Builtin commands and unambiguous prefix matches are never shadowed. The external command is exec'd with remaining argv, replacing the kamal process. --- lib/kamal/cli/main.rb | 24 +++++++ test/cli/main_test.rb | 162 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 186 insertions(+) diff --git a/lib/kamal/cli/main.rb b/lib/kamal/cli/main.rb index 873cf6657..950d3c788 100644 --- a/lib/kamal/cli/main.rb +++ b/lib/kamal/cli/main.rb @@ -1,4 +1,28 @@ class Kamal::Cli::Main < Kamal::Cli::Base + def self.dispatch(command, given_args, given_opts, config) + if command.nil? && (ext = resolve_external_command(given_args.first)) + exec ext, *given_args.drop(1) + else + super + end + end + + def self.resolve_external_command(name) + return if name.nil? || name.start_with?("-") || \ + name.include?("/") || find_command_possibilities(name).any? + + local = File.join(".kamal", "bin", name) + return local if File.file?(local) && File.executable?(local) + + ENV["PATH"]&.split(File::PATH_SEPARATOR)&.each do |dir| + candidate = File.join(dir, "kamal-#{name}") + return candidate if File.file?(candidate) && File.executable?(candidate) + end + + nil + end + private_class_method :resolve_external_command + desc "setup", "Setup all accessories, push the env, and deploy app to servers" option :skip_push, aliases: "-P", type: :boolean, default: false, desc: "Skip image build and push" option :no_cache, type: :boolean, default: false, desc: "Build without using Docker's build cache" diff --git a/test/cli/main_test.rb b/test/cli/main_test.rb index 16b2807e2..9793e6c97 100644 --- a/test/cli/main_test.rb +++ b/test/cli/main_test.rb @@ -819,6 +819,168 @@ class CliMainTest < CliTestCase end end + test "external command from local .kamal/bin" do + Dir.mktmpdir do |tmpdir| + Dir.chdir(tmpdir) do + FileUtils.mkdir_p(".kamal/bin") + File.write(".kamal/bin/foo", "#!/bin/sh\n") + File.chmod(0755, ".kamal/bin/foo") + + Kamal::Cli::Main.expects(:exec).with(".kamal/bin/foo") + with_argv([ "foo" ]) { Kamal::Cli::Main.start } + end + end + end + + test "external command from PATH" do + Dir.mktmpdir do |tmpdir| + Dir.chdir(tmpdir) do + bin_dir = File.join(tmpdir, "bin") + FileUtils.mkdir_p(bin_dir) + File.write(File.join(bin_dir, "kamal-bar"), "#!/bin/sh\n") + File.chmod(0755, File.join(bin_dir, "kamal-bar")) + + original_path = ENV["PATH"] + ENV["PATH"] = "#{bin_dir}#{File::PATH_SEPARATOR}#{original_path}" + + Kamal::Cli::Main.expects(:exec).with(File.join(bin_dir, "kamal-bar")) + with_argv([ "bar" ]) { Kamal::Cli::Main.start } + ensure + ENV["PATH"] = original_path + end + end + end + + test "local external command takes priority over PATH" do + Dir.mktmpdir do |tmpdir| + Dir.chdir(tmpdir) do + FileUtils.mkdir_p(".kamal/bin") + File.write(".kamal/bin/baz", "#!/bin/sh\n") + File.chmod(0755, ".kamal/bin/baz") + + bin_dir = File.join(tmpdir, "path_bin") + FileUtils.mkdir_p(bin_dir) + File.write(File.join(bin_dir, "kamal-baz"), "#!/bin/sh\n") + File.chmod(0755, File.join(bin_dir, "kamal-baz")) + + original_path = ENV["PATH"] + ENV["PATH"] = "#{bin_dir}#{File::PATH_SEPARATOR}#{original_path}" + + Kamal::Cli::Main.expects(:exec).with(".kamal/bin/baz") + with_argv([ "baz" ]) { Kamal::Cli::Main.start } + ensure + ENV["PATH"] = original_path + end + end + end + + test "builtin commands are not shadowed by external commands" do + Dir.mktmpdir do |tmpdir| + Dir.chdir(tmpdir) do + FileUtils.mkdir_p(".kamal/bin") + File.write(".kamal/bin/version", "#!/bin/sh\n") + File.chmod(0755, ".kamal/bin/version") + + Kamal::Cli::Main.expects(:exec).never + with_argv([ "version" ]) { Kamal::Cli::Main.start } + end + end + end + + test "builtin prefix matches are not shadowed by external commands" do + Dir.mktmpdir do |tmpdir| + Dir.chdir(tmpdir) do + FileUtils.mkdir_p(".kamal/bin") + File.write(".kamal/bin/ver", "#!/bin/sh\n") + File.chmod(0755, ".kamal/bin/ver") + + Kamal::Cli::Main.expects(:exec).never + with_argv([ "ver" ]) { Kamal::Cli::Main.start } + end + end + end + + test "external command passes arguments through" do + Dir.mktmpdir do |tmpdir| + Dir.chdir(tmpdir) do + FileUtils.mkdir_p(".kamal/bin") + File.write(".kamal/bin/foo", "#!/bin/sh\n") + File.chmod(0755, ".kamal/bin/foo") + + Kamal::Cli::Main.expects(:exec).with(".kamal/bin/foo", "--bar", "baz") + with_argv([ "foo", "--bar", "baz" ]) { Kamal::Cli::Main.start } + end + end + end + + test "non-executable file in .kamal/bin is skipped" do + Dir.mktmpdir do |tmpdir| + Dir.chdir(tmpdir) do + FileUtils.mkdir_p(".kamal/bin") + File.write(".kamal/bin/foo", "#!/bin/sh\n") + File.chmod(0644, ".kamal/bin/foo") + + Kamal::Cli::Main.expects(:exec).never + with_argv([ "foo" ]) do + error = assert_raises(RuntimeError) { Kamal::Cli::Main.start } + assert_match /Configuration file not found/, error.message + end + end + end + end + + test "directory in .kamal/bin is skipped" do + Dir.mktmpdir do |tmpdir| + Dir.chdir(tmpdir) do + FileUtils.mkdir_p(".kamal/bin/foo") + + Kamal::Cli::Main.expects(:exec).never + with_argv([ "foo" ]) do + error = assert_raises(RuntimeError) { Kamal::Cli::Main.start } + assert_match /Configuration file not found/, error.message + end + end + end + end + + test "directory on PATH is skipped" do + Dir.mktmpdir do |tmpdir| + Dir.chdir(tmpdir) do + bin_dir = File.join(tmpdir, "bin") + FileUtils.mkdir_p(File.join(bin_dir, "kamal-foo")) + + original_path = ENV["PATH"] + ENV["PATH"] = "#{bin_dir}#{File::PATH_SEPARATOR}#{original_path}" + + Kamal::Cli::Main.expects(:exec).never + with_argv([ "foo" ]) do + error = assert_raises(RuntimeError) { Kamal::Cli::Main.start } + assert_match /Configuration file not found/, error.message + end + ensure + ENV["PATH"] = original_path + end + end + end + + test "path traversal in command name is rejected" do + Dir.mktmpdir do |tmpdir| + Dir.chdir(tmpdir) do + FileUtils.mkdir_p(".kamal/bin") + target = File.join(tmpdir, "evil") + File.write(target, "#!/bin/sh\n") + File.chmod(0755, target) + File.symlink(target, ".kamal/bin/../foo") + + Kamal::Cli::Main.expects(:exec).never + with_argv([ "../foo" ]) do + error = assert_raises(RuntimeError) { Kamal::Cli::Main.start } + assert_match /Configuration file not found/, error.message + end + end + end + end + test "upgrade rolling" do invoke_options = base_invoke_options(config_file: "deploy_with_accessories.yml", version: nil, confirmed: true, rolling: false) Kamal::Cli::Main.any_instance.expects(:invoke).with("kamal:cli:proxy:upgrade", [], invoke_options).times(4) From 692be1eca8a5658c7aeabdf19042f3215f22d0b8 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 5 Oct 2026 22:14:16 -0700 Subject: [PATCH 2/4] Exec external commands without a shell exec with a lone string splits it on spaces, or hands it to a shell when it has metacharacters, so `kamal bar` with kamal-bar in a PATH directory like "~/my tools" ran something else than the file we'd just checked. Pass the path as [command, argv0] so it is always run as is. --- lib/kamal/cli/main.rb | 2 +- test/cli/main_test.rb | 28 ++++++++++++++++++++++++---- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/lib/kamal/cli/main.rb b/lib/kamal/cli/main.rb index 950d3c788..b598cc4e2 100644 --- a/lib/kamal/cli/main.rb +++ b/lib/kamal/cli/main.rb @@ -1,7 +1,7 @@ class Kamal::Cli::Main < Kamal::Cli::Base def self.dispatch(command, given_args, given_opts, config) if command.nil? && (ext = resolve_external_command(given_args.first)) - exec ext, *given_args.drop(1) + exec [ ext, ext ], *given_args.drop(1) else super end diff --git a/test/cli/main_test.rb b/test/cli/main_test.rb index 9793e6c97..f1d221b36 100644 --- a/test/cli/main_test.rb +++ b/test/cli/main_test.rb @@ -826,7 +826,7 @@ class CliMainTest < CliTestCase File.write(".kamal/bin/foo", "#!/bin/sh\n") File.chmod(0755, ".kamal/bin/foo") - Kamal::Cli::Main.expects(:exec).with(".kamal/bin/foo") + Kamal::Cli::Main.expects(:exec).with([ ".kamal/bin/foo", ".kamal/bin/foo" ]) with_argv([ "foo" ]) { Kamal::Cli::Main.start } end end @@ -843,7 +843,27 @@ class CliMainTest < CliTestCase original_path = ENV["PATH"] ENV["PATH"] = "#{bin_dir}#{File::PATH_SEPARATOR}#{original_path}" - Kamal::Cli::Main.expects(:exec).with(File.join(bin_dir, "kamal-bar")) + Kamal::Cli::Main.expects(:exec).with([ File.join(bin_dir, "kamal-bar"), File.join(bin_dir, "kamal-bar") ]) + with_argv([ "bar" ]) { Kamal::Cli::Main.start } + ensure + ENV["PATH"] = original_path + end + end + end + + test "external command from a PATH directory with a space runs without a shell" do + Dir.mktmpdir do |tmpdir| + Dir.chdir(tmpdir) do + bin_dir = File.join(tmpdir, "my tools") + FileUtils.mkdir_p(bin_dir) + File.write(File.join(bin_dir, "kamal-bar"), "#!/bin/sh\n") + File.chmod(0755, File.join(bin_dir, "kamal-bar")) + + original_path = ENV["PATH"] + ENV["PATH"] = "#{bin_dir}#{File::PATH_SEPARATOR}#{original_path}" + + # A lone string argument to exec is split on spaces or run by a shell + Kamal::Cli::Main.expects(:exec).with([ File.join(bin_dir, "kamal-bar"), File.join(bin_dir, "kamal-bar") ]) with_argv([ "bar" ]) { Kamal::Cli::Main.start } ensure ENV["PATH"] = original_path @@ -866,7 +886,7 @@ class CliMainTest < CliTestCase original_path = ENV["PATH"] ENV["PATH"] = "#{bin_dir}#{File::PATH_SEPARATOR}#{original_path}" - Kamal::Cli::Main.expects(:exec).with(".kamal/bin/baz") + Kamal::Cli::Main.expects(:exec).with([ ".kamal/bin/baz", ".kamal/bin/baz" ]) with_argv([ "baz" ]) { Kamal::Cli::Main.start } ensure ENV["PATH"] = original_path @@ -907,7 +927,7 @@ class CliMainTest < CliTestCase File.write(".kamal/bin/foo", "#!/bin/sh\n") File.chmod(0755, ".kamal/bin/foo") - Kamal::Cli::Main.expects(:exec).with(".kamal/bin/foo", "--bar", "baz") + Kamal::Cli::Main.expects(:exec).with([ ".kamal/bin/foo", ".kamal/bin/foo" ], "--bar", "baz") with_argv([ "foo", "--bar", "baz" ]) { Kamal::Cli::Main.start } end end From b0aa12050742aefa5e798b53b6954f399f6f8d19 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 5 Oct 2026 23:27:55 -0700 Subject: [PATCH 3/4] Let aliases win over external commands An external command found before Thor got to the dynamic command quietly replaced any alias of the same name: install kamal-console anywhere on the PATH and `kamal console` stopped opening the console the config defines. Look an unknown command up the way Kamal always has, as an alias first, and only run an external command when there isn't one: built-in commands, then aliases, then .kamal/bin/, then kamal- on the PATH. Built-in commands still never read the config to get there. An unknown command reads it to look for an alias, unless there's no config file at all, since then there's no alias to find and the external command doesn't need one. The external command gets its arguments as given, including when an alias expands to it, rather than ARGV. And --hosts, --roles and --primary are no longer applied while looking up an alias: the alias starts over with them, and an external command takes them as its own, so `kamal foo -h` no longer fails to match a host named "hosts". --- lib/kamal/cli/alias/command.rb | 19 +- lib/kamal/cli/base.rb | 14 +- lib/kamal/cli/main.rb | 30 +-- lib/kamal/commander.rb | 4 + lib/kamal/configuration/docs/alias.yml | 6 + test/cli/main_test.rb | 285 +++++++++++++------------ 6 files changed, 196 insertions(+), 162 deletions(-) diff --git a/lib/kamal/cli/alias/command.rb b/lib/kamal/cli/alias/command.rb index c96261d51..546c23670 100644 --- a/lib/kamal/cli/alias/command.rb +++ b/lib/kamal/cli/alias/command.rb @@ -1,10 +1,27 @@ class Kamal::Cli::Alias::Command < Thor::DynamicCommand + # Thor only gets here when no built-in command matches. An alias in the config + # comes next, then an external command, then Thor's error. def run(instance, args = []) - if (command = KAMAL.resolve_alias(name)) + external = Kamal::Cli::Main.external_command(name) + + if (command = configured_alias(external)) KAMAL.reset Kamal::Cli::Main.start(Shellwords.split(command) + ARGV[1..-1]) + elsif external + exec [ external, external ], *given_args(instance) else super end end + + private + # There's no alias without a config file, and an external command doesn't need one + def configured_alias(external) + KAMAL.resolve_alias(name) unless external && !KAMAL.config_file? + end + + # The arguments after the command name, as given, options included + def given_args(instance) + instance.instance_variable_get("@_initializer")[1] + end end diff --git a/lib/kamal/cli/base.rb b/lib/kamal/cli/base.rb index 9d99f759e..82a6dbdf4 100644 --- a/lib/kamal/cli/base.rb +++ b/lib/kamal/cli/base.rb @@ -45,6 +45,10 @@ def initialize(args = [], local_options = {}, config = {}) end private + def dynamic_command? + @_initializer.last[:current_command].is_a?(Kamal::Cli::Alias::Command) + end + def options_with_subcommand_class_options options.merge(@_initializer.last[:class_options] || {}) end @@ -65,9 +69,13 @@ def initialize_commander destination: options[:destination], version: options[:version] - commander.specific_hosts = options[:hosts]&.split(",") - commander.specific_roles = options[:roles]&.split(",") - commander.specific_primary! if options[:primary] + # A command Thor doesn't know is looked up as an alias, which starts over with + # its own selectors, or run as an external command, so don't load the config for them + unless dynamic_command? + commander.specific_hosts = options[:hosts]&.split(",") + commander.specific_roles = options[:roles]&.split(",") + commander.specific_primary! if options[:primary] + end commander.lock_wait = options[:lock_wait] commander.lock_wait_timeout = options[:lock_wait_timeout] diff --git a/lib/kamal/cli/main.rb b/lib/kamal/cli/main.rb index b598cc4e2..871c32827 100644 --- a/lib/kamal/cli/main.rb +++ b/lib/kamal/cli/main.rb @@ -1,28 +1,16 @@ class Kamal::Cli::Main < Kamal::Cli::Base - def self.dispatch(command, given_args, given_opts, config) - if command.nil? && (ext = resolve_external_command(given_args.first)) - exec [ ext, ext ], *given_args.drop(1) - else - super + # The executable to run for a command that is neither built in nor an alias in + # the config: .kamal/bin/, or else kamal- on the PATH. An empty PATH entry + # isn't taken for the current directory: .kamal/bin is the place for a project's own. + def self.external_command(name) + if name.present? && !name.start_with?("-") && !name.include?("/") + path_dirs = ENV["PATH"].to_s.split(File::PATH_SEPARATOR).reject(&:empty?) + + [ File.join(".kamal", "bin", name), *path_dirs.map { |dir| File.join(dir, "kamal-#{name}") } ] + .find { |path| File.file?(path) && File.executable?(path) } end end - def self.resolve_external_command(name) - return if name.nil? || name.start_with?("-") || \ - name.include?("/") || find_command_possibilities(name).any? - - local = File.join(".kamal", "bin", name) - return local if File.file?(local) && File.executable?(local) - - ENV["PATH"]&.split(File::PATH_SEPARATOR)&.each do |dir| - candidate = File.join(dir, "kamal-#{name}") - return candidate if File.file?(candidate) && File.executable?(candidate) - end - - nil - end - private_class_method :resolve_external_command - desc "setup", "Setup all accessories, push the env, and deploy app to servers" option :skip_push, aliases: "-P", type: :boolean, default: false, desc: "Skip image build and push" option :no_cache, type: :boolean, default: false, desc: "Build without using Docker's build cache" diff --git a/lib/kamal/commander.rb b/lib/kamal/commander.rb index 3879268ae..25f716f66 100644 --- a/lib/kamal/commander.rb +++ b/lib/kamal/commander.rb @@ -43,6 +43,10 @@ def configured? @config || @config_kwargs end + def config_file? + @config.present? || @config_kwargs[:config_file].exist? + end + def specific_primary! @specifics = nil if specific_roles.present? diff --git a/lib/kamal/configuration/docs/alias.yml b/lib/kamal/configuration/docs/alias.yml index 472a63831..e28b32b40 100644 --- a/lib/kamal/configuration/docs/alias.yml +++ b/lib/kamal/configuration/docs/alias.yml @@ -27,3 +27,9 @@ aliases: # # Aliases can include a destination with the `-d` flag: staging_deploy: deploy -d staging + +# Aliases and external commands +# +# A command that isn't built in runs an alias of that name, if there is one, and +# otherwise an external command: an executable `.kamal/bin/`, or `kamal-` +# on the PATH. Built-in commands can't be overridden by either. diff --git a/test/cli/main_test.rb b/test/cli/main_test.rb index f1d221b36..11948f6b0 100644 --- a/test/cli/main_test.rb +++ b/test/cli/main_test.rb @@ -819,185 +819,166 @@ class CliMainTest < CliTestCase end end - test "external command from local .kamal/bin" do - Dir.mktmpdir do |tmpdir| - Dir.chdir(tmpdir) do - FileUtils.mkdir_p(".kamal/bin") - File.write(".kamal/bin/foo", "#!/bin/sh\n") - File.chmod(0755, ".kamal/bin/foo") - - Kamal::Cli::Main.expects(:exec).with([ ".kamal/bin/foo", ".kamal/bin/foo" ]) - with_argv([ "foo" ]) { Kamal::Cli::Main.start } - end + test "external command from .kamal/bin" do + in_external_command_dir do + write_executable ".kamal/bin/foo" + + expects_external_command ".kamal/bin/foo" + with_argv([ "foo" ]) { Kamal::Cli::Main.start } end end test "external command from PATH" do - Dir.mktmpdir do |tmpdir| - Dir.chdir(tmpdir) do - bin_dir = File.join(tmpdir, "bin") - FileUtils.mkdir_p(bin_dir) - File.write(File.join(bin_dir, "kamal-bar"), "#!/bin/sh\n") - File.chmod(0755, File.join(bin_dir, "kamal-bar")) - - original_path = ENV["PATH"] - ENV["PATH"] = "#{bin_dir}#{File::PATH_SEPARATOR}#{original_path}" - - Kamal::Cli::Main.expects(:exec).with([ File.join(bin_dir, "kamal-bar"), File.join(bin_dir, "kamal-bar") ]) - with_argv([ "bar" ]) { Kamal::Cli::Main.start } - ensure - ENV["PATH"] = original_path - end + in_external_command_dir do |dir| + write_executable "#{dir}/bin/kamal-bar" + prepend_path "#{dir}/bin" + + expects_external_command "#{dir}/bin/kamal-bar" + with_argv([ "bar" ]) { Kamal::Cli::Main.start } end end test "external command from a PATH directory with a space runs without a shell" do - Dir.mktmpdir do |tmpdir| - Dir.chdir(tmpdir) do - bin_dir = File.join(tmpdir, "my tools") - FileUtils.mkdir_p(bin_dir) - File.write(File.join(bin_dir, "kamal-bar"), "#!/bin/sh\n") - File.chmod(0755, File.join(bin_dir, "kamal-bar")) - - original_path = ENV["PATH"] - ENV["PATH"] = "#{bin_dir}#{File::PATH_SEPARATOR}#{original_path}" - - # A lone string argument to exec is split on spaces or run by a shell - Kamal::Cli::Main.expects(:exec).with([ File.join(bin_dir, "kamal-bar"), File.join(bin_dir, "kamal-bar") ]) - with_argv([ "bar" ]) { Kamal::Cli::Main.start } - ensure - ENV["PATH"] = original_path - end + in_external_command_dir do |dir| + write_executable "#{dir}/my tools/kamal-bar" + prepend_path "#{dir}/my tools" + + # A lone string argument to exec is split on spaces or run by a shell + expects_external_command "#{dir}/my tools/kamal-bar" + with_argv([ "bar" ]) { Kamal::Cli::Main.start } end end - test "local external command takes priority over PATH" do - Dir.mktmpdir do |tmpdir| - Dir.chdir(tmpdir) do - FileUtils.mkdir_p(".kamal/bin") - File.write(".kamal/bin/baz", "#!/bin/sh\n") - File.chmod(0755, ".kamal/bin/baz") + test "external command in .kamal/bin takes priority over PATH" do + in_external_command_dir do |dir| + write_executable ".kamal/bin/baz" + write_executable "#{dir}/bin/kamal-baz" + prepend_path "#{dir}/bin" - bin_dir = File.join(tmpdir, "path_bin") - FileUtils.mkdir_p(bin_dir) - File.write(File.join(bin_dir, "kamal-baz"), "#!/bin/sh\n") - File.chmod(0755, File.join(bin_dir, "kamal-baz")) + expects_external_command ".kamal/bin/baz" + with_argv([ "baz" ]) { Kamal::Cli::Main.start } + end + end - original_path = ENV["PATH"] - ENV["PATH"] = "#{bin_dir}#{File::PATH_SEPARATOR}#{original_path}" + test "external command gets the arguments as given, options included" do + in_external_command_dir do + write_executable ".kamal/bin/foo" - Kamal::Cli::Main.expects(:exec).with([ ".kamal/bin/baz", ".kamal/bin/baz" ]) - with_argv([ "baz" ]) { Kamal::Cli::Main.start } - ensure - ENV["PATH"] = original_path - end + expects_external_command ".kamal/bin/foo", "--bar", "baz", "-v" + with_argv([ "foo", "--bar", "baz", "-v" ]) { Kamal::Cli::Main.start } end end - test "builtin commands are not shadowed by external commands" do - Dir.mktmpdir do |tmpdir| - Dir.chdir(tmpdir) do - FileUtils.mkdir_p(".kamal/bin") - File.write(".kamal/bin/version", "#!/bin/sh\n") - File.chmod(0755, ".kamal/bin/version") + test "external command gets selectors without Kamal applying them" do + config_file = File.expand_path("test/fixtures/deploy_with_aliases.yml") - Kamal::Cli::Main.expects(:exec).never - with_argv([ "version" ]) { Kamal::Cli::Main.start } - end + in_external_command_dir do + write_executable ".kamal/bin/foo" + + # -h with no value would otherwise be --hosts=hosts, and fail to match a host + expects_external_command ".kamal/bin/foo", "-h", "-c", config_file + with_argv([ "foo", "-h", "-c", config_file ]) { Kamal::Cli::Main.start } end end - test "builtin prefix matches are not shadowed by external commands" do - Dir.mktmpdir do |tmpdir| - Dir.chdir(tmpdir) do - FileUtils.mkdir_p(".kamal/bin") - File.write(".kamal/bin/ver", "#!/bin/sh\n") - File.chmod(0755, ".kamal/bin/ver") + test "built-in commands win over external commands, without loading the config" do + in_external_command_dir do |dir| + write_executable ".kamal/bin/version" + write_executable "#{dir}/bin/kamal-version" + prepend_path "#{dir}/bin" - Kamal::Cli::Main.expects(:exec).never - with_argv([ "ver" ]) { Kamal::Cli::Main.start } - end + Kamal::Cli::Alias::Command.any_instance.expects(:exec).never + Kamal::Configuration.expects(:load_raw_config).never + Kamal::Configuration.expects(:create_from).never + + output = stdouted { with_argv([ "version" ]) { Kamal::Cli::Main.start } } + assert_equal Kamal::VERSION, output.strip end end - test "external command passes arguments through" do - Dir.mktmpdir do |tmpdir| - Dir.chdir(tmpdir) do - FileUtils.mkdir_p(".kamal/bin") - File.write(".kamal/bin/foo", "#!/bin/sh\n") - File.chmod(0755, ".kamal/bin/foo") + test "built-in prefix matches win over external commands" do + in_external_command_dir do + write_executable ".kamal/bin/ver" - Kamal::Cli::Main.expects(:exec).with([ ".kamal/bin/foo", ".kamal/bin/foo" ], "--bar", "baz") - with_argv([ "foo", "--bar", "baz" ]) { Kamal::Cli::Main.start } - end + Kamal::Cli::Alias::Command.any_instance.expects(:exec).never + Kamal::Configuration.expects(:load_raw_config).never + + output = stdouted { with_argv([ "ver" ]) { Kamal::Cli::Main.start } } + assert_equal Kamal::VERSION, output.strip + end + end + + test "aliases win over external commands of the same name" do + config_file = File.expand_path("test/fixtures/deploy_with_aliases.yml") + + in_external_command_dir do |dir| + write_executable ".kamal/bin/console" + write_executable "#{dir}/bin/kamal-console" + prepend_path "#{dir}/bin" + + Kamal::Cli::Alias::Command.any_instance.expects(:exec).never + + output = stdouted { with_argv([ "console", "-c", config_file ]) { Kamal::Cli::Main.start } } + assert_match "docker exec app-console-999 bin/console on 1.1.1.5", output + end + end + + test "external command runs when the config has no alias of that name" do + config_file = File.expand_path("test/fixtures/deploy_with_aliases.yml") + + in_external_command_dir do + write_executable ".kamal/bin/foo" + + expects_external_command ".kamal/bin/foo", "-c", config_file + with_argv([ "foo", "-c", config_file ]) { Kamal::Cli::Main.start } + end + end + + test "alias can expand to an external command" do + config = File.read("test/fixtures/deploy_simple.yml") + "aliases:\n foo_list: foo list\n" + + in_external_command_dir do + write_executable ".kamal/bin/foo" + FileUtils.mkdir_p("config") + File.write("config/deploy.yml", config) + + expects_external_command ".kamal/bin/foo", "list", "--all" + with_argv([ "foo_list", "--all" ]) { Kamal::Cli::Main.start } end end test "non-executable file in .kamal/bin is skipped" do - Dir.mktmpdir do |tmpdir| - Dir.chdir(tmpdir) do - FileUtils.mkdir_p(".kamal/bin") - File.write(".kamal/bin/foo", "#!/bin/sh\n") - File.chmod(0644, ".kamal/bin/foo") - - Kamal::Cli::Main.expects(:exec).never - with_argv([ "foo" ]) do - error = assert_raises(RuntimeError) { Kamal::Cli::Main.start } - assert_match /Configuration file not found/, error.message - end - end + in_external_command_dir do + write_executable ".kamal/bin/foo", mode: 0644 + + assert_no_external_command "foo" end end test "directory in .kamal/bin is skipped" do - Dir.mktmpdir do |tmpdir| - Dir.chdir(tmpdir) do - FileUtils.mkdir_p(".kamal/bin/foo") - - Kamal::Cli::Main.expects(:exec).never - with_argv([ "foo" ]) do - error = assert_raises(RuntimeError) { Kamal::Cli::Main.start } - assert_match /Configuration file not found/, error.message - end - end + in_external_command_dir do + FileUtils.mkdir_p(".kamal/bin/foo") + + assert_no_external_command "foo" end end test "directory on PATH is skipped" do - Dir.mktmpdir do |tmpdir| - Dir.chdir(tmpdir) do - bin_dir = File.join(tmpdir, "bin") - FileUtils.mkdir_p(File.join(bin_dir, "kamal-foo")) - - original_path = ENV["PATH"] - ENV["PATH"] = "#{bin_dir}#{File::PATH_SEPARATOR}#{original_path}" - - Kamal::Cli::Main.expects(:exec).never - with_argv([ "foo" ]) do - error = assert_raises(RuntimeError) { Kamal::Cli::Main.start } - assert_match /Configuration file not found/, error.message - end - ensure - ENV["PATH"] = original_path - end + in_external_command_dir do |dir| + FileUtils.mkdir_p("#{dir}/bin/kamal-foo") + prepend_path "#{dir}/bin" + + assert_no_external_command "foo" end end test "path traversal in command name is rejected" do - Dir.mktmpdir do |tmpdir| - Dir.chdir(tmpdir) do - FileUtils.mkdir_p(".kamal/bin") - target = File.join(tmpdir, "evil") - File.write(target, "#!/bin/sh\n") - File.chmod(0755, target) - File.symlink(target, ".kamal/bin/../foo") - - Kamal::Cli::Main.expects(:exec).never - with_argv([ "../foo" ]) do - error = assert_raises(RuntimeError) { Kamal::Cli::Main.start } - assert_match /Configuration file not found/, error.message - end - end + in_external_command_dir do |dir| + write_executable "#{dir}/evil" + FileUtils.mkdir_p(".kamal/bin") + File.symlink("#{dir}/evil", ".kamal/bin/../foo") + + assert_no_external_command "../foo" end end @@ -1019,6 +1000,36 @@ class CliMainTest < CliTestCase end private + def in_external_command_dir + Dir.mktmpdir do |tmpdir| + Dir.chdir(tmpdir) { yield tmpdir } + end + end + + def write_executable(path, mode: 0755) + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, "#!/bin/sh\n") + File.chmod(mode, path) + end + + # The PATH is restored with the rest of the environment on teardown + def prepend_path(dir) + ENV["PATH"] = [ dir, ENV["PATH"] ].join(File::PATH_SEPARATOR) + end + + def expects_external_command(path, *args) + Kamal::Cli::Alias::Command.any_instance.expects(:exec).with([ path, path ], *args) + end + + def assert_no_external_command(name) + Kamal::Cli::Alias::Command.any_instance.expects(:exec).never + + with_argv([ name ]) do + error = assert_raises(RuntimeError) { Kamal::Cli::Main.start } + assert_match /Configuration file not found/, error.message + end + end + def run_command(*command, config_file: "deploy_simple") with_argv([ *command, "-c", "test/fixtures/#{config_file}.yml" ]) do stdouted { Kamal::Cli::Main.start } From c81c4bd67c4da8e515d6f7b8ee82a583b73f9afb Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Mon, 5 Oct 2026 23:47:00 -0700 Subject: [PATCH 4/4] Render the config for alias lookup with its destination in the environment Loading the config sets KAMAL_DESTINATION before rendering its ERB, but looking an alias up in the raw config didn't, so an alias written with ENV["KAMAL_DESTINATION"] only worked when --hosts, --roles or --primary had loaded the whole config first. Now that those no longer load it for an unknown command, set the destination for the raw read too, so `kamal info -d world` sees the same config with or without selectors. --- lib/kamal/commander.rb | 2 ++ test/cli/main_test.rb | 21 +++++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/lib/kamal/commander.rb b/lib/kamal/commander.rb index 25f716f66..1420ba2c2 100644 --- a/lib/kamal/commander.rb +++ b/lib/kamal/commander.rb @@ -137,6 +137,8 @@ def resolve_alias(name) if @config @config.aliases[name]&.command else + # Rendered with its destination in the environment, as loading the config would + ENV["KAMAL_DESTINATION"] = @config_kwargs[:destination] raw_config = Kamal::Configuration.load_raw_config(**@config_kwargs.to_h.slice(:config_file, :destination)) raw_config[:aliases]&.dig(name) end diff --git a/test/cli/main_test.rb b/test/cli/main_test.rb index 11948f6b0..1dce492e3 100644 --- a/test/cli/main_test.rb +++ b/test/cli/main_test.rb @@ -947,6 +947,27 @@ class CliMainTest < CliTestCase end end + test "alias lookup renders the config with its destination in the environment, selectors or not" do + config = File.read("test/fixtures/deploy_simple.yml") + <<~YAML + aliases: + info: <%= ENV.fetch("KAMAL_DESTINATION") == "world" ? "version" : "details" %> + YAML + + in_external_command_dir do + FileUtils.mkdir_p("config") + File.write("config/deploy.yml", config) + File.write("config/deploy.world.yml", "env:\n clear:\n WORLD: 1\n") + + [ [], [ "--primary" ] ].each do |selector| + ENV.delete("KAMAL_DESTINATION") + KAMAL.reset + + output = stdouted { with_argv([ "info", "-d", "world", *selector ]) { Kamal::Cli::Main.start } } + assert_equal Kamal::VERSION, output.strip, "with #{selector.inspect}" + end + end + end + test "non-executable file in .kamal/bin is skipped" do in_external_command_dir do write_executable ".kamal/bin/foo", mode: 0644