diff options
author | Homu <homu@barosl.com> | 2016-06-11 06:57:43 +0900 |
---|---|---|
committer | Homu <homu@barosl.com> | 2016-06-11 06:57:43 +0900 |
commit | fb72f369f1e8ad069ba408eafdf0e8c923080705 (patch) | |
tree | 6edc0cf431051248d3c7b283d04f2a0181c185ad | |
parent | 6ebda982776d2c8ae0f38401fbaab47a231d75e1 (diff) | |
parent | 0aaa3f3a9b236acfc5198196c845cc9d6320ce30 (diff) | |
download | bundler-fb72f369f1e8ad069ba408eafdf0e8c923080705.tar.gz |
Auto merge of #4608 - asutoshpalai:plugin, r=segiddins
Plugin system
This is the working PR for plugin system for bundler.
A rough workflow shall be
- [x] Add functionality to install a plugin as gem from RubyGem source and also from git source
- [x] Add support for command line plugin
- [ ] Add support for source plugin
- [ ] Add support for life cycle hooks
- [ ] Finalize all the functionalities
34 files changed, 1467 insertions, 95 deletions
diff --git a/lib/bundler.rb b/lib/bundler.rb index ace1e7f3c4..9cfc7157b5 100644 --- a/lib/bundler.rb +++ b/lib/bundler.rb @@ -38,6 +38,7 @@ module Bundler autoload :MatchPlatform, "bundler/match_platform" autoload :Mirror, "bundler/mirror" autoload :Mirrors, "bundler/mirror" + autoload :Plugin, "bundler/plugin" autoload :RemoteSpecification, "bundler/remote_specification" autoload :Resolver, "bundler/resolver" autoload :Retry, "bundler/retry" @@ -195,8 +196,7 @@ module Bundler end def settings - return @settings if defined?(@settings) - @settings = Settings.new(app_config_path) + @settings ||= Settings.new(app_config_path) rescue GemfileNotFound @settings = Settings.new(Pathname.new(".bundle").expand_path) end @@ -379,6 +379,8 @@ module Bundler end def reset! + @root = nil + @settings = nil @definition = nil end diff --git a/lib/bundler/cli.rb b/lib/bundler/cli.rb index bb21e6f4e5..8585142352 100644 --- a/lib/bundler/cli.rb +++ b/lib/bundler/cli.rb @@ -16,6 +16,7 @@ module Bundler def initialize(*args) super + Bundler.reset! custom_gemfile = options[:gemfile] || Bundler.settings[:gemfile] ENV["BUNDLE_GEMFILE"] = File.expand_path(custom_gemfile) if custom_gemfile && !custom_gemfile.empty? @@ -82,6 +83,10 @@ module Bundler end def self.handle_no_command_error(command, has_namespace = $thor_runner) + if Bundler.settings[:plugins] && Bundler::Plugin.command?(command) + return Bundler::Plugin.exec_command(command, ARGV[1..-1]) + end + return super unless command_path = Bundler.which("bundler-#{command}") Kernel.exec(command_path, *ARGV[1..-1]) @@ -437,6 +442,12 @@ module Bundler Env.new.write($stdout) end + if Bundler.settings[:plugins] + require "bundler/cli/plugin" + desc "plugin SUBCOMMAND ...ARGS", "manage the bundler plugins" + subcommand "plugin", Plugin + end + # Reformat the arguments passed to bundle that include a --help flag # into the corresponding `bundle help #{command}` call def self.reformatted_help_args(args) diff --git a/lib/bundler/cli/install.rb b/lib/bundler/cli/install.rb index da201ec5d9..dbe25977a4 100644 --- a/lib/bundler/cli/install.rb +++ b/lib/bundler/cli/install.rb @@ -17,28 +17,9 @@ module Bundler end end - if options[:without] && options[:with] - conflicting_groups = options[:without] & options[:with] - unless conflicting_groups.empty? - Bundler.ui.error "You can't list a group in both, --with and --without." \ - "The offending groups are: #{conflicting_groups.join(", ")}." - exit 1 - end - end - - Bundler.settings.with = [] if options[:with] && options[:with].empty? - Bundler.settings.without = [] if options[:without] && options[:without].empty? - - with = options.fetch("with", []) - with |= Bundler.settings.with.map(&:to_s) - with -= options[:without] if options[:without] + check_for_group_conflicts - without = options.fetch("without", []) - without |= Bundler.settings.without.map(&:to_s) - without -= options[:with] if options[:with] - - options[:with] = with - options[:without] = without + normalize_groups ENV["RB_USER_INSTALL"] = "1" if Bundler::FREEBSD @@ -47,16 +28,7 @@ module Bundler check_for_options_conflicts - if options["trust-policy"] - unless Bundler.rubygems.security_policies.keys.include?(options["trust-policy"]) - Bundler.ui.error "Rubygems doesn't know about trust policy '#{options["trust-policy"]}'. " \ - "The known policies are: #{Bundler.rubygems.security_policies.keys.join(", ")}." - exit 1 - end - Bundler.settings["trust-policy"] = options["trust-policy"] - else - Bundler.settings["trust-policy"] = nil if Bundler.settings["trust-policy"] - end + check_trust_policy if options[:deployment] || options[:frozen] unless Bundler.default_lockfile.exist? @@ -77,25 +49,15 @@ module Bundler options[:system] = true end - Bundler.settings[:path] = nil if options[:system] - Bundler.settings[:path] = "vendor/bundle" if options[:deployment] - Bundler.settings[:path] = options["path"] if options["path"] - Bundler.settings[:path] ||= "bundle" if options["standalone"] - Bundler.settings[:bin] = options["binstubs"] if options["binstubs"] - Bundler.settings[:bin] = nil if options["binstubs"] && options["binstubs"].empty? - Bundler.settings[:shebang] = options["shebang"] if options["shebang"] - Bundler.settings[:jobs] = options["jobs"] if options["jobs"] - Bundler.settings[:no_prune] = true if options["no-prune"] - Bundler.settings[:no_install] = true if options["no-install"] - Bundler.settings[:clean] = options["clean"] if options["clean"] - Bundler.settings.without = options[:without] - Bundler.settings.with = options[:with] + normalize_settings + Bundler::Fetcher.disable_endpoint = options["full-index"] - Bundler.settings[:disable_shared_gems] = Bundler.settings[:path] ? true : nil # rubygems plugins sometimes hook into the gem install process Gem.load_env_plugins if Gem.respond_to?(:load_env_plugins) + Plugin.gemfile_install(Bundler.default_gemfile) if Bundler.settings[:plugins] + definition = Bundler.definition definition.validate_ruby! @@ -119,16 +81,7 @@ module Bundler end end - Installer.ambiguous_gems.to_a.each do |name, installed_from_uri, *also_found_in_uris| - Bundler.ui.error "Warning: the gem '#{name}' was found in multiple sources." - Bundler.ui.error "Installed from: #{installed_from_uri}" - Bundler.ui.error "Also found in:" - also_found_in_uris.each {|uri| Bundler.ui.error " * #{uri}" } - Bundler.ui.error "You should add a source requirement to restrict this gem to your preferred source." - Bundler.ui.error "For example:" - Bundler.ui.error " gem '#{name}', :source => '#{installed_from_uri}'" - Bundler.ui.error "Then uninstall the gem '#{name}' (or delete all bundled gems) and then install again." - end + warn_ambiguous_gems if Bundler.settings[:clean] && Bundler.settings[:path] require "bundler/cli/clean" @@ -182,6 +135,17 @@ module Bundler Bundler.ui.info msg end + def check_for_group_conflicts + if options[:without] && options[:with] + conflicting_groups = options[:without] & options[:with] + unless conflicting_groups.empty? + Bundler.ui.error "You can't list a group in both, --with and --without." \ + "The offending groups are: #{conflicting_groups.join(", ")}." + exit 1 + end + end + end + def check_for_options_conflicts if (options[:path] || options[:deployment]) && options[:system] error_message = String.new @@ -190,5 +154,72 @@ module Bundler raise InvalidOption.new(error_message) end end + + def check_trust_policy + if options["trust-policy"] + unless Bundler.rubygems.security_policies.keys.include?(options["trust-policy"]) + Bundler.ui.error "Rubygems doesn't know about trust policy '#{options["trust-policy"]}'. " \ + "The known policies are: #{Bundler.rubygems.security_policies.keys.join(", ")}." + exit 1 + end + Bundler.settings["trust-policy"] = options["trust-policy"] + else + Bundler.settings["trust-policy"] = nil if Bundler.settings["trust-policy"] + end + end + + def normalize_groups + Bundler.settings.with = [] if options[:with] && options[:with].empty? + Bundler.settings.without = [] if options[:without] && options[:without].empty? + + with = options.fetch("with", []) + with |= Bundler.settings.with.map(&:to_s) + with -= options[:without] if options[:without] + + without = options.fetch("without", []) + without |= Bundler.settings.without.map(&:to_s) + without -= options[:with] if options[:with] + + options[:with] = with + options[:without] = without + end + + def normalize_settings + Bundler.settings[:path] = nil if options[:system] + Bundler.settings[:path] = "vendor/bundle" if options[:deployment] + Bundler.settings[:path] = options["path"] if options["path"] + Bundler.settings[:path] ||= "bundle" if options["standalone"] + + Bundler.settings[:bin] = options["binstubs"] if options["binstubs"] + Bundler.settings[:bin] = nil if options["binstubs"] && options["binstubs"].empty? + + Bundler.settings[:shebang] = options["shebang"] if options["shebang"] + + Bundler.settings[:jobs] = options["jobs"] if options["jobs"] + + Bundler.settings[:no_prune] = true if options["no-prune"] + + Bundler.settings[:no_install] = true if options["no-install"] + + Bundler.settings[:clean] = options["clean"] if options["clean"] + + Bundler.settings.without = options[:without] + Bundler.settings.with = options[:with] + + Bundler.settings[:disable_shared_gems] = Bundler.settings[:path] ? true : nil + end + + def warn_ambiguous_gems + Installer.ambiguous_gems.to_a.each do |name, installed_from_uri, *also_found_in_uris| + Bundler.ui.error "Warning: the gem '#{name}' was found in multiple sources." + Bundler.ui.error "Installed from: #{installed_from_uri}" + Bundler.ui.error "Also found in:" + also_found_in_uris.each {|uri| Bundler.ui.error " * #{uri}" } + Bundler.ui.error "You should add a source requirement to restrict this gem to your preferred source." + Bundler.ui.error "For example:" + Bundler.ui.error " gem '#{name}', :source => '#{installed_from_uri}'" + Bundler.ui.error "Then uninstall the gem '#{name}' (or delete all bundled gems) and then install again." + end + end end end diff --git a/lib/bundler/cli/plugin.rb b/lib/bundler/cli/plugin.rb new file mode 100644 index 0000000000..277822dafc --- /dev/null +++ b/lib/bundler/cli/plugin.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true +require "bundler/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) or from a git source provided with (--git option). 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 "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" + def install(*plugins) + Bundler::Plugin.install(plugins, options) + end + end +end diff --git a/lib/bundler/dsl.rb b/lib/bundler/dsl.rb index dc482a698d..4208f9b575 100644 --- a/lib/bundler/dsl.rb +++ b/lib/bundler/dsl.rb @@ -224,6 +224,10 @@ module Bundler @env = old end + def plugin(*args) + # Pass on + end + def method_missing(name, *args) raise GemfileError, "Undefined local variable or method `#{name}' for Gemfile" end diff --git a/lib/bundler/errors.rb b/lib/bundler/errors.rb index f6c9150121..69eb57e844 100644 --- a/lib/bundler/errors.rb +++ b/lib/bundler/errors.rb @@ -36,6 +36,7 @@ module Bundler class LockfileError < BundlerError; status_code(20); end class CyclicDependencyError < BundlerError; status_code(21); end class GemfileLockNotFound < BundlerError; status_code(22); end + class PluginError < BundlerError; status_code(23); end class GemfileEvalError < GemfileError; end class MarshalError < StandardError; end diff --git a/lib/bundler/inline.rb b/lib/bundler/inline.rb index 219086956b..6f3cd6c132 100644 --- a/lib/bundler/inline.rb +++ b/lib/bundler/inline.rb @@ -41,6 +41,7 @@ def gemfile(install = false, options = {}, &gemfile) end ENV["BUNDLE_GEMFILE"] ||= "Gemfile" + Bundler::Plugin.gemfile_install(&gemfile) if Bundler.settings[:plugins] builder = Bundler::Dsl.new builder.instance_eval(&gemfile) diff --git a/lib/bundler/plugin.rb b/lib/bundler/plugin.rb new file mode 100644 index 0000000000..9aabd73a42 --- /dev/null +++ b/lib/bundler/plugin.rb @@ -0,0 +1,156 @@ +# frozen_string_literal: true + +module Bundler + module Plugin + autoload :API, "bundler/plugin/api" + autoload :DSL, "bundler/plugin/dsl" + autoload :Index, "bundler/plugin/index" + autoload :Installer, "bundler/plugin/installer" + autoload :SourceList, "bundler/plugin/source_list" + + class MalformattedPlugin < PluginError; end + class UndefinedCommandError < PluginError; end + + PLUGIN_FILE_NAME = "plugins.rb".freeze + + module_function + + @commands = {} + + # Installs a new plugin by the given name + # + # @param [Array<String>] names the name of plugin to be installed + # @param [Hash] options various parameters as described in description + # @option options [String] :source rubygems source to fetch the plugin gem from + # @option options [String] :version (optional) the version of the plugin to install + def install(names, options) + paths = Installer.new.install(names, options) + + save_plugins paths + rescue PluginError => e + paths.values.map {|path| Bundler.rm_rf(path) } if paths + Bundler.ui.error "Failed to install plugin #{name}: #{e.message}\n #{e.backtrace.join("\n ")}" + end + + # Evaluates the Gemfile with a limited DSL and installs the plugins + # specified by plugin method + # + # @param [Pathname] gemfile path + def gemfile_install(gemfile = nil, &inline) + if block_given? + builder = DSL.new + builder.instance_eval(&inline) + definition = builder.to_definition(nil, true) + else + definition = DSL.evaluate(gemfile, nil, {}) + end + return unless definition.dependencies.any? + + plugins = Installer.new.install_definition(definition) + + save_plugins plugins + end + + # The index object used to store the details about the plugin + def index + @index ||= Index.new + end + + # The directory root to all plugin related data + def root + @root ||= Bundler.user_bundle_path.join("plugin") + end + + # The cache directory for plugin stuffs + def cache + @cache ||= root.join("cache") + end + + # To be called via the API to register to handle a command + def add_command(command, cls) + @commands[command] = cls + end + + # Checks if any plugins handles the command + def command?(command) + !index.command_plugin(command).nil? + end + + # To be called from Cli class to pass the command and argument to + # approriate plugin class + def exec_command(command, args) + raise UndefinedCommandError, "Command #{command} not found" unless command? command + + load_plugin index.command_plugin(command) unless @commands.key? command + + @commands[command].new.exec(command, args) + end + + # currently only intended for specs + # + # @return [String, nil] installed path + def installed?(plugin) + Index.new.installed?(plugin) + end + + # Post installation processing and registering with index + # + # @param [Hash] plugins mapped to their installtion path + def save_plugins(plugins) + plugins.each do |name, path| + path = Pathname.new path + validate_plugin! path + register_plugin name, path + Bundler.ui.info "Installed plugin #{name}" + end + end + + # Checks if the gem is good to be a plugin + # + # At present it only checks whether it contains plugins.rb file + # + # @param [Pathname] plugin_path the path plugin is installed at + # @raise [Error] if plugins.rb file is not found + def validate_plugin!(plugin_path) + plugin_file = plugin_path.join(PLUGIN_FILE_NAME) + raise MalformattedPlugin, "#{PLUGIN_FILE_NAME} was not found in the plugin!" unless plugin_file.file? + end + + # Runs the plugins.rb file in an isolated namespace, records the plugin + # actions it registers for and then passes the data to index to be stored. + # + # @param [String] name the name of the plugin + # @param [Pathname] path the path where the plugin is installed at + def register_plugin(name, path) + commands = @commands + + @commands = {} + + begin + load path.join(PLUGIN_FILE_NAME), true + rescue StandardError => e + raise MalformattedPlugin, "#{e.class}: #{e.message}" + end + + index.register_plugin name, path.to_s, @commands.keys + ensure + @commands = commands + end + + # Executes the plugins.rb file + # + # @param [String] name of the plugin + def load_plugin(name) + # Need to ensure before this that plugin root where the rest of gems + # are installed to be on load path to support plugin deps. Currently not + # done to avoid conflicts + path = index.plugin_path(name) + + load path.join(PLUGIN_FILE_NAME) + end + + class << self + private :load_plugin, :register_plugin, :save_plugins, :validate_plugin! + end + end +end diff --git a/lib/bundler/plugin/api.rb b/lib/bundler/plugin/api.rb new file mode 100644 index 0000000000..9631446d9b --- /dev/null +++ b/lib/bundler/plugin/api.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module Bundler + # This is the interfacing class represents the API that we intend to provide + # the plugins to use. + # + # For plugins to be independent of the Bundler internals they shall limit their + # interactions to methods of this class only. This will save them from breaking + # when some internal change. + # + # Currently we are delegating the methods defined in Bundler class to + # itself. So, this class acts as a buffer. + # + # If there is some change in the Bundler class that is incompatible to its + # previous behavior or if otherwise desired, we can reimplement(or implement) + # the method to preserve compatibility. + # + # To use this, either the class can inherit this class or use it directly. + # For example of both types of use, refer the file `spec/plugins/command.rb` + # + # To use it without inheriting, you will have to create an object of this + # to use the functions (except for declaration functions like command, source, + # and hooks). + module Plugin + class API + # The plugins should declare that they handle a command through this helper. + # + # @param [String] command being handled by them + # @param [Class] (optional) class that shall handle the command. If not + # provided, the `self` class will be used. + def self.command(command, cls = self) + Plugin.add_command command, cls + end + + # The cache dir to be used by the plugins for persistance storage + # + # @return [Pathname] path of the cache dir + def cache + Plugin.cache.join("plugins") + end + + # A tmp dir to be used by plugins + # Accepts names that get concatenated as suffix + # + # @return [Pathname] object for the new directory created + def tmp(*names) + Bundler.tmp(["plugin", *names].join("-")) + end + + def method_missing(name, *args, &blk) + super unless Bundler.respond_to?(name) + Bundler.send(name, *args, &blk) + end + end + end +end diff --git a/lib/bundler/plugin/dsl.rb b/lib/bundler/plugin/dsl.rb new file mode 100644 index 0000000000..f65054f014 --- /dev/null +++ b/lib/bundler/plugin/dsl.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Bundler + # Dsl to parse the Gemfile looking for plugins to install + module Plugin + class DSL < Bundler::Dsl + class PluginGemfileError < PluginError; end + alias_method :_gem, :gem # To use for plugin installation as gem + + # So that we don't have to override all there methods to dummy ones + # explicitly. + # They will be handled by method_missing + [:gemspec, :gem, :path, :install_if, :platforms, :env].each {|m| undef_method m } + + def initialize + super + @sources = Plugin::SourceList.new + end + + def plugin(name, *args) + _gem(name, *args) + end + + def method_missing(name, *args) + raise PluginGemfileError, "Undefined local variable or method `#{name}' for Gemfile" unless Bundler::Dsl.method_defined? name + end + end + end +end diff --git a/lib/bundler/plugin/index.rb b/lib/bundler/plugin/index.rb new file mode 100644 index 0000000000..1e39eb0042 --- /dev/null +++ b/lib/bundler/plugin/index.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +module Bundler + # Manages which plugins are installed and their sources. This also is supposed to map + # which plugin does what (currently the features are not implemented so this class is + # now a stub class). + module Plugin + class Index + class CommandConflict < PluginError + def initialize(plugin, commands) + msg = "Command(s) `#{commands.join("`, `")}` declared by #{plugin} are already registered." + super msg + end + end + + def initialize + @plugin_paths = {} + @commands = {} + + load_index + end + + # This function is to be called when a new plugin is installed. This function shall add + # the functions of the plugin to existing maps and also the name to source location. + # + # @param [String] name of the plugin to be registered + # @param [String] path where the plugin is installed + # @param [Array<String>] commands that are handled by the plugin + def register_plugin(name, path, commands) + @plugin_paths[name] = path + + common = commands & @commands.keys + raise CommandConflict.new(name, common) unless common.empty? + commands.each {|c| @commands[c] = name } + + save_index + end + + # Path where the index file is stored + def index_file + Plugin.root.join("index") + end + + def plugin_path(name) + Pathname.new @plugin_paths[name] + end + + # Fetch the name of plugin handling the command + def command_plugin(command) + @commands[command] + end + + def installed?(name) + @plugin_paths[name] + end + + private + + # Reads the index file from the directory and initializes the instance variables. + def load_index + SharedHelpers.filesystem_access(index_file, :read) do |index_f| + valid_file = index_f && index_f.exist? && !index_f.size.zero? + break unless valid_file + data = index_f.read + require "bundler/yaml_serializer" + index = YAMLSerializer.load(data) + @plugin_paths = index["plugin_paths"] || {} + @commands = index["commands"] || {} + end + end + + # Should be called when any of the instance variables change. Stores the instance + # variables in YAML format. (The instance variables are supposed to be only String key value pairs) + def save_index + index = { + "plugin_paths" => @plugin_paths, + "commands" => @commands, + } + + require "bundler/yaml_serializer" + SharedHelpers.filesystem_access(index_file) do |index_f| + FileUtils.mkdir_p(index_f.dirname) + File.open(index_f, "w") {|f| f.puts YAMLSerializer.dump(index) } + end + end + end + end +end diff --git a/lib/bundler/plugin/installer.rb b/lib/bundler/plugin/installer.rb new file mode 100644 index 0000000000..2c10bb24c8 --- /dev/null +++ b/lib/bundler/plugin/installer.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +module Bundler + # Handles the installation of plugin in appropriate directories. + # + # This class is supposed to be wrapper over the existing gem installation infra + # but currently it itself handles everything as the Source's subclasses (e.g. Source::RubyGems) + # are heavily dependent on the Gemfile. + module Plugin + class Installer + autoload :Rubygems, "bundler/plugin/installer/rubygems" + autoload :Git, "bundler/plugin/installer/git" + + def install(names, options) + version = options[:version] || [">= 0"] + + if options[:git] + install_git(names, version, options) + else + sources = options[:source] || Bundler.rubygems.sources + install_rubygems(names, version, sources) + end + end + + # Installs the plugin from Definition object created by limited parsing of + # Gemfile searching for plugins to be installed + # + # @param [Definition] definiton object + # @return [Hash] map of plugin names to thier paths + def install_definition(definition) + plugins = definition.dependencies.map(&:name) + + def definition.lock(*); end + definition.resolve_remotely! + specs = definition.specs + + paths = install_from_specs specs + + Hash[paths.select {|name, _| plugins.include? name }] + end + + private + + def install_git(names, version, options) + uri = options.delete(:git) + options["uri"] = uri + + source_list = SourceList.new + source_list.add_git_source(options) + + # To support both sources + if options[:source] + source_list.add_rubygems_source("remotes" => options[:source]) + end + + deps = names.map {|name| Dependency.new name, version } + + definition = Definition.new(nil, deps, source_list, true) + install_definition(definition) + end + + # Installs the plugin from rubygems source and returns the path where the + # plugin was installed + # + # @param [String] name of the plugin gem to search in the source + # @param [Array] version of the gem to install + # @param [String, Array<String>] source(s) to resolve the gem + # + # @return [String] the path where the plugin was installed + def install_rubygems(names, version, sources) + deps = names.map {|name| Dependency.new name, version } + + source_list = SourceList.new + source_list.add_rubygems_source("remotes" => sources) + + definition = Definition.new(nil, deps, source_list, true) + install_definition(definition) + end + + # Installs the plugins and deps from the provided specs and returns map of + # gems to their paths + # + # @param specs to install + # + # @return [Hash] map of names to path where the plugin was installed + def install_from_specs(specs) + paths = {} + + specs.each do |spec| + spec.source.install spec + + paths[spec.name] = spec.full_gem_path + end + + paths + end + end + end +end diff --git a/lib/bundler/plugin/installer/git.rb b/lib/bundler/plugin/installer/git.rb new file mode 100644 index 0000000000..fbb6c5e40e --- /dev/null +++ b/lib/bundler/plugin/installer/git.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Bundler + module Plugin + class Installer + class Git < Bundler::Source::Git + def cache_path + @cache_path ||= begin + git_scope = "#{base_name}-#{uri_hash}" + + Plugin.cache.join("bundler", "git", git_scope) + end + end + + def install_path + @install_path ||= begin + git_scope = "#{base_name}-#{shortref_for_path(revision)}" + + Plugin.root.join("bundler", "gems", git_scope) + end + end + + def version_message(spec) + "#{spec.name} #{spec.version}" + end + + def root + Plugin.root + end + + def generate_bin(spec, disable_extensions = false) + # Need to find a way without code duplication + # For now, we can ignore this + end + end + end + end +end diff --git a/lib/bundler/plugin/installer/rubygems.rb b/lib/bundler/plugin/installer/rubygems.rb new file mode 100644 index 0000000000..7ae74fa93b --- /dev/null +++ b/lib/bundler/plugin/installer/rubygems.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Bundler + module Plugin + class Installer + class Rubygems < Bundler::Source::Rubygems + def version_message(spec) + "#{spec.name} #{spec.version}" + end + + private + + def requires_sudo? + false # Will change on implementation of project level plugins + end + + def rubygems_dir + Plugin.root + end + + def cache_path + Plugin.cache + end + end + end + end +end diff --git a/lib/bundler/plugin/source_list.rb b/lib/bundler/plugin/source_list.rb new file mode 100644 index 0000000000..6b1f1aee36 --- /dev/null +++ b/lib/bundler/plugin/source_list.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Bundler + # SourceList object to be used while parsing the Gemfile, setting the + # approptiate options to be used with Source classes for plugin installation + module Plugin + class SourceList < Bundler::SourceList + def initialize + @path_sources = [] + @git_sources = [] + @rubygems_aggregate = Plugin::Installer::Rubygems.new + @rubygems_sources = [] + end + + def add_git_source(options = {}) + add_source_to_list Plugin::Installer::Git.new(options), git_sources + end + + def add_rubygems_source(options = {}) + add_source_to_list Plugin::Installer::Rubygems.new(options), @rubygems_sources + end + end + end +end diff --git a/lib/bundler/rubygems_ext.rb b/lib/bundler/rubygems_ext.rb index 2f36c29cd9..53db29a959 100644 --- a/lib/bundler/rubygems_ext.rb +++ b/lib/bundler/rubygems_ext.rb @@ -22,9 +22,11 @@ module Gem alias_method :rg_full_gem_path, :full_gem_path alias_method :rg_loaded_from, :loaded_from + attr_writer :full_gem_path unless instance_methods.include?(:full_gem_path=) + def full_gem_path if source.respond_to?(:path) - Pathname.new(loaded_from).dirname.expand_path(Bundler.root).to_s.untaint + Pathname.new(loaded_from).dirname.expand_path(source.root).to_s.untaint else rg_full_gem_path end diff --git a/lib/bundler/settings.rb b/lib/bundler/settings.rb index de0644adce..0dd1d762e6 100644 --- a/lib/bundler/settings.rb +++ b/lib/bundler/settings.rb @@ -3,7 +3,7 @@ require "uri" module Bundler class Settings - BOOL_KEYS = %w(frozen cache_all no_prune disable_local_branch_check disable_shared_gems ignore_messages gem.mit gem.coc silence_root_warning no_install).freeze + BOOL_KEYS = %w(frozen cache_all no_prune disable_local_branch_check disable_shared_gems ignore_messages gem.mit gem.coc silence_root_warning no_install plugins).freeze NUMBER_KEYS = %w(retry timeout redirect ssl_verify_mode).freeze DEFAULT_CONFIG = { :retry => 3, :timeout => 10, :redirect => 5 }.freeze @@ -201,21 +201,14 @@ module Bundler hash.delete(key) if value.nil? SharedHelpers.filesystem_access(file) do |p| FileUtils.mkdir_p(p.dirname) - p.open("w") {|f| f.write(serialize_hash(hash)) } + require "bundler/yaml_serializer" + p.open("w") {|f| f.write(YAMLSerializer.dump(hash)) } end end value end - def serialize_hash(hash) - yaml = String.new("---\n") - hash.each do |key, value| - yaml << key << ": " << value.to_s.gsub(/\s+/, " ").inspect << "\n" - end - yaml - end - def global_config_file if ENV["BUNDLE_CONFIG"] && !ENV["BUNDLE_CONFIG"].empty? Pathname.new(ENV["BUNDLE_CONFIG"]) @@ -246,20 +239,11 @@ module Bundler SharedHelpers.filesystem_access(config_file, :read) do valid_file = config_file && config_file.exist? && !config_file.size.zero? return {} if ignore_config? || !valid_file - config_pairs = config_file.read.scan(CONFIG_REGEX).map do |m| - key, _, value = m - [convert_to_backward_compatible_key(key), value.gsub(/\s+/, " ").tr('"', "'")] - end - Hash[config_pairs] + require "bundler/yaml_serializer" + YAMLSerializer.load config_file.read end end - def convert_to_backward_compatible_key(key) - key = "#{key}/" if key =~ /https?:/i && key !~ %r{/\Z} - key = key.gsub(".", "__") if key.include?(".") - key - end - # TODO: duplicates Rubygems#normalize_uri # TODO: is this the correct place to validate mirror URIs? def self.normalize_uri(uri) diff --git a/lib/bundler/source/git.rb b/lib/bundler/source/git.rb index 2bbb5092e2..76955ee49e 100644 --- a/lib/bundler/source/git.rb +++ b/lib/bundler/source/git.rb @@ -86,6 +86,7 @@ module Bundler def install_path @install_path ||= begin git_scope = "#{base_name}-#{shortref_for_path(revision)}" + path = Bundler.install_path.join(git_scope) if !path.exist? && Bundler.requires_sudo? @@ -227,8 +228,8 @@ module Bundler end def serialize_gemspecs_in(destination) - expanded_path = destination.expand_path(Bundler.root) - Dir["#{expanded_path}/#{@glob}"].each do |spec_path| + destination = destination.expand_path(Bundler.root) if destination.relative? + Dir["#{destination}/#{@glob}"].each do |spec_path| # Evaluate gemspecs and cache the result. Gemspecs # in git might require git or other dependencies. # The gemspecs we cache should already be evaluated. diff --git a/lib/bundler/source/path.rb b/lib/bundler/source/path.rb index 2147f2fdc3..bbdd30b1e6 100644 --- a/lib/bundler/source/path.rb +++ b/lib/bundler/source/path.rb @@ -107,6 +107,10 @@ module Bundler name end + def root + Bundler.root + end + def is_a_path? instance_of?(Path) end diff --git a/lib/bundler/source/rubygems.rb b/lib/bundler/source/rubygems.rb index 44fdfbdecb..d9606e7087 100644 --- a/lib/bundler/source/rubygems.rb +++ b/lib/bundler/source/rubygems.rb @@ -21,7 +21,7 @@ module Bundler @dependency_names = [] @allow_remote = false @allow_cached = false - @caches = [Bundler.app_cache, *Bundler.rubygems.gem_cache] + @caches = [cache_path, *Bundler.rubygems.gem_cache] Array(options["remotes"] || []).reverse_each {|r| add_remote(r) } end @@ -124,11 +124,11 @@ module Bundler Bundler.ui.confirm message path = cached_gem(spec) - if Bundler.requires_sudo? + if requires_sudo? install_path = Bundler.tmp(spec.full_name) bin_path = install_path.join("bin") else - install_path = Bundler.rubygems.gem_dir + install_path = rubygems_dir bin_path = Bundler.system_bindir end @@ -143,12 +143,13 @@ module Bundler :env_shebang => true ).install end + spec.full_gem_path = installed_spec.full_gem_path # SUDO HAX - if Bundler.requires_sudo? + if requires_sudo? Bundler.rubygems.repository_subdirectories.each do |name| src = File.join(install_path, name, "*") - dst = File.join(Bundler.rubygems.gem_dir, name) + dst = File.join(rubygems_dir, name) if name == "extensions" && Dir.glob(src).any? src = File.join(src, "*/*") ext_src = Dir.glob(src).first @@ -174,7 +175,7 @@ module Bundler spec.post_install_message ensure - Bundler.rm_rf(install_path) if Bundler.requires_sudo? + Bundler.rm_rf(install_path) if requires_sudo? end def cache(spec, custom_path = nil) @@ -248,7 +249,7 @@ module Bundler end def loaded_from(spec) - "#{Bundler.rubygems.gem_dir}/specifications/#{spec.full_name}.gemspec" + "#{rubygems_dir}/specifications/#{spec.full_name}.gemspec" end def cached_gem(spec) @@ -316,8 +317,7 @@ module Bundler @cached_specs ||= begin idx = installed_specs.dup - path = Bundler.app_cache - Dir["#{path}/*.gem"].each do |gemfile| + Dir["#{cache_path}/*.gem"].each do |gemfile| next if gemfile =~ /^bundler\-[\d\.]+?\.gem/ s ||= Bundler.rubygems.spec_from_gem(gemfile) s.source = self @@ -402,16 +402,16 @@ module Bundler uri = spec.remote.uri spec.fetch_platform - download_path = Bundler.requires_sudo? ? Bundler.tmp(spec.full_name) : Bundler.rubygems.gem_dir - gem_path = "#{Bundler.rubygems.gem_dir}/cache/#{spec.full_name}.gem" + download_path = requires_sudo? ? Bundler.tmp(spec.full_name) : rubygems_dir + gem_path = "#{rubygems_dir}/cache/#{spec.full_name}.gem" SharedHelpers.filesystem_access("#{download_path}/cache") do |p| FileUtils.mkdir_p(p) end Bundler.rubygems.download_gem(spec, uri, download_path) - if Bundler.requires_sudo? - SharedHelpers.filesystem_access("#{Bundler.rubygems.gem_dir}/cache") do |p| + if requires_sudo? + SharedHelpers.filesystem_access("#{rubygems_dir}/cache") do |p| Bundler.mkdir_p(p) end Bundler.sudo "mv #{download_path}/cache/#{spec.full_name}.gem #{gem_path}" @@ -419,7 +419,7 @@ module Bundler gem_path ensure - Bundler.rm_rf(download_path) if Bundler.requires_sudo? + Bundler.rm_rf(download_path) if requires_sudo? end def builtin_gem?(spec) @@ -433,6 +433,18 @@ module Bundler def installed?(spec) installed_specs[spec].any? end + + def requires_sudo? + Bundler.requires_sudo? + end + + def rubygems_dir + Bundler.rubygems.gem_dir + end + + def cache_path + Bundler.app_cache + end end end end diff --git a/lib/bundler/yaml_serializer.rb b/lib/bundler/yaml_serializer.rb new file mode 100644 index 0000000000..327baa4ee7 --- /dev/null +++ b/lib/bundler/yaml_serializer.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +module Bundler + # A stub yaml serializer that can handle only hashes and strings (as of now). + module YAMLSerializer + module_function + + def dump(hash) + yaml = String.new("---") + yaml << dump_hash(hash) + end + + def dump_hash(hash) + yaml = String.new("\n") + hash.each do |k, v| + yaml << k << ":" + if v.is_a?(Hash) + yaml << dump_hash(v).gsub(/^(?!$)/, " ") # indent all non-empty lines + else + yaml << " " << v.to_s.gsub(/\s+/, " ").inspect << "\n" + end + end + yaml + end + + SCAN_REGEX = / + ^ + ([ ]*) # indentations + (.*) # key + (?::(?=\s)) # : (without the lookahead the #key includes this when : is present in value) + [ ]? + (?: !\s)? # optional exclamation mark found with ruby 1.9.3 + (['"]?) # optional opening quote + (.*) # value + \3 # matching closing quote + $ + /xo + + def load(str) + res = {} + stack = [res] + str.scan(SCAN_REGEX).each do |(indent, key, _, val)| + key = convert_to_backward_compatible_key(key) + depth = indent.scan(/ /).length + if val.empty? + new_hash = {} + stack[depth][key] = new_hash + stack[depth + 1] = new_hash + else + stack[depth][key] = val + end + end + res + end + + # for settings' keys + def convert_to_backward_compatible_key(key) + key = "#{key}/" if key =~ /https?:/i && key !~ %r{/\Z} + key = key.gsub(".", "__") if key.include?(".") + key + end + + class << self + private :dump_hash, :convert_to_backward_compatible_key + end + end +end diff --git a/spec/bundler/plugin/api_spec.rb b/spec/bundler/plugin/api_spec.rb new file mode 100644 index 0000000000..23134dd853 --- /dev/null +++ b/spec/bundler/plugin/api_spec.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true +require "spec_helper" + +describe Bundler::Plugin::API do + context "plugin declarations" do + before do + stub_const "UserPluginClass", Class.new(Bundler::Plugin::API) + end + + it "declares a command plugin with same class as handler" do + allow(Bundler::Plugin). + to receive(:add_command).with("meh", UserPluginClass).once + + UserPluginClass.command "meh" + end + + it "accepts another class as argument that handles the command" do + stub_const "NewClass", Class.new + allow(Bundler::Plugin).to receive(:add_command).with("meh", NewClass).once + + UserPluginClass.command "meh", NewClass + end + end + + context "bundler interfaces provided" do + before do + stub_const "UserPluginClass", Class.new(Bundler::Plugin::API) + end + + subject(:api) { UserPluginClass.new } + + # A test of delegation + it "provides the bundler settings" do + expect(api.settings).to eq(Bundler.settings) + end + + context "#tmp" do + it "provides a tmp dir" do + expect(api.tmp("mytmp")).to be_directory + end + + it "accepts multiple names for suffix" do + expect(api.tmp("myplugin", "download")).to be_directory + end + end + end +end diff --git a/spec/bundler/plugin/dsl_spec.rb b/spec/bundler/plugin/dsl_spec.rb new file mode 100644 index 0000000000..876564f22b --- /dev/null +++ b/spec/bundler/plugin/dsl_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true +require "spec_helper" + +describe Bundler::Plugin::DSL do + DSL = Bundler::Plugin::DSL + + subject(:dsl) { Bundler::Plugin::DSL.new } + + before do + allow(Bundler).to receive(:root) { Pathname.new "/" } + end + + describe "it ignores only the methods defined in Bundler::Dsl" do + it "doesn't raises error for Dsl methods" do + expect { dsl.install_if }.not_to raise_error + end + + it "raises error for other methods" do + expect { dsl.no_method }.to raise_error(DSL::PluginGemfileError) + end + end +end diff --git a/spec/bundler/plugin/index_spec.rb b/spec/bundler/plugin/index_spec.rb new file mode 100644 index 0000000000..f969aad12f --- /dev/null +++ b/spec/bundler/plugin/index_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true +require "spec_helper" + +describe Bundler::Plugin::Index do + Index = Bundler::Plugin::Index + + subject(:index) { Index.new } + + before do + build_lib "new-plugin", :path => lib_path("new-plugin") do |s| + s.write "plugins.rb" + end + end + + describe "#register plugin" do + before do + index.register_plugin("new-plugin", lib_path("new-plugin").to_s, []) + end + + it "is available for retrieval" do + expect(index.plugin_path("new-plugin")).to eq(lib_path("new-plugin")) + end + + it "is persistent" do + new_index = Index.new + expect(new_index.plugin_path("new-plugin")).to eq(lib_path("new-plugin")) + end + end + + describe "commands" do + before do + index.register_plugin("cplugin", lib_path("cplugin").to_s, ["newco"]) + end + + it "returns the plugins name on query" do + expect(index.command_plugin("newco")).to eq("cplugin") + end + + it "raises error on conflict" do + expect do + index.register_plugin("aplugin", lib_path("aplugin").to_s, ["newco"]) + end.to raise_error(Index::CommandConflict) + end + end +end diff --git a/spec/bundler/plugin/installer_spec.rb b/spec/bundler/plugin/installer_spec.rb new file mode 100644 index 0000000000..33a0ddea48 --- /dev/null +++ b/spec/bundler/plugin/installer_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true +require "spec_helper" + +describe Bundler::Plugin::Installer do + subject(:installer) { Bundler::Plugin::Installer.new } + + describe "cli install" do + it "uses Gem.sources when non of the source is provided" do + sources = double(:sources) + allow(Bundler).to receive_message_chain("rubygems.sources") { sources } + + allow(installer).to receive(:install_rubygems). + with("new-plugin", [">= 0"], sources).once + + installer.install("new-plugin", {}) + end + + describe "with mocked installers" do + it "returns the installation path after installing git plugins" do + allow(installer).to receive(:install_git). + and_return("new-plugin" => "/git/install/path") + + expect(installer.install(["new-plugin"], :git => "https://some.ran/dom")). + to eq("new-plugin" => "/git/install/path") + end + + it "returns the installation path after installing rubygems plugins" do + allow(installer).to receive(:install_rubygems). + and_return("new-plugin" => "/rubygems/install/path") + + expect(installer.install(["new-plugin"], :source => "https://some.ran/dom")). + to eq("new-plugin" => "/rubygems/install/path") + end + end + + describe "with actual installers" do + before do + build_repo2 do + build_plugin "re-plugin" + build_plugin "ma-plugin" + end + end + + it "returns the installation path after installing git plugins" do + build_git "ga-plugin", :path => lib_path("ga-plugin") do |s| + s.write "plugins.rb" + end + + rev = revision_for(lib_path("ga-plugin")) + expected = { "ga-plugin" => Bundler::Plugin.root.join("bundler", "gems", "ga-plugin-#{rev[0..11]}").to_s } + + opts = { :git => "file://#{lib_path("ga-plugin")}" } + expect(installer.install(["ga-plugin"], opts)).to eq(expected) + end + + it "returns the installation path after installing rubygems plugins" do + opts = { :source => "file://#{gem_repo2}" } + expect(installer.install(["re-plugin"], opts)). + to eq("re-plugin" => plugin_gems("re-plugin-1.0").to_s) + end + + it "accepts multiple plugins" do + opts = { :source => "file://#{gem_repo2}" } + + expect(installer.install(["re-plugin", "ma-plugin"], opts)). + to eq("re-plugin" => plugin_gems("re-plugin-1.0").to_s, + "ma-plugin" => plugin_gems("ma-plugin-1.0").to_s) + end + end + end +end diff --git a/spec/bundler/plugin/source_list_spec.rb b/spec/bundler/plugin/source_list_spec.rb new file mode 100644 index 0000000000..774156b27c --- /dev/null +++ b/spec/bundler/plugin/source_list_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true +require "spec_helper" + +describe Bundler::Plugin::SourceList do + SourceList = Bundler::Plugin::SourceList + + before do + allow(Bundler).to receive(:root) { Pathname.new "/" } + end + + subject(:source_list) { SourceList.new } + + describe "adding sources uses classes for plugin" do + it "uses Plugin::Installer::Rubygems for rubygems sources" do + source = source_list. + add_rubygems_source("remotes" => ["https://existing-rubygems.org"]) + expect(source).to be_instance_of(Bundler::Plugin::Installer::Rubygems) + end + + it "uses Plugin::Installer::Git for git sources" do + source = source_list. + add_git_source("uri" => "git://existing-git.org/path.git") + expect(source).to be_instance_of(Bundler::Plugin::Installer::Git) + end + end +end diff --git a/spec/bundler/plugin_spec.rb b/spec/bundler/plugin_spec.rb new file mode 100644 index 0000000000..517f539bd8 --- /dev/null +++ b/spec/bundler/plugin_spec.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true +require "spec_helper" + +describe Bundler::Plugin do + Plugin = Bundler::Plugin + + let(:installer) { double(:installer) } + let(:index) { double(:index) } + + before do + build_lib "new-plugin", :path => lib_path("new-plugin") do |s| + s.write "plugins.rb" + end + + build_lib "another-plugin", :path => lib_path("another-plugin") do |s| + s.write "plugins.rb" + end + + allow(Plugin::Installer).to receive(:new) { installer } + allow(Plugin).to receive(:index) { index } + allow(index).to receive(:register_plugin) + end + + describe "install command" do + let(:opts) { { "version" => "~> 1.0", "source" => "foo" } } + + before do + allow(installer).to receive(:install).with(["new-plugin"], opts) do + { "new_plugin" => lib_path("new-plugin") } + end + end + + it "passes the name and options to installer" do + allow(installer).to receive(:install).with(["new-plugin"], opts) do + { "new-plugin" => lib_path("new-plugin").to_s } + end.once + + subject.install ["new-plugin"], opts + end + + it "validates the installed plugin" do + allow(subject). + to receive(:validate_plugin!).with(lib_path("new-plugin")).once + + subject.install ["new-plugin"], opts + end + + it "registers the plugin with index" do + allow(index).to receive(:register_plugin). + with("new-plugin", lib_path("new-plugin").to_s, []).once + subject.install ["new-plugin"], opts + end + + context "multiple plugins" do + it do + allow(installer).to receive(:install). + with(["new-plugin", "another-plugin"], opts) do + { + "new_plugin" => lib_path("new-plugin"), + "another-plugin" => lib_path("another-plugin"), + } + end.once + + allow(subject).to receive(:validate_plugin!).twice + allow(index).to receive(:register_plugin).twice + subject.install ["new-plugin", "another-plugin"], opts + end + end + end + + describe "evaluate gemfile for plugins" do + let(:definition) { double("definition") } + let(:gemfile) { bundled_app("Gemfile") } + + before do + allow(Plugin::DSL).to receive(:evaluate) { definition } + end + + it "doesn't calls installer without any plugins" do + allow(definition).to receive(:dependencies) { [] } + allow(installer).to receive(:install_definition).never + + subject.gemfile_install(gemfile) + end + + it "should validate and register the plugins" do + allow(definition).to receive(:dependencies) { [1, 2] } + plugin_paths = { + "new-plugin" => lib_path("new-plugin"), + "another-plugin" => lib_path("another-plugin"), + } + allow(installer).to receive(:install_definition) { plugin_paths } + + expect(subject).to receive(:validate_plugin!).twice + expect(subject).to receive(:register_plugin).twice + + subject.gemfile_install(gemfile) + end + end + + describe "#command?" do + it "returns true value for commands in index" do + allow(index). + to receive(:command_plugin).with("newcommand") { "my-plugin" } + result = subject.command? "newcommand" + expect(result).to be_truthy + end + + it "returns false value for commands not in index" do + allow(index).to receive(:command_plugin).with("newcommand") { nil } + result = subject.command? "newcommand" + expect(result).to be_falsy + end + end + + describe "#exec_command" do + it "raises UndefinedCommandError when command is not found" do + allow(index).to receive(:command_plugin).with("newcommand") { nil } + expect { subject.exec_command("newcommand", []) }. + to raise_error(Plugin::UndefinedCommandError) + end + end +end diff --git a/spec/bundler/yaml_serializer_spec.rb b/spec/bundler/yaml_serializer_spec.rb new file mode 100644 index 0000000000..53dbbc6766 --- /dev/null +++ b/spec/bundler/yaml_serializer_spec.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true +require "spec_helper" +require "bundler/yaml_serializer" + +describe Bundler::YAMLSerializer do + subject(:serializer) { Bundler::YAMLSerializer } + + describe "#dump" do + it "works for simple hash" do + hash = { "Q" => "Where does Thursday come before Wednesday? In the dictionary. :P" } + + expected = strip_whitespace <<-YAML + --- + Q: "Where does Thursday come before Wednesday? In the dictionary. :P" + YAML + + expect(serializer.dump(hash)).to eq(expected) + end + + it "handles nested hash" do + hash = { + "nice-one" => { + "read_ahead" => "All generalizations are false, including this one", + }, + } + + expected = strip_whitespace <<-YAML + --- + nice-one: + read_ahead: "All generalizations are false, including this one" + YAML + + expect(serializer.dump(hash)).to eq(expected) + end + end + + describe "#load" do + it "works for simple hash" do + yaml = strip_whitespace <<-YAML + --- + Jon: "Air is free dude!" + Jack: "Yes.. until you buy a bag of chips!" + YAML + + hash = { + "Jon" => "Air is free dude!", + "Jack" => "Yes.. until you buy a bag of chips!", + } + + expect(serializer.load(yaml)).to eq(hash) + end + + it "works for nested hash" do + yaml = strip_whitespace <<-YAML + baa: + baa: "black sheep" + have: "you any wool?" + yes: "merry have I" + three: "bags full" + YAML + + hash = { + "baa" => { + "baa" => "black sheep", + "have" => "you any wool?", + "yes" => "merry have I", + }, + "three" => "bags full", + } + + expect(serializer.load(yaml)).to eq(hash) + end + + it "handles colon in key/value" do + yaml = strip_whitespace <<-YAML + BUNDLE_MIRROR__HTTPS://RUBYGEMS__ORG/: http://rubygems-mirror.org + YAML + + expect(serializer.load(yaml)).to eq("BUNDLE_MIRROR__HTTPS://RUBYGEMS__ORG/" => "http://rubygems-mirror.org") + end + end + + describe "against yaml lib" do + let(:hash) do + { + "a_joke" => { + "my-stand" => "I can totally keep secrets", + "but" => "The people I tell them to can't :P", + }, + "sales" => { + "item" => "A Parachute", + "description" => "Only used once, never opened.", + }, + "one-more" => "I'd tell you a chemistry joke but I know I wouldn't get a reaction.", + } + end + + context "#load" do + it "retrieves the original hash" do + require "yaml" + expect(serializer.load(YAML.dump(hash))).to eq(hash) + end + end + + context "#dump" do + it "retrieves the original hash" do + require "yaml" + expect(YAML.load(serializer.dump(hash))).to eq(hash) + end + end + end +end diff --git a/spec/plugins/command.rb b/spec/plugins/command.rb new file mode 100644 index 0000000000..71e87a5b01 --- /dev/null +++ b/spec/plugins/command.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true +require "spec_helper" + +describe "command plugins" do + before do + build_repo2 do + build_plugin "command-mah" do |s| + s.write "plugins.rb", <<-RUBY + module Mah + class Plugin < Bundler::Plugin::API + command "mahcommand" # declares the command + + def exec(command, args) + puts "MahHello" + end + end + end + RUBY + end + end + + bundle "plugin install command-mah --source file://#{gem_repo2}" + end + + it "executes without arguments" do + expect(out).to include("Installed plugin command-mah") + + bundle "mahcommand" + expect(out).to eq("MahHello") + end + + it "accepts the arguments" do + build_repo2 do + build_plugin "the-echoer" do |s| + s.write "plugins.rb", <<-RUBY + module Resonance + class Echoer + # Another method to declare the command + Bundler::Plugin::API.command "echo", self + + def exec(command, args) + puts "You gave me \#{args.join(", ")}" + end + end + end + RUBY + end + end + + bundle "plugin install the-echoer --source file://#{gem_repo2}" + expect(out).to include("Installed plugin the-echoer") + + bundle "echo tacos tofu lasange", "no-color" => false + expect(out).to eq("You gave me tacos, tofu, lasange") + end + + it "raises error on redeclaration of command" do + build_repo2 do + build_plugin "copycat" do |s| + s.write "plugins.rb", <<-RUBY + module CopyCat + class Cheater < Bundler::Plugin::API + command "mahcommand", self + + def exec(command, args) + end + end + end + RUBY + end + end + + bundle "plugin install copycat --source file://#{gem_repo2}" + + expect(out).not_to include("Installed plugin copycat") + + expect(out).to include("Failed to install plugin") + + expect(out).to include("Command(s) `mahcommand` declared by copycat are already registered.") + end +end diff --git a/spec/plugins/install.rb b/spec/plugins/install.rb new file mode 100644 index 0000000000..c9c0776f34 --- /dev/null +++ b/spec/plugins/install.rb @@ -0,0 +1,158 @@ +# frozen_string_literal: true +require "spec_helper" + +describe "bundler plugin install" do + before do + build_repo2 do + build_plugin "foo" + build_plugin "kung-foo" + end + end + + it "shows propper message when gem in not found in the source" do + bundle "plugin install no-foo --source file://#{gem_repo1}" + + expect(out).to include("Could not find") + end + + it "installs from rubygems source" do + bundle "plugin install foo --source file://#{gem_repo2}" + + expect(out).to include("Installed plugin foo") + plugin_should_be_installed("foo") + end + + it "installs multiple plugins" do + bundle "plugin install foo kung-foo --source file://#{gem_repo2}" + + expect(out).to include("Installed plugin foo") + expect(out).to include("Installed plugin kung-foo") + + plugin_should_be_installed("foo", "kung-foo") + end + + it "uses the same version for multiple plugins" do + update_repo2 do + build_plugin "foo", "1.1" + build_plugin "kung-foo", "1.1" + end + + bundle "plugin install foo kung-foo --version '1.0' --source file://#{gem_repo2}" + + expect(out).to include("Installing foo 1.0") + expect(out).to include("Installing kung-foo 1.0") + plugin_should_be_installed("foo", "kung-foo") + end + + context "malformatted plugin" do + it "fails when plugins.rb is missing" do + build_repo2 do + build_gem "charlie" + end + + bundle "plugin install charlie --source file://#{gem_repo2}" + + expect(out).to include("plugins.rb was not found") + + expect(out).not_to include("Installed plugin") + + expect(plugin_gems("charlie-1.0")).not_to be_directory + end + + it "fails when plugins.rb throws exception on load" do + build_repo2 do + build_plugin "chaplin" do |s| + s.write "plugins.rb", <<-RUBY + raise "I got you man" + RUBY + end + end + + bundle "plugin install chaplin --source file://#{gem_repo2}" + + expect(out).not_to include("Installed plugin") + + expect(plugin_gems("chaplin-1.0")).not_to be_directory + end + end + + context "git plugins" do + it "installs form a git source" do + build_git "foo" do |s| + s.write "plugins.rb" + end + + bundle "plugin install foo --git file://#{lib_path("foo-1.0")}" + + expect(out).to include("Installed plugin foo") + plugin_should_be_installed("foo") + end + end + + context "Gemfile eval" do + it "installs plugins listed in gemfile" do + gemfile <<-G + source 'file://#{gem_repo2}' + plugin 'foo' + gem 'rack', "1.0.0" + G + + bundle "install" + + expect(out).to include("Installed plugin foo") + + expect(out).to include("Bundle complete!") + + should_be_installed("rack 1.0.0") + plugin_should_be_installed("foo") + end + + it "accepts plugin version" do + update_repo2 do + build_plugin "foo", "1.1.0" + end + + install_gemfile <<-G + source 'file://#{gem_repo2}' + plugin 'foo', "1.0" + G + + bundle "install" + + expect(out).to include("Installing foo 1.0") + + plugin_should_be_installed("foo") + + expect(out).to include("Bundle complete!") + end + + it "accepts git sources" do + build_git "ga-plugin" do |s| + s.write "plugins.rb" + end + + install_gemfile <<-G + plugin 'ga-plugin', :git => "#{lib_path("ga-plugin-1.0")}" + G + + expect(out).to include("Installed plugin ga-plugin") + plugin_should_be_installed("ga-plugin") + end + end + + context "inline gemfiles" do + it "installs the listed plugins" do + code = <<-RUBY + require "bundler/inline" + + gemfile do + source 'file://#{gem_repo2}' + plugin 'foo' + end + RUBY + + ruby code + plugin_should_be_installed("foo") + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index c9359012fd..7aaaa0871c 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -41,6 +41,7 @@ Spec::Rubygems.setup FileUtils.rm_rf(Spec::Path.gem_repo1) ENV["RUBYOPT"] = "#{ENV["RUBYOPT"]} -r#{Spec::Path.root}/spec/support/hax.rb" ENV["BUNDLE_SPEC_RUN"] = "true" +ENV["BUNDLE_PLUGINS"] = "true" # Don't wrap output in tests ENV["THOR_COLUMNS"] = "10000" diff --git a/spec/support/builders.rb b/spec/support/builders.rb index eaa68f51df..8e19f9ae94 100644 --- a/spec/support/builders.rb +++ b/spec/support/builders.rb @@ -419,6 +419,10 @@ module Spec GitReader.new lib_path(spec.full_name) end + def build_plugin(name, *args, &blk) + build_with(PluginBuilder, name, args, &blk) + end + private def build_with(builder, name, args, &blk) @@ -701,6 +705,12 @@ module Spec end end + class PluginBuilder < GemBuilder + def _default_files + @_default_files ||= super.merge("plugins.rb" => "") + end + end + TEST_CERT = <<-CERT.gsub(/^\s*/, "") -----BEGIN CERTIFICATE----- MIIDMjCCAhqgAwIBAgIBATANBgkqhkiG9w0BAQUFADAnMQwwCgYDVQQDDAN5b3Ux diff --git a/spec/support/matchers.rb b/spec/support/matchers.rb index 7047e1be76..76b0c32eef 100644 --- a/spec/support/matchers.rb +++ b/spec/support/matchers.rb @@ -63,6 +63,14 @@ module Spec end end + def plugin_should_be_installed(*names) + names.each do |name| + path = Plugin.installed?(name) + expect(path).to be_truthy + expect(Pathname.new(path).join("plugins.rb")).to exist + end + end + def should_be_locked expect(bundled_app("Gemfile.lock")).to exist end diff --git a/spec/support/path.rb b/spec/support/path.rb index d1f698acbe..4aa99ee118 100644 --- a/spec/support/path.rb +++ b/spec/support/path.rb @@ -81,6 +81,14 @@ module Spec Pathname.new(File.expand_path("../../../lib", __FILE__)) end + def plugin_root(*args) + home ".bundle", "plugin", *args + end + + def plugin_gems(*args) + plugin_root "gems", *args + end + extend self end end |