bundler-interactive 0.1.2.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7cb83b26ff4dea18f9dcfb7c8aa94ca8b1a90ed6e87f6cf3350686e75d828dee
4
- data.tar.gz: ca98b4c3abd8b9654341d960f19af1867f29fa87da2d3cb72620527d1fa9308d
3
+ metadata.gz: dfd73e115e07573a200d343fa615b58145d99c31875eec4dd5e9082d8738b264
4
+ data.tar.gz: fbf663bfb19615ee023946b2adec69079a4d7ba7b2a6d69d94c2391d3cfa8fa6
5
5
  SHA512:
6
- metadata.gz: 618be7c402a08da7a0e4a46098b2aed18ec20dad968e3d34be05ed0192bbb90acbe90fd364bc6394ab5e2fe891d98917110c919195a9a147b45e63420c43168b
7
- data.tar.gz: d4aa3d4202a94a0a555d315f46c34e44969c886d753864ae2097953f19c052c556c749df1cc221af737ab0e445a574ceec8a931e05fda51fe362e4ec097adb0b
6
+ metadata.gz: 5b5621ad3e3e46be1b93afb3bbdf1f0ce974168cfd340766cee04357a88e3ad75aead8a2d794d535eec6462e4697cc28e7fa0853bc27b206e1f83bbcdc606d67
7
+ data.tar.gz: f051064dc386dfc6ab892f8c6406d56e154892d817c76b46b8f9cb4e3d054cfe81f770a539730260e0cdeb0ebd8f2c4f05e8bf6c1683b7af708609c023774460
@@ -0,0 +1,37 @@
1
+ name: CI
2
+ on:
3
+ push:
4
+ branches:
5
+ - master
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ name: Ruby ${{ matrix.ruby }} / Bundler ${{ matrix.bundler }}
11
+ runs-on: ubuntu-latest
12
+ timeout-minutes: 10
13
+ strategy:
14
+ fail-fast: false
15
+ matrix:
16
+ # Bundler 4.x requires Ruby >= 3.2.
17
+ ruby: ["3.2", "3.3", "3.4", "4.0"]
18
+ # This is a Bundler plugin, so the Bundler version is the axis that
19
+ # matters most: "default" exercises the Bundler that ships with each
20
+ # Ruby, and "latest" guards against API drift and covers the cooldown
21
+ # feature (Bundler >= 4.0.13).
22
+ bundler: [default, latest]
23
+
24
+ steps:
25
+ - name: Checkout code
26
+ uses: actions/checkout@v5
27
+
28
+ - name: Set up Ruby and bundle install
29
+ uses: ruby/setup-ruby@v1
30
+ with:
31
+ ruby-version: ${{ matrix.ruby }}
32
+ bundler: ${{ matrix.bundler }}
33
+ bundler-cache: true
34
+ rubygems: latest
35
+
36
+ - name: Run tests
37
+ run: bundle exec rspec
data/.gitignore CHANGED
@@ -11,3 +11,6 @@ Gemfile.lock
11
11
 
12
12
  # rspec failure tracking
13
13
  .rspec_status
14
+
15
+ *.pem
16
+ bundler-interactive*.gem
data/README.md CHANGED
@@ -1,6 +1,9 @@
1
1
  # Bundler::Interactive
2
2
 
3
- This is a plugin to Bundler which provides an interactive interface to manage updating outdated gems within your project similar to [`yarn upgrade-interactive`]
3
+ A Bundler plugin that turns updating your gems into a quick, interactive pass
4
+ think [`yarn upgrade-interactive`], but for your Gemfile. Instead of guessing
5
+ which gems are behind and editing version numbers by hand, you get a list you
6
+ can walk through and pick exactly what to bump.
4
7
 
5
8
  ## Installation
6
9
 
@@ -8,8 +11,57 @@ This is a plugin to Bundler which provides an interactive interface to manage up
8
11
 
9
12
  ## Usage
10
13
 
14
+ From a project with a Gemfile, run:
15
+
11
16
  $ bundle update-interactive
12
17
 
18
+ (`bundle upgrade-interactive` works too — it's an alias, for muscle memory
19
+ either way.)
20
+
21
+ You'll see one row per outdated gem, showing its group (development, test, …)
22
+ and the versions you can move to:
23
+
24
+ ```
25
+ Select gems and target versions to update:
26
+ * = newer version held by cooldown
27
+ ❯ rake (default) 12.0.0 [12.3.3] 13.4.2
28
+ rexml (development,test) 3.4.0 [3.4.1] 3.4.4*
29
+ ```
30
+
31
+ Each row offers up to three targets:
32
+
33
+ - **current** — what's installed now
34
+ - **in-range** — the newest version your Gemfile constraint already allows
35
+ - **latest** — the newest version published, even if it's beyond your constraint
36
+
37
+ ### Keys
38
+
39
+ | Key | Action |
40
+ | -------------- | ------------------------------------------------- |
41
+ | `↑` / `↓` | move between gems |
42
+ | `←` / `→` | choose a target version for the highlighted gem |
43
+ | `space` | skip a gem (or restore it) |
44
+ | `enter` | apply your selections |
45
+ | `q` / `Ctrl-C` | cancel without changing anything |
46
+
47
+ The selected version is shown in `[brackets]`. By default each gem is set to its
48
+ in-range latest; gems where the only newer option is **beyond** your constraint
49
+ start out skipped, so pressing `enter` never changes your Gemfile unless you ask
50
+ it to.
51
+
52
+ When you hit `enter`, Bundler updates the lockfile for everything you picked. If
53
+ you chose a version that's outside the current constraint, the matching line in
54
+ your Gemfile is rewritten to allow it first.
55
+
56
+ ### Cooldown
57
+
58
+ If you've enabled Bundler's [cooldown] setting (Bundler 4.0.13+), which holds
59
+ back gem versions for a few days after release as a supply-chain safeguard,
60
+ `bundle update-interactive` respects it: the versions you can pick are the ones
61
+ Bundler will actually install. A version that's newer but still inside the
62
+ cooldown window is shown with an asterisk (`*`) so you know an update is coming
63
+ once it clears.
64
+
13
65
  ## Development
