├── .gitignore ├── .rspec ├── .rubocop.yml ├── .ruby-version ├── CHANGELOG.md ├── Gemfile ├── LICENSE.txt ├── Makefile ├── README.md ├── Rakefile ├── bin ├── console └── setup ├── devpack.gemspec ├── exe └── devpack ├── lib ├── devpack.rb └── devpack │ ├── config.rb │ ├── exec.rb │ ├── gem_glob.rb │ ├── gem_ref.rb │ ├── gem_spec.rb │ ├── gems.rb │ ├── initializers.rb │ ├── install.rb │ ├── messages.rb │ ├── railtie.rb │ ├── timeable.rb │ └── version.rb └── spec ├── devpack ├── config_spec.rb ├── gem_glob_spec.rb ├── gem_spec_spec.rb ├── gems_spec.rb └── initializers_spec.rb ├── devpack_spec.rb ├── fixtures └── example.gemspec └── spec_helper.rb /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | 10 | # rspec failure tracking 11 | .rspec_status 12 | Gemfile.lock 13 | 14 | devpack-*.gem 15 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | Metrics/BlockLength: 2 | Exclude: 3 | - "spec/**/*" 4 | 5 | AllCops: 6 | NewCops: enable 7 | Exclude: 8 | - "spec/fixtures/**/*" 9 | 10 | Bundler/GemFilename: 11 | Exclude: 12 | - "lib/devpack/gems.rb" 13 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 2.5.9 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 0.1.0 4 | 5 | Core functionality implemented. Load a `.devpack` configuration file from current directory or, if not present, from a parent directory. Attempt to locate and `require` all listed gems. 6 | 7 | ## 0.1.1 8 | 9 | Use `GEM_PATH` instead of `GEM_HOME` to locate gems. 10 | 11 | Optimise load time by searching non-recursively in `/gems` directory (for each path listed in `GEM_PATH`). 12 | 13 | Load latest version of gem by default. Allow specifying version with rubygems syntax `example:0.1.0`. 14 | 15 | Permit comments in config file. 16 | 17 | Use `Gem::Specification` to load "rendered" gemspec (i.e. the file created by rubygems when the gem is installed). This version of the gemspec will load very quickly so no need to do custom gemspec parsing any more. This also accounts for "missing" gemspecs. 18 | 19 | ## 0.1.2 20 | 21 | Recursively include gem dependencies in `$LOAD_PATH` rather than assuming that any dependencies are already loaded. 22 | 23 | Include original error message when warning that a gem was unable to be loaded. 24 | 25 | ## 0.1.3 26 | 27 | Use a more appropriate method of identifying the latest version of a gem (use `Gem::Version` to sort matched gem paths). 28 | 29 | Fix edge case where e.g. `pry-rails-0.1.0` was matching for `pry` due to naive match logic. Split on last dash instead of first (i.e. don't assume gems will not have a dash in their name; last dash separates gem name from version in directory name). 30 | 31 | ## 0.2.0 32 | 33 | Add support for initializers. Files located in a `.devpack_initializers` directory will be loaded after gems configured in `.devpack` have been loaded. When using _Rails_ these files will be loaded using the `after_initialize` hook. Thanks to @joshmn for this idea: https://github.com/bobf/devpack/issues/1 34 | 35 | Show full tracebacks of load errors when `DEVPACK_DEBUG` is set in environment. 36 | 37 | Rename `DISABLE_DEVPACK` environment variable to `DEVPACK_DISABLE` for consistency. 38 | 39 | ## 0.2.1 40 | 41 | Fully activate gem on load: add gem spec to `Gem.loaded_specs` and set instance variables `@loaded` and `@activated` to `true`. This mimics `Gem::Specification#activate` to ensure that anything that depends on these characteristics will function as normal. 42 | 43 | ## 0.4.2 44 | 45 | - Add support for gems which are not required automatically 46 | - Fix requiring gems where a hyphen in the gem name corresponds to a path slash in the require. 47 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | gemspec 6 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Bob Farrell 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: test 2 | test: 3 | bundle exec rspec 4 | bundle exec rubocop 5 | bundle exec strong_versions 6 | 7 | .PHONY: build 8 | build: test 9 | bundle exec gem build devpack.gemspec 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Devpack 2 | 3 | Include a single gem in your `Gemfile` to allow developers to optionally include their preferred set of development gems without cluttering the `Gemfile`. Configurable globally or per-project. 4 | 5 | ## Installation 6 | 7 | Create a file named `.devpack` in your project's directory, or in any parent directory: 8 | 9 | ``` 10 | # .devpack 11 | awesome_print 12 | byebug 13 | better_errors 14 | 15 | # Optionally specify a version: 16 | pry:0.13.1 17 | 18 | # Or add an asterisk at the start of a line to not require the gem automatically: 19 | # (equivalent to adding `require: false` to the gem's entry in the Gemfile) 20 | *brakeman 21 | ``` 22 | 23 | Add _Devpack_ to any project's `Gemfile`: 24 | 25 | ```ruby 26 | # Gemfile 27 | group :development, :test do 28 | gem 'devpack', '~> 0.4.0' 29 | end 30 | ``` 31 | 32 | Rebuild your bundle: 33 | 34 | ```bash 35 | bundle install 36 | ``` 37 | 38 | ## Usage 39 | 40 | Load _Devpack_ (if your gems are not auto-loaded as in e.g. a _Rails_ application environment): 41 | 42 | ```ruby 43 | require 'devpack' 44 | ``` 45 | 46 | _Devpack_ will attempt to load all configured gems immediately, providing feedback to _stderr_. All dependencies are loaded with `require` after being recursively verified for compatibily with bundled gems before loading. 47 | 48 | It is recommended to use a [global configuration](#global-configuration). 49 | 50 | When using a per-project configuration, `.devpack` files should be added to `.gitignore`. 51 | 52 | ### Gem Installation 53 | 54 | A convenience command is provided to install all gems listed in `.devpack` file that are not already installed: 55 | 56 | ```bash 57 | bundle exec devpack install 58 | ``` 59 | 60 | ### Executing Commands Provided by DevPack Gems 61 | 62 | Use the `exec` command to run a command provided by a gem installed via _Devpack_. 63 | 64 | An example use case of this is the [Guard](https://github.com/guard/guard) gem: 65 | 66 | ```bash 67 | bundle exec devpack exec guard 68 | ``` 69 | 70 | ### Initializers 71 | 72 | Custom initializers can be loaded by creating a directory named `.devpack_initializers` containing a set of `.rb` files. 73 | 74 | Initializers will be loaded in alphabetical order after all gems listed in the `.devpack` configuration file have been loaded. 75 | 76 | Initializers that fail to load (for any reason) will generate a warning. 77 | 78 | ```ruby 79 | # .devpack_initializers/pry.rb 80 | 81 | Pry.config.pager = false 82 | ``` 83 | 84 | #### Rails 85 | 86 | If _Rails_ is detected then files in the `.devpack_initializers` directory will be loaded using the _Rails_ `after_initialize` hook (i.e. after all other frameworks have been initialized). 87 | 88 | ```ruby 89 | # .devpack_initializers/bullet.rb 90 | 91 | Bullet.enable = true 92 | ``` 93 | 94 | ### Global Configuration 95 | 96 | To configure globally simply save your `.devpack` configuration file to any parent directory of your project directory, e.g. `~/.devpack`. 97 | 98 | This strategy also applies to `.devpack_initializers`. 99 | 100 | ### Silencing 101 | 102 | To prevent _Devpack_ from displaying messages on load, set the environment variable `DEVPACK_SILENT=1` to any value: 103 | ```bash 104 | DEVPACK_SILENT=1 bundle exec ruby myapp.rb 105 | ``` 106 | 107 | ### Disabling 108 | 109 | To disable _Devpack_ set the environment variable `DEVPACK_DISABLE` to any value: 110 | ```bash 111 | DEVPACK_DISABLE=1 bundle exec ruby myapp.rb 112 | ``` 113 | 114 | ### Debugging 115 | 116 | To see the full traceback of any errors encountered at load time set the environment variable `DEVPACK_DEBUG` to any value: 117 | ```bash 118 | DEVPACK_DEBUG=1 bundle exec ruby myapp.rb 119 | ``` 120 | 121 | ## License 122 | 123 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 124 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/gem_tasks' 4 | require 'rspec/core/rake_task' 5 | 6 | RSpec::Core::RakeTask.new(:spec) 7 | 8 | task default: :spec 9 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require 'bundler/setup' 5 | require 'devpack' 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | # (If you use this, don't forget to add pry to your Gemfile!) 11 | # require "pry" 12 | # Pry.start 13 | 14 | require 'irb' 15 | IRB.start(__FILE__) 16 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /devpack.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'lib/devpack/version' 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = 'devpack' 7 | spec.version = Devpack::VERSION 8 | spec.authors = ['Bob Farrell'] 9 | spec.email = ['git@bob.frl'] 10 | 11 | spec.summary = 'Conveniently tailor your development environment' 12 | spec.description = 'Allow developers to optionally include a set of development gems without adding to the Gemfile.' 13 | spec.homepage = 'https://github.com/bobf/devpack' 14 | spec.license = 'MIT' 15 | spec.required_ruby_version = Gem::Requirement.new('>= 2.5.0') 16 | 17 | spec.metadata['homepage_uri'] = spec.homepage 18 | spec.metadata['source_code_uri'] = spec.homepage 19 | spec.metadata['changelog_uri'] = "#{spec.homepage}/blob/master/CHANGELOG.md" 20 | spec.metadata['rubygems_mfa_required'] = 'true' 21 | 22 | spec.files = Dir.chdir(File.expand_path(__dir__)) do 23 | `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 24 | end 25 | spec.bindir = 'exe' 26 | spec.executables = ['devpack'] 27 | spec.require_paths = ['lib'] 28 | 29 | spec.add_development_dependency 'byebug', '~> 11.1' 30 | spec.add_development_dependency 'rspec', '~> 3.9' 31 | spec.add_development_dependency 'rspec-its', '~> 1.3' 32 | spec.add_development_dependency 'rubocop', '~> 1.8' 33 | spec.add_development_dependency 'rubocop-rspec', '~> 2.1' 34 | spec.add_development_dependency 'strong_versions', '~> 0.4.4' 35 | end 36 | -------------------------------------------------------------------------------- /exe/devpack: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | command = ARGV[0] 5 | 6 | ENV['DEVPACK_DISABLE'] = '1' if command == 'install' 7 | 8 | require 'open3' 9 | 10 | require 'devpack' 11 | 12 | case command 13 | when 'install' 14 | require 'devpack/install' 15 | when 'exec' 16 | require 'devpack/exec' 17 | else 18 | warn "[devpack] Unknown command: #{command}" 19 | exit 1 20 | end 21 | -------------------------------------------------------------------------------- /lib/devpack.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rubygems' 4 | require 'pathname' 5 | require 'set' 6 | 7 | require 'devpack/timeable' 8 | require 'devpack/config' 9 | require 'devpack/gem_ref' 10 | require 'devpack/gems' 11 | require 'devpack/gem_glob' 12 | require 'devpack/gem_spec' 13 | require 'devpack/initializers' 14 | require 'devpack/messages' 15 | require 'devpack/version' 16 | 17 | # Provides helper method for writing warning messages. 18 | module Devpack 19 | # Base class for all Devpack errors. Accepts additional argument `meta` to store object info. 20 | class Error < StandardError 21 | attr_reader :message, :meta 22 | 23 | def initialize(message = nil, meta = nil) 24 | @message = message 25 | @meta = meta 26 | super(message) 27 | end 28 | end 29 | 30 | class GemNotFoundError < Error; end 31 | class GemIncompatibilityError < Error; end 32 | 33 | class << self 34 | def warn(level, message) 35 | return if silent? 36 | 37 | prefixed = message.split("\n").map { |line| "#{prefix(level)} #{line}" }.join("\n") 38 | Kernel.warn(prefixed) 39 | end 40 | 41 | def debug? 42 | ENV.key?('DEVPACK_DEBUG') 43 | end 44 | 45 | def disabled? 46 | ENV.key?('DEVPACK_DISABLE') 47 | end 48 | 49 | def silent? 50 | ENV.key?('DEVPACK_SILENT') 51 | end 52 | 53 | def rails? 54 | defined?(Rails::Railtie) 55 | end 56 | 57 | def config 58 | @config ||= Devpack::Config.new(Dir.pwd) 59 | end 60 | 61 | private 62 | 63 | def prefix(level) 64 | "#{Messages.color(:blue) { '[' }}devpack#{Messages.color(:blue) { ']' }} #{icon(level)}" 65 | end 66 | 67 | def icon(level) 68 | { 69 | success: Messages.color(:green) { '✓' }, 70 | info: Messages.color(:cyan) { 'ℹ' }, 71 | error: Messages.color(:red) { '✗' } 72 | }.fetch(level) 73 | end 74 | end 75 | end 76 | 77 | unless Devpack.disabled? 78 | require 'devpack/railtie' if Devpack.rails? 79 | 80 | Devpack::Gems.new(Devpack.config).load 81 | Devpack::Initializers.new(Devpack.config).load unless Devpack.rails? 82 | end 83 | -------------------------------------------------------------------------------- /lib/devpack/config.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Devpack 4 | # Locates and parses .devpack config file 5 | class Config 6 | FILENAME = '.devpack' 7 | INITIALIZERS_DIRECTORY_NAME = '.devpack_initializers' 8 | MAX_PARENTS = 100 # Avoid infinite loops (symlinks/weird file systems) 9 | 10 | def initialize(pwd) 11 | @pwd = Pathname.new(pwd) 12 | end 13 | 14 | def requested_gems 15 | return nil if devpack_path.nil? 16 | 17 | File.readlines(devpack_path) 18 | .map(&filter_comments) 19 | .compact 20 | .map { |line| GemRef.parse(line) } 21 | end 22 | 23 | def devpack_path 24 | @devpack_path ||= located_path(@pwd, FILENAME, :file) 25 | end 26 | 27 | def devpack_initializers_path 28 | @devpack_initializers_path ||= located_path(@pwd, INITIALIZERS_DIRECTORY_NAME, :directory) 29 | end 30 | 31 | def devpack_initializer_paths 32 | devpack_initializers_path&.glob(File.join('**', '*.rb'))&.map(&:to_s)&.sort || [] 33 | end 34 | 35 | private 36 | 37 | def located_path(next_parent, filename, type) 38 | loop.with_index(1) do |_, index| 39 | return nil if index > MAX_PARENTS 40 | 41 | path = next_parent.join(filename) 42 | next_parent = next_parent.parent 43 | next unless File.exist?(path) && File.public_send("#{type}?", path) 44 | 45 | return path 46 | end 47 | end 48 | 49 | def filter_comments 50 | proc do |line| 51 | stripped = line.strip 52 | next nil if stripped.empty? 53 | next nil if stripped.start_with?('#') 54 | 55 | stripped.gsub(/\s*#.*$/, '') # Remove inline comments (like this one) 56 | end 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/devpack/exec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Gem.module_eval do 4 | def self.devpack_bin_path(gem_name, command, _version = nil) 5 | File.join(Gem.loaded_specs[gem_name].full_gem_path, Gem.loaded_specs[gem_name].bindir, command) 6 | end 7 | 8 | class << self 9 | alias_method :_orig_activate_bin_path, :activate_bin_path 10 | alias_method :_orig_bin_path, :bin_path 11 | 12 | def activate_bin_path(*args) 13 | _orig_activate_bin_path(*args) 14 | rescue Gem::Exception 15 | devpack_bin_path(*args) 16 | end 17 | 18 | def bin_path(*args) 19 | _orig_bin_path(*args) 20 | rescue Gem::Exception 21 | devpack_bin_path(*args) 22 | end 23 | end 24 | end 25 | 26 | def devpack_exec(args) 27 | options = Bundler::Thor::CoreExt::HashWithIndifferentAccess.new({ 'keep_file_descriptors' => true }) 28 | Bundler::CLI::Exec.new(options, args).run 29 | end 30 | 31 | devpack_exec(ARGV[1..-1]) 32 | -------------------------------------------------------------------------------- /lib/devpack/gem_glob.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Devpack 4 | # Locates gems by searching in paths listed in GEM_PATH 5 | class GemGlob 6 | def find(name) 7 | matched_paths(name) 8 | .sort { |a, b| version(a) <=> version(b) } 9 | .reverse 10 | end 11 | 12 | private 13 | 14 | def glob 15 | @glob ||= gem_paths.map { |path| Dir.glob(path.join('gems', '*')) }.flatten 16 | end 17 | 18 | def gem_paths 19 | return [] if gem_path.nil? 20 | 21 | gem_path.split(':').map { |path| Pathname.new(path) } 22 | end 23 | 24 | def match?(name_with_version, basename) 25 | name, _, version = name_with_version.partition(':') 26 | return true if version.empty? && basename.rpartition('-').first == name 27 | return true if !version.empty? && basename == "#{name}-#{version}" 28 | 29 | false 30 | end 31 | 32 | def matched_paths(name) 33 | glob.select do |path| 34 | pathname = Pathname.new(path) 35 | next unless pathname.directory? 36 | 37 | basename = pathname.basename.to_s 38 | match?(name, basename) 39 | end 40 | end 41 | 42 | def version(path) 43 | Gem::Version.new(File.split(path).last.rpartition('-').last) 44 | end 45 | 46 | def gem_path 47 | return ENV.fetch('GEM_PATH', nil) if ENV.key?('GEM_PATH') 48 | return ENV.fetch('GEM_HOME', nil) if ENV.key?('GEM_HOME') 49 | 50 | nil 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/devpack/gem_ref.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Devpack 4 | # Provides extended syntax for gem definitions, allowing `require: false` semantics via an 5 | # asterisrk prefix in `.devpack` file, e.g. `*my_gem` 6 | class GemRef 7 | def self.parse(line) 8 | name, _, version = line.partition(':') 9 | no_require = name.start_with?('*') 10 | name = name.sub('*', '') if no_require 11 | new(name: name, version: version.empty? ? nil : Gem::Requirement.new(version), no_require: no_require) 12 | end 13 | 14 | def initialize(name:, version: nil, no_require: false) 15 | @name = name 16 | @version = version 17 | @no_require = no_require 18 | end 19 | 20 | attr_reader :name, :version 21 | 22 | def require? 23 | !@no_require 24 | end 25 | 26 | def eql?(other) 27 | name == other.name && version == other.version && require? == other.require? 28 | end 29 | 30 | def to_s 31 | "#{require? ? '*' : ''}#{@name}:#{@version}" 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/devpack/gem_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Devpack 4 | # Locates relevant gemspec for a given gem and provides a full list of paths 5 | # for all `require_paths` listed in gemspec. 6 | # rubocop:disable Metrics/ClassLength 7 | class GemSpec 8 | attr_reader :name, :root 9 | 10 | def initialize(glob, name, requirement, root: nil) 11 | @name = name 12 | @glob = glob 13 | @requirement = requirement 14 | @root = root || self 15 | @dependency = Gem::Dependency.new(@name, @requirement) 16 | end 17 | 18 | def require_paths(visited = Set.new) 19 | raise GemNotFoundError.new("Gem not found: #{required_version}", self) if gemspec.nil? 20 | 21 | (immediate_require_paths + dependency_require_paths(visited)).compact.flatten.uniq 22 | end 23 | 24 | def gemspec 25 | return Gem.loaded_specs[@name] if compatible?(Gem.loaded_specs[@name]) 26 | 27 | @gemspec ||= gemspecs.find do |spec| 28 | next false if spec.nil? 29 | 30 | raise_incompatible(spec) unless compatible?(spec) 31 | 32 | @dependency.requirement.satisfied_by?(spec.version) 33 | end 34 | end 35 | 36 | def pretty_name 37 | return @name.to_s if @requirement.nil? 38 | 39 | "#{@name} #{@requirement}" 40 | end 41 | 42 | def root? 43 | self == @root 44 | end 45 | 46 | def required_version 47 | return @name.to_s if compatible_spec.nil? && @requirement.nil? 48 | return "#{@name}:#{compatible_version}" if compatible_spec.nil? 49 | 50 | "#{@name}:#{compatible_spec.version}" 51 | end 52 | 53 | private 54 | 55 | def compatible?(spec) 56 | return false if spec.nil? 57 | return false if incompatible_version_loaded?(spec) 58 | 59 | compatible_specs?([@dependency] + spec.runtime_dependencies) 60 | end 61 | 62 | def compatible_spec 63 | @compatible_spec ||= gemspecs.compact 64 | .select { |spec| requirements_satisfied_by?(spec.version) } 65 | .max_by(&:version) 66 | end 67 | 68 | def incompatible_version_loaded?(spec) 69 | matched = Gem.loaded_specs[spec.name] 70 | return false if matched.nil? 71 | 72 | !matched.satisfies_requirement?(@dependency) 73 | end 74 | 75 | def raise_incompatible(spec) 76 | raise GemIncompatibilityError.new('Incompatible dependencies', incompatible_dependencies(spec)) 77 | end 78 | 79 | def compatible_version 80 | @requirement.requirements.map(&:last).max_by { |version| @requirement.satisfied_by?(version) } 81 | end 82 | 83 | def requirements_satisfied_by?(version) 84 | @dependency.requirement.satisfied_by?(version) 85 | end 86 | 87 | def compatible_specs?(dependencies) 88 | Gem.loaded_specs.values.all? { |spec| compatible_dependencies?(dependencies, spec) } 89 | end 90 | 91 | def compatible_dependencies?(dependencies, spec) 92 | dependencies.all? { |dependency| compatible_dependency?(dependency, spec) } 93 | end 94 | 95 | def incompatible_dependencies(spec) 96 | dependencies = [@dependency] + spec.runtime_dependencies 97 | Gem.loaded_specs.map do |_name, loaded_spec| 98 | next nil if compatible_dependencies?(dependencies, loaded_spec) 99 | 100 | [@root, dependencies.reject { |dependency| compatible_dependency?(dependency, loaded_spec) }] 101 | end.compact 102 | end 103 | 104 | def compatible_dependency?(dependency, spec) 105 | return false if spec.nil? 106 | return true unless dependency.name == spec.name 107 | 108 | dependency.requirement.satisfied_by?(spec.version) 109 | end 110 | 111 | def gemspecs 112 | @gemspecs ||= gemspec_paths.map { |path| Gem::Specification.load(path.to_s) } 113 | end 114 | 115 | def dependency_require_paths(visited) 116 | dependencies.map do |dependency| 117 | next nil if visited.include?(dependency) 118 | 119 | visited << dependency 120 | GemSpec.new(@glob, dependency.name, dependency.requirement, root: @root).require_paths(visited) 121 | end 122 | end 123 | 124 | def dependencies 125 | gemspec.runtime_dependencies 126 | end 127 | 128 | def gem_paths 129 | return nil if candidates.empty? 130 | 131 | candidates.map { |candidate| Pathname.new(candidate) } 132 | end 133 | 134 | def gemspec_paths 135 | return [] if gem_paths.nil? 136 | 137 | gem_paths.map do |path| 138 | path.join('..', '..', 'specifications', "#{path.basename}.gemspec").expand_path 139 | end 140 | end 141 | 142 | def immediate_require_paths 143 | gemspec 144 | .require_paths 145 | .map { |path| File.join(gemspec.full_gem_path, path) } 146 | end 147 | 148 | def candidates 149 | @candidates ||= @glob.find(@name) 150 | end 151 | end 152 | # rubocop:enable Metrics/ClassLength 153 | end 154 | -------------------------------------------------------------------------------- /lib/devpack/gems.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Devpack 4 | # Loads requested gems from configuration 5 | class Gems 6 | include Timeable 7 | 8 | attr_reader :missing 9 | 10 | def initialize(config, glob = GemGlob.new) 11 | @config = config 12 | @gem_glob = glob 13 | @failures = [] 14 | @missing = [] 15 | @incompatible = [] 16 | end 17 | 18 | def load(silent: false) 19 | return [] if @config.requested_gems.nil? 20 | 21 | gems, time = timed { load_devpack } 22 | names = gems.map(&:first) 23 | summarize(gems, time) unless silent 24 | names 25 | end 26 | 27 | private 28 | 29 | def summarize(gems, time) 30 | @failures.each { |failure| warn(:error, Messages.failure(failure[:name], failure[:message])) } 31 | warn(:success, Messages.loaded(@config, gems, time.round(2))) 32 | warn(:info, Messages.install_missing(@missing)) unless @missing.empty? 33 | warn(:info, Messages.alert_incompatible(@incompatible.flatten(1))) unless @incompatible.empty? 34 | end 35 | 36 | def load_devpack 37 | @config.requested_gems.map do |gem| 38 | load_gem(gem) 39 | end.compact 40 | end 41 | 42 | def load_gem(gem) 43 | name = gem.name 44 | [name, activate(gem)] 45 | rescue LoadError => e 46 | deactivate(name) 47 | nil.tap { @failures << { name: name, message: load_error_message(e) } } 48 | rescue GemNotFoundError => e 49 | nil.tap { @missing << e.meta } 50 | rescue GemIncompatibilityError => e 51 | nil.tap { @incompatible << e.meta } 52 | end 53 | 54 | def activate(gem) 55 | spec = GemSpec.new(@gem_glob, gem.name, gem.version) 56 | update_load_path(spec.require_paths) 57 | # NOTE: do this before we require, because some gems use the gemspec to 58 | # declare their version... 59 | Gem.loaded_specs[gem.name] = spec.gemspec 60 | loaded = require_gem(gem.name) if gem.require? 61 | spec.gemspec&.activated = true 62 | spec.gemspec&.instance_variable_set(:@loaded, true) 63 | loaded 64 | end 65 | 66 | def require_gem(name) 67 | Kernel.require(name) 68 | rescue LoadError => e 69 | raise e unless name.include?('-') 70 | 71 | namespaced_file = name.tr('-', '/') 72 | Kernel.require namespaced_file 73 | end 74 | 75 | def deactivate(name) 76 | Gem.loaded_specs.delete(name) 77 | end 78 | 79 | def warn(level, message) 80 | Devpack.warn(level, message) 81 | end 82 | 83 | def load_error_message(error) 84 | return "(#{error.message})" unless Devpack.debug? 85 | 86 | %[(#{error.message})\n#{error.backtrace.join("\n")}] 87 | end 88 | 89 | def update_load_path(paths) 90 | $LOAD_PATH.concat(paths) 91 | ENV['RUBYLIB'] = $LOAD_PATH.join(':') 92 | end 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /lib/devpack/initializers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Devpack 4 | # Loads requested initializers from configuration 5 | class Initializers 6 | include Timeable 7 | 8 | def initialize(config) 9 | @config = config 10 | end 11 | 12 | def load 13 | initializers, time = timed { load_initializers } 14 | path = @config.devpack_initializers_path 15 | return if path.nil? 16 | 17 | args = path, initializers, time.round(2) 18 | Devpack.warn(:success, Messages.loaded_initializers(*args)) 19 | end 20 | 21 | private 22 | 23 | def load_initializers 24 | @config.devpack_initializer_paths.map { |path| load_initializer(path) } 25 | end 26 | 27 | def load_initializer(path) 28 | require path 29 | rescue ScriptError, StandardError => e 30 | Devpack.warn(:error, Messages.initializer_failure(path, message(e))) 31 | nil 32 | end 33 | 34 | def message(error) 35 | return "(#{error.class.name} - #{error.message&.split("\n")&.first})" unless Devpack.debug? 36 | 37 | %[(#{error.class.name})\n#{error.message}\n#{error.backtrace.join("\n")}] 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/devpack/install.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | missing = Devpack::Gems.new(Devpack.config).tap { |gems| gems.load(silent: true) }.missing 4 | install_command = "bundle exec gem install -V #{missing.map(&:required_version).join(' ')}" unless missing.empty? 5 | if install_command.nil? 6 | Devpack.warn(:info, Devpack::Messages.no_gems_to_install) 7 | else 8 | Devpack.warn(:info, install_command) 9 | output, status = Open3.capture2e(install_command) 10 | if status.success? 11 | Devpack.warn(:success, 'Installation complete.') 12 | else 13 | Devpack.warn(:error, "Installation failed. Manually verify this command: #{install_command}") 14 | puts output 15 | end 16 | end 17 | exit 0 18 | -------------------------------------------------------------------------------- /lib/devpack/messages.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Devpack 4 | # Generates output messages. 5 | class Messages 6 | class << self 7 | def failure(name, error_message) 8 | base = "Failed to load `#{name}`" 9 | "#{base}. #{error_message}" 10 | end 11 | 12 | def initializer_failure(path, error_message) 13 | "Failed to load initializer `#{path}`: #{error_message}" 14 | end 15 | 16 | # rubocop:disable Metrics/AbcSize 17 | def loaded(config, gems, time) 18 | loaded = gems.size - gems.reject { |_, devpack_loaded| devpack_loaded }.size 19 | of_total = gems.size == config.requested_gems.size ? nil : " of #{color(:cyan) { config.requested_gems.size }}" 20 | path = color(:cyan) { config.devpack_path } 21 | base = "Loaded #{color(:green) { loaded }}#{of_total} development gem(s) from #{path} in #{time} seconds" 22 | return "#{base}." if loaded == gems.size 23 | 24 | "#{base} (#{color(:cyan) { gems.size - loaded }} gem(s) were already loaded by environment)." 25 | end 26 | # rubocop:enable Metrics/AbcSize 27 | 28 | def loaded_initializers(path, initializers, time) 29 | "Loaded #{color(:green) { initializers.compact.size }} initializer(s) from '#{path}' in #{time} seconds" 30 | end 31 | 32 | def install_missing(missing) 33 | command = color(:cyan) { 'bundle exec devpack install' } 34 | grouped_missing = missing 35 | .group_by(&:root) 36 | .map do |root, dependencies| 37 | next (color(:cyan) { root.pretty_name }).to_s if dependencies.all?(&:root?) 38 | 39 | formatted_dependencies = dependencies.map { |dependency| color(:yellow) { dependency.pretty_name } } 40 | "#{color(:cyan) { root.pretty_name }}: #{formatted_dependencies.join(', ')}" 41 | end 42 | "Install #{missing.size} missing gem(s): #{command} # [#{grouped_missing.join(', ')}]" 43 | end 44 | 45 | def alert_incompatible(incompatible) 46 | grouped_dependencies = {} 47 | incompatible.each do |spec, dependencies| 48 | key = spec.root.pretty_name 49 | grouped_dependencies[key] ||= [] 50 | grouped_dependencies[key] << dependencies 51 | end 52 | alert_incompatible_message(grouped_dependencies) 53 | end 54 | 55 | def no_gems_to_install 56 | "No gems to install: #{Devpack::Messages.color(:green) { Devpack.config.requested_gems.size.to_s }} "\ 57 | "gems already installed from #{Devpack::Messages.color(:cyan) { Devpack.config.devpack_path }}" 58 | end 59 | 60 | def test 61 | puts "#{color(:green) { 'green' }} #{color(:red) { 'red' }} #{color(:blue) { 'blue' }}" 62 | puts "#{color(:cyan) { 'cyan' }} #{color(:yellow) { 'yellow' }} #{color(:magenta) { 'magenta' }}" 63 | end 64 | 65 | def color(name) 66 | "#{palette.fetch(name)}#{yield}#{palette.fetch(:reset)}" 67 | end 68 | 69 | private 70 | 71 | def indented(message) 72 | message.split("\n").map { |line| " #{line}" }.join("\n") 73 | end 74 | 75 | def command(gems) 76 | "bundle exec gem install #{gems.join(' ')}" 77 | end 78 | 79 | def alert_incompatible_message(grouped_dependencies) 80 | incompatible_dependencies = grouped_dependencies.sort.map do |name, dependencies| 81 | "#{color(:cyan) { name }}: "\ 82 | "#{dependencies.flatten.map { |dependency| color(:yellow) { dependency.to_s } }.join(', ')}" 83 | end 84 | "Unable to resolve version conflicts for #{color(:yellow) { incompatible_dependencies.size }} "\ 85 | "dependencies: #{incompatible_dependencies.join(', ')}}" 86 | end 87 | 88 | def palette 89 | { 90 | reset: "\e[39m", 91 | red: "\e[31m", 92 | green: "\e[32m", 93 | yellow: "\e[33m", 94 | blue: "\e[34m", 95 | magenta: "\e[35m", 96 | cyan: "\e[36m" 97 | } 98 | end 99 | end 100 | end 101 | end 102 | -------------------------------------------------------------------------------- /lib/devpack/railtie.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Devpack 4 | # Loads Devpack initializers after standard Rails initializers have been loaded. 5 | class Railtie < Rails::Railtie 6 | config.after_initialize { Devpack::Initializers.new(Devpack.config).load } 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/devpack/timeable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Provides result and run time of a given block. 4 | module Timeable 5 | def timed 6 | start = Time.now.utc 7 | result = yield 8 | [result, Time.now.utc - start] 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/devpack/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Devpack 4 | VERSION = '0.4.2' 5 | end 6 | -------------------------------------------------------------------------------- /spec/devpack/config_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Devpack::Config do 4 | subject(:config) { described_class.new(pwd) } 5 | 6 | let(:pwd) { File.join(Dir.tmpdir, 'example') } 7 | let(:config_path) { File.join(pwd, '.devpack') } 8 | let(:lines) { %w[gem1 gem2 gem3] } 9 | let(:initializers) do 10 | %w[initializer1.rb initializer2.rb initializer3.rb].map do |path| 11 | File.join(pwd, '.devpack_initializers', path) 12 | end 13 | end 14 | 15 | context 'without initializers directory' do 16 | its(:devpack_initializer_paths) { is_expected.to eql [] } 17 | its(:devpack_initializers_path) { is_expected.to be_nil } 18 | end 19 | 20 | context 'with initializers directory' do 21 | before do 22 | FileUtils.mkdir_p(pwd) 23 | File.write(config_path, lines.join("\n")) unless config_path.nil? 24 | FileUtils.mkdir_p(File.join(pwd, '.devpack_initializers')) 25 | initializers.each { |initializer| FileUtils.touch(initializer) } 26 | end 27 | 28 | after { FileUtils.rm_r(pwd) if File.exist?(pwd) } 29 | 30 | it { is_expected.to be_a described_class } 31 | its(:requested_gems) do 32 | is_expected.to eql [ 33 | Devpack::GemRef.new(name: 'gem1', version: nil, no_require: false), 34 | Devpack::GemRef.new(name: 'gem2', version: nil, no_require: false), 35 | Devpack::GemRef.new(name: 'gem3', version: nil, no_require: false) 36 | ] 37 | end 38 | 39 | its(:devpack_path) { is_expected.to eql Pathname.new(config_path) } 40 | its(:devpack_initializers_path) do 41 | is_expected.to eql Pathname.new(File.join(pwd, '.devpack_initializers')) 42 | end 43 | 44 | context 'config located in parent directory' do 45 | let(:pwd) { File.join(Dir.tmpdir, 'parent1', 'parent2', 'parent3', 'example') } 46 | let(:config_path) { File.join(Dir.tmpdir, 'parent1', '.devpack') } 47 | let(:initializers_path) { File.join(Dir.tmpdir, '.devpack_initializers') } 48 | 49 | its(:devpack_path) { is_expected.to eql Pathname.new(config_path) } 50 | its(:devpack_initializer_paths) { is_expected.to eql initializers } 51 | end 52 | 53 | describe 'parent directory limiting' do 54 | let(:base) { File.join(Dir.tmpdir, 'example') } 55 | let(:config_path) { File.join(base, '.devpack') } 56 | let(:pwd) { File.join(base, ['parent'] * parents) } 57 | 58 | before do 59 | FileUtils.mkdir_p(pwd) 60 | end 61 | 62 | after { FileUtils.rm_r(base) } 63 | 64 | context 'with too many parent directories' do 65 | let(:parents) { Devpack::Config::MAX_PARENTS } 66 | its(:devpack_path) { is_expected.to be_nil } 67 | its(:devpack_initializer_paths) { is_expected.to eql initializers } 68 | end 69 | 70 | context 'with maximum parent directories' do 71 | let(:parents) { Devpack::Config::MAX_PARENTS - 1 } 72 | its(:devpack_path) { is_expected.to eql Pathname.new(config_path) } 73 | its(:devpack_initializer_paths) { is_expected.to eql initializers } 74 | end 75 | end 76 | 77 | describe 'comments in config file' do 78 | let(:lines) do 79 | [ 80 | '# a comment', 81 | 'gem1', 82 | ' # an indented comment', 83 | '', 84 | 'gem2', "\t # a tab-indented comment", 85 | 'gem3 # an in-line comment', 86 | '*gem4 # will not be required' 87 | ] 88 | end 89 | 90 | its(:requested_gems) do 91 | is_expected.to eql [ 92 | Devpack::GemRef.new(name: 'gem1', version: nil, no_require: false), 93 | Devpack::GemRef.new(name: 'gem2', version: nil, no_require: false), 94 | Devpack::GemRef.new(name: 'gem3', version: nil, no_require: false), 95 | Devpack::GemRef.new(name: 'gem4', version: nil, no_require: true) 96 | ] 97 | end 98 | end 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /spec/devpack/gem_glob_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Devpack::GemGlob do 4 | subject(:gem_glob) { described_class.new } 5 | 6 | it { is_expected.to be_a described_class } 7 | describe '#find' do 8 | subject(:find) { gem_glob.find(name) } 9 | let(:name) { 'example' } 10 | 11 | context 'gem not present in GEM_PATH' do 12 | it { is_expected.to be_empty } 13 | end 14 | 15 | context 'gem present in GEM_PATH' do 16 | let(:base_path) { File.join(Dir.tmpdir, 'gem_path') } 17 | let(:gem_path) { File.join(base_path, 'gems', 'example-0.1.0') } 18 | 19 | before do 20 | stub_const('ENV', ENV.to_h.merge('GEM_PATH' => base_path)) 21 | FileUtils.mkdir_p(gem_path) 22 | end 23 | 24 | after { FileUtils.rm_r(gem_path) } 25 | 26 | it { is_expected.to eql [gem_path] } 27 | 28 | context 'gem version provided' do 29 | let(:name) { 'example:0.1.0' } 30 | it { is_expected.to eql [gem_path] } 31 | end 32 | 33 | context 'another gem name is a substring of sought gem name' do 34 | let(:name) { 'pry' } 35 | let(:gem_path) { File.join(base_path, 'gems', 'pry-0.1.0') } 36 | let(:other_path) { File.join(base_path, 'gems', 'pry-rails-0.1.0') } 37 | before { FileUtils.mkdir_p(other_path) } 38 | after { FileUtils.rm_r(other_path) } 39 | it { is_expected.to eql [gem_path] } 40 | end 41 | 42 | context 'older version is string-sorted higher than newer version' do 43 | let(:name) { 'example' } 44 | let(:gem_path) { File.join(base_path, 'gems', 'example-0.10.0') } 45 | let(:other_path) { File.join(base_path, 'gems', 'example-0.9.0') } 46 | before { FileUtils.mkdir_p(other_path) } 47 | after { FileUtils.rm_r(other_path) } 48 | it { is_expected.to eql [gem_path, other_path] } 49 | end 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /spec/devpack/gem_spec_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Devpack::GemSpec do 4 | subject(:gem_spec) { described_class.new(glob, name, requirement) } 5 | 6 | let(:requirement) { instance_double(Gem::Requirement, satisfied_by?: true) } 7 | let(:root) { Pathname.new(Dir.tmpdir) } 8 | let(:glob) { instance_double(Devpack::GemGlob, find: [root.join('gems', 'example-0.1.0')]) } 9 | let(:name) { 'example' } 10 | let(:gemspec_content) do 11 | File.read(File.expand_path(File.join(__dir__, '..', 'fixtures', 'example.gemspec'))) 12 | end 13 | 14 | before do 15 | FileUtils.mkdir_p(root.join('gems', 'example-0.1.0')) 16 | FileUtils.mkdir_p(root.join('specifications', 'example-0.1.0')) 17 | File.write(root.join('specifications', 'example-0.1.0.gemspec'), gemspec_content) 18 | allow(Gem).to receive(:loaded_specs) { {} } 19 | end 20 | 21 | it { is_expected.to be_a described_class } 22 | its(:require_paths) { is_expected.to eql [root.join('gems', 'example-0.1.0', 'lib').to_s] } 23 | its(:gemspec) { is_expected.to be_a Gem::Specification } 24 | end 25 | -------------------------------------------------------------------------------- /spec/devpack/gems_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Devpack::Gems do 4 | subject(:gems) { described_class.new(config, glob) } 5 | 6 | let(:config) do 7 | instance_double( 8 | Devpack::Config, 9 | requested_gems: requested_gems, 10 | devpack_path: devpack_path.join('.devpack') 11 | ) 12 | end 13 | let(:glob) { instance_double(Devpack::GemGlob) } 14 | let(:project_path) { Pathname.new(Dir.tmpdir).join('example') } 15 | let(:devpack_path) { project_path } 16 | let(:requested_gems) { [] } 17 | let(:gem_home) { Pathname.new(File.join(Dir.tmpdir, 'gem_home')) } 18 | it { is_expected.to be_a described_class } 19 | its(:load) { is_expected.to be_an Array } 20 | 21 | describe '#load' do 22 | subject(:gems_load) { gems.load } 23 | 24 | let(:installed_gems_require) { %w[installed1 installed2 installed3 installed4-module] } 25 | let(:installed_gems_no_require) { %w[installed_no_require1] } 26 | let(:installed_gems) { installed_gems_require + installed_gems_no_require } 27 | let(:not_installed_gems) { %w[not_installed1 not_installed2 not_installed3] } 28 | 29 | let(:gem_refs_to_require) { installed_gems_require.map { |name| Devpack::GemRef.parse(name) } } 30 | let(:gem_refs_no_require) do 31 | installed_gems_no_require.map do |name| 32 | Devpack::GemRef.new(name: name, no_require: true) 33 | end 34 | end 35 | let(:gem_refs) { gem_refs_to_require + gem_refs_no_require } 36 | let(:not_installed_gem_refs) { not_installed_gems.map { |name| Devpack::GemRef.parse(name) } } 37 | 38 | let(:loaded_gems) { {} } 39 | let(:gemspec) do 40 | instance_double( 41 | Gem::Specification, 42 | version: Gem::Version.new('1'), 43 | name: 'gem', 44 | runtime_dependencies: [], 45 | require_paths: [], 46 | 'activated=': nil 47 | ) 48 | end 49 | 50 | before do 51 | stub_const('ENV', ENV.to_h.merge('GEM_PATH' => "#{gem_home}:/some/other/directory")) 52 | FileUtils.mkdir_p(project_path) 53 | allow(Kernel).to receive(:require).and_call_original 54 | allow(Gem).to receive(:loaded_specs) { loaded_gems } 55 | allow(glob).to receive(:find).with(any_args) do |name| 56 | installed_gems.include?(name) ? [name] : [] 57 | end 58 | allow(Gem::Specification).to receive(:load) { gemspec } 59 | 60 | installed_gems_require.each do |name| 61 | if name.include?('-') 62 | allow(Kernel).to receive(:require).with(name.tr('-', '/')) 63 | else 64 | allow(Kernel).to receive(:require).with(name) 65 | end 66 | end 67 | installed_gems_no_require.each { |name| expect(Kernel).not_to receive(:require).with(name) } 68 | end 69 | 70 | context 'with .devpack file in provided directory' do 71 | context 'with all specified gems installed' do 72 | before { expect(Devpack).to receive(:warn).exactly(1).times } 73 | let(:requested_gems) { gem_refs } 74 | it { is_expected.to eql installed_gems } 75 | 76 | it 'adds gemspec to Gem.loaded_specs' do 77 | subject 78 | expect(loaded_gems.keys).to contain_exactly('installed1', 'installed2', 'installed3', 'installed4-module', 79 | 'installed_no_require1') 80 | end 81 | end 82 | 83 | context 'with gems with hyphen in name' do 84 | before { expect(Devpack).to receive(:warn).exactly(1).times } 85 | let(:requested_gems) { gem_refs } 86 | it { is_expected.to eql installed_gems } 87 | 88 | it 'adds gemspec to Gem.loaded_specs' do 89 | subject 90 | expect(loaded_gems.keys).to contain_exactly('installed1', 'installed2', 'installed3', 'installed4-module', 91 | 'installed_no_require1') 92 | end 93 | end 94 | 95 | context 'with some specified gems not installed' do 96 | let(:requested_gems) { gem_refs + not_installed_gem_refs } 97 | it 'provides list of installed gems' do 98 | allow(Devpack).to receive(:warn) 99 | expect(subject).to eql installed_gems 100 | end 101 | 102 | it 'provides installation instructions for missing gems' do 103 | expect(Devpack).to receive(:warn).once do |message| 104 | puts message 105 | end 106 | expect(Devpack).to receive(:warn).once 107 | subject 108 | end 109 | end 110 | 111 | context 'with missing gems and DEVPACK_DEBUG enabled' do 112 | let(:requested_gems) { not_installed_gem_refs } 113 | before { allow(Devpack).to receive(:debug?) { true } } 114 | it 'issues a warning including error and traceback' do 115 | expect(Devpack).to receive(:warn).at_least(:once).with(any_args) do |_level, message| 116 | next if message.include?('development gem(s)') 117 | next if message.include?('Install 3 missing gem(s)') 118 | 119 | [ 120 | 'Failed to load', 121 | 'Devpack::GemNotFoundError', 122 | '/devpack/lib/devpack/gems.rb', 123 | "`activate'", 124 | "`load_gem'" 125 | ].each { |line| expect(message).to include line } 126 | end 127 | subject 128 | end 129 | end 130 | end 131 | 132 | context 'no .devpack file present in provided directory' do 133 | let(:requested_gems) { nil } 134 | before do 135 | expect(Devpack).to_not receive(:warn) 136 | end 137 | it { is_expected.to be_empty } 138 | end 139 | 140 | context '.gemspec found in specifications directory' do 141 | let(:installed_gems_require) { ['example'] } 142 | let(:installed_gems_no_require) { [] } 143 | let(:not_installed_gems) { [] } 144 | let(:example_gem_path) { gem_home.join('gems', 'example-1.0.0') } 145 | let(:gemspec_path) do 146 | File.expand_path(File.join(__dir__, '..', 'fixtures', 'example.gemspec')) 147 | end 148 | 149 | before do 150 | FileUtils.mkdir_p(example_gem_path.join('lib')) 151 | FileUtils.mkdir_p(gem_home.join('specifications')) 152 | gem_home.join('specifications', 'example.gemspec') 153 | .write(File.read(gemspec_path)) 154 | example_gem_path.join('lib', 'example.rb').write('') 155 | end 156 | 157 | after { FileUtils.rm_r(gem_home.to_s) } 158 | before { expect(Devpack).to receive(:warn).exactly(1).times } 159 | 160 | it { is_expected.to eql requested_gems } 161 | end 162 | end 163 | end 164 | -------------------------------------------------------------------------------- /spec/devpack/initializers_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Devpack::Initializers do 4 | subject(:initializers) { described_class.new(config) } 5 | 6 | let(:config) do 7 | instance_double( 8 | Devpack::Config, 9 | devpack_initializer_paths: initializer_paths, 10 | devpack_initializers_path: initializers_path 11 | ) 12 | end 13 | 14 | let(:initializers_path) { Pathname.new(Dir.tmpdir).join('.devpack_initializers') } 15 | let(:initializer_paths) { [initializers_path.join('example_initializer1.rb')] } 16 | 17 | before { FileUtils.mkdir_p(initializers_path) } 18 | after { FileUtils.rm_rf(initializers_path) } 19 | after { $LOADED_FEATURES.reject! { |path| initializer_paths.include?(Pathname.new(path)) } } 20 | 21 | describe '#load' do 22 | subject(:initializers_load) { initializers.load } 23 | 24 | context 'loadable initializer' do 25 | before do 26 | File.write(initializers_path.join('example_initializer1.rb'), initializer_content) 27 | end 28 | 29 | let(:initializer_content) { 'module ExampleInitializer1; end' } 30 | 31 | it 'loads located files' do 32 | subject 33 | expect(defined?(ExampleInitializer1)).to be_truthy 34 | end 35 | 36 | after { Object.send(:remove_const, :ExampleInitializer1) } 37 | end 38 | 39 | context 'multiple initializers' do 40 | before do 41 | File.write(initializers_path.join('example_initializer1.rb'), initializer1_content) 42 | File.write(initializers_path.join('example_initializer2.rb'), initializer2_content) 43 | File.write(initializers_path.join('example_initializer3.rb'), initializer3_content) 44 | end 45 | 46 | let(:initializer1_content) { 'module ExampleInitializer1; end' } 47 | let(:initializer2_content) { 'module ExampleInitializer2; end' } 48 | let(:initializer3_content) { 'module ExampleInitializer3; end' } 49 | let(:initializer_paths) do 50 | [ 51 | initializers_path.join('example_initializer1.rb'), 52 | initializers_path.join('example_initializer2.rb'), 53 | initializers_path.join('example_initializer3.rb') 54 | ] 55 | end 56 | 57 | it 'loads located files' do 58 | subject 59 | expect( 60 | defined?(ExampleInitializer1) && 61 | defined?(ExampleInitializer2) && 62 | defined?(ExampleInitializer3) 63 | ).to be_truthy 64 | end 65 | 66 | after { Object.send(:remove_const, :ExampleInitializer1) } 67 | after { Object.send(:remove_const, :ExampleInitializer2) } 68 | after { Object.send(:remove_const, :ExampleInitializer3) } 69 | end 70 | 71 | context 'unloadable initializer (SyntaxError)' do 72 | let(:initializer_content) { 'not a valid module' } 73 | 74 | before do 75 | File.write(initializers_path.join('example_initializer1.rb'), initializer_content) 76 | end 77 | 78 | it 'suppresses errors' do 79 | expect { subject }.to_not raise_error 80 | end 81 | 82 | it 'issues a warning including error' do 83 | allow(Devpack).to receive(:warn) 84 | error = "SyntaxError - #{initializer_paths.first}:1: syntax error, unexpected end-of-input" 85 | expect(Devpack) 86 | .to receive(:warn) 87 | .at_least(:once) 88 | .with(:error, "Failed to load initializer `#{initializer_paths.first}`: (#{error})") 89 | subject 90 | end 91 | end 92 | 93 | context 'unloadable initializer (NameError)' do 94 | let(:initializer_content) { 'method_name_that_does_not_exist' } 95 | 96 | before do 97 | File.write(initializers_path.join('example_initializer1.rb'), initializer_content) 98 | end 99 | 100 | it 'suppresses errors' do 101 | expect { subject }.to_not raise_error 102 | end 103 | 104 | it 'issues a warning including error' do 105 | allow(Devpack).to receive(:warn) 106 | error = ['(NameError - undefined local variable or method ', 107 | "`method_name_that_does_not_exist' for main:Object)"].join 108 | expect(Devpack) 109 | .to receive(:warn) 110 | .with(:error, "Failed to load initializer `#{initializer_paths.first}`: #{error}") 111 | subject 112 | end 113 | 114 | context 'with DEVPACK_DEBUG enabled' do 115 | before { allow(Devpack).to receive(:debug?) { true } } 116 | 117 | it 'issues a warning including error and traceback' do 118 | expect(Devpack) 119 | .to receive(:warn) 120 | .at_least(:once) 121 | .with(any_args) do |_level, message| 122 | next if message.start_with?('Loaded') 123 | 124 | ["/lib/devpack/initializers.rb:28:in `load_initializer'", 125 | "/lib/devpack/initializers.rb:24:in `block in load_initializers'", 126 | "/lib/devpack/initializers.rb:24:in `map'", 127 | "/lib/devpack/initializers.rb:24:in `load_initializers'", 128 | "/lib/devpack/initializers.rb:13:in `block in load'"].each do |line| 129 | expect(message).to include line 130 | end 131 | end 132 | subject 133 | end 134 | end 135 | end 136 | end 137 | end 138 | -------------------------------------------------------------------------------- /spec/devpack_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Devpack do 4 | it 'has a version number' do 5 | expect(Devpack::VERSION).not_to be nil 6 | end 7 | 8 | describe '.warn' do 9 | it 'calls Kernel.warn with a prefix' do 10 | expect(Kernel).to receive(:warn).with( 11 | "\e[34m[\e[39mdevpack\e[34m]\e[39m \e[36mℹ\e[39m a warning message" 12 | ) 13 | described_class.warn(:info, 'a warning message') 14 | end 15 | 16 | it 'prefixes multiple lines' do 17 | expect(Kernel).to receive(:warn).with( 18 | ["\e[34m[\e[39mdevpack\e[34m]\e[39m \e[36mℹ\e[39m line1", 19 | "\e[34m[\e[39mdevpack\e[34m]\e[39m \e[36mℹ\e[39m line2", 20 | "\e[34m[\e[39mdevpack\e[34m]\e[39m \e[36mℹ\e[39m line3"].join("\n") 21 | ) 22 | described_class.warn(:info, "line1\nline2\nline3") 23 | end 24 | end 25 | 26 | describe '.debug?' do 27 | subject { described_class.debug? } 28 | 29 | context 'debug not enabled' do 30 | before { stub_const('ENV', {}) } 31 | it { is_expected.to be false } 32 | end 33 | 34 | context 'debug enabled' do 35 | before { stub_const('ENV', ENV.to_h.merge('DEVPACK_DEBUG' => '1')) } 36 | it { is_expected.to be true } 37 | end 38 | end 39 | 40 | describe '.disabled?' do 41 | subject { described_class.disabled? } 42 | 43 | context 'disabled not enabled' do 44 | before { stub_const('ENV', {}) } 45 | it { is_expected.to be false } 46 | end 47 | 48 | context 'disabled enabled' do 49 | before { stub_const('ENV', ENV.to_h.merge('DEVPACK_DISABLE' => '1')) } 50 | it { is_expected.to be true } 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /spec/fixtures/example.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | # stub: example 0.1.0 ruby lib 3 | 4 | Gem::Specification.new do |s| 5 | s.name = "example".freeze 6 | s.version = "0.1.0" 7 | 8 | s.required_rubygems_version = Gem::Requirement.new(">= 0".freeze) if s.respond_to? :required_rubygems_version= 9 | s.metadata = { "changelog_uri" => "https://github.com/example/example/blob/master/CHANGELOG.md", "homepage_uri" => "https://github.com/example/example", "source_code_uri" => "https://github.com/example/example" } if s.respond_to? :metadata= 10 | s.require_paths = ["lib".freeze] 11 | s.authors = ["Example Author".freeze] 12 | s.date = "2020-07-04" 13 | s.description = "Provide a list of gems to load in your own environment".freeze 14 | s.email = ["author@example.com".freeze] 15 | s.homepage = "https://github.com/example/example".freeze 16 | s.licenses = ["MIT".freeze] 17 | s.required_ruby_version = Gem::Requirement.new(">= 2.3.0".freeze) 18 | s.rubygems_version = "3.0.3".freeze 19 | s.summary = "Conveniently tailor your development environment".freeze 20 | 21 | s.installed_by_version = "3.0.3" if s.respond_to? :installed_by_version 22 | 23 | if s.respond_to? :specification_version then 24 | s.specification_version = 4 25 | 26 | if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then 27 | s.add_development_dependency(%q.freeze, ["~> 11.1"]) 28 | s.add_development_dependency(%q.freeze, ["~> 3.9"]) 29 | s.add_development_dependency(%q.freeze, ["~> 1.3"]) 30 | s.add_development_dependency(%q.freeze, ["~> 0.86.0"]) 31 | s.add_development_dependency(%q.freeze, ["~> 0.4.4"]) 32 | else 33 | s.add_dependency(%q.freeze, ["~> 11.1"]) 34 | s.add_dependency(%q.freeze, ["~> 3.9"]) 35 | s.add_dependency(%q.freeze, ["~> 1.3"]) 36 | s.add_dependency(%q.freeze, ["~> 0.86.0"]) 37 | s.add_dependency(%q.freeze, ["~> 0.4.4"]) 38 | end 39 | else 40 | s.add_dependency(%q.freeze, ["~> 11.1"]) 41 | s.add_dependency(%q.freeze, ["~> 3.9"]) 42 | s.add_dependency(%q.freeze, ["~> 1.3"]) 43 | s.add_dependency(%q.freeze, ["~> 0.86.0"]) 44 | s.add_dependency(%q.freeze, ["~> 0.4.4"]) 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/setup' 4 | require 'tempfile' 5 | 6 | ENV['DEVPACK_DISABLE'] = '1' # We invoke manually in tests 7 | require 'devpack' 8 | 9 | require 'rspec/its' 10 | 11 | RSpec.configure do |config| 12 | # Enable flags like --only-failures and --next-failure 13 | config.example_status_persistence_file_path = '.rspec_status' 14 | 15 | # Disable RSpec exposing methods globally on `Module` and `main` 16 | config.disable_monkey_patching! 17 | 18 | config.expect_with :rspec do |c| 19 | c.syntax = :expect 20 | end 21 | end 22 | --------------------------------------------------------------------------------