Skip to content

Commit

Permalink
Merge pull request #6309 from rubygems/segiddins/gem-exec
Browse files Browse the repository at this point in the history
Add gem exec command
  • Loading branch information
indirect committed Feb 22, 2023
2 parents 75dac08 + 38ee82c commit 451ac56
Show file tree
Hide file tree
Showing 5 changed files with 1,113 additions and 5 deletions.
2 changes: 2 additions & 0 deletions Manifest.txt
Expand Up @@ -347,6 +347,7 @@ lib/rubygems/commands/cleanup_command.rb
lib/rubygems/commands/contents_command.rb
lib/rubygems/commands/dependency_command.rb
lib/rubygems/commands/environment_command.rb
lib/rubygems/commands/exec_command.rb
lib/rubygems/commands/fetch_command.rb
lib/rubygems/commands/generate_index_command.rb
lib/rubygems/commands/help_command.rb
Expand Down Expand Up @@ -616,6 +617,7 @@ test/rubygems/test_gem_commands_cleanup_command.rb
test/rubygems/test_gem_commands_contents_command.rb
test/rubygems/test_gem_commands_dependency_command.rb
test/rubygems/test_gem_commands_environment_command.rb
test/rubygems/test_gem_commands_exec_command.rb
test/rubygems/test_gem_commands_fetch_command.rb
test/rubygems/test_gem_commands_generate_index_command.rb
test/rubygems/test_gem_commands_help_command.rb
Expand Down
14 changes: 9 additions & 5 deletions lib/rubygems/command.rb
Expand Up @@ -201,11 +201,15 @@ def get_all_gem_names
# respectively.
def get_all_gem_names_and_versions
get_all_gem_names.map do |name|
if /\A(.*):(#{Gem::Requirement::PATTERN_RAW})\z/ =~ name
[$1, $2]
else
[name]
end
extract_gem_name_and_version(name)
end
end

def extract_gem_name_and_version(name) # :nodoc:
if /\A(.*):(#{Gem::Requirement::PATTERN_RAW})\z/ =~ name
[$1, $2]
else
[name]
end
end

Expand Down
1 change: 1 addition & 0 deletions lib/rubygems/command_manager.rb
Expand Up @@ -43,6 +43,7 @@ class Gem::CommandManager
:contents,
:dependency,
:environment,
:exec,
:fetch,
:generate_index,
:help,
Expand Down
246 changes: 246 additions & 0 deletions lib/rubygems/commands/exec_command.rb
@@ -0,0 +1,246 @@
# frozen_string_literal: true
require_relative "../command"
require_relative "../dependency_installer"
require_relative "../gem_runner"
require_relative "../package"
require_relative "../version_option"

class Gem::Commands::ExecCommand < Gem::Command
include Gem::VersionOption

def initialize
super "exec", "Run a command from a gem", {
version: Gem::Requirement.default,
}

add_version_option
add_prerelease_option "to be installed"

add_option "-g", "--gem GEM", "run the executable from the given gem" do |value, options|
options[:gem_name] = value
end

add_option(:"Install/Update", "--conservative",
"Prefer the most recent installed version, ",
"rather than the latest version overall") do |value, options|
options[:conservative] = true
end
end

def arguments # :nodoc:
"COMMAND the executable command to run"
end

def defaults_str # :nodoc:
"--version '#{Gem::Requirement.default}'"
end

def description # :nodoc:
<<-EOF
The exec command handles installing (if necessary) and running an executable
from a gem, regardless of whether that gem is currently installed.
The exec command can be thought of as a shortcut to running `gem install` and
then the executable from the installed gem.
For example, `gem exec rails new .` will run `rails new .` in the current
directory, without having to manually run `gem install rails`.
Additionally, the exec command ensures the most recent version of the gem
is used (unless run with `--conservative`), and that the gem is not installed
to the same gem path as user-installed gems.
EOF
end

def usage # :nodoc:
"#{program_name} [options --] COMMAND [args]"
end

def execute
gem_paths = { "GEM_HOME" => Gem.paths.home, "GEM_PATH" => Gem.paths.path.join(File::PATH_SEPARATOR), "GEM_SPEC_CACHE" => Gem.paths.spec_cache_dir }.compact

check_executable

print_command
if options[:gem_name] == "gem" && options[:executable] == "gem"
set_gem_exec_install_paths
Gem::GemRunner.new.run options[:args]
return
elsif options[:conservative]
install_if_needed
else
install
activate!
end

load!
ensure
ENV.update(gem_paths) if gem_paths
Gem.clear_paths
end

private

def handle_options(args)
args = add_extra_args(args)
check_deprecated_options(args)
@options = Marshal.load Marshal.dump @defaults # deep copy
parser.order!(args) do |v|
# put the non-option back at the front of the list of arguments
args.unshift(v)

# stop parsing once we hit the first non-option,
# so you can call `gem exec rails --version` and it prints the rails
# version rather than rubygem's
break
end
@options[:args] = args

options[:executable], gem_version = extract_gem_name_and_version(options[:args].shift)
options[:gem_name] ||= options[:executable]

if gem_version
if options[:version].none?
options[:version] = Gem::Requirement.new(gem_version)
else
options[:version].concat [gem_version]
end
end

if options[:prerelease] && !options[:version].prerelease?
if options[:version].none?
options[:version] = Gem::Requirement.default_prerelease
else
options[:version].concat [Gem::Requirement.default_prerelease]
end
end
end

def check_executable
if options[:executable].nil?
raise Gem::CommandLineError,
"Please specify an executable to run (e.g. #{program_name} COMMAND)"
end
end

def print_command
verbose "running #{program_name} with:\n"
opts = options.reject {|_, v| v.nil? || Array(v).empty? }
max_length = opts.map {|k, _| k.size }.max
opts.each do |k, v|
next if v.nil?
verbose "\t#{k.to_s.rjust(max_length)}: #{v}"
end
verbose ""
end

def install_if_needed
activate!
rescue Gem::MissingSpecError
verbose "#{Gem::Dependency.new(options[:gem_name], options[:version])} not available locally, installing from remote"
install
activate!
end

def set_gem_exec_install_paths
home = File.join(Gem.dir, "gem_exec")

ENV["GEM_PATH"] = ([home] + Gem.path).join(File::PATH_SEPARATOR)
ENV["GEM_HOME"] = home
Gem.clear_paths
end

def install
set_gem_exec_install_paths

gem_name = options[:gem_name]
gem_version = options[:version]

install_options = options.merge(
minimal_deps: false,
wrappers: true
)

suppress_always_install do
dep_installer = Gem::DependencyInstaller.new install_options

request_set = dep_installer.resolve_dependencies gem_name, gem_version

verbose "Gems to install:"
request_set.sorted_requests.each do |activation_request|
verbose "\t#{activation_request.full_name}"
end

request_set.install install_options
end

Gem::Specification.reset
rescue Gem::InstallError => e
alert_error "Error installing #{gem_name}:\n\t#{e.message}"
terminate_interaction 1
rescue Gem::GemNotFoundException => e
show_lookup_failure e.name, e.version, e.errors, false

terminate_interaction 2
rescue Gem::UnsatisfiableDependencyError => e
show_lookup_failure e.name, e.version, e.errors, false,
"'#{gem_name}' (#{gem_version})"

terminate_interaction 2
end

def activate!
gem(options[:gem_name], options[:version])
Gem.finish_resolve

verbose "activated #{options[:gem_name]} (#{Gem.loaded_specs[options[:gem_name]].version})"
end

def load!
argv = ARGV.clone
ARGV.replace options[:args]

exe = executable = options[:executable]

contains_executable = Gem.loaded_specs.values.select do |spec|
spec.executables.include?(executable)
end

if contains_executable.any? {|s| s.name == executable }
contains_executable.select! {|s| s.name == executable }
end

if contains_executable.empty?
if (spec = Gem.loaded_specs[executable]) && (exe = spec.executable)
contains_executable << spec
else
alert_error "Failed to load executable `#{executable}`," \
" are you sure the gem `#{options[:gem_name]}` contains it?"
terminate_interaction 1
end
end

if contains_executable.size > 1
alert_error "Ambiguous which gem `#{executable}` should come from: " \
"the options are #{contains_executable.map(&:name)}, " \
"specify one via `-g`"
terminate_interaction 1
end

load Gem.activate_bin_path(contains_executable.first.name, exe, ">= 0.a")
ensure
ARGV.replace argv
end

def suppress_always_install
name = :always_install
cls = ::Gem::Resolver::InstallerSet
method = cls.instance_method(name)
cls.define_method(name) { [] }

begin
yield
ensure
cls.define_method(name, method)
end
end
end

0 comments on commit 451ac56

Please sign in to comment.