mirror of
https://github.com/ruby/ruby.git
synced 2026-10-29 17:06:21 +08:00
Third-party tools like bundler-audit read lockfiles through Bundler::LockfileParser without evaluating the Gemfile, and a PLUGIN SOURCE block whose plugin isn't installed locally aborted the whole parse with UnknownSourceError, taking even DEPENDENCIES down with it. Fall back to the inert UnloadedSource placeholder instead, which also lets bundle install converge away a plugin source that was removed from the Gemfile. https://github.com/ruby/rubygems/issues/9614 https://github.com/ruby/rubygems/commit/78248fa294 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
416 lines
14 KiB
Ruby
416 lines
14 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require_relative "plugin/api"
|
|
|
|
module Bundler
|
|
module Plugin
|
|
autoload :DSL, File.expand_path("plugin/dsl", __dir__)
|
|
autoload :Events, File.expand_path("plugin/events", __dir__)
|
|
autoload :Index, File.expand_path("plugin/index", __dir__)
|
|
autoload :Installer, File.expand_path("plugin/installer", __dir__)
|
|
autoload :SourceList, File.expand_path("plugin/source_list", __dir__)
|
|
autoload :UnloadedSource, File.expand_path("plugin/unloaded_source", __dir__)
|
|
|
|
class MalformattedPlugin < PluginError; end
|
|
class UndefinedCommandError < PluginError; end
|
|
class UnknownSourceError < PluginError; end
|
|
class PluginInstallError < PluginError; end
|
|
|
|
PLUGIN_FILE_NAME = "plugins.rb"
|
|
|
|
# Module-level flag set while .gemfile_install parses the Gemfile and
|
|
# consulted by .from_lock to substitute plugin sources with
|
|
# UnloadedSource. It relies on definitions being built one at a time in
|
|
# a single thread; if they are ever built concurrently or reentrantly,
|
|
# this needs to be replaced by explicit state passed down to the
|
|
# lockfile parser.
|
|
@gemfile_parse = false
|
|
|
|
module_function
|
|
|
|
def reset!
|
|
instance_variables.each {|i| remove_instance_variable(i) }
|
|
|
|
@sources = {}
|
|
@commands = {}
|
|
@hooks_by_event = Hash.new {|h, k| h[k] = [] }
|
|
@loaded_plugin_names = []
|
|
@index = nil
|
|
end
|
|
|
|
reset!
|
|
|
|
# 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.
|
|
# Refer to cli/plugin for available options
|
|
def install(names, options)
|
|
raise InvalidOption, "You cannot specify `--branch` and `--ref` at the same time." if options["branch"] && options["ref"]
|
|
|
|
specs = Installer.new.install(names, options)
|
|
|
|
save_plugins specs.slice(*names)
|
|
rescue PluginError
|
|
specs_to_delete = specs.select {|k, _v| names.include?(k) && !index.commands.values.include?(k) }
|
|
specs_to_delete.each_value {|spec| Bundler.rm_rf(spec.full_gem_path) }
|
|
|
|
raise
|
|
end
|
|
|
|
# Uninstalls plugins by the given names
|
|
#
|
|
# @param [Array<String>] names the names of plugins to be uninstalled
|
|
def uninstall(names, options)
|
|
if names.empty? && !options[:all]
|
|
Bundler.ui.error "No plugins to uninstall. Specify at least 1 plugin to uninstall.\n"\
|
|
"Use --all option to uninstall all the installed plugins."
|
|
return
|
|
end
|
|
|
|
names = index.installed_plugins if options[:all]
|
|
if names.any?
|
|
names.each do |name|
|
|
if index.installed?(name)
|
|
path = index.plugin_path(name).to_s
|
|
Bundler.rm_rf(path) if index.installed_in_plugin_root?(name)
|
|
index.unregister_plugin(name)
|
|
Bundler.ui.info "Uninstalled plugin #{name}"
|
|
else
|
|
Bundler.ui.error "Plugin #{name} is not installed \n"
|
|
end
|
|
end
|
|
else
|
|
Bundler.ui.info "No plugins installed"
|
|
end
|
|
end
|
|
|
|
# List installed plugins and commands
|
|
#
|
|
def list
|
|
installed_plugins = index.installed_plugins
|
|
if installed_plugins.any?
|
|
output = String.new
|
|
installed_plugins.each do |plugin|
|
|
output << "#{plugin}\n"
|
|
output << "-----\n"
|
|
index.plugin_commands(plugin).each do |command|
|
|
output << " #{command}\n"
|
|
end
|
|
output << "\n"
|
|
end
|
|
else
|
|
output = "No plugins installed"
|
|
end
|
|
Bundler.ui.info output
|
|
end
|
|
|
|
# Evaluates the Gemfile with a limited DSL and installs the plugins
|
|
# specified by plugin method
|
|
#
|
|
# @param [Pathname] gemfile path
|
|
# @param [Proc] block that can be evaluated for (inline) Gemfile
|
|
def gemfile_install(gemfile = nil, lockfile = nil, unlock = {}, &inline)
|
|
@gemfile_parse = true
|
|
Bundler.configure
|
|
builder = DSL.new
|
|
if block_given?
|
|
builder.instance_eval(&inline)
|
|
else
|
|
builder.eval_gemfile(gemfile)
|
|
end
|
|
builder.check_primary_source_safety
|
|
|
|
plugins = builder.dependencies.map(&:name)
|
|
return if plugins.empty?
|
|
|
|
# skip the update if unlocking specific gems, but none of them are plugins
|
|
# declared in the Gemfile
|
|
if unlock.is_a?(Hash) && unlock[:gems] && !unlock[:gems].empty? &&
|
|
(unlock[:gems] & plugins).empty?
|
|
unlock = {}
|
|
end
|
|
|
|
# resolve remotely when unlocking, so that plugins can be updated.
|
|
# Definition#initialize consumes the unlock hash, so this must be decided
|
|
# before building the definition.
|
|
updating = unlock == true || (unlock.is_a?(Hash) && !unlock.empty?)
|
|
|
|
definition = builder.to_definition(lockfile, unlock)
|
|
|
|
installed_specs = Installer.new.install_definition(definition, updating)
|
|
|
|
save_plugins installed_specs.slice(*plugins), builder.inferred_plugins
|
|
rescue RuntimeError => e
|
|
unless e.is_a?(GemfileError)
|
|
Bundler.ui.error "Failed to install plugin: #{e.message}\n #{e.backtrace[0]}"
|
|
end
|
|
raise
|
|
ensure
|
|
@gemfile_parse = false
|
|
end
|
|
|
|
# The index object used to store the details about the plugin
|
|
def index
|
|
@index ||= Index.new
|
|
end
|
|
|
|
# The directory root for all plugin related data
|
|
#
|
|
# If run in an app, points to local root, in app_config_path
|
|
# Otherwise, points to global root, in Bundler.user_bundle_path("plugin")
|
|
def root
|
|
@root ||= if SharedHelpers.in_bundle?
|
|
local_root
|
|
else
|
|
global_root
|
|
end
|
|
end
|
|
|
|
def local_root
|
|
Bundler.app_config_path.join("plugin")
|
|
end
|
|
|
|
# The global directory root for all plugin related data
|
|
def global_root
|
|
Bundler.user_bundle_path("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 plugin 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
|
|
# appropriate 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
|
|
|
|
# To be called via the API to register to handle a source plugin
|
|
def add_source(source, cls)
|
|
@sources[source] = cls
|
|
end
|
|
|
|
# Checks if any plugin declares the source
|
|
def source?(name)
|
|
!!source_plugin(name)
|
|
end
|
|
|
|
# Returns the plugin that handles the source +name+ if any
|
|
def source_plugin(name)
|
|
index.source_plugin(name.to_s)
|
|
end
|
|
|
|
# @return [Class] that handles the source. The class includes API::Source
|
|
def source(name)
|
|
raise UnknownSourceError, "Source #{name} not found" unless source_plugin(name)
|
|
|
|
load_plugin(index.source_plugin(name)) unless @sources.key? name
|
|
|
|
@sources[name]
|
|
end
|
|
|
|
# @param [Hash] The options that are present in the lockfile
|
|
# @return [API::Source] the instance of the class that handles the source
|
|
# type passed in locked_opts
|
|
def from_lock(locked_opts)
|
|
opts = locked_opts.merge("uri" => locked_opts["remote"])
|
|
# when reading the lockfile while doing the plugin-install-from-gemfile phase,
|
|
# we need to ignore any plugin sources
|
|
return UnloadedSource.new(opts) if @gemfile_parse
|
|
# use an inert placeholder when the plugin handling this source is not
|
|
# installed, so that the lockfile can still be parsed
|
|
return UnloadedSource.new(opts) unless source?(locked_opts["type"])
|
|
|
|
src = source(locked_opts["type"])
|
|
|
|
src.new(opts)
|
|
end
|
|
|
|
# To be called via the API to register a hooks and corresponding block that
|
|
# will be called to handle the hook
|
|
def add_hook(event, &block)
|
|
unless Events.defined_event?(event)
|
|
raise ArgumentError, "Event '#{event}' not defined in Bundler::Plugin::Events"
|
|
end
|
|
@hooks_by_event[event.to_s] << block
|
|
end
|
|
|
|
# Runs all the hooks that are registered for the passed event
|
|
#
|
|
# It passes the passed arguments and block to the block registered with
|
|
# the api.
|
|
#
|
|
# @param [String] event
|
|
def hook(event, *args, &arg_blk)
|
|
return unless Bundler.settings[:plugins]
|
|
unless Events.defined_event?(event)
|
|
raise ArgumentError, "Event '#{event}' not defined in Bundler::Plugin::Events"
|
|
end
|
|
|
|
plugins = index.hook_plugins(event)
|
|
return unless plugins.any?
|
|
|
|
plugins.each {|name| load_plugin(name) }
|
|
|
|
@hooks_by_event[event].each {|blk| blk.call(*args, &arg_blk) }
|
|
end
|
|
|
|
# currently only intended for specs
|
|
#
|
|
# @return [String, nil] installed path
|
|
def installed?(plugin)
|
|
(path = index.installed?(plugin)) &&
|
|
index.plugin_path(plugin).join(PLUGIN_FILE_NAME).file? &&
|
|
path
|
|
end
|
|
|
|
# @return [true, false] whether the plugin is loaded
|
|
def loaded?(plugin)
|
|
@loaded_plugin_names.include?(plugin)
|
|
end
|
|
|
|
# Post installation processing and registering with index
|
|
#
|
|
# @param [Hash] specs of plugins mapped to installation path (currently they
|
|
# contain all the installed specs, including plugins)
|
|
# @param [Array<String>] names of inferred source plugins that can be ignored
|
|
def save_plugins(specs, optional_plugins = [])
|
|
specs.each do |name, spec|
|
|
save_plugin(name, spec, optional_plugins.include?(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 [MalformattedPlugin] 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
|
|
|
|
# Validates and registers a plugin.
|
|
#
|
|
# @param [String] name the name of the plugin
|
|
# @param [Specification] spec of installed plugin
|
|
# @param [Boolean] optional_plugin, removed if there is conflict with any
|
|
# other plugin (used for default source plugins)
|
|
#
|
|
# @raise [PluginInstallError] if validation or registration raises any error
|
|
def save_plugin(name, spec, optional_plugin = false)
|
|
return if index.up_to_date?(spec)
|
|
|
|
validate_plugin! Pathname.new(spec.full_gem_path)
|
|
installed = register_plugin(name, spec, optional_plugin)
|
|
Bundler.ui.info "Installed plugin #{name}" if installed
|
|
rescue PluginError => e
|
|
raise PluginInstallError, "Failed to install plugin `#{spec.name}`, due to #{e.class} (#{e.message})"
|
|
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 [Specification] spec of installed plugin
|
|
# @param [Boolean] optional_plugin, removed if there is conflict with any
|
|
# other plugin (used for default source plugins)
|
|
#
|
|
# @raise [MalformattedPlugin] if plugins.rb raises any error
|
|
def register_plugin(name, spec, optional_plugin = false)
|
|
commands = @commands
|
|
sources = @sources
|
|
hooks = @hooks_by_event
|
|
|
|
@commands = {}
|
|
@sources = {}
|
|
@hooks_by_event = Hash.new {|h, k| h[k] = [] }
|
|
|
|
load_paths = spec.load_paths
|
|
Gem.add_to_load_path(*load_paths)
|
|
path = Pathname.new spec.full_gem_path
|
|
|
|
begin
|
|
load path.join(PLUGIN_FILE_NAME), true
|
|
rescue StandardError => e
|
|
raise MalformattedPlugin, "#{e.class}: #{e.message}"
|
|
end
|
|
|
|
if optional_plugin && @sources.keys.any? {|s| source_plugin(s) }
|
|
Bundler.rm_rf(path)
|
|
false
|
|
else
|
|
index.register_plugin(name, path.to_s, load_paths, @commands.keys,
|
|
@sources.keys, @hooks_by_event.keys)
|
|
true
|
|
end
|
|
ensure
|
|
@commands = commands
|
|
@sources = sources
|
|
@hooks_by_event = hooks
|
|
end
|
|
|
|
# Executes the plugins.rb file
|
|
#
|
|
# @param [String] name of the plugin
|
|
def load_plugin(name)
|
|
return unless name && !name.empty?
|
|
return if loaded?(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)
|
|
|
|
paths = index.load_paths(name)
|
|
invalid_paths = paths.reject {|p| File.directory?(p) }
|
|
|
|
if invalid_paths.any?
|
|
Bundler.ui.warn <<~MESSAGE
|
|
The following plugin paths don't exist: #{invalid_paths.join(", ")}.
|
|
|
|
This can happen if the plugin was installed with a different version of Ruby that has since been uninstalled.
|
|
|
|
If you would like to reinstall the plugin, run:
|
|
|
|
bundler plugin uninstall #{name} && bundler plugin install #{name}
|
|
|
|
Continuing without installing plugin #{name}.
|
|
MESSAGE
|
|
|
|
return
|
|
end
|
|
|
|
Gem.add_to_load_path(*paths)
|
|
|
|
load path.join(PLUGIN_FILE_NAME)
|
|
|
|
@loaded_plugin_names << name
|
|
rescue RuntimeError => e
|
|
Bundler.ui.error "Failed loading plugin #{name}: #{e.message}"
|
|
raise
|
|
end
|
|
|
|
class << self
|
|
private :load_plugin, :register_plugin, :save_plugins, :validate_plugin!
|
|
end
|
|
end
|
|
end
|