├── .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 |
--------------------------------------------------------------------------------