14
66
 
15
67
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
@@ -22,3 +74,4 @@ Bug reports and pull requests are welcome on GitHub at https://github.com/wesric
22
74
 
23
75
 
24
76
  [`yarn upgrade-interactive`]: https://yarnpkg.com/lang/en/docs/cli/upgrade-interactive/
77
+ [cooldown]: https://blog.rubygems.org/2026/06/03/cooldown-let-new-gems-be-vetted.html
@@ -39,7 +39,7 @@ Gem::Specification.new do |spec|
39
39
  spec.required_ruby_version = '>= 2.3.0'
40
40
 
41
41
  spec.add_development_dependency 'bundler', '>= 1.16'
42
- spec.add_development_dependency 'rake', '~> 10.0'
42
+ spec.add_development_dependency 'rake', '>= 10.0'
43
43
  spec.add_development_dependency 'rspec', '~> 3.0'
44
44
 
45
45
  spec.add_dependency 'tty-prompt'
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bundler
4
+ module Interactive
5
+ # Rewrites a gem's version requirement in the Gemfile when the user picks a
6
+ # version beyond the declared constraint.
7
+ #
8
+ # Bundler's own Injector can only append or remove entries, so this performs
9
+ # a targeted, in-place edit of the existing `gem "name", ...` line, keeping
10
+ # any groups, options, and trailing comments intact. When no editable line
11
+ # is found it warns and returns false rather than risk corrupting the file.
12
+ class GemfileEditor
13
+ def initialize(name, target_version, gemfile_path: Bundler.default_gemfile)
14
+ @name = name
15
+ @target = Gem::Version.new(target_version.to_s)
16
+ @gemfile_path = gemfile_path
17
+ end
18
+
19
+ def rewrite!
20
+ content = File.read(@gemfile_path)
21
+ updated = replace_requirement(content)
22
+
23
+ if updated.nil?
24
+ warn_unfound
25
+ return false
26
+ end
27
+
28
+ File.write(@gemfile_path, updated)
29
+ true
30
+ rescue StandardError => e
31
+ warn_failure(e)
32
+ false
33
+ end
34
+
35
+ # A pessimistic requirement that admits the chosen version, pinned to its
36
+ # major.minor (e.g. 8.0.1 -> "~> 8.0").
37
+ def new_requirement
38
+ "~> #{@target.segments.first(2).join('.')}"
39
+ end
40
+
41
+ private
42
+
43
+ def replace_requirement(content)
44
+ pattern = /^(?<indent>[ \t]*)gem\s+(?<q>['"])#{Regexp.escape(@name)}\k<q>(?<rest>[^\n]*)$/
45
+ match = content.match(pattern)
46
+ return nil unless match
47
+
48
+ content.sub(pattern) do
49
+ "#{match[:indent]}gem #{match[:q]}#{@name}#{match[:q]}#{rebuild_rest(match[:rest])}"
50
+ end
51
+ end
52
+
53
+ # Strips any existing version-requirement string literals that follow the
54
+ # gem name and inserts the new requirement, preserving option pairs and a
55
+ # trailing comment.
56
+ def rebuild_rest(rest)
57
+ code, comment = split_comment(rest)
58
+ remainder = strip_leading_versions(code.strip.sub(/\A,\s*/, ''))
59
+
60
+ parts = ["\"#{new_requirement}\""]
61
+ parts << remainder unless remainder.empty?
62
+
63
+ result = ", #{parts.join(', ')}"
64
+ comment.empty? ? result : "#{result} #{comment}"
65
+ end
66
+
67
+ def split_comment(rest)
68
+ if (index = comment_index(rest))
69
+ [rest[0...index], rest[index..-1].strip]
70
+ else
71
+ [rest, '']
72
+ end
73
+ end
74
+
75
+ # Index of a `#` that begins a comment (i.e. one not inside a string).
76
+ def comment_index(rest)
77
+ in_string = nil
78
+ rest.each_char.with_index do |char, index|
79
+ if in_string
80
+ in_string = nil if char == in_string
81
+ elsif char == '"' || char == "'"
82
+ in_string = char
83
+ elsif char == '#'
84
+ return index
85
+ end
86
+ end
87
+ nil
88
+ end
89
+
90
+ def strip_leading_versions(code)
91
+ loop do
92
+ match = code.match(/\A(?<q>['"])(?<req>[^'"]*)\k<q>\s*,?\s*/)
93
+ break unless match && version_requirement?(match[:req])
94
+
95
+ code = code[match[0].length..-1]
96
+ end
97
+ code.strip
98
+ end
99
+
100
+ def version_requirement?(string)
101
+ Gem::Requirement::PATTERN.match?(string.strip)
102
+ end
103
+
104
+ def warn_unfound
105
+ Bundler.ui.warn(
106
+ "bundler-interactive: could not find an editable `gem \"#{@name}\"` line in " \
107
+ "#{@gemfile_path}; updating #{@name} within its existing constraint instead."
108
+ )
109
+ end
110
+
111
+ def warn_failure(error)
112
+ Bundler.ui.warn(
113
+ "bundler-interactive: failed to rewrite the Gemfile for #{@name} " \
114
+ "(#{error.message}); updating within its existing constraint instead."
115
+ )
116
+ end
117
+ end
118
+ end
119
+ end
@@ -2,54 +2,165 @@
2
2
 
3
3
  module Bundler
4
4
  module Interactive
5
+ # View-model for a single outdated gem row in the interactive table.
6
+ #
7
+ # It exposes an ordered, de-duplicated list of selectable version +choices+
8
+ # (current -> in-range latest -> absolute latest) and tracks which one the
9
+ # user has highlighted. The renderer cycles the selection with
10
+ # +cycle_left+/+cycle_right+ and reads +selected_version+ when applying.
5
11
  class OutdatedGem
6
- VERSION_NAMES = %w[MAJOR MINOR PATCH].freeze
12
+ Choice = Struct.new(:version, :role, :held, :git, :ref, keyword_init: true)
7
13
 
8
- def initialize(current_spec, active_spec, dependency)
14
+ def initialize(current_spec:, resolved_spec:, dependency:,
15
+ latest_version: nil, pending_version: nil,
16
+ cooldown_active: false)
9
17
  @current_spec = current_spec
10
- @active_spec = active_spec
18
+ @resolved_spec = resolved_spec
11
19
  @dependency = dependency
20
+ @latest_version = latest_version || resolved_spec.version
21
+ @pending_version = pending_version
22
+ @cooldown_active = cooldown_active
23
+ @selected_index = default_index
12
24
  end
13
25
 
26
+ attr_reader :dependency, :latest_version, :pending_version, :selected_index
27
+
14
28
  def name(padding = 0)
15
29
  current_spec.name.ljust(padding)
16
30
  end
17
31
 
32
+ def groups
33
+ dependency ? Array(dependency.groups) : []
34
+ end
35
+
36
+ # Only the noteworthy groups; the implicit :default group renders as blank.
37
+ def group_label
38
+ (groups.map(&:to_s) - ['default']).join(',')
39
+ end
40
+
18
41
  def current_version
19
42
  current_spec.version
20
43
  end
21
44
 
22
- def active_version
23
- active_spec.version
45
+ def resolved_version
46
+ resolved_spec.version
47
+ end
48
+
49
+ def choices
50
+ @choices ||= build_choices
51
+ end
52
+
53
+ # True when there is anything to pick beyond the installed version.
54
+ def upgradable?
55
+ choices.size > 1
56
+ end
57
+
58
+ def selected
59
+ choices[@selected_index]
60
+ end
61
+
62
+ def selected_version
63
+ selected.version
64
+ end
65
+
66
+ def cycle_left
67
+ @selected_index = (@selected_index - 1) % choices.size
68
+ end
69
+
70
+ def cycle_right
71
+ @selected_index = (@selected_index + 1) % choices.size
72
+ end
73
+
74
+ def select_current!
75
+ @selected_index = choices.index { |choice| choice.role == :current } || 0
76
+ end
77
+
78
+ # Jump to the best upgrade target (in-range latest, else absolute latest).
79
+ def select_upgrade!
80
+ index = choices.index { |choice| choice.role == :in_range } ||
81
+ choices.index { |choice| choice.role == :latest }
82
+ @selected_index = index if index
83
+ end
84
+
85
+ # A row is "skipped" (left untouched) when the highlighted choice is the
86
+ # currently installed version.
87
+ def skip?
88
+ selected.role == :current
89
+ end
90
+
91
+ def git_changed?
92
+ current_git != resolved_git && !(current_git.nil? && resolved_git.nil?)
24
93
  end
25
94
 
26
- def label
27
- "#{version_label.ljust(10)} " \
28
- "newest #{active_version.to_s.ljust(10)} " \
29
- "installed #{current_version.to_s.ljust(10)} " \
30
- "#{dependency_text.ljust(20)}"
95
+ # The selected target is newer than the Gemfile constraint allows, so the
96
+ # Gemfile requirement must be rewritten before updating.
97
+ def cross_constraint?
98
+ return false unless dependency
99
+ return false if git_changed?
100
+
101
+ !dependency.requirement.satisfied_by?(selected_version)
102
+ end
103
+
104
+ # Informational note for an in-range update that is being withheld by the
105
+ # cooldown window but is not otherwise visible as a selectable choice.
106
+ def cooldown_note
107
+ return nil unless @cooldown_active
108
+ return nil unless pending_version
109
+ return nil if choices.any?(&:held)
110
+
111
+ "#{pending_version} held by cooldown"
31
112
  end
32
113
 
33
114
  private
34
115
 
35
- attr_reader :current_spec, :active_spec, :dependency
116
+ attr_reader :current_spec, :resolved_spec
117
+
118
+ def build_choices
119
+ list = [Choice.new(version: current_version, role: :current, held: false, git: false, ref: short_ref(current_spec))]
120
+
121
+ if git_changed?
122
+ list << Choice.new(version: resolved_version, role: :in_range, held: false, git: true, ref: short_ref(resolved_spec))
123
+ elsif resolved_version > current_version
124
+ list << Choice.new(version: resolved_version, role: :in_range, held: held?(resolved_version), git: false, ref: nil)
125
+ end
126
+
127
+ if latest_version > resolved_version && latest_version > current_version
128
+ list << Choice.new(version: latest_version, role: :latest, held: held?(latest_version), git: false, ref: nil)
129
+ end
130
+
131
+ list.uniq { |choice| [choice.version, choice.git] }
132
+ end
133
+
134
+ # The short git revision for a git-sourced spec (e.g. "380c0bc"), or nil.
135
+ def short_ref(spec)
136
+ return nil unless spec.respond_to?(:git_version)
137
+
138
+ ref = spec.git_version
139
+ ref && !ref.strip.empty? ? ref.strip : nil
140
+ end
141
+
142
+ # Default to the current version (skip), matching `yarn
143
+ # upgrade-interactive` — nothing is touched until you opt a gem in.
144
+ def default_index
145
+ choices.index { |choice| choice.role == :current } || 0
146
+ end
147
+
148
+ # A version is held by cooldown when it satisfies the Gemfile constraint
149
+ # (so it is not merely blocked by the declared range) yet sits above the
150
+ # version resolution was willing to pick.
151
+ def held?(version)
152
+ return false unless @cooldown_active
153
+ return false unless dependency
36
154
 
37
- def dependency_text
38
- dependency ? "requested #{dependency.requirement}" : ''
155
+ version > resolved_version && dependency.requirement.satisfied_by?(version)
39
156
  end
40
157
 
41
- def version_label
42
- index = version_diff_index
43
- index ? VERSION_NAMES[index] : 'GIT'
158
+ def current_git
159
+ current_spec.respond_to?(:git_version) ? current_spec.git_version : nil
44
160
  end
45
161
 
46
- def version_diff_index
47
- active_version
48
- .segments
49
- .zip(current_version.segments)
50
- .index do |active_segment, current_segment|
51
- active_segment.to_i > current_segment.to_i
52
- end
162
+ def resolved_git
163
+ resolved_spec.respond_to?(:git_version) ? resolved_spec.git_version : nil
53
164
  end
54
165
  end
55
166
  end
@@ -2,18 +2,49 @@
2
2
 
3
3
  module Bundler
4
4
  module Interactive
5
+ # Applies the version selections the user made in the interactive table.
6
+ #
7
+ # Selections whose target exceeds the Gemfile constraint have their
8
+ # requirement rewritten first (via GemfileEditor); then every selected gem
9
+ # is unlocked and re-resolved through a fresh Bundler definition so the
10
+ # lockfile is updated in one pass.
5
11
  class Update
6
- attr_reader :gems
7
-
8
- def initialize(gems)
9
- @gems = gems
12
+ def initialize(selections)
13
+ @selections = Array(selections)
10
14
  end
11
15
 
12
16
  def update!
13
- puts "Updating #{gems.join(', ')}..."
14
- # Update Gemfile?
15
- definition = Bundler.definition(gems: gems)
17
+ return if selections.empty?
18
+
19
+ rewrite_gemfile!
20
+ names = selections.map(&:name)
21
+
22
+ Bundler.ui.info("Updating #{names.join(', ')}...")
23
+ definition = Bundler.definition(gems: names)
24
+ definition.resolve_remotely!
16
25
  Bundler::Installer.install(Bundler.root, definition)
26
+ definition.lock
27
+
28
+ print_summary
29
+ end
30
+
31
+ private
32
+
33
+ attr_reader :selections
34
+
35
+ def rewrite_gemfile!
36
+ selections.select(&:cross_constraint?).each do |selection|
37
+ GemfileEditor.new(selection.name, selection.selected_version).rewrite!
38
+ end
39
+ end
40
+
41
+ def print_summary
42
+ Bundler.ui.confirm('Updated:')
43
+ selections.each do |selection|
44
+ Bundler.ui.confirm(
45
+ " #{selection.name} #{selection.current_version} -> #{selection.selected_version}"
46
+ )
47
+ end
17
48
  end
18
49
  end
19
50
  end
@@ -1,60 +1,350 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'io/console'
4
+
5
+ module Bundler
6
+ module Interactive
7
+ # When bundler-interactive runs as an installed Bundler plugin, its gem
8
+ # dependencies (tty-prompt/tty-reader and their transitive deps) are
9
+ # installed alongside this gem under the plugin root, but Bundler does not
10
+ # add them to the load path -- see the "to support plugin deps" note in
11
+ # Bundler::Plugin#load_plugin. This bridges that gap.
12
+ module PluginDependencies
13
+ # Adds every sibling gem's lib directory to the load path so the plugin's
14
+ # dependency closure can be required. +anchor+ is a directory inside this
15
+ # gem (e.g. __dir__); its ancestor named "gems" is the plugin gem home,
16
+ # whose children are this gem and its installed dependencies. Appends
17
+ # (rather than prepends) so host application gems keep precedence.
18
+ def self.load!(anchor)
19
+ gems_dir = File.expand_path('../../../..', anchor)
20
+ return unless File.basename(gems_dir) == 'gems'
21
+
22
+ Dir[File.join(gems_dir, '*', 'lib')].sort.each do |lib|
23
+ $LOAD_PATH.push(lib) unless $LOAD_PATH.include?(lib)
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+
3
30
  begin
4
- require 'tty-prompt'
31
+ require 'tty-reader'
5
32
  rescue LoadError
6
- # tty-prompt probably wasn't loaded when we tried to install the plugin
7
- # for now, this is fine
8
- nil
33
+ Bundler::Interactive::PluginDependencies.load!(__dir__)
34
+ require 'tty-reader'
9
35
  end
10
36
 
11
37
  module Bundler
12
38
  module Interactive
39
+ # The `bundle update-interactive` command.
40
+ #
41
+ # Renders a navigable table of outdated gems: up/down move between gems,
42
+ # left/right cycle a gem's target version (current / in-range latest /
43
+ # absolute latest), space skips or restores a row, enter applies the
44
+ # selection, and q (or Ctrl-C) cancels.
13
45
  class UpdateInteractive < Bundler::Plugin::API
46
+ KEY_UP = ["\e[A", "\eOA"].freeze
47
+ KEY_DOWN = ["\e[B", "\eOB"].freeze
48
+ KEY_RIGHT = ["\e[C", "\eOC"].freeze
49
+ KEY_LEFT = ["\e[D", "\eOD"].freeze
50
+ KEY_RETURN = ["\r", "\n"].freeze
51
+ KEY_SPACE = [' '].freeze
52
+ KEY_QUIT = ['q', "\e"].freeze
53
+
54
+ # Fixed version columns, mirroring `yarn upgrade-interactive`.
55
+ ROLE_ORDER = %i[current in_range latest].freeze
56
+ COLUMN_LABELS = { current: 'Current', in_range: 'Range', latest: 'Latest' }.freeze
57
+
58
+ # Semver bump levels, coloured like `yarn upgrade-interactive`.
59
+ LEVEL_COLOR = { major: '31', minor: '33', patch: '32' }.freeze # red / yellow / green
60
+
14
61
  def exec(_command, _args)
15
- response = prompt.multi_select(
16
- 'Select one or more gems to update:', outdated_gem_list,
17
- echo: false, per_page: 20, filter: true, cycle: true
18
- )
19
- Update.new(response).update! if response.any?
20
- rescue TTY::Reader::InputInterrupt
21
- prompt.say('Canceling...')
62
+ if outdated_gems.empty?
63
+ Bundler.ui.info('Bundle up to date!')
64
+ return
65
+ end
66
+
67
+ selection = interactive_select
68
+ return Bundler.ui.info('Canceling...') if selection.nil?
69
+
70
+ chosen = selection.reject(&:skip?)
71
+ return Bundler.ui.info('No gems selected.') if chosen.empty?
72
+
73
+ Update.new(chosen).update!
74
+ # TTY::Reader raises InputInterrupt (a subclass of Interrupt) on Ctrl-C
75
+ # because the reader is created with `interrupt: :error`. Rescue Interrupt
76
+ # directly so exception handling never references the TTY constant, which
77
+ # keeps a clear error if the dependency ever fails to load.
78
+ rescue Interrupt
79
+ show_cursor
80
+ Bundler.ui.info("\nCanceling...")
22
81
  end
23
82
 
24
- def outdated_gem_list
25
- outdated_gems.map do |gem|
26
- {
27
- name: "#{gem.name(name_padding)} #{gem.label}",
28
- value: gem.name
29
- }
30
- end
83
+ def outdated_gems
84
+ @outdated_gems ||= VersionResolver.new(
85
+ current_specs: current_gems,
86
+ resolved_definition: definition,
87
+ dependencies: dependencies,
88
+ cooldown_days: cooldown_days
89
+ ).outdated_gems
31
90
  end
32
91
 
33
92
  private
34
93
 
35
- def prompt
36
- @prompt ||= TTY::Prompt.new
94
+ # --- interaction loop ----------------------------------------------------
95
+
96
+ def interactive_select
97
+ @gems = outdated_gems
98
+ reader = TTY::Reader.new(interrupt: :error)
99
+ @cursor = 0
100
+ @offset = 0
101
+
102
+ hide_cursor
103
+ render(first: true)
104
+
105
+ loop do
106
+ case reader.read_keypress
107
+ when *KEY_UP then @cursor = (@cursor - 1) % @gems.size
108
+ when *KEY_DOWN then @cursor = (@cursor + 1) % @gems.size
109
+ when *KEY_LEFT then @gems[@cursor].cycle_left
110
+ when *KEY_RIGHT then @gems[@cursor].cycle_right
111
+ when *KEY_SPACE then toggle_skip(@gems[@cursor])
112
+ when *KEY_RETURN then return @gems
113
+ when *KEY_QUIT then return nil
114
+ end
115
+ render
116
+ end
117
+ ensure
118
+ show_cursor
37
119
  end
38
120
 
39
- def outdated_gems
40
- @outdated_gems ||= begin
41
- current_gems
42
- .sort_by(&:name)
43
- .each_with_object([]) do |current_spec, outdated|
44
- active_spec = definition.find_resolved_spec(current_spec)
45
- next unless gem_outdated?(current_spec, active_spec)
46
-
47
- outdated << OutdatedGem.new(current_spec, active_spec, dependencies[current_spec.name])
48
- end
121
+ def toggle_skip(gem)
122
+ gem.skip? ? gem.select_upgrade! : gem.select_current!
123
+ end
124
+
125
+ # --- rendering -----------------------------------------------------------
126
+
127
+ # Renders the header, a scrolling window of gem rows sized to the
128
+ # terminal, and a footer. Keeping the block within the terminal height is
129
+ # what lets the in-place `cursor_up` redraw work instead of scrolling the
130
+ # whole list off-screen on every keypress.
131
+ def render(first: false)
132
+ total = @gems.size
133
+ page = [page_size, total].min
134
+ adjust_viewport(total, page)
135
+
136
+ lines = header_lines
137
+ lines << column_header_line
138
+ (@offset...(@offset + page)).each { |i| lines << row_line(@gems[i], i == @cursor) }
139
+ lines << ''
140
+ lines << footer_line(total, page)
141
+
142
+ output = +''
143
+ output << cursor_up(@line_count) if !first && @line_count
144
+ lines.each { |line| output << "\e[2K" << line.rstrip << "\n" }
145
+ @line_count = lines.size
146
+
147
+ $stdout.print(output)
148
+ $stdout.flush
149
+ end
150
+
151
+ def adjust_viewport(total, page)
152
+ @offset = @cursor if @cursor < @offset
153
+ @offset = @cursor - page + 1 if @cursor > @offset + page - 1
154
+ max_offset = [total - page, 0].max
155
+ @offset = [[@offset, max_offset].min, 0].max
156
+ end
157
+
158
+ def page_size
159
+ @page_size ||= [terminal_rows - header_lines.size - 4, 3].max
160
+ end
161
+
162
+ def terminal_rows
163
+ (IO.console && IO.console.winsize.first) || 24
164
+ rescue StandardError
165
+ 24
166
+ end
167
+
168
+ def header_lines
169
+ lines = [
170
+ colorize('Select gems and target versions to update:', '1'),
171
+ dim('↑/↓ move · ←/→ choose version · space skip/restore · enter apply · q cancel'),
172
+ legend_line
173
+ ]
174
+ lines << dim('* = newer version held by cooldown') if cooldown_present?
175
+ lines << ''
176
+ lines
177
+ end
178
+
179
+ def legend_line
180
+ levels = %i[major minor patch].map { |level| color_for_level(level.to_s, level) }
181
+ dim('diff: ') + levels.join(dim(' · '))
182
+ end
183
+
184
+ def cooldown_present?
185
+ return @cooldown_present if defined?(@cooldown_present)
186
+
187
+ @cooldown_present = outdated_gems.any? do |gem|
188
+ gem.cooldown_note || gem.choices.any?(&:held)
189
+ end
190
+ end
191
+
192
+ def column_header_line
193
+ name_blank = ' ' * (2 + name_padding)
194
+ cells = present_roles.map { |role| bold(COLUMN_LABELS[role].ljust(column_width(role))) }
195
+ cells << bold('Group') if show_group?
196
+ "#{name_blank} #{cells.join(' ')}".rstrip
197
+ end
198
+
199
+ def row_line(gem, active)
200
+ pointer = active ? '❯ ' : ' '
201
+ cells = present_roles.map do |role|
202
+ choice = cell_for(gem, role)
203
+ version_cell(choice, choice && gem.selected.equal?(choice), column_width(role), gem.current_version)
204
+ end
205
+ cells << gem.group_label if show_group?
206
+ note = gem.cooldown_note ? " #{dim("← #{gem.cooldown_note}")}" : ''
207
+
208
+ "#{pointer}#{gem.name(name_padding)} #{cells.join(' ')}#{note}"
209
+ end
210
+
211
+ # Show the Group column only when at least one gem belongs to a group
212
+ # other than the implicit :default.
213
+ def show_group?
214
+ return @show_group if defined?(@show_group)
215
+
216
+ @show_group = outdated_gems.any? { |gem| !gem.group_label.empty? }
217
+ end
218
+
219
+ # Renders one version into a fixed-width column with a radio marker
220
+ # (● selected / ○ not), colouring the changed portion of the version by
221
+ # bump level so the table shows patch/minor/major at a glance. Padding is
222
+ # computed from the visible (uncoloured) text so columns stay aligned.
223
+ def version_cell(choice, selected, width, current)
224
+ return ' ' * width if choice.nil?
225
+
226
+ marker = selected ? '●' : '○'
227
+ ref = choice.ref ? " @#{choice.ref}" : ''
228
+ held = choice.held ? '*' : ''
229
+ visible = "#{marker} #{version_text(choice)}"
230
+ marker_out = selected ? color_for_level(marker, diff_level(choice.version, current)) : dim(marker)
231
+
232
+ "#{marker_out} #{colorize_diff(choice.version, current)}#{dim(ref)}#{held}" +
233
+ (' ' * [width - visible.length, 0].max)
234
+ end
235
+
236
+ # Returns the version string with the segments that differ from +current+
237
+ # (and everything after) coloured by bump level; the unchanged prefix is
238
+ # left plain. e.g. 8.1 -> 8.1.3 colours ".3" green, 7 -> 7.29.7 ".29.7"
239
+ # yellow, 4 -> 5.3.0 the whole thing red.
240
+ def colorize_diff(version, current)
241
+ index = diff_index(version, current)
242
+ return version.to_s if index.nil?
243
+
244
+ segments = version.segments
245
+ prefix = segments[0...index].join('.')
246
+ suffix = color_for_level(segments[index..-1].join('.'), diff_level(version, current))
247
+ prefix.empty? ? suffix : "#{prefix}.#{suffix}"
248
+ end
249
+
250
+ def diff_index(version, current)
251
+ current_segments = current.segments
252
+ version.segments.each_index.find { |i| version.segments[i] != current_segments[i] }
253
+ end
254
+
255
+ def diff_level(version, current)
256
+ case diff_index(version, current)
257
+ when nil then nil
258
+ when 0 then :major
259
+ when 1 then :minor
260
+ else :patch
49
261
  end
50
262
  end
51
263
 
264
+ def color_for_level(text, level)
265
+ level ? colorize(text, LEVEL_COLOR[level]) : text
266
+ end
267
+
268
+ def version_text(choice)
269
+ ref = choice.ref ? " @#{choice.ref}" : ''
270
+ "#{choice.version}#{ref}#{choice.held ? '*' : ''}"
271
+ end
272
+
273
+ def cell_for(gem, role)
274
+ gem.choices.find { |choice| choice.role == role }
275
+ end
276
+
277
+ def present_roles
278
+ @present_roles ||= ROLE_ORDER.select do |role|
279
+ outdated_gems.any? { |gem| cell_for(gem, role) }
280
+ end
281
+ end
282
+
283
+ def column_width(role)
284
+ @column_widths ||= {}
285
+ # +2 for the "● " radio marker that precedes each version.
286
+ @column_widths[role] ||= outdated_gems.map do |gem|
287
+ (choice = cell_for(gem, role)) ? version_text(choice).length + 2 : 0
288
+ end.push(COLUMN_LABELS[role].length).max
289
+ end
290
+
291
+ def footer_line(total, page)
292
+ count = @gems.count { |gem| !gem.skip? }
293
+ parts = ["#{count} of #{total} selected to update"]
294
+ parts << "showing #{@offset + 1}-#{@offset + page} of #{total}" if total > page
295
+ dim(parts.join(' · '))
296
+ end
297
+
298
+ def bold(text)
299
+ colorize(text, '1')
300
+ end
301
+
302
+ def cursor_up(count)
303
+ count.positive? ? "\e[#{count}A" : ''
304
+ end
305
+
306
+ def hide_cursor
307
+ $stdout.print("\e[?25l") if color?
308
+ end
309
+
310
+ def show_cursor
311
+ $stdout.print("\e[?25h") if color?
312
+ end
313
+
314
+ def colorize(text, code)
315
+ color? ? "\e[#{code}m#{text}\e[0m" : text
316
+ end
317
+
318
+ def dim(text)
319
+ colorize(text, '2')
320
+ end
321
+
322
+ def color?
323
+ $stdout.tty?
324
+ end
325
+
326
+ def name_padding
327
+ @name_padding ||= outdated_gems.map { |gem| gem.name.length }.max
328
+ end
329
+
330
+ # --- bundler data --------------------------------------------------------
331
+
52
332
  def current_gems
53
333
  @current_gems ||= Bundler.definition.tap(&:validate_runtime!).resolve
54
334
  end
55
335
 
336
+ # Adapts a resolved Bundler SpecSet to the lookup the resolver expects.
337
+ # (Bundler::Definition#find_resolved_spec was removed in Bundler 4.x.)
338
+ ResolvedSpecs = Struct.new(:spec_set) do
339
+ def find_resolved_spec(current_spec)
340
+ spec_set.find_by_name_and_platform(current_spec.name, current_spec.platform)
341
+ end
342
+ end
343
+
56
344
  def definition
57
- @definition ||= Bundler.definition(true).tap(&:resolve_remotely!)
345
+ @definition ||= ResolvedSpecs.new(
346
+ Bundler.definition(true).tap(&:resolve_remotely!).resolve
347
+ )
58
348
  end
59
349
 
60
350
  def dependencies
@@ -62,15 +352,18 @@ module Bundler
62
352
  Bundler.load.dependencies.map { |dep| [dep.name, dep] }.to_h
63
353
  end
64
354
 
65
- def gem_outdated?(current_spec, active_spec)
66
- active_spec.version > current_spec.version ||
67
- active_spec.git_version != current_spec.git_version
68
- rescue NoMethodError
69
- puts "Gem #{current_spec.name}, couldn't find an active_spec"
70
- end
355
+ # Cooldown is opt-in and only exists on Bundler >= 4.0.13, so read it
356
+ # defensively and treat anything unparseable as "off".
357
+ def cooldown_days
358
+ return @cooldown_days if defined?(@cooldown_days)
71
359
 
72
- def name_padding
73
- @name_padding ||= outdated_gems.map { |gem| gem.name.length }.max
360
+ @cooldown_days =
361
+ begin
362
+ value = Bundler.settings[:cooldown]
363
+ value.nil? ? 0 : value.to_i
364
+ rescue StandardError
365
+ 0
366
+ end
74
367
  end
75
368
  end
76
369
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Bundler
4
4
  module Interactive
5
- VERSION = '0.1.2.1'
5
+ VERSION = '0.2.1'
6
6
  end
7
7
  end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bundler
4
+ module Interactive
5
+ # Gathers the candidate versions for every outdated gem.
6
+ #
7
+ # For each installed gem it computes:
8
+ # * the currently installed (locked) version,
9
+ # * the in-range latest that dependency resolution selected (which already
10
+ # respects the Gemfile constraint and, when configured, the cooldown
11
+ # window),
12
+ # * the absolute latest published version from the gem's source, and
13
+ # * the in-range version held back by cooldown, if any.
14
+ #
15
+ # Collaborators are injected so the resolver can be unit-tested without a
16
+ # live Bundler environment.
17
+ class VersionResolver
18
+ def initialize(current_specs:, resolved_definition:, dependencies:, cooldown_days: 0)
19
+ @current_specs = current_specs
20
+ @resolved_definition = resolved_definition
21
+ @dependencies = dependencies
22
+ @cooldown_days = cooldown_days.to_i
23
+ end
24
+
25
+ def outdated_gems
26
+ current_specs.sort_by(&:name).each_with_object([]) do |current_spec, list|
27
+ resolved = resolved_definition.find_resolved_spec(current_spec)
28
+ next unless resolved
29
+
30
+ gem = build(current_spec, resolved)
31
+ # A gem is worth listing when it offers any upgrade target — an
32
+ # in-range bump, a newer git revision, or a version beyond the
33
+ # declared constraint (which `bundle outdated` surfaces too).
34
+ list << gem if gem.upgradable?
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ attr_reader :current_specs, :resolved_definition, :dependencies, :cooldown_days
41
+
42
+ def build(current_spec, resolved)
43
+ dependency = dependencies[current_spec.name]
44
+ available = available_versions(current_spec, resolved)
45
+
46
+ OutdatedGem.new(
47
+ current_spec: current_spec,
48
+ resolved_spec: resolved,
49
+ dependency: dependency,
50
+ latest_version: available.max || resolved.version,
51
+ pending_version: pending_version(resolved, dependency, available),
52
+ cooldown_active: cooldown_active?
53
+ )
54
+ end
55
+
56
+ # All published versions for the gem, straight from the source index (this
57
+ # list is *not* cooldown-filtered, which is what lets us detect held
58
+ # versions). Prereleases are dropped unless the installed gem is itself a
59
+ # prerelease, mirroring `bundle outdated`.
60
+ def available_versions(current_spec, resolved)
61
+ specs = resolved.source.specs.search(resolved.name)
62
+ specs = specs.select { |spec| installable?(spec, current_spec) }
63
+ versions = specs.map(&:version)
64
+ versions = versions.reject(&:prerelease?) unless current_spec.version.prerelease?
65
+ versions.uniq.sort
66
+ rescue StandardError
67
+ []
68
+ end
69
+
70
+ def installable?(spec, current_spec)
71
+ return true unless spec.respond_to?(:installable_on_platform?)
72
+
73
+ spec.installable_on_platform?(current_spec.platform)
74
+ rescue StandardError
75
+ true
76
+ end
77
+
78
+ # The highest in-range version available in the source that resolution
79
+ # refused to pick — i.e. a version waiting out the cooldown window.
80
+ def pending_version(resolved, dependency, available)
81
+ return nil unless cooldown_active?
82
+ return nil unless dependency
83
+
84
+ candidate = available.select { |version| dependency.requirement.satisfied_by?(version) }.max
85
+ return nil unless candidate
86
+
87
+ candidate > resolved.version ? candidate : nil
88
+ end
89
+
90
+ def cooldown_active?
91
+ cooldown_days.positive?
92
+ end
93
+ end
94
+ end
95
+ end
data/plugins.rb CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  require 'bundler/interactive'
4
4
 
5
- Bundler::Plugin::API.command(
6
- 'update-interactive', Bundler::Interactive::UpdateInteractive
7
- )
5
+ # `update-interactive` matches Bundler's own verb (`bundle update`);
6
+ # `upgrade-interactive` is an alias for parity with `yarn upgrade-interactive`.
7
+ %w[update-interactive upgrade-interactive].each do |command|
8
+ Bundler::Plugin::API.command(command, Bundler::Interactive::UpdateInteractive)
9
+ end
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bundler-interactive
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2.1
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Wes Rich
8
- autorequire:
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2019-08-02 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: bundler
@@ -28,14 +27,14 @@ dependencies:
28
27
  name: rake
29
28
  requirement: !ruby/object:Gem::Requirement
30
29
  requirements:
31
- - - "~>"
30
+ - - ">="
32
31
  - !ruby/object:Gem::Version
33
32
  version: '10.0'
34
33
  type: :development
35
34
  prerelease: false
36
35
  version_requirements: !ruby/object:Gem::Requirement
37
36
  requirements:
38
- - - "~>"
37
+ - - ">="
39
38
  - !ruby/object:Gem::Version
40
39
  version: '10.0'
41
40
  - !ruby/object:Gem::Dependency
@@ -73,11 +72,10 @@ executables: []
73
72
  extensions: []
74
73
  extra_rdoc_files: []
75
74
  files:
75
+ - ".github/workflows/ci.yml"
76
76
  - ".gitignore"
77
77
  - ".rspec"
78
78
  - ".rubocop.yml"
79
- - ".semaphore/semaphore.yml"
80
- - ".travis.yml"
81
79
  - Gemfile
82
80
  - README.md
83
81
  - Rakefile
@@ -85,10 +83,12 @@ files:
85
83
  - bin/setup
86
84
  - bundler-interactive.gemspec
87
85
  - lib/bundler/interactive.rb
86
+ - lib/bundler/interactive/gemfile_editor.rb
88
87
  - lib/bundler/interactive/outdated_gem.rb
89
88
  - lib/bundler/interactive/update.rb
90
89
  - lib/bundler/interactive/update_interactive.rb
91
90
  - lib/bundler/interactive/version.rb
91
+ - lib/bundler/interactive/version_resolver.rb
92
92
  - plugins.rb
93
93
  homepage: https://github.com/wesrich/Bundler-Interactive
94
94
  licenses: []
@@ -97,7 +97,6 @@ metadata:
97
97
  homepage_uri: https://github.com/wesrich/Bundler-Interactive
98
98
  source_code_uri: https://github.com/wesrich/Bundler-Interactive
99
99
  changelog_uri: https://github.com/wesrich/Bundler-Interactive
100
- post_install_message:
101
100
  rdoc_options: []
102
101
  require_paths:
103
102
  - lib
@@ -112,8 +111,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
112
111
  - !ruby/object:Gem::Version
113
112
  version: '0'
114
113
  requirements: []
115
- rubygems_version: 3.0.4
116
- signing_key:
114
+ rubygems_version: 4.0.10
117
115
  specification_version: 4
118
116
  summary: Interactive prompt for Bundler
119
117
  test_files: []
@@ -1,26 +0,0 @@
1
- version: v1.0
2
- name: Bundler::Interactive
3
- agent:
4
- machine:
5
- type: e1-standard-2
6
- os_image: ubuntu1804
7
-
8
- blocks:
9
- - name: Build
10
- execution_time_limit:
11
- minutes: 1
12
- task:
13
- jobs:
14
- - name: Tests
15
- matrix:
16
- - env_var: RUBY_VERSION
17
- values: ['2.3', '2.4', '2.5', '2.6.3']
18
- commands:
19
- - checkout
20
- - sem-version ruby $RUBY_VERSION
21
-
22
- # - cache restore gems-$RUBY_VERSION,gems-
23
- # - bundle check --path=vendor/bundle || bundle install
24
- # - cache store gems-$RUBY_VERSION vendor/bundle
25
- - bundle install
26
- - bundle exec rspec --format documentation
data/.travis.yml DELETED
@@ -1,9 +0,0 @@
1
- ---
2
- sudo: false
3
- language: ruby
4
- cache: bundler
5
- rvm:
6
- - 2.3
7
- - 2.4
8
- - 2.5
9
- - 2.6