diff options
Diffstat (limited to 'lib/bundler/cli')
28 files changed, 2898 insertions, 0 deletions
diff --git a/lib/bundler/cli/add.rb b/lib/bundler/cli/add.rb new file mode 100644 index 0000000000..d65ed68b4a --- /dev/null +++ b/lib/bundler/cli/add.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module Bundler + class CLI::Add + attr_reader :gems, :options, :version + + def initialize(options, gems) + @gems = gems + @options = options + @options[:group] = options[:group].split(",").map(&:strip) unless options[:group].nil? + @version = options[:version].split(",").map(&:strip) unless options[:version].nil? + end + + def run + Bundler.ui.level = "warn" if options[:quiet] + + validate_options! + inject_dependencies + perform_bundle_install unless options["skip-install"] + end + + private + + def perform_bundle_install + Installer.install(Bundler.root, Bundler.definition) + Bundler.load.cache if Bundler.app_cache.exist? + end + + def inject_dependencies + dependencies = gems.map {|g| Bundler::Dependency.new(g, version, options) } + + Injector.inject(dependencies, + conservative_versioning: options[:version].nil?, # Perform conservative versioning only when version is not specified + pessimistic: options[:pessimistic], + strict: options[:strict]) + end + + def validate_options! + raise InvalidOption, "You cannot specify `--git` and `--github` at the same time." if options["git"] && options["github"] + + unless options["git"] || options["github"] + raise InvalidOption, "You cannot specify `--branch` unless `--git` or `--github` is specified." if options["branch"] + + raise InvalidOption, "You cannot specify `--ref` unless `--git` or `--github` is specified." if options["ref"] + end + + raise InvalidOption, "You cannot specify `--branch` and `--ref` at the same time." if options["branch"] && options["ref"] + + raise InvalidOption, "You cannot specify `--strict` and `--pessimistic` at the same time." if options[:strict] && options[:pessimistic] + + # raise error when no gems are specified + raise InvalidOption, "Please specify gems to add." if gems.empty? + + version.to_a.each do |v| + raise InvalidOption, "Invalid gem requirement pattern '#{v}'" unless Gem::Requirement::PATTERN.match?(v.to_s) + end + end + end +end diff --git a/lib/bundler/cli/binstubs.rb b/lib/bundler/cli/binstubs.rb new file mode 100644 index 0000000000..8ce138df96 --- /dev/null +++ b/lib/bundler/cli/binstubs.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module Bundler + class CLI::Binstubs + attr_reader :options, :gems + def initialize(options, gems) + @options = options + @gems = gems + end + + def run + Bundler.definition.validate_runtime! + path_option = options["path"] + path_option = nil if path_option&.empty? + Bundler.settings.set_command_option :bin, path_option if options["path"] + Bundler.settings.set_command_option_if_given :shebang, options["shebang"] + installer = Installer.new(Bundler.root, Bundler.definition) + + installer_opts = { + force: options[:force], + binstubs_cmd: true, + all_platforms: options["all-platforms"], + } + + if options[:all] + raise InvalidOption, "Cannot specify --all with specific gems" unless gems.empty? + @gems = Bundler.definition.specs.map(&:name) + installer_opts.delete(:binstubs_cmd) + elsif gems.empty? + Bundler.ui.error "`bundle binstubs` needs at least one gem to run." + exit 1 + end + + gems.each do |gem_name| + spec = Bundler.definition.specs.find {|s| s.name == gem_name } + unless spec + raise GemNotFound, Bundler::CLI::Common.gem_not_found_message( + gem_name, Bundler.definition.specs + ) + end + + if options[:standalone] + if gem_name == "bundler" + Bundler.ui.warn("Sorry, Bundler can only be run via RubyGems.") unless options[:all] + next + end + + Bundler.settings.temporary(path: Bundler.settings[:path] || Bundler.root) do + installer.generate_standalone_bundler_executable_stubs(spec, installer_opts) + end + else + installer.generate_bundler_executable_stubs(spec, installer_opts) + end + end + end + end +end diff --git a/lib/bundler/cli/cache.rb b/lib/bundler/cli/cache.rb new file mode 100644 index 0000000000..59605df847 --- /dev/null +++ b/lib/bundler/cli/cache.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Bundler + class CLI::Cache + attr_reader :options + + def initialize(options) + @options = options + end + + def run + Bundler.ui.level = "warn" if options[:quiet] + Bundler.settings.set_command_option_if_given :cache_path, options["cache-path"] + + install + + Bundler.settings.temporary(cache_all_platforms: options["all-platforms"]) do + Bundler.load.cache + end + end + + private + + def install + require_relative "install" + options = self.options.dup + options["local"] = false if Bundler.settings[:cache_all_platforms] + options["no-cache"] = true + Bundler::CLI::Install.new(options).run + end + end +end diff --git a/lib/bundler/cli/check.rb b/lib/bundler/cli/check.rb new file mode 100644 index 0000000000..493eb3ec6a --- /dev/null +++ b/lib/bundler/cli/check.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Bundler + class CLI::Check + attr_reader :options + + def initialize(options) + @options = options + end + + def run + Bundler.settings.set_command_option_if_given :path, options[:path] + + definition = Bundler.definition + definition.validate_runtime! + + begin + definition.check! + not_installed = definition.missing_specs + rescue GemNotFound, GitError, SolveFailure + Bundler.ui.error "Bundler can't satisfy your Gemfile's dependencies." + Bundler.ui.warn "Install missing gems with `bundle install`." + exit 1 + end + + if not_installed.any? + Bundler.ui.error "The following gems are missing" + not_installed.each {|s| Bundler.ui.error " * #{s.name} (#{s.version})" } + Bundler.ui.warn "Install missing gems with `bundle install`" + exit 1 + elsif !Bundler.default_lockfile.file? && Bundler.frozen_bundle? + Bundler.ui.error "This bundle has been frozen, but there is no #{SharedHelpers.relative_lockfile_path} present" + exit 1 + else + definition.lock(true) unless options[:"dry-run"] + Bundler.ui.info "The Gemfile's dependencies are satisfied" + end + end + end +end diff --git a/lib/bundler/cli/clean.rb b/lib/bundler/cli/clean.rb new file mode 100644 index 0000000000..c6b0968e3e --- /dev/null +++ b/lib/bundler/cli/clean.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Bundler + class CLI::Clean + attr_reader :options + + def initialize(options) + @options = options + end + + def run + require_path_or_force unless options[:"dry-run"] + Bundler.load.clean(options[:"dry-run"]) + end + + protected + + def require_path_or_force + return unless Bundler.use_system_gems? && !options[:force] + raise InvalidOption, "Cleaning all the gems on your system is dangerous! " \ + "If you're sure you want to remove every system gem not in this " \ + "bundle, run `bundle clean --force`." + end + end +end diff --git a/lib/bundler/cli/common.rb b/lib/bundler/cli/common.rb new file mode 100644 index 0000000000..2f332ff364 --- /dev/null +++ b/lib/bundler/cli/common.rb @@ -0,0 +1,155 @@ +# frozen_string_literal: true + +module Bundler + module CLI::Common + def self.output_post_install_messages(messages) + return if Bundler.settings["ignore_messages"] + messages.to_a.each do |name, msg| + print_post_install_message(name, msg) unless Bundler.settings["ignore_messages.#{name}"] + end + end + + def self.print_post_install_message(name, msg) + Bundler.ui.confirm "Post-install message from #{name}:" + Bundler.ui.info msg + end + + def self.output_fund_metadata_summary + return if Bundler.settings["ignore_funding_requests"] + definition = Bundler.definition + current_dependencies = definition.requested_dependencies + current_specs = definition.specs + + count = current_dependencies.count {|dep| current_specs[dep.name].first.metadata.key?("funding_uri") } + + return if count.zero? + + intro = count > 1 ? "#{count} installed gems you directly depend on are" : "#{count} installed gem you directly depend on is" + message = "#{intro} looking for funding.\n Run `bundle fund` for details" + Bundler.ui.info message + end + + def self.output_without_groups_message(command) + return if Bundler.settings[:without].empty? + Bundler.ui.confirm without_groups_message(command) + end + + def self.without_groups_message(command) + command_in_past_tense = command == :install ? "installed" : "updated" + groups = Bundler.settings[:without] + "Gems in the #{verbalize_groups(groups)} were not #{command_in_past_tense}." + end + + def self.verbalize_groups(groups) + groups.map! {|g| "'#{g}'" } + group_list = [groups[0...-1].join(", "), groups[-1..-1]]. + reject {|s| s.to_s.empty? }.join(" and ") + group_str = groups.size == 1 ? "group" : "groups" + "#{group_str} #{group_list}" + end + + def self.select_spec(name, regex_match = nil) + specs = [] + regexp = Regexp.new(name) if regex_match + + Bundler.definition.specs.each do |spec| + return spec if spec.name == name + specs << spec if regexp && spec.name.match?(regexp) + end + + default_spec = default_gem_spec(name) + specs << default_spec if default_spec + + case specs.count + when 0 + dep_in_other_group = Bundler.definition.current_dependencies.find {|dep|dep.name == name } + + if dep_in_other_group + raise GemNotFound, "Could not find gem '#{name}', because it's in the #{verbalize_groups(dep_in_other_group.groups)}, configured to be ignored." + else + raise GemNotFound, gem_not_found_message(name, Bundler.definition.dependencies) + end + when 1 + specs.first + else + ask_for_spec_from(specs) + end + rescue RegexpError + raise GemNotFound, gem_not_found_message(name, Bundler.definition.dependencies) + end + + def self.default_gem_spec(name) + gem_spec = Gem::Specification.find_all_by_name(name).last + gem_spec if gem_spec&.default_gem? + end + + def self.ask_for_spec_from(specs) + specs.each_with_index do |spec, index| + Bundler.ui.info "#{index.succ} : #{spec.name}", true + end + Bundler.ui.info "0 : - exit -", true + + num = Bundler.ui.ask("> ").to_i + num > 0 ? specs[num - 1] : nil + end + + def self.gem_not_found_message(missing_gem_name, alternatives) + message = "Could not find gem '#{missing_gem_name}'." + alternate_names = alternatives.map {|a| a.respond_to?(:name) ? a.name : a } + if alternate_names.include?(missing_gem_name.downcase) + message += "\nDid you mean '#{missing_gem_name.downcase}'?" + elsif defined?(DidYouMean::SpellChecker) + suggestions = DidYouMean::SpellChecker.new(dictionary: alternate_names).correct(missing_gem_name) + message += "\nDid you mean #{word_list(suggestions)}?" unless suggestions.empty? + end + message + end + + def self.ensure_all_gems_in_lockfile!(names, locked_gems = Bundler.locked_gems) + return unless locked_gems + + locked_names = locked_gems.specs.map(&:name).uniq + names.-(locked_names).each do |g| + raise GemNotFound, gem_not_found_message(g, locked_names) + end + end + + def self.configure_gem_version_promoter(definition, options) + patch_level = patch_level_options(options) + patch_level << :patch if patch_level.empty? && Bundler.settings[:prefer_patch] + raise InvalidOption, "Provide only one of the following options: #{patch_level.join(", ")}" unless patch_level.length <= 1 + + definition.gem_version_promoter.tap do |gvp| + gvp.level = patch_level.first || :major + gvp.strict = options[:strict] || options["filter-strict"] + gvp.pre = options[:pre] + end + end + + def self.patch_level_options(options) + [:major, :minor, :patch].select {|v| options.keys.include?(v.to_s) } + end + + def self.clean_after_install? + clean = Bundler.settings[:clean] + return clean unless clean.nil? + clean ||= Bundler.feature_flag.bundler_5_mode? && Bundler.settings[:path].nil? + clean &&= !Bundler.use_system_gems? + clean + end + + def self.word_list(words) + if words.empty? + return "" + end + + words = words.map {|word| "'#{word}'" } + + if words.length == 1 + return words[0] + end + + [words[0..-2].join(", "), words[-1]].join(" or ") + end + end +end diff --git a/lib/bundler/cli/config.rb b/lib/bundler/cli/config.rb new file mode 100644 index 0000000000..976cda7484 --- /dev/null +++ b/lib/bundler/cli/config.rb @@ -0,0 +1,208 @@ +# frozen_string_literal: true + +module Bundler + class CLI::Config < Thor + class_option :parseable, type: :boolean, banner: "Use minimal formatting for more parseable output" + + def self.scope_options + method_option :global, type: :boolean, banner: "Only change the global config" + method_option :local, type: :boolean, banner: "Only change the local config" + end + private_class_method :scope_options + + desc "base NAME [VALUE]", "The Bundler 1 config interface", hide: true + scope_options + method_option :delete, type: :boolean, banner: "delete" + def base(name = nil, *value) + new_args = + if ARGV.size == 1 + ["config", "list"] + elsif ARGV.include?("--delete") + ARGV.map {|arg| arg == "--delete" ? "unset" : arg } + elsif ARGV.include?("--global") || ARGV.include?("--local") || ARGV.size == 3 + ["config", "set", *ARGV[1..-1]] + else + ["config", "get", ARGV[1]] + end + + message = "Using the `config` command without a subcommand [list, get, set, unset] is deprecated and will be removed in the future. Use `bundle #{new_args.join(" ")}` instead." + SharedHelpers.feature_deprecated! message + + Base.new(options, name, value, self).run + end + + desc "list", "List out all configured settings" + def list + Base.new(options, nil, nil, self).run + end + + desc "get NAME", "Returns the value for the given key" + def get(name) + Base.new(options, name, nil, self).run + end + + desc "set NAME VALUE", "Sets the given value for the given key" + scope_options + def set(name, value, *value_) + Base.new(options, name, value_.unshift(value), self).run + end + + desc "unset NAME", "Unsets the value for the given key" + scope_options + def unset(name) + options[:delete] = true + Base.new(options, name, nil, self).run + end + + default_task :base + + class Base + attr_reader :name, :value, :options, :scope, :thor + + def initialize(options, name, value, thor) + @options = options + @name = name + value = Array(value) + @value = value.empty? ? nil : value.join(" ") + @thor = thor + validate_scope! + end + + def run + unless name + warn_unused_scope "Ignoring --#{scope}" + confirm_all + return + end + + if options[:delete] + if !explicit_scope? || scope != "global" + Bundler.settings.set_local(name, nil) + end + if !explicit_scope? || scope != "local" + Bundler.settings.set_global(name, nil) + end + return + end + + if value.nil? + warn_unused_scope "Ignoring --#{scope} since no value to set was given" + current_value = Bundler.settings[name] + + if options[:parseable] + if value = Bundler.settings[name] + Bundler.ui.info("#{name}=#{value}") + end + else + confirm(name) + end + + if current_value.nil? + exit 1 + else + return + end + end + + Bundler.ui.info(message) if message + Bundler.settings.send("set_#{scope}", name, new_value) + end + + def confirm_all + if @options[:parseable] + thor.with_padding do + Bundler.settings.all.each do |setting| + val = Bundler.settings[setting] + Bundler.ui.info "#{setting}=#{val}" + end + end + else + Bundler.ui.confirm "Settings are listed in order of priority. The top value will be used.\n" + Bundler.settings.all.each do |setting| + Bundler.ui.confirm setting + show_pretty_values_for(setting) + Bundler.ui.confirm "" + end + end + end + + def confirm(name) + Bundler.ui.confirm "Settings for `#{name}` in order of priority. The top value will be used" + show_pretty_values_for(name) + end + + def new_value + pathname = Pathname.new(value) + if name.start_with?("local.") && pathname.directory? + pathname.expand_path.to_s + else + value + end + end + + def message + locations = Bundler.settings.locations(name) + if @options[:parseable] + "#{name}=#{new_value}" if new_value + elsif scope == "global" + if !locations[:local].nil? + "Your application has set #{name} to #{locations[:local].inspect}. " \ + "This will override the global value you are currently setting" + elsif locations[:env] + "You have a bundler environment variable for #{name} set to " \ + "#{locations[:env].inspect}. This will take precedence over the global value you are setting" + elsif !locations[:global].nil? && locations[:global] != value + "You are replacing the current global value of #{name}, which is currently " \ + "#{locations[:global].inspect}" + end + elsif scope == "local" && !locations[:local].nil? && locations[:local] != value + "You are replacing the current local value of #{name}, which is currently " \ + "#{locations[:local].inspect}" + end + end + + def show_pretty_values_for(setting) + thor.with_padding do + Bundler.settings.pretty_values_for(setting).each do |line| + Bundler.ui.info line + end + end + end + + def explicit_scope? + @explicit_scope + end + + def warn_unused_scope(msg) + return unless explicit_scope? + return if options[:parseable] + + Bundler.ui.warn(msg) + end + + def validate_scope! + @explicit_scope = true + scopes = %w[global local].select {|s| options[s] } + case scopes.size + when 0 + @scope = inside_app? ? "local" : "global" + @explicit_scope = false + when 1 + @scope = scopes.first + else + raise InvalidOption, + "The options #{scopes.join " and "} were specified. Please only use one of the switches at a time." + end + end + + private + + def inside_app? + Bundler.root + true + rescue GemfileNotFound + false + end + end + end +end diff --git a/lib/bundler/cli/console.rb b/lib/bundler/cli/console.rb new file mode 100644 index 0000000000..2d1a2ce458 --- /dev/null +++ b/lib/bundler/cli/console.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module Bundler + class CLI::Console + attr_reader :options, :group + def initialize(options, group) + @options = options + @group = group + end + + def run + group ? Bundler.require(:default, *group.split(" ").map!(&:to_sym)) : Bundler.require + ARGV.clear + + console = get_console(Bundler.settings[:console] || "irb") + console.start + end + + def get_console(name) + require name + get_constant(name) + rescue LoadError + if name == "irb" + if defined?(Gem::BUNDLED_GEMS) && Gem::BUNDLED_GEMS.respond_to?(:force_activate) + Gem::BUNDLED_GEMS.force_activate "irb" + require name + return get_constant(name) + end + Bundler.ui.error "#{name} is not available" + exit 1 + else + Bundler.ui.error "Couldn't load console #{name}, falling back to irb" + name = "irb" + retry + end + end + + def get_constant(name) + const_name = { + "pry" => :Pry, + "ripl" => :Ripl, + "irb" => :IRB, + }[name] + Object.const_get(const_name) + end + end +end diff --git a/lib/bundler/cli/doctor.rb b/lib/bundler/cli/doctor.rb new file mode 100644 index 0000000000..5fd6a73d91 --- /dev/null +++ b/lib/bundler/cli/doctor.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Bundler + class CLI::Doctor < Thor + default_command(:diagnose) + + desc "diagnose [OPTIONS]", "Checks the bundle for common problems" + long_desc <<-D + Doctor scans the OS dependencies of each of the gems requested in the Gemfile. If + missing dependencies are detected, Bundler prints them and exits status 1. + Otherwise, Bundler prints a success message and exits with a status of 0. + D + method_option "gemfile", type: :string, banner: "Use the specified gemfile instead of Gemfile" + method_option "quiet", type: :boolean, banner: "Only output warnings and errors." + method_option "ssl", type: :boolean, default: false, banner: "Diagnose SSL problems." + def diagnose + require_relative "doctor/diagnose" + Diagnose.new(options).run + end + + desc "ssl [OPTIONS]", "Diagnose SSL problems" + long_desc <<-D + Diagnose SSL problems, especially related to certificates or TLS version while connecting to https://rubygems.org. + D + method_option "host", type: :string, banner: "The host to diagnose." + method_option "tls-version", type: :string, banner: "Specify the SSL/TLS version when running the diagnostic. Accepts either <1.1> or <1.2>" + method_option "verify-mode", type: :string, banner: "Specify the mode used for certification verification. Accepts either <peer> or <none>" + def ssl + require_relative "doctor/ssl" + SSL.new(options).run + end + end +end diff --git a/lib/bundler/cli/doctor/diagnose.rb b/lib/bundler/cli/doctor/diagnose.rb new file mode 100644 index 0000000000..a878025dda --- /dev/null +++ b/lib/bundler/cli/doctor/diagnose.rb @@ -0,0 +1,167 @@ +# frozen_string_literal: true + +require "rbconfig" +require "shellwords" + +module Bundler + class CLI::Doctor::Diagnose + DARWIN_REGEX = /\s+(.+) \(compatibility / + LDD_REGEX = /\t\S+ => (\S+) \(\S+\)/ + + attr_reader :options + + def initialize(options) + @options = options + end + + def otool_available? + Bundler.which("otool") + end + + def ldd_available? + Bundler.which("ldd") + end + + def dylibs_darwin(path) + output = `/usr/bin/otool -L #{path.shellescape}`.chomp + dylibs = output.split("\n")[1..-1].filter_map {|l| l.match(DARWIN_REGEX)&.match(1) }.uniq + # ignore @rpath and friends + dylibs.reject {|dylib| dylib.start_with? "@" } + end + + def dylibs_ldd(path) + output = `/usr/bin/ldd #{path.shellescape}`.chomp + output.split("\n").filter_map do |l| + match = l.match(LDD_REGEX) + next if match.nil? + match.captures[0] + end + end + + def dylibs(path) + case RbConfig::CONFIG["host_os"] + when /darwin/ + return [] unless otool_available? + dylibs_darwin(path) + when /(linux|solaris|bsd)/ + return [] unless ldd_available? + dylibs_ldd(path) + else # Windows, etc. + Bundler.ui.warn("Dynamic library check not supported on this platform.") + [] + end + end + + def bundles_for_gem(spec) + Dir.glob("#{spec.full_gem_path}/**/*.bundle") + end + + def lookup_with_fiddle(path) + require "fiddle" + Fiddle.dlopen(path) + false + rescue Fiddle::DLError + true + end + + def check! + require_relative "../check" + Bundler::CLI::Check.new({}).run + end + + def diagnose_ssl + require_relative "ssl" + Bundler::CLI::Doctor::SSL.new({}).run + end + + def run + Bundler.ui.level = "warn" if options[:quiet] + Bundler.settings.validate! + check! + diagnose_ssl if options[:ssl] + + definition = Bundler.definition + broken_links = {} + + definition.specs.each do |spec| + bundles_for_gem(spec).each do |bundle| + bad_paths = dylibs(bundle).select do |f| + lookup_with_fiddle(f) + end + if bad_paths.any? + broken_links[spec] ||= [] + broken_links[spec].concat(bad_paths) + end + end + end + + permissions_valid = check_home_permissions + + if broken_links.any? + message = "The following gems are missing OS dependencies:" + broken_links.flat_map do |spec, paths| + paths.uniq.map do |path| + "\n * #{spec.name}: #{path}" + end + end.sort.each {|m| message += m } + raise ProductionError, message + elsif permissions_valid + Bundler.ui.info "No issues found with the installed bundle" + end + end + + private + + def check_home_permissions + require "find" + files_not_readable = [] + files_not_readable_and_owned_by_different_user = [] + files_not_owned_by_current_user_but_still_readable = [] + broken_symlinks = [] + Find.find(Bundler.bundle_path.to_s).each do |f| + if !File.exist?(f) + broken_symlinks << f + elsif !File.readable?(f) + if File.stat(f).uid != Process.uid + files_not_readable_and_owned_by_different_user << f + else + files_not_readable << f + end + elsif File.stat(f).uid != Process.uid + files_not_owned_by_current_user_but_still_readable << f + end + end + + ok = true + + if broken_symlinks.any? + Bundler.ui.warn "Broken links exist in the Bundler home. Please report them to the offending gem's upstream repo. These files are:\n - #{broken_symlinks.join("\n - ")}" + + ok = false + end + + if files_not_owned_by_current_user_but_still_readable.any? + Bundler.ui.warn "Files exist in the Bundler home that are owned by another " \ + "user, but are still readable. These files are:\n - #{files_not_owned_by_current_user_but_still_readable.join("\n - ")}" + + ok = false + end + + if files_not_readable_and_owned_by_different_user.any? + Bundler.ui.warn "Files exist in the Bundler home that are owned by another " \ + "user, and are not readable. These files are:\n - #{files_not_readable_and_owned_by_different_user.join("\n - ")}" + + ok = false + end + + if files_not_readable.any? + Bundler.ui.warn "Files exist in the Bundler home that are not " \ + "readable by the current user. These files are:\n - #{files_not_readable.join("\n - ")}" + + ok = false + end + + ok + end + end +end diff --git a/lib/bundler/cli/doctor/ssl.rb b/lib/bundler/cli/doctor/ssl.rb new file mode 100644 index 0000000000..21fc4edf2d --- /dev/null +++ b/lib/bundler/cli/doctor/ssl.rb @@ -0,0 +1,249 @@ +# frozen_string_literal: true + +require "rubygems/remote_fetcher" +require "uri" + +module Bundler + class CLI::Doctor::SSL + attr_reader :options + + def initialize(options) + @options = options + end + + def run + return unless openssl_installed? + + output_ssl_environment + bundler_success = bundler_connection_successful? + rubygem_success = rubygem_connection_successful? + + return unless net_http_connection_successful? + + Explanation.summarize(bundler_success, rubygem_success, host) + end + + private + + def host + @options[:host] || "rubygems.org" + end + + def tls_version + @options[:"tls-version"].then do |version| + "TLS#{version.sub(".", "_")}".to_sym if version + end + end + + def verify_mode + mode = @options[:"verify-mode"] || :peer + + @verify_mode ||= mode.then {|mod| OpenSSL::SSL.const_get("verify_#{mod}".upcase) } + end + + def uri + @uri ||= URI("https://#{host}") + end + + def openssl_installed? + require "openssl" + + true + rescue LoadError + Bundler.ui.warn(<<~MSG) + Oh no! Your Ruby doesn't have OpenSSL, so it can't connect to #{host}. + You'll need to recompile or reinstall Ruby with OpenSSL support and try again. + MSG + + false + end + + def output_ssl_environment + Bundler.ui.info(<<~MESSAGE) + Here's your OpenSSL environment: + + OpenSSL: #{OpenSSL::VERSION} + Compiled with: #{OpenSSL::OPENSSL_VERSION} + Loaded with: #{OpenSSL::OPENSSL_LIBRARY_VERSION} + MESSAGE + end + + def bundler_connection_successful? + Bundler.ui.info("\nTrying connections to #{uri}:\n") + + bundler_uri = Gem::URI(uri.to_s) + Bundler::Fetcher.new( + Bundler::Source::Rubygems::Remote.new(bundler_uri) + ).send(:connection).request(bundler_uri) + + Bundler.ui.info("Bundler: success") + + true + rescue StandardError => error + Bundler.ui.warn("Bundler: failed (#{Explanation.explain_bundler_or_rubygems_error(error)})") + + false + end + + def rubygem_connection_successful? + Gem::RemoteFetcher.fetcher.fetch_path(uri) + Bundler.ui.info("RubyGems: success") + + true + rescue StandardError => error + Bundler.ui.warn("RubyGems: failed (#{Explanation.explain_bundler_or_rubygems_error(error)})") + + false + end + + def net_http_connection_successful? + ::Gem::Net::HTTP.new(uri.host, uri.port).tap do |http| + http.use_ssl = true + http.min_version = tls_version + http.max_version = tls_version + http.verify_mode = verify_mode + end.start + + Bundler.ui.info("Ruby net/http: success") + warn_on_unsupported_tls12 + + true + rescue StandardError => error + Bundler.ui.warn(<<~MSG) + Ruby net/http: failed + + Unfortunately, this Ruby can't connect to #{host}. + + #{Explanation.explain_net_http_error(error, host, tls_version)} + MSG + + false + end + + def warn_on_unsupported_tls12 + ctx = OpenSSL::SSL::SSLContext.new + supported = true + + if ctx.respond_to?(:min_version=) + begin + ctx.min_version = ctx.max_version = OpenSSL::SSL::TLS1_2_VERSION + rescue OpenSSL::SSL::SSLError, NameError + supported = false + end + else + supported = OpenSSL::SSL::SSLContext::METHODS.include?(:TLSv1_2) # rubocop:disable Naming/VariableNumber + end + + Bundler.ui.warn(<<~EOM) unless supported + + WARNING: Although your Ruby can connect to #{host} today, your OpenSSL is very old! + WARNING: You will need to upgrade OpenSSL to use #{host}. + + EOM + end + + module Explanation + extend self + + def explain_bundler_or_rubygems_error(error) + case error.message + when /certificate verify failed/ + "certificate verification" + when /read server hello A/ + "SSL/TLS protocol version mismatch" + when /tlsv1 alert protocol version/ + "requested TLS version is too old" + else + error.message + end + end + + def explain_net_http_error(error, host, tls_version) + case error.message + # Check for certificate errors + when /certificate verify failed/ + <<~MSG + #{show_ssl_certs} + Your Ruby can't connect to #{host} because you are missing the certificate files OpenSSL needs to verify you are connecting to the genuine #{host} servers. + MSG + # Check for TLS version errors + when /read server hello A/, /tlsv1 alert protocol version/ + if tls_version.to_s == "TLS1_3" + "Your Ruby can't connect to #{host} because #{tls_version} isn't supported yet.\n" + else + <<~MSG + Your Ruby can't connect to #{host} because your version of OpenSSL is too old. + You'll need to upgrade your OpenSSL install and/or recompile Ruby to use a newer OpenSSL. + MSG + end + # OpenSSL doesn't support TLS version specified by argument + when /unknown SSL method/ + "Your Ruby can't connect because #{tls_version} isn't supported by your version of OpenSSL." + else + <<~MSG + Even worse, we're not sure why. + + Here's the full error information: + #{error.class}: #{error.message} + #{error.backtrace.join("\n ")} + + You might have more luck using Mislav's SSL doctor.rb script. You can get it here: + https://github.com/mislav/ssl-tools/blob/8b3dec4/doctor.rb + + Read more about the script and how to use it in this blog post: + https://mislav.net/2013/07/ruby-openssl/ + MSG + end + end + + def summarize(bundler_success, rubygems_success, host) + guide_url = "http://ruby.to/ssl-check-failed" + + message = if bundler_success && rubygems_success + <<~MSG + Hooray! This Ruby can connect to #{host}. + You are all set to use Bundler and RubyGems. + + MSG + elsif !bundler_success && !rubygems_success + <<~MSG + For some reason, your Ruby installation can connect to #{host}, but neither RubyGems nor Bundler can. + The most likely fix is to manually upgrade RubyGems by following the instructions at #{guide_url}. + After you've done that, run `gem install bundler` to upgrade Bundler, and then run this script again to make sure everything worked. ❣ + + MSG + elsif !bundler_success + <<~MSG + Although your Ruby installation and RubyGems can both connect to #{host}, Bundler is having trouble. + The most likely way to fix this is to upgrade Bundler by running `gem install bundler`. + Run this script again after doing that to make sure everything is all set. + If you're still having trouble, check out the troubleshooting guide at #{guide_url}. + + MSG + else + <<~MSG + It looks like Ruby and Bundler can connect to #{host}, but RubyGems itself cannot. + You can likely solve this by manually downloading and installing a RubyGems update. + Visit #{guide_url} for instructions on how to manually upgrade RubyGems. + + MSG + end + + Bundler.ui.info("\n#{message}") + end + + private + + def show_ssl_certs + ssl_cert_file = ENV["SSL_CERT_FILE"] || OpenSSL::X509::DEFAULT_CERT_FILE + ssl_cert_dir = ENV["SSL_CERT_DIR"] || OpenSSL::X509::DEFAULT_CERT_DIR + + <<~MSG + Below affect only Ruby net/http connections: + SSL_CERT_FILE: #{File.exist?(ssl_cert_file) ? "exists #{ssl_cert_file}" : "is missing #{ssl_cert_file}"} + SSL_CERT_DIR: #{Dir.exist?(ssl_cert_dir) ? "exists #{ssl_cert_dir}" : "is missing #{ssl_cert_dir}"} + MSG + end + end + end +end diff --git a/lib/bundler/cli/exec.rb b/lib/bundler/cli/exec.rb new file mode 100644 index 0000000000..2fdc416286 --- /dev/null +++ b/lib/bundler/cli/exec.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +require_relative "../current_ruby" + +module Bundler + class CLI::Exec + attr_reader :options, :args, :cmd + + TRAPPED_SIGNALS = %w[INT].freeze + + def initialize(options, args) + @options = options + @cmd = args.shift + @args = args + @args << { close_others: !options.keep_file_descriptors? } unless Bundler.current_ruby.jruby? + end + + def run + validate_cmd! + SharedHelpers.set_bundle_environment + if bin_path = Bundler.which(cmd) + if !Bundler.settings[:disable_exec_load] && directly_loadable?(bin_path) + bin_path.delete_suffix!(".bat") if Gem.win_platform? + kernel_load(bin_path, *args) + else + bin_path = "./" + bin_path unless File.absolute_path?(bin_path) + kernel_exec(bin_path, *args) + end + else + # exec using the given command + kernel_exec(cmd, *args) + end + end + + private + + def validate_cmd! + return unless cmd.nil? + Bundler.ui.error "bundler: exec needs a command to run" + exit 128 + end + + def kernel_exec(*args) + Kernel.exec(*args) + rescue Errno::EACCES, Errno::ENOEXEC + Bundler.ui.error "bundler: not executable: #{cmd}" + exit 126 + rescue Errno::ENOENT + Bundler.ui.error "bundler: command not found: #{cmd}" + Bundler.ui.warn "Install missing gem executables with `bundle install`" + exit 127 + end + + def kernel_load(file, *args) + args.pop if args.last.is_a?(Hash) + ARGV.replace(args) + $0 = file + Process.setproctitle(process_title(file, args)) if Process.respond_to?(:setproctitle) + require_relative "../setup" + TRAPPED_SIGNALS.each {|s| trap(s, "DEFAULT") } + Kernel.load(file) + rescue SystemExit, SignalException + raise + rescue Exception # rubocop:disable Lint/RescueException + Bundler.ui.error "bundler: failed to load command: #{cmd} (#{file})" + Bundler::FriendlyErrors.disable! + raise + end + + def process_title(file, args) + "#{file} #{args.join(" ")}".strip + end + + def directly_loadable?(file) + if Gem.win_platform? + script_wrapper?(file) + else + ruby_shebang?(file) + end + end + + def script_wrapper?(file) + script_file = file.delete_suffix(".bat") + return false unless File.exist?(script_file) + + if File.zero?(script_file) + Bundler.ui.warn "#{script_file} is empty" + return false + end + + header = File.open(file, "r") {|f| f.read(32) } + ruby_exe = "#{RbConfig::CONFIG["RUBY_INSTALL_NAME"]}#{RbConfig::CONFIG["EXEEXT"]}" + ruby_exe = "ruby.exe" if ruby_exe.empty? + header.include?(ruby_exe) + end + + def ruby_shebang?(file) + possibilities = [ + "#!/usr/bin/env ruby\n", + "#!/usr/bin/env jruby\n", + "#!/usr/bin/env truffleruby\n", + "#!#{Gem.ruby}\n", + ] + + if File.zero?(file) + Bundler.ui.warn "#{file} is empty" + return false + end + + first_line = File.open(file, "rb") {|f| f.read(possibilities.map(&:size).max) } + possibilities.any? {|shebang| first_line.start_with?(shebang) } + end + end +end diff --git a/lib/bundler/cli/fund.rb b/lib/bundler/cli/fund.rb new file mode 100644 index 0000000000..ad7f31f3d6 --- /dev/null +++ b/lib/bundler/cli/fund.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Bundler + class CLI::Fund + attr_reader :options + + def initialize(options) + @options = options + end + + def run + Bundler.definition.validate_runtime! + + groups = Array(options[:group]).map(&:to_sym) + + deps = if groups.any? + Bundler.definition.dependencies_for(groups) + else + Bundler.definition.requested_dependencies + end + + fund_info = deps.each_with_object([]) do |dep, arr| + spec = Bundler.definition.specs[dep.name].first + if spec.metadata.key?("funding_uri") + arr << "* #{spec.name} (#{spec.version})\n Funding: #{spec.metadata["funding_uri"]}" + end + end + + if fund_info.empty? + Bundler.ui.info "None of the installed gems you directly depend on are looking for funding." + else + Bundler.ui.info fund_info.join("\n") + end + end + end +end diff --git a/lib/bundler/cli/gem.rb b/lib/bundler/cli/gem.rb new file mode 100644 index 0000000000..c8c24c8e66 --- /dev/null +++ b/lib/bundler/cli/gem.rb @@ -0,0 +1,476 @@ +# frozen_string_literal: true + +module Bundler + class CLI + Bundler.require_thor_actions + include Thor::Actions + end + + class CLI::Gem + DEFAULT_GITHUB_USERNAME = "[USERNAME]" + + attr_reader :options, :gem_name, :thor, :name, :target, :extension + + def initialize(options, gem_name, thor) + @options = options + @gem_name = resolve_name(gem_name) + + @thor = thor + thor.behavior = :invoke + thor.destination_root = nil + + @name = @gem_name + @target = Pathname.new(SharedHelpers.pwd).join(gem_name) + + @extension = options[:ext] + + validate_ext_name if @extension + end + + def run + Bundler.ui.confirm "Creating gem '#{name}'..." + + underscored_name = name.tr("-", "_") + namespaced_path = name.tr("-", "/") + constant_name = name.gsub(/-[_-]*(?![_-]|$)/) { "::" }.gsub(/([_-]+|(::)|^)(.|$)/) { $2.to_s + $3.upcase } + constant_array = constant_name.split("::") + minitest_constant_name = constant_array.clone.tap {|a| a[-1] = "Test#{a[-1]}" }.join("::") # Foo::Bar => Foo::TestBar + + use_git = Bundler.git_present? && options[:git] + + git_author_name = use_git ? `git config user.name`.chomp : "" + git_username = use_git ? `git config github.user`.chomp : "" + git_user_email = use_git ? `git config user.email`.chomp : "" + github_username = github_username(git_username) + + if github_username.empty? + homepage_uri = "TODO: Put your gem's website or public repo URL here." + source_code_uri = "TODO: Put your gem's public repo URL here." + changelog_uri = "TODO: Put your gem's CHANGELOG.md URL here." + else + homepage_uri = "https://github.com/#{github_username}/#{name}" + source_code_uri = "https://github.com/#{github_username}/#{name}" + changelog_uri = "https://github.com/#{github_username}/#{name}/blob/main/CHANGELOG.md" + end + + config = { + name: name, + underscored_name: underscored_name, + namespaced_path: namespaced_path, + makefile_path: "#{underscored_name}/#{underscored_name}", + constant_name: constant_name, + constant_array: constant_array, + author: git_author_name.empty? ? "TODO: Write your name" : git_author_name, + email: git_user_email.empty? ? "TODO: Write your email address" : git_user_email, + test: options[:test], + ext: extension, + exe: options[:exe], + bundle: options[:bundle], + bundler_version: bundler_dependency_version, + git: use_git, + github_username: github_username.empty? ? DEFAULT_GITHUB_USERNAME : github_username, + required_ruby_version: required_ruby_version, + rust_builder_required_rubygems_version: rust_builder_required_rubygems_version, + minitest_constant_name: minitest_constant_name, + ignore_paths: %w[bin/], + homepage_uri: homepage_uri, + source_code_uri: source_code_uri, + changelog_uri: changelog_uri, + } + ensure_safe_gem_name(name, constant_array) + + templates = { + "Gemfile.tt" => Bundler.preferred_gemfile_name, + "lib/newgem.rb.tt" => "lib/#{namespaced_path}.rb", + "lib/newgem/version.rb.tt" => "lib/#{namespaced_path}/version.rb", + "sig/newgem.rbs.tt" => "sig/#{namespaced_path}.rbs", + "newgem.gemspec.tt" => "#{name}.gemspec", + "Rakefile.tt" => "Rakefile", + "README.md.tt" => "README.md", + "bin/console.tt" => "bin/console", + "bin/setup.tt" => "bin/setup", + } + + executables = %w[ + bin/console + bin/setup + ] + + case Bundler.preferred_gemfile_name + when "Gemfile" + config[:ignore_paths] << "Gemfile" + when "gems.rb" + config[:ignore_paths] << "gems.rb" + config[:ignore_paths] << "gems.locked" + end + + if use_git + templates.merge!("gitignore.tt" => ".gitignore") + config[:ignore_paths] << ".gitignore" + end + + if test_framework = ask_and_set_test_framework + config[:test] = test_framework + + case test_framework + when "rspec" + templates.merge!( + "rspec.tt" => ".rspec", + "spec/spec_helper.rb.tt" => "spec/spec_helper.rb", + "spec/newgem_spec.rb.tt" => "spec/#{namespaced_path}_spec.rb" + ) + config[:test_task] = :spec + config[:ignore_paths] << ".rspec" + config[:ignore_paths] << "spec/" + when "minitest" + # Generate path for minitest target file (FileList["test/**/test_*.rb"]) + # foo => test/test_foo.rb + # foo-bar => test/foo/test_bar.rb + # foo_bar => test/test_foo_bar.rb + paths = namespaced_path.rpartition("/") + paths[2] = "test_#{paths[2]}" + minitest_namespaced_path = paths.join("") + + templates.merge!( + "test/minitest/test_helper.rb.tt" => "test/test_helper.rb", + "test/minitest/test_newgem.rb.tt" => "test/#{minitest_namespaced_path}.rb" + ) + config[:test_task] = :test + config[:ignore_paths] << "test/" + when "test-unit" + templates.merge!( + "test/test-unit/test_helper.rb.tt" => "test/test_helper.rb", + "test/test-unit/newgem_test.rb.tt" => "test/#{namespaced_path}_test.rb" + ) + config[:test_task] = :test + config[:ignore_paths] << "test/" + end + end + + config[:ci] = ask_and_set_ci + case config[:ci] + when "github" + templates.merge!("github/workflows/main.yml.tt" => ".github/workflows/main.yml") + if extension == "rust" + templates.merge!("github/workflows/build-gems.yml.tt" => ".github/workflows/build-gems.yml") + end + config[:ignore_paths] << ".github/" + when "gitlab" + templates.merge!("gitlab-ci.yml.tt" => ".gitlab-ci.yml") + config[:ignore_paths] << ".gitlab-ci.yml" + when "circle" + templates.merge!("circleci/config.yml.tt" => ".circleci/config.yml") + config[:ignore_paths] << ".circleci/" + end + + if ask_and_set(:mit, "Do you want to license your code permissively under the MIT license?", + "Using a MIT license means that any other developer or company will be legally allowed " \ + "to use your code for free as long as they admit you created it. You can read more about " \ + "the MIT license at https://choosealicense.com/licenses/mit.") + config[:mit] = true + Bundler.ui.info "MIT License enabled in config" + templates.merge!("LICENSE.txt.tt" => "LICENSE.txt") + end + + if ask_and_set(:coc, "Do you want to include a code of conduct in gems you generate?", + "Codes of conduct can increase contributions to your project by contributors who " \ + "prefer safe, respectful, productive, and collaborative spaces. \n" \ + "See https://github.com/ruby/rubygems/blob/master/CODE_OF_CONDUCT.md") + config[:coc] = true + Bundler.ui.info "Code of conduct enabled in config" + templates.merge!("CODE_OF_CONDUCT.md.tt" => "CODE_OF_CONDUCT.md") + end + + if ask_and_set(:changelog, "Do you want to include a changelog?", + "A changelog is a file which contains a curated, chronologically ordered list of notable " \ + "changes for each version of a project. To make it easier for users and contributors to" \ + " see precisely what notable changes have been made between each release (or version) of" \ + " the project. Whether consumers or developers, the end users of software are" \ + " human beings who care about what's in the software. When the software changes, people " \ + "want to know why and how. see https://keepachangelog.com") + config[:changelog] = true + Bundler.ui.info "Changelog enabled in config" + templates.merge!("CHANGELOG.md.tt" => "CHANGELOG.md") + end + + config[:linter] = ask_and_set_linter + case config[:linter] + when "rubocop" + Bundler.ui.info "RuboCop enabled in config" + templates.merge!("rubocop.yml.tt" => ".rubocop.yml") + config[:ignore_paths] << ".rubocop.yml" + when "standard" + Bundler.ui.info "Standard enabled in config" + templates.merge!("standard.yml.tt" => ".standard.yml") + config[:ignore_paths] << ".standard.yml" + end + + if config[:exe] + templates.merge!("exe/newgem.tt" => "exe/#{name}") + executables.push("exe/#{name}") + end + + if extension == "c" + templates.merge!( + "ext/newgem/extconf-c.rb.tt" => "ext/#{name}/extconf.rb", + "ext/newgem/newgem.h.tt" => "ext/#{name}/#{underscored_name}.h", + "ext/newgem/newgem.c.tt" => "ext/#{name}/#{underscored_name}.c" + ) + end + + if extension == "rust" + templates.merge!( + "Cargo.toml.tt" => "Cargo.toml", + "ext/newgem/Cargo.toml.tt" => "ext/#{name}/Cargo.toml", + "ext/newgem/build.rs.tt" => "ext/#{name}/build.rs", + "ext/newgem/extconf-rust.rb.tt" => "ext/#{name}/extconf.rb", + "ext/newgem/src/lib.rs.tt" => "ext/#{name}/src/lib.rs", + ) + end + + if extension == "go" + templates.merge!( + "ext/newgem/go.mod.tt" => "ext/#{name}/go.mod", + "ext/newgem/extconf-go.rb.tt" => "ext/#{name}/extconf.rb", + "ext/newgem/newgem.h.tt" => "ext/#{name}/#{underscored_name}.h", + "ext/newgem/newgem.go.tt" => "ext/#{name}/#{underscored_name}.go", + "ext/newgem/newgem-go.c.tt" => "ext/#{name}/#{underscored_name}.c", + ) + + config[:go_module_username] = config[:github_username] == DEFAULT_GITHUB_USERNAME ? "username" : config[:github_username] + end + + if target.exist? && !target.directory? + Bundler.ui.error "Couldn't create a new gem named `#{gem_name}` because there's an existing file named `#{gem_name}`." + exit Bundler::BundlerError.all_errors[Bundler::GenericSystemCallError] + end + + if use_git + Bundler.ui.info "\nInitializing git repo in #{target}" + require "shellwords" + `git init #{target.to_s.shellescape}` + + config[:git_default_branch] = File.read("#{target}/.git/HEAD").split("/").last.chomp + end + + templates.each do |src, dst| + destination = target.join(dst) + thor.template("newgem/#{src}", destination, config) + end + + executables.each do |file| + path = target.join(file) + executable = (path.stat.mode | 0o111) + path.chmod(executable) + end + + if use_git + IO.popen(%w[git add .], { chdir: target }, &:read) + end + + if config[:bundle] + Bundler.ui.info "Running bundle install in the new gem directory." + Dir.chdir(target) do + system("bundle install") + end + end + + # Open gemspec in editor + open_editor(options["edit"], target.join("#{name}.gemspec")) if options[:edit] + + Bundler.ui.info "\nGem '#{name}' was successfully created. " \ + "For more information on making a RubyGem visit https://guides.rubygems.org/make-your-own-gem/" + end + + private + + def resolve_name(name) + Pathname.new(SharedHelpers.pwd).join(name).basename.to_s + end + + def ask_and_set(key, prompt, explanation) + choice = options[key] + choice = Bundler.settings["gem.#{key}"] if choice.nil? + + if choice.nil? + Bundler.ui.info "\n#{explanation}" + choice = Bundler.ui.yes? "#{prompt} y/(n):" + Bundler.settings.set_global("gem.#{key}", choice) + end + + choice + end + + def validate_ext_name + return unless gem_name.index("-") + + Bundler.ui.error "You have specified a gem name which does not conform to the \n" \ + "naming guidelines for C extensions. For more information, \n" \ + "see the 'Extension Naming' section at the following URL:\n" \ + "https://guides.rubygems.org/gems-with-extensions/\n" + exit 1 + end + + def ask_and_set_test_framework + return if skip?(:test) + test_framework = options[:test] || Bundler.settings["gem.test"] + + if test_framework.to_s.empty? + Bundler.ui.info "\nDo you want to generate tests with your gem?" + Bundler.ui.info hint_text("test") + + result = Bundler.ui.ask "Enter a test framework. rspec/minitest/test-unit/(none):" + if /rspec|minitest|test-unit/.match?(result) + test_framework = result + else + test_framework = false + end + end + + if Bundler.settings["gem.test"].nil? + Bundler.settings.set_global("gem.test", test_framework) + end + + if options[:test] == Bundler.settings["gem.test"] + Bundler.ui.info "#{options[:test]} is already configured, ignoring --test flag." + end + + test_framework + end + + def skip?(option) + options.key?(option) && options[option].nil? + end + + def hint_text(setting) + if Bundler.settings["gem.#{setting}"] == false + "Your choice will only be applied to this gem." + else + "Future `bundle gem` calls will use your choice. " \ + "This setting can be changed anytime with `bundle config gem.#{setting}`." + end + end + + def ask_and_set_ci + return if skip?(:ci) + ci_template = options[:ci] || Bundler.settings["gem.ci"] + + if ci_template.to_s.empty? + Bundler.ui.info "\nDo you want to set up continuous integration for your gem? " \ + "Supported services:\n" \ + "* CircleCI: https://circleci.com/\n" \ + "* GitHub Actions: https://github.com/features/actions\n" \ + "* GitLab CI: https://docs.gitlab.com/ee/ci/\n" + Bundler.ui.info hint_text("ci") + + result = Bundler.ui.ask "Enter a CI service. github/gitlab/circle/(none):" + if /github|gitlab|circle/.match?(result) + ci_template = result + else + ci_template = false + end + end + + if Bundler.settings["gem.ci"].nil? + Bundler.settings.set_global("gem.ci", ci_template) + end + + if options[:ci] == Bundler.settings["gem.ci"] + Bundler.ui.info "#{options[:ci]} is already configured, ignoring --ci flag." + end + + ci_template + end + + def ask_and_set_linter + return if skip?(:linter) + linter_template = options[:linter] || Bundler.settings["gem.linter"] + + if linter_template.to_s.empty? + Bundler.ui.info "\nDo you want to add a code linter and formatter to your gem? " \ + "Supported Linters:\n" \ + "* RuboCop: https://rubocop.org\n" \ + "* Standard: https://github.com/standardrb/standard\n" + Bundler.ui.info hint_text("linter") + + result = Bundler.ui.ask "Enter a linter. rubocop/standard/(none):" + if /rubocop|standard/.match?(result) + linter_template = result + else + linter_template = false + end + end + + if Bundler.settings["gem.linter"].nil? + Bundler.settings.set_global("gem.linter", linter_template) + end + + # Once gem.linter safely set, unset the deprecated gem.rubocop + unless Bundler.settings["gem.rubocop"].nil? + Bundler.settings.set_global("gem.rubocop", nil) + end + + if options[:linter] == Bundler.settings["gem.linter"] + Bundler.ui.info "#{options[:linter]} is already configured, ignoring --linter flag." + end + + linter_template + end + + def bundler_dependency_version + v = Gem::Version.new(Bundler::VERSION) + req = v.segments[0..1] + req << "a" if v.prerelease? + req.join(".") + end + + def ensure_safe_gem_name(name, constant_array) + if /^\d/.match?(name) + Bundler.ui.error "Invalid gem name #{name} Please give a name which does not start with numbers." + exit 1 + end + + if /[A-Z]/.match?(name) + Bundler.ui.warn "Gem names with capital letters are not recommended. Please use only lowercase letters, numbers, and hyphens." + end + + constant_name = constant_array.join("::") + + existing_constant = constant_array.inject(Object) do |c, s| + defined = begin + c.const_defined?(s) + rescue NameError + Bundler.ui.error "Invalid gem name #{name} -- `#{constant_name}` is an invalid constant name" + exit 1 + end + (defined && c.const_get(s)) || break + end + + return unless existing_constant + Bundler.ui.error "Invalid gem name #{name} constant #{constant_name} is already in use. Please choose another gem name." + exit 1 + end + + def open_editor(editor, file) + thor.run(%(#{editor} "#{file}")) + end + + def rust_builder_required_rubygems_version + "3.3.11" + end + + def required_ruby_version + "3.2.0" + end + + def github_username(git_username) + if options[:github_username].nil? + git_username + elsif options[:github_username] == false + "" + else + options[:github_username] + end + end + end +end diff --git a/lib/bundler/cli/info.rb b/lib/bundler/cli/info.rb new file mode 100644 index 0000000000..cd01d4949b --- /dev/null +++ b/lib/bundler/cli/info.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +module Bundler + class CLI::Info + attr_reader :gem_name, :options + def initialize(options, gem_name) + @options = options + @gem_name = gem_name + end + + def run + Bundler.ui.silence do + Bundler.definition.validate_runtime! + Bundler.load.lock + end + + spec = spec_for_gem(gem_name) + + if spec + return print_gem_path(spec) if @options[:path] + return print_gem_version(spec) if @options[:version] + print_gem_info(spec) + end + end + + private + + def spec_for_gem(name) + Bundler::CLI::Common.select_spec(name, :regex_match) + end + + def print_gem_version(spec) + Bundler.ui.info spec.version.to_s + end + + def print_gem_path(spec) + name = spec.name + if name == "bundler" + path = File.expand_path("../../..", __dir__) + else + path = spec.full_gem_path + if spec.installation_missing? + return Bundler.ui.warn "The gem #{name} is missing. It should be installed at #{path}, but was not found" + end + end + + Bundler.ui.info path + end + + def print_gem_info(spec) + metadata = spec.metadata + name = spec.name + gem_info = String.new + gem_info << " * #{name} (#{spec.version}#{spec.git_version})\n" + gem_info << "\tSummary: #{spec.summary}\n" if spec.summary + gem_info << "\tHomepage: #{spec.homepage}\n" if spec.homepage + gem_info << "\tDocumentation: #{metadata["documentation_uri"]}\n" if metadata.key?("documentation_uri") + gem_info << "\tSource Code: #{metadata["source_code_uri"]}\n" if metadata.key?("source_code_uri") + gem_info << "\tFunding: #{metadata["funding_uri"]}\n" if metadata.key?("funding_uri") + gem_info << "\tWiki: #{metadata["wiki_uri"]}\n" if metadata.key?("wiki_uri") + gem_info << "\tChangelog: #{metadata["changelog_uri"]}\n" if metadata.key?("changelog_uri") + gem_info << "\tBug Tracker: #{metadata["bug_tracker_uri"]}\n" if metadata.key?("bug_tracker_uri") + gem_info << "\tMailing List: #{metadata["mailing_list_uri"]}\n" if metadata.key?("mailing_list_uri") + gem_info << "\tPath: #{spec.full_gem_path}\n" + gem_info << "\tDefault Gem: yes\n" if spec.respond_to?(:default_gem?) && spec.default_gem? + gem_info << "\tReverse Dependencies: \n\t\t#{gem_dependencies.join("\n\t\t")}" if gem_dependencies.any? + + if name != "bundler" && spec.installation_missing? + return Bundler.ui.warn "The gem #{name} is missing. Gemspec information is still available though:\n#{gem_info}" + end + + Bundler.ui.info gem_info + end + + def gem_dependencies + @gem_dependencies ||= Bundler.definition.specs.filter_map do |spec| + dependency = spec.dependencies.find {|dep| dep.name == gem_name } + next unless dependency + "#{spec.name} (#{spec.version}) depends on #{gem_name} (#{dependency.requirements_list.join(", ")})" + end.sort + end + end +end diff --git a/lib/bundler/cli/init.rb b/lib/bundler/cli/init.rb new file mode 100644 index 0000000000..246b9d6460 --- /dev/null +++ b/lib/bundler/cli/init.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module Bundler + class CLI::Init + attr_reader :options + def initialize(options) + @options = options + end + + def run + if File.exist?(gemfile) + Bundler.ui.error "#{gemfile} already exists at #{File.expand_path(gemfile)}" + exit 1 + end + + unless File.writable?(Dir.pwd) + Bundler.ui.error "Can not create #{gemfile} as the current directory is not writable." + exit 1 + end + + if options[:gemspec] + gemspec = File.expand_path(options[:gemspec]) + unless File.exist?(gemspec) + Bundler.ui.error "Gem specification #{gemspec} doesn't exist" + exit 1 + end + + spec = Bundler.load_gemspec_uncached(gemspec) + + File.open(gemfile, "wb") do |file| + file << "# Generated from #{gemspec}\n" + file << spec.to_gemfile + end + else + File.open(File.expand_path("../templates/Gemfile", __dir__), "r") do |template| + File.open(gemfile, "wb") do |destination| + IO.copy_stream(template, destination) + end + end + end + + puts "Writing new #{gemfile} to #{SharedHelpers.pwd}/#{gemfile}" + end + + private + + def gemfile + @gemfile ||= options[:gemfile] || Bundler.preferred_gemfile_name + end + end +end diff --git a/lib/bundler/cli/install.rb b/lib/bundler/cli/install.rb new file mode 100644 index 0000000000..67feba84bd --- /dev/null +++ b/lib/bundler/cli/install.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +module Bundler + class CLI::Install + attr_reader :options + def initialize(options) + @options = options + end + + def run + Bundler.ui.level = "warn" if options[:quiet] + + warn_if_root + + if options[:local] + Bundler.self_manager.restart_with_locked_bundler_if_needed + else + Bundler.self_manager.install_locked_bundler_and_restart_with_it_if_needed + end + + Bundler::SharedHelpers.set_env "RB_USER_INSTALL", "1" if Gem.freebsd_platform? + + if target_rbconfig_path = options[:"target-rbconfig"] + Bundler.rubygems.set_target_rbconfig(target_rbconfig_path) + end + + check_trust_policy + + if Bundler.frozen_bundle? && !Bundler.default_lockfile.exist? + flag = "deployment setting" if Bundler.settings[:deployment] + flag = "frozen setting" if Bundler.settings[:frozen] + raise ProductionError, "The #{flag} requires a lockfile. Please make " \ + "sure you have checked your #{SharedHelpers.relative_lockfile_path} into version control " \ + "before deploying." + end + + normalize_settings + + Bundler::Fetcher.disable_endpoint = options["full-index"] + + Plugin.gemfile_install(Bundler.default_gemfile) if Bundler.settings[:plugins] + + # For install we want to enable strict validation + # (rather than some optimizations we perform at app runtime). + definition = Bundler.definition(strict: true) + definition.validate_runtime! + definition.lockfile = options["lockfile"] if options["lockfile"] + definition.lockfile = false if options["no-lock"] + + installer = Installer.install(Bundler.root, definition, options) + + Bundler.settings.temporary(cache_all_platforms: options[:local] ? false : Bundler.settings[:cache_all_platforms]) do + Bundler.load.cache(nil, options[:local]) if Bundler.app_cache.exist? && !options["no-cache"] && !Bundler.frozen_bundle? + end + + Bundler.ui.confirm "Bundle complete! #{dependencies_count_for(definition)}, #{gems_installed_for(definition)}." + Bundler::CLI::Common.output_without_groups_message(:install) + + if Bundler.use_system_gems? + Bundler.ui.confirm "Use `bundle info [gemname]` to see where a bundled gem is installed." + else + relative_path = Bundler.configured_bundle_path.base_path_relative_to_pwd + Bundler.ui.confirm "Bundled gems are installed into `#{relative_path}`" + end + + Bundler::CLI::Common.output_post_install_messages installer.post_install_messages + + if CLI::Common.clean_after_install? + require_relative "clean" + Bundler::CLI::Clean.new(options).run + end + + Bundler::CLI::Common.output_fund_metadata_summary + rescue Gem::InvalidSpecificationException + Bundler.ui.warn "You have one or more invalid gemspecs that need to be fixed." + raise + end + + private + + def warn_if_root + return if Bundler.settings[:silence_root_warning] || Gem.win_platform? || !Process.uid.zero? + Bundler.ui.warn "Don't run Bundler as root. Installing your bundle as root " \ + "will break this application for all non-root users on this machine.", wrap: true + end + + def dependencies_count_for(definition) + count = definition.dependencies.count + "#{count} Gemfile #{count == 1 ? "dependency" : "dependencies"}" + end + + def gems_installed_for(definition) + count = definition.specs.count {|spec| spec.name != "bundler" } + "#{count} #{count == 1 ? "gem" : "gems"} now installed" + end + + def check_trust_policy + trust_policy = options["trust-policy"] + unless Bundler.rubygems.security_policies.keys.unshift(nil).include?(trust_policy) + raise InvalidOption, "RubyGems doesn't know about trust policy '#{trust_policy}'. " \ + "The known policies are: #{Bundler.rubygems.security_policies.keys.join(", ")}." + end + Bundler.settings.set_command_option_if_given :"trust-policy", trust_policy + end + + def normalize_settings + if options["standalone"] && Bundler.settings[:path].nil? && !options["local"] + Bundler.settings.set_command_option :path, "bundle" + end + + Bundler.settings.set_command_option_if_given :shebang, options["shebang"] + + Bundler.settings.set_command_option_if_given :jobs, options["jobs"] + + Bundler.settings.set_command_option_if_given :no_prune, options["no-prune"] + + Bundler.settings.set_command_option_if_given :no_install, options["no-install"] + + Bundler.settings.set_command_option_if_given :clean, options["clean"] + + options[:force] = options[:redownload] if options[:redownload] + end + end +end diff --git a/lib/bundler/cli/issue.rb b/lib/bundler/cli/issue.rb new file mode 100644 index 0000000000..cbfb7da2d8 --- /dev/null +++ b/lib/bundler/cli/issue.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require "rbconfig" + +module Bundler + class CLI::Issue + def run + Bundler.ui.info <<~EOS + Did you find an issue with Bundler? Before filing a new issue, + be sure to check out these resources: + + 1. Check out our troubleshooting guide for quick fixes to common issues: + https://github.com/ruby/rubygems/blob/master/doc/bundler/TROUBLESHOOTING.md + + 2. Instructions for common Bundler uses can be found on the documentation + site: https://bundler.io/ + + 3. Information about each Bundler command can be found in the Bundler + man pages: https://bundler.io/man/bundle.1.html + + Hopefully the troubleshooting steps above resolved your problem! If things + still aren't working the way you expect them to, please let us know so + that we can diagnose and help fix the problem you're having, by filling + in the new issue form located at + https://github.com/ruby/rubygems/issues/new?labels=Bundler&template=bundler-related-issue.md, + and copy and pasting the information below. + + EOS + + Bundler.ui.info Bundler::Env.report + + Bundler.ui.info "\n## Bundle Doctor" + doctor + end + + def doctor + require_relative "doctor/diagnose" + Bundler::CLI::Doctor::Diagnose.new({}).run + end + end +end diff --git a/lib/bundler/cli/list.rb b/lib/bundler/cli/list.rb new file mode 100644 index 0000000000..6a467f45a9 --- /dev/null +++ b/lib/bundler/cli/list.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +require "json" + +module Bundler + class CLI::List + def initialize(options) + @options = options + @without_group = options["without-group"].map(&:to_sym) + @only_group = options["only-group"].map(&:to_sym) + @format = options["format"] + end + + def run + raise InvalidOption, "The `--only-group` and `--without-group` options cannot be used together" if @only_group.any? && @without_group.any? + + raise InvalidOption, "The `--name-only` and `--paths` options cannot be used together" if @options["name-only"] && @options[:paths] + + specs = if @only_group.any? || @without_group.any? + filtered_specs_by_groups + else + begin + Bundler.load.specs + rescue GemNotFound => e + Bundler.ui.error e.message + Bundler.ui.warn "Install missing gems with `bundle install`." + exit 1 + end + end.reject {|s| s.name == "bundler" }.sort_by(&:name) + + case @format + when "json" + print_json(specs: specs) + when nil + print_human(specs: specs) + else + raise InvalidOption, "Unknown option`--format=#{@format}`. Supported formats: `json`" + end + end + + private + + def print_json(specs:) + gems = if @options["name-only"] + specs.map {|s| { name: s.name } } + else + specs.map do |s| + { + name: s.name, + version: s.version.to_s, + git_version: s.git_version&.strip, + }.tap do |h| + h[:path] = s.full_gem_path if @options["paths"] + end + end + end + Bundler.ui.info({ gems: gems }.to_json) + end + + def print_human(specs:) + return Bundler.ui.info "No gems in the Gemfile" if specs.empty? + + return specs.each {|s| Bundler.ui.info s.name } if @options["name-only"] + return specs.each {|s| Bundler.ui.info s.full_gem_path } if @options["paths"] + + Bundler.ui.info "Gems included by the bundle:" + + specs.each {|s| Bundler.ui.info " * #{s.name} (#{s.version}#{s.git_version})" } + + Bundler.ui.info "Use `bundle info` to print more detailed information about a gem" + end + + def verify_group_exists(groups) + (@without_group + @only_group).each do |group| + raise InvalidOption, "`#{group}` group could not be found." unless groups.include?(group) + end + end + + def filtered_specs_by_groups + definition = Bundler.definition + groups = definition.groups + + verify_group_exists(groups) + + show_groups = + if @without_group.any? + groups.reject {|g| @without_group.include?(g) } + elsif @only_group.any? + groups.select {|g| @only_group.include?(g) } + else + groups + end.map(&:to_sym) + + definition.specs_for(show_groups) + end + end +end diff --git a/lib/bundler/cli/lock.rb b/lib/bundler/cli/lock.rb new file mode 100644 index 0000000000..2f78868936 --- /dev/null +++ b/lib/bundler/cli/lock.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +module Bundler + class CLI::Lock + attr_reader :options + + def initialize(options) + @options = options + end + + def run + unless Bundler.default_gemfile + Bundler.ui.error "Unable to find a Gemfile to lock" + exit 1 + end + + check_for_conflicting_options + + print = options[:print] + previous_output_stream = Bundler.ui.output_stream + Bundler.ui.output_stream = :stderr if print + + Bundler::Fetcher.disable_endpoint = options["full-index"] + + update = options[:update] + conservative = options[:conservative] + bundler = options[:bundler] + + if update.is_a?(Array) # unlocking specific gems + Bundler::CLI::Common.ensure_all_gems_in_lockfile!(update) + update = { gems: update, conservative: conservative } + elsif update && conservative + update = { conservative: conservative } + elsif update && bundler + update = { bundler: bundler } + end + + Bundler.settings.temporary(frozen: false) do + definition = Bundler.definition(update, Bundler.default_lockfile) + definition.add_checksums if options["add-checksums"] + + Bundler::CLI::Common.configure_gem_version_promoter(definition, options) if options[:update] + + options["remove-platform"].each do |platform_string| + platform = Gem::Platform.new(platform_string) + definition.remove_platform(platform) + end + + options["add-platform"].each do |platform_string| + platform = Gem::Platform.new(platform_string) + if platform.to_s == "unknown" + Bundler.ui.error "The platform `#{platform_string}` is unknown to RubyGems and can't be added to the lockfile." + exit 1 + end + definition.add_platform(platform) + end + + if definition.platforms.empty? + raise InvalidOption, "Removing all platforms from the bundle is not allowed" + end + + definition.remotely! unless options[:local] + + if options["normalize-platforms"] + definition.normalize_platforms + end + + if print + puts definition.to_lock + else + file = options[:lockfile] + file = file ? Pathname.new(file).expand_path : Bundler.default_lockfile + + puts "Writing lockfile to #{file}" + definition.write_lock(file, false) + end + end + + Bundler.ui.output_stream = previous_output_stream + end + + private + + def check_for_conflicting_options + if options["normalize-platforms"] && options["add-platform"].any? + raise InvalidOption, "--normalize-platforms can't be used with --add-platform" + end + + if options["normalize-platforms"] && options["remove-platform"].any? + raise InvalidOption, "--normalize-platforms can't be used with --remove-platform" + end + end + end +end diff --git a/lib/bundler/cli/open.rb b/lib/bundler/cli/open.rb new file mode 100644 index 0000000000..f24693b843 --- /dev/null +++ b/lib/bundler/cli/open.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Bundler + class CLI::Open + attr_reader :options, :name, :path + def initialize(options, name) + @options = options + @name = name + @path = options[:path] unless options[:path].nil? + end + + def run + raise InvalidOption, "Cannot specify `--path` option without a value" if !@path.nil? && @path.empty? + editor = [ENV["BUNDLER_EDITOR"], ENV["VISUAL"], ENV["EDITOR"]].find {|e| !e.nil? && !e.empty? } + return Bundler.ui.info("To open a bundled gem, set $EDITOR or $BUNDLER_EDITOR") unless editor + return unless spec = Bundler::CLI::Common.select_spec(name, :regex_match) + if spec.default_gem? + Bundler.ui.info "Unable to open #{name} because it's a default gem, so the directory it would normally be installed to does not exist." + else + root_path = spec.full_gem_path + require "shellwords" + command = Shellwords.split(editor) << File.join([root_path, path].compact) + Bundler.with_original_env do + system(*command, { chdir: root_path }) + end || Bundler.ui.info("Could not run '#{command.join(" ")}'") + end + end + end +end diff --git a/lib/bundler/cli/outdated.rb b/lib/bundler/cli/outdated.rb new file mode 100644 index 0000000000..ae827dbb4b --- /dev/null +++ b/lib/bundler/cli/outdated.rb @@ -0,0 +1,319 @@ +# frozen_string_literal: true + +module Bundler + class CLI::Outdated + attr_reader :options, :gems, :options_include_groups, :filter_options_patch, :sources, :strict + attr_accessor :outdated_gems + + def initialize(options, gems) + @options = options + @gems = gems + @sources = Array(options[:source]) + + @filter_options_patch = options.keys & %w[filter-major filter-minor filter-patch] + + @outdated_gems = [] + + @options_include_groups = [:group, :groups].any? do |v| + options.keys.include?(v.to_s) + end + + # the patch level options imply strict is also true. It wouldn't make + # sense otherwise. + @strict = options["filter-strict"] || Bundler::CLI::Common.patch_level_options(options).any? + end + + def run + check_for_deployment_mode! + + Bundler.definition.validate_runtime! + current_specs = Bundler.ui.silence { Bundler.definition.resolve } + + gems.each do |gem_name| + if current_specs[gem_name].empty? + raise GemNotFound, "Could not find gem '#{gem_name}'." + end + end + + current_dependencies = Bundler.ui.silence do + Bundler.load.dependencies.map {|dep| [dep.name, dep] }.to_h + end + + definition = if gems.empty? && sources.empty? + # We're doing a full update + Bundler.definition(true) + else + Bundler.definition(gems: gems, sources: sources) + end + + Bundler::CLI::Common.configure_gem_version_promoter( + Bundler.definition, + options.merge(strict: @strict) + ) + + definition_resolution = proc do + options[:local] ? definition.resolve_with_cache! : definition.resolve_remotely! + end + + if options[:parseable] + Bundler.ui.progress(&definition_resolution) + else + definition_resolution.call + end + + Bundler.ui.info "" + + # Loop through the current specs + gemfile_specs, dependency_specs = current_specs.partition do |spec| + current_dependencies.key? spec.name + end + + specs = if options["only-explicit"] + gemfile_specs + else + gemfile_specs + dependency_specs + end + + specs.sort_by(&:name).uniq(&:name).each do |current_spec| + next unless gems.empty? || gems.include?(current_spec.name) + + active_spec = retrieve_active_spec(definition, current_spec) + next unless active_spec + + next unless filter_options_patch.empty? || update_present_via_semver_portions(current_spec, active_spec, options) + + gem_outdated = Gem::Version.new(active_spec.version) > Gem::Version.new(current_spec.version) + next unless gem_outdated || (current_spec.git_version != active_spec.git_version) + + dependency = current_dependencies[current_spec.name] + groups = "" + if dependency && !options[:parseable] + groups = dependency.groups.join(", ") + end + + outdated_gems << { + active_spec: active_spec, + current_spec: current_spec, + dependency: dependency, + groups: groups, + } + end + + relevant_outdated_gems = if options_include_groups + outdated_gems.group_by {|g| g[:groups] }.sort.flat_map do |groups, gems| + contains_group = groups.split(", ").include?(options[:group]) + next unless options[:groups] || contains_group + + gems + end.compact + else + outdated_gems + end + + if relevant_outdated_gems.empty? + unless options[:parseable] + Bundler.ui.info(nothing_outdated_message) + end + else + if options[:parseable] + print_gems(relevant_outdated_gems) + else + print_gems_table(relevant_outdated_gems) + end + + exit 1 + end + end + + private + + def loaded_from_for(spec) + return unless spec.respond_to?(:loaded_from) + + spec.loaded_from + end + + def groups_text(group_text, groups) + "#{group_text}#{groups.split(",").size > 1 ? "s" : ""} \"#{groups}\"" + end + + def nothing_outdated_message + if filter_options_patch.any? + display = filter_options_patch.map do |o| + o.sub("filter-", "") + end.join(" or ") + + "No #{display} updates to display.\n" + else + "Bundle up to date!\n" + end + end + + def retrieve_active_spec(definition, current_spec) + active_spec = definition.resolve.find_by_name_and_platform(current_spec.name, current_spec.platform) + return unless active_spec + + return active_spec if strict + + active_specs = active_spec.source.specs.search(current_spec.name).select {|spec| spec.installable_on_platform?(current_spec.platform) }.sort_by(&:version) + if !current_spec.version.prerelease? && !options[:pre] && active_specs.size > 1 + active_specs.delete_if {|b| b.respond_to?(:version) && b.version.prerelease? } + end + active_specs.last + end + + def print_gems(gems_list) + gems_list.each do |gem| + print_gem( + gem[:current_spec], + gem[:active_spec], + gem[:dependency], + gem[:groups], + ) + end + end + + def print_gems_table(gems_list) + data = gems_list.map do |gem| + gem_column_for( + gem[:current_spec], + gem[:active_spec], + gem[:dependency], + gem[:groups], + ) + end + + print_indented([table_header] + data) + end + + def print_gem(current_spec, active_spec, dependency, groups) + spec_version = "#{active_spec.version}#{active_spec.git_version}" + if Bundler.ui.debug? + loaded_from = loaded_from_for(active_spec) + spec_version += " (from #{loaded_from})" if loaded_from + end + current_version = "#{current_spec.version}#{current_spec.git_version}" + + if dependency&.specific? + dependency_version = %(, requested #{dependency.requirement}) + end + + spec_outdated_info = "#{active_spec.name} (newest #{spec_version}, " \ + "installed #{current_version}#{dependency_version}" + + release_date = release_date_for(active_spec) + spec_outdated_info += ", released #{release_date}" unless release_date.empty? + spec_outdated_info += ")" + + output_message = if options[:parseable] + spec_outdated_info.to_s + elsif options_include_groups || groups.empty? + " * #{spec_outdated_info}" + else + " * #{spec_outdated_info} in #{groups_text("group", groups)}" + end + + Bundler.ui.info output_message.rstrip + end + + def gem_column_for(current_spec, active_spec, dependency, groups) + current_version = "#{current_spec.version}#{current_spec.git_version}" + spec_version = "#{active_spec.version}#{active_spec.git_version}" + dependency = dependency.requirement if dependency + + ret_val = [active_spec.name, current_version, spec_version, dependency.to_s, groups.to_s] + ret_val << release_date_for(active_spec) + ret_val << loaded_from_for(active_spec).to_s if Bundler.ui.debug? + ret_val + end + + def check_for_deployment_mode! + return unless Bundler.frozen_bundle? + suggested_command = if Bundler.settings.locations("frozen").keys.&([:global, :local]).any? + "bundle config unset frozen" + elsif Bundler.settings.locations("deployment").keys.&([:global, :local]).any? + "bundle config unset deployment" + end + raise ProductionError, "You are trying to check outdated gems in " \ + "deployment mode. Run `bundle outdated` elsewhere.\n" \ + "\nIf this is a development machine, remove the " \ + "#{Bundler.default_gemfile} freeze" \ + "\nby running `#{suggested_command}`." + end + + def update_present_via_semver_portions(current_spec, active_spec, options) + current_major = current_spec.version.segments.first + active_major = active_spec.version.segments.first + + update_present = false + update_present = active_major > current_major if options["filter-major"] + + if !update_present && (options["filter-minor"] || options["filter-patch"]) && current_major == active_major + current_minor = get_version_semver_portion_value(current_spec, 1) + active_minor = get_version_semver_portion_value(active_spec, 1) + + update_present = active_minor > current_minor if options["filter-minor"] + + if !update_present && options["filter-patch"] && current_minor == active_minor + current_patch = get_version_semver_portion_value(current_spec, 2) + active_patch = get_version_semver_portion_value(active_spec, 2) + + update_present = active_patch > current_patch + end + end + + update_present + end + + def get_version_semver_portion_value(spec, version_portion_index) + version_section = spec.version.segments[version_portion_index, 1] + version_section.to_a[0].to_i + end + + def print_indented(matrix) + header = matrix[0] + data = matrix[1..-1] + + column_sizes = Array.new(header.size) do |index| + matrix.max_by {|row| row[index].length }[index].length + end + + Bundler.ui.info justify(header, column_sizes) + + data.sort_by! {|row| row[0] } + + data.each do |row| + Bundler.ui.info justify(row, column_sizes) + end + end + + def table_header + header = ["Gem", "Current", "Latest", "Requested", "Groups", "Release Date"] + header << "Path" if Bundler.ui.debug? + header + end + + def release_date_for(spec) + return "" unless spec.respond_to?(:date) + + date = spec.date + return "" unless date + + return "" unless Gem.const_defined?(:DEFAULT_SOURCE_DATE_EPOCH) + default_date = Time.at(Gem::DEFAULT_SOURCE_DATE_EPOCH).utc + default_date = Time.utc(default_date.year, default_date.month, default_date.day) + + date = date.utc if date.respond_to?(:utc) + + return "" if date == default_date + + date.strftime("%Y-%m-%d") + end + + def justify(row, sizes) + row.each_with_index.map do |element, index| + element.ljust(sizes[index]) + end.join(" ").strip + "\n" + end + end +end diff --git a/lib/bundler/cli/platform.rb b/lib/bundler/cli/platform.rb new file mode 100644 index 0000000000..32d68abbb1 --- /dev/null +++ b/lib/bundler/cli/platform.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module Bundler + class CLI::Platform + attr_reader :options + def initialize(options) + @options = options + end + + def run + ruby_version = if Bundler.locked_gems + Bundler.locked_gems.ruby_version&.gsub(/p\d+\Z/, "") + else + Bundler.definition.ruby_version&.single_version_string + end + + output = [] + + if options[:ruby] + if ruby_version + output << ruby_version + else + output << "No ruby version specified" + end + else + platforms = Bundler.definition.platforms.map {|p| "* #{p}" } + + output << "Your platform is: #{Gem::Platform.local}" + output << "Your app has gems that work on these platforms:\n#{platforms.join("\n")}" + + if ruby_version + output << "Your Gemfile specifies a Ruby version requirement:\n* #{ruby_version}" + + begin + Bundler.definition.validate_runtime! + output << "Your current platform satisfies the Ruby version requirement." + rescue RubyVersionMismatch => e + output << e.message + end + else + output << "Your Gemfile does not specify a Ruby version requirement." + end + end + + Bundler.ui.info output.join("\n\n") + end + end +end diff --git a/lib/bundler/cli/plugin.rb b/lib/bundler/cli/plugin.rb new file mode 100644 index 0000000000..32fa660fe0 --- /dev/null +++ b/lib/bundler/cli/plugin.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require_relative "../vendored_thor" +module Bundler + class CLI::Plugin < Thor + desc "install PLUGINS", "Install the plugin from the source" + long_desc <<-D + Install plugins either from the rubygems source provided (with --source option), from a git source provided with --git, or a local path provided with --path. If no sources are provided, it uses Gem.sources + D + method_option "source", type: :string, default: nil, banner: "URL of the RubyGems source to fetch the plugin from" + method_option "version", type: :string, default: nil, banner: "The version of the plugin to fetch" + method_option "git", type: :string, default: nil, banner: "URL of the git repo to fetch from" + method_option "local_git", type: :string, default: nil, banner: "Path of the local git repo to fetch from (removed)" + method_option "branch", type: :string, default: nil, banner: "The git branch to checkout" + method_option "ref", type: :string, default: nil, banner: "The git revision to check out" + method_option "path", type: :string, default: nil, banner: "Path of a local gem to directly use" + def install(*plugins) + if options.key?(:local_git) + raise InvalidOption, "--local_git has been removed, use --git" + end + + Bundler::Plugin.install(plugins, options) + end + + desc "uninstall PLUGINS", "Uninstall the plugins" + long_desc <<-D + Uninstall given list of plugins. To uninstall all the plugins, use -all option. + D + method_option "all", type: :boolean, default: nil, banner: "Uninstall all the installed plugins. If no plugin is installed, then it does nothing." + def uninstall(*plugins) + Bundler::Plugin.uninstall(plugins, options) + end + + desc "list", "List the installed plugins and available commands" + def list + Bundler::Plugin.list + end + end +end diff --git a/lib/bundler/cli/pristine.rb b/lib/bundler/cli/pristine.rb new file mode 100644 index 0000000000..f463f0bce8 --- /dev/null +++ b/lib/bundler/cli/pristine.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +module Bundler + class CLI::Pristine + def initialize(gems) + @gems = gems + end + + def run + CLI::Common.ensure_all_gems_in_lockfile!(@gems) + definition = Bundler.definition + definition.validate_runtime! + installer = Bundler::Installer.new(Bundler.root, definition) + git_sources = [] + + ProcessLock.lock do + installed_specs = definition.specs.reject do |spec| + next if spec.name == "bundler" # Source::Rubygems doesn't install bundler + next if !@gems.empty? && !@gems.include?(spec.name) + + gem_name = "#{spec.name} (#{spec.version}#{spec.git_version})" + gem_name += " (#{spec.platform})" if !spec.platform.nil? && spec.platform != Gem::Platform::RUBY + + case source = spec.source + when Source::Rubygems + cached_gem = spec.cache_file + unless File.exist?(cached_gem) + Bundler.ui.error("Failed to pristine #{gem_name}. Cached gem #{cached_gem} does not exist.") + next + end + + FileUtils.rm_rf spec.full_gem_path + when Source::Git + if source.local? + Bundler.ui.warn("Cannot pristine #{gem_name}. Gem is locally overridden.") + next + end + + source.remote! + if extension_cache_path = source.extension_cache_path(spec) + FileUtils.rm_rf extension_cache_path + end + FileUtils.rm_rf spec.extension_dir + FileUtils.rm_rf spec.full_gem_path + + next if git_sources.include?(source) + git_sources << source + else + Bundler.ui.warn("Cannot pristine #{gem_name}. Gem is sourced from local path.") + next + end + + true + end.map(&:name) + + jobs = Bundler.settings.installation_parallelization + pristine_count = definition.specs.count - installed_specs.count + # allow a pristining a single gem to skip the parallel worker + jobs = [jobs, pristine_count].min + ParallelInstaller.call(installer, definition.specs, jobs, false, true, skip: installed_specs) + end + end + end +end diff --git a/lib/bundler/cli/remove.rb b/lib/bundler/cli/remove.rb new file mode 100644 index 0000000000..44a4d891dd --- /dev/null +++ b/lib/bundler/cli/remove.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Bundler + class CLI::Remove + def initialize(gems, options) + @gems = gems + @options = options + end + + def run + raise InvalidOption, "Please specify gems to remove." if @gems.empty? + + Injector.remove(@gems, {}) + Installer.install(Bundler.root, Bundler.definition) + end + end +end diff --git a/lib/bundler/cli/show.rb b/lib/bundler/cli/show.rb new file mode 100644 index 0000000000..67fdcc797e --- /dev/null +++ b/lib/bundler/cli/show.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +module Bundler + class CLI::Show + attr_reader :options, :gem_name, :latest_specs + def initialize(options, gem_name) + @options = options + @gem_name = gem_name + @verbose = options[:verbose] + @latest_specs = fetch_latest_specs if @verbose + end + + def run + Bundler.ui.silence do + Bundler.definition.validate_runtime! + Bundler.load.lock + end + + if gem_name + if gem_name == "bundler" + path = File.expand_path("../../..", __dir__) + else + spec = Bundler::CLI::Common.select_spec(gem_name, :regex_match) + return unless spec + path = spec.full_gem_path + unless File.directory?(path) + return Bundler.ui.warn "The gem #{gem_name} is missing. It should be installed at #{path}, but was not found" + end + end + return Bundler.ui.info(path) + end + + if options[:paths] + Bundler.load.specs.sort_by(&:name).map do |s| + Bundler.ui.info s.full_gem_path + end + else + Bundler.ui.info "Gems included by the bundle:" + Bundler.load.specs.sort_by(&:name).each do |s| + desc = " * #{s.name} (#{s.version}#{s.git_version})" + if @verbose + latest = latest_specs.find {|l| l.name == s.name } + Bundler.ui.info <<~END + #{desc.lstrip} + \tSummary: #{s.summary || "No description available."} + \tHomepage: #{s.homepage || "No website available."} + \tStatus: #{outdated?(s, latest) ? "Outdated - #{s.version} < #{latest.version}" : "Up to date"} + END + else + Bundler.ui.info desc + end + end + end + end + + private + + def fetch_latest_specs + definition = Bundler.definition(true) + Bundler.ui.info "Fetching remote specs for outdated check...\n\n" + Bundler.ui.silence { definition.remotely! } + Bundler.reset! + definition.specs + end + + def outdated?(current, latest) + return false unless latest + Gem::Version.new(current.version) < Gem::Version.new(latest.version) + end + end +end diff --git a/lib/bundler/cli/update.rb b/lib/bundler/cli/update.rb new file mode 100644 index 0000000000..9cc90acc58 --- /dev/null +++ b/lib/bundler/cli/update.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +module Bundler + class CLI::Update + attr_reader :options, :gems + def initialize(options, gems) + @options = options + @gems = gems + end + + def run + Bundler.ui.level = "warn" if options[:quiet] + + update_bundler = options[:bundler] + + Bundler.self_manager.update_bundler_and_restart_with_it_if_needed(update_bundler) if update_bundler + + Plugin.gemfile_install(Bundler.default_gemfile) if Bundler.settings[:plugins] + + sources = Array(options[:source]) + groups = Array(options[:group]).map(&:to_sym) + + full_update = gems.empty? && sources.empty? && groups.empty? && !options[:ruby] && !update_bundler + + if full_update && !options[:all] + if Bundler.settings[:update_requires_all_flag] + raise InvalidOption, "To update everything, pass the `--all` flag." + end + SharedHelpers.feature_deprecated! "Pass --all to `bundle update` to update everything" + elsif !full_update && options[:all] + raise InvalidOption, "Cannot specify --all along with specific options." + end + + conservative = options[:conservative] + + if full_update + if conservative + Bundler.definition(conservative: conservative) + else + Bundler.definition(true) + end + else + unless Bundler.default_lockfile.exist? + raise GemfileLockNotFound, "This Bundle hasn't been installed yet. " \ + "Run `bundle install` to update and install the bundled gems." + end + Bundler::CLI::Common.ensure_all_gems_in_lockfile!(gems) + + if groups.any? + deps = Bundler.definition.dependencies.select {|d| (d.groups & groups).any? } + gems.concat(deps.map(&:name)) + end + + Bundler.definition(gems: gems, sources: sources, ruby: options[:ruby], + conservative: conservative, + bundler: update_bundler) + end + + Bundler::CLI::Common.configure_gem_version_promoter(Bundler.definition, options) + + Bundler::Fetcher.disable_endpoint = options["full-index"] + + opts = options.dup + opts["update"] = true + opts["local"] = options[:local] + opts["force"] = options[:redownload] if options[:redownload] + + Bundler.settings.set_command_option_if_given :jobs, opts["jobs"] + + Bundler.definition.validate_runtime! + + if locked_gems = Bundler.definition.locked_gems + previous_locked_info = locked_gems.specs.reduce({}) do |h, s| + h[s.name] = { spec: s, version: s.version, source: s.source.identifier } + h + end + end + + installer = Installer.install Bundler.root, Bundler.definition, opts + Bundler.load.cache if Bundler.app_cache.exist? + + if CLI::Common.clean_after_install? + require_relative "clean" + Bundler::CLI::Clean.new(options).run + end + + if locked_gems + gems.each do |name| + locked_info = previous_locked_info[name] + next unless locked_info + + locked_spec = locked_info[:spec] + new_spec = Bundler.definition.specs[name].first + unless new_spec + unless locked_spec.installable_on_platform?(Bundler.local_platform) + Bundler.ui.warn "Bundler attempted to update #{name} but it was not considered because it is for a different platform from the current one" + end + + next + end + + locked_source = locked_info[:source] + new_source = new_spec.source.identifier + next if locked_source != new_source + + new_version = new_spec.version + locked_version = locked_info[:version] + if new_version < locked_version + Bundler.ui.warn "Note: #{name} version regressed from #{locked_version} to #{new_version}" + elsif new_version == locked_version + Bundler.ui.warn "Bundler attempted to update #{name} but its version stayed the same" + end + end + end + + Bundler.ui.confirm "Bundle updated!" + Bundler::CLI::Common.output_without_groups_message(:update) + Bundler::CLI::Common.output_post_install_messages installer.post_install_messages + + Bundler::CLI::Common.output_fund_metadata_summary + end + end +end |
