bundle_update_interactive 0.5.0 → 0.7.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 32a7f01a5cd12b428ccbf01ae0dbe016fcea9d3a3ab9e390b602f9387ece19f8
4
- data.tar.gz: 3fc3df0da19212ffa444e4d8b1d5ac6b4e33eff27704d7c0d18ea98c3aac1c91
3
+ metadata.gz: 58fa0e12f3322d018b1fcaa788dd3b8a2c3354042fe1e6ab21313d0bc0d7518b
4
+ data.tar.gz: a43866c8bad9bc0256b369cbf7e29db56c2b06293f2ae3a4a3e560ff75506e16
5
5
  SHA512:
6
- metadata.gz: a13fb74ddbd25a6570b0ec6655b539328edca5140cb8156a2d1b94c64d0c5d195089330a408498a79d881d7b7013fb31b98490163250d1d7e34ab68499b7f291
7
- data.tar.gz: db26535a1961ad67e3932f94ca358a747e8e1209b83f8c44070ded841338cf052123960ae8d89f6664c4aac7c4fc987ce07fa72cc1e373f772c48ba0d9c0054e
6
+ metadata.gz: 5325810c277c4a3f58d3308b9899c9097a2c723996e79030ccda96f79408c83734b2bcf9ae5920a1ee74f32837ebae91f4e946aa9ff08225db73bf6d06d69e91
7
+ data.tar.gz: d241d58cb408a0b6294a8b56e010b8ffa841bdbb5ccf28c794fb2db4ebcc562f7ec486742ec36720ed9522c9cf2ce1d27d8a68619fefbee59d28810b3044e7fb
data/README.md CHANGED
@@ -63,6 +63,12 @@ Some gems, notably `rails`, are composed of smaller gems like `actionpack`, `act
63
63
 
64
64
  Therefore, if any Rails component has a security vulnerability, `bundle update-interactive` will automatically roll up that information into a single `rails` line item, so you can select it and upgrade all of its components in one shot.
65
65
 
66
+ ### Held back gems
67
+
68
+ When a newer version of a gem is available, but updating is not allowed due to a Gemfile requirement, `update-interactive` will report that the gem has been held back.
69
+
70
+ <img src="images/held-back.png" alt="Screenshot of rails and selenium-webdriver gems held back due to Gemfile requirements" width="717" />
71
+
66
72
  ### Changelogs
67
73
 
68
74
  `bundle update-interactive` will do its best to find an appropriate changelog for each gem.
@@ -19,6 +19,14 @@ module BundleUpdateInteractive
19
19
  `#{command.join(" ")}`.tap { raise "bundle lock command failed" unless Process.last_status.success? }
20
20
  end
21
21
 
22
+ def parse_outdated(*gems)
23
+ command = ["#{bundle_bin.shellescape} outdated --parseable", *gems.flatten.map(&:shellescape)]
24
+ output = `#{command.join(" ")}`
25
+ raise "bundle outdated command failed" if output.empty? && !Process.last_status.success?
26
+
27
+ output.scan(/^(\S+) \(newest (\S+),/).to_h
28
+ end
29
+
22
30
  private
23
31
 
24
32
  def bundle_bin
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "launchy"
3
4
  require "pastel"
4
5
  require "tty/prompt"
5
6
  require "tty/screen"
@@ -8,6 +9,7 @@ class BundleUpdateInteractive::CLI
8
9
  class MultiSelect
9
10
  class List < TTY::Prompt::MultiList
10
11
  def initialize(prompt, **options)
12
+ @opener = options.delete(:opener)
11
13
  defaults = {
12
14
  cycle: true,
13
15
  help_color: :itself.to_proc,
@@ -21,48 +23,64 @@ class BundleUpdateInteractive::CLI
21
23
  def selected_names
22
24
  ""
23
25
  end
26
+
27
+ # Unregister tty-prompt's default ctrl-a and ctrl-r bindings
28
+ alias select_all keyctrl_a
29
+ alias reverse_selection keyctrl_r
30
+ def keyctrl_a(*); end
31
+ def keyctrl_r(*); end
32
+
33
+ def keypress(event)
34
+ case event.value
35
+ when "k", "p" then keyup
36
+ when "j", "n" then keydown
37
+ when "a" then select_all
38
+ when "r" then reverse_selection
39
+ when "o" then opener&.call(choices[@active - 1].value)
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ attr_reader :opener
24
46
  end
25
47
 
26
- def self.prompt_for_gems_to_update(outdated_gems)
27
- table = Table.new(outdated_gems)
48
+ def self.prompt_for_gems_to_update(outdated_gems, prompt: nil)
49
+ table = Table.updatable(outdated_gems)
28
50
  title = "#{outdated_gems.length} gems can be updated."
29
- chosen = new(title: title, table: table).prompt
51
+ opener = lambda do |gem|
52
+ url = outdated_gems[gem].changelog_uri
53
+ Launchy.open(url) unless url.nil?
54
+ end
55
+ chosen = new(title: title, table: table, prompt: prompt, opener: opener).prompt
30
56
  outdated_gems.slice(*chosen)
31
57
  end
32
58
 
33
- def initialize(title:, table:)
59
+ def initialize(title:, table:, opener: nil, prompt: nil)
34
60
  @title = title
35
61
  @table = table
36
- @tty_prompt = TTY::Prompt.new(
62
+ @opener = opener
63
+ @tty_prompt = prompt || TTY::Prompt.new(
37
64
  interrupt: lambda {
38
65
  puts
39
66
  exit(130)
40
67
  }
41
68
  )
42
- add_keybindings
43
-
44
69
  @pastel = BundleUpdateInteractive.pastel
45
70
  end
46
71
 
47
72
  def prompt
48
73
  choices = table.gem_names.to_h { |name| [table.render_gem(name), name] }
49
- tty_prompt.invoke_select(List, title, choices, help: help)
74
+ tty_prompt.invoke_select(List, title, choices, help: help, opener: opener)
50
75
  end
51
76
 
52
77
  private
53
78
 
54
- attr_reader :pastel, :table, :tty_prompt, :title
55
-
56
- def add_keybindings
57
- tty_prompt.on(:keypress) do |event|
58
- tty_prompt.trigger(:keyup) if %w[k p].include?(event.value)
59
- tty_prompt.trigger(:keydown) if %w[j n].include?(event.value)
60
- end
61
- end
79
+ attr_reader :pastel, :table, :opener, :tty_prompt, :title
62
80
 
63
81
  def help
64
82
  [
65
- pastel.dim("\nPress <space> to select, ↑/↓ move, <ctrl-a> all, <ctrl-r> reverse, <enter> to finish."),
83
+ pastel.dim("\nPress <space> to select, ↑/↓ move, <a> all, <r> reverse, <o> open url, <enter> to finish."),
66
84
  "\n ",
67
85
  table.render_header
68
86
  ].join
@@ -16,17 +16,6 @@ class BundleUpdateInteractive::CLI
16
16
  @pastel = BundleUpdateInteractive.pastel
17
17
  end
18
18
 
19
- def to_a
20
- [
21
- formatted_gem_name,
22
- formatted_current_version,
23
- "→",
24
- formatted_updated_version,
25
- formatted_gemfile_groups,
26
- formatted_changelog_uri
27
- ]
28
- end
29
-
30
19
  def formatted_gem_name
31
20
  vulnerable? ? pastel.white.on_red(name) : apply_semver_highlight(name)
32
21
  end
@@ -46,6 +35,10 @@ class BundleUpdateInteractive::CLI
46
35
  gemfile_groups&.map(&:inspect)&.join(", ")
47
36
  end
48
37
 
38
+ def formatted_gemfile_requirement
39
+ gemfile_requirement.to_s == ">= 0" ? "" : gemfile_requirement.to_s
40
+ end
41
+
49
42
  def formatted_changelog_uri
50
43
  pastel.blue(changelog_uri)
51
44
  end
@@ -4,12 +4,44 @@ require "pastel"
4
4
 
5
5
  class BundleUpdateInteractive::CLI
6
6
  class Table
7
- HEADERS = ["name", "from", nil, "to", "group", "url"].freeze
7
+ class << self
8
+ def withheld(gems)
9
+ columns = [
10
+ ["name", :formatted_gem_name],
11
+ ["requirement", :formatted_gemfile_requirement],
12
+ ["current", :formatted_current_version],
13
+ ["latest", :formatted_updated_version],
14
+ ["group", :formatted_gemfile_groups],
15
+ ["url", :formatted_changelog_uri]
16
+ ]
17
+ new(gems, columns)
18
+ end
8
19
 
9
- def initialize(outdated_gems)
20
+ def updatable(gems)
21
+ columns = [
22
+ ["name", :formatted_gem_name],
23
+ ["from", :formatted_current_version],
24
+ [nil, "→"],
25
+ ["to", :formatted_updated_version],
26
+ ["group", :formatted_gemfile_groups],
27
+ ["url", :formatted_changelog_uri]
28
+ ]
29
+ new(gems, columns)
30
+ end
31
+ end
32
+
33
+ def initialize(gems, columns)
10
34
  @pastel = BundleUpdateInteractive.pastel
11
- @headers = HEADERS.map { |h| pastel.dim.underline(h) }
12
- @rows = outdated_gems.transform_values { |gem| Row.new(gem).to_a.map(&:to_s) }
35
+ @headers = columns.map { |header, _| pastel.dim.underline(header) }
36
+ @rows = gems.transform_values do |gem|
37
+ row = Row.new(gem)
38
+ columns.map do |_, col|
39
+ case col
40
+ when Symbol then row.public_send(col).to_s
41
+ when String then col
42
+ end
43
+ end
44
+ end
13
45
  @column_widths = calculate_column_widths
14
46
  end
15
47
 
@@ -11,19 +11,16 @@ module BundleUpdateInteractive
11
11
 
12
12
  def run(argv: ARGV) # rubocop:disable Metrics/AbcSize
13
13
  options = Options.parse(argv)
14
-
15
14
  report = generate_report(options)
16
- puts("No gems to update.").then { return } if report.updateable_gems.empty?
17
15
 
18
- puts
19
- puts legend
20
- puts
21
- selected_gems = MultiSelect.prompt_for_gems_to_update(report.updateable_gems)
16
+ puts_legend_and_withheld_gems(report) unless report.empty?
17
+ puts("No gems to update.").then { return } if report.updatable_gems.empty?
18
+
19
+ selected_gems = MultiSelect.prompt_for_gems_to_update(report.updatable_gems)
22
20
  puts("No gems to update.").then { return } if selected_gems.empty?
23
21
 
24
- puts "\nUpdating the following gems."
25
- puts
26
- puts Table.new(selected_gems).render
22
+ puts "Updating the following gems."
23
+ puts Table.updatable(selected_gems).render
27
24
  puts
28
25
  report.bundle_update!(*selected_gems.keys)
29
26
  rescue Exception => e # rubocop:disable Lint/RescueException
@@ -32,6 +29,17 @@ module BundleUpdateInteractive
32
29
 
33
30
  private
34
31
 
32
+ def puts_legend_and_withheld_gems(report)
33
+ puts
34
+ puts legend
35
+ puts
36
+ return if report.withheld_gems.empty?
37
+
38
+ puts "The following gems are being held back and cannot be updated."
39
+ puts Table.withheld(report.withheld_gems).render
40
+ puts
41
+ end
42
+
35
43
  def legend
36
44
  pastel = BundleUpdateInteractive.pastel
37
45
  <<~LEGEND
@@ -46,14 +54,13 @@ module BundleUpdateInteractive
46
54
 
47
55
  def generate_report(options)
48
56
  whisper "Resolving latest gem versions..."
49
- report = Report.generate(groups: options.exclusively)
50
- updateable_gems = report.updateable_gems
51
- return report if updateable_gems.empty?
57
+ report = Reporter.new(groups: options.exclusively).generate_report
58
+ return report if report.empty?
52
59
 
53
60
  whisper "Checking for security vulnerabilities..."
54
61
  report.scan_for_vulnerabilities!
55
62
 
56
- progress "Finding changelogs", updateable_gems.values, &:changelog_uri
63
+ progress "Finding changelogs", report.all_gems.values, &:changelog_uri
57
64
  report
58
65
  end
59
66
 
@@ -4,13 +4,14 @@ module BundleUpdateInteractive
4
4
  class OutdatedGem
5
5
  attr_accessor :name,
6
6
  :gemfile_groups,
7
+ :gemfile_requirement,
7
8
  :git_source_uri,
8
9
  :current_version,
9
10
  :current_git_version,
10
11
  :updated_version,
11
12
  :updated_git_version
12
13
 
13
- attr_writer :rubygems_source, :vulnerable
14
+ attr_writer :changelog_uri, :rubygems_source, :vulnerable
14
15
 
15
16
  def initialize(**attrs)
16
17
  @vulnerable = nil
@@ -7,38 +7,20 @@ require "set"
7
7
 
8
8
  module BundleUpdateInteractive
9
9
  class Report
10
- class << self
11
- def generate(groups: [])
12
- gemfile = Gemfile.parse
13
- current_lockfile = Lockfile.parse
14
- gems = groups.any? ? current_lockfile.gems_exclusively_installed_by(gemfile: gemfile, groups: groups) : nil
10
+ attr_reader :withheld_gems, :updatable_gems
15
11
 
16
- updated_lockfile = gems&.none? ? nil : Lockfile.parse(BundlerCommands.read_updated_lockfile(*Array(gems)))
17
- new(gemfile: gemfile, current_lockfile: current_lockfile, updated_lockfile: updated_lockfile)
18
- end
19
- end
20
-
21
- attr_reader :outdated_gems
22
-
23
- def initialize(gemfile:, current_lockfile:, updated_lockfile:)
12
+ def initialize(current_lockfile:, withheld_gems:, updatable_gems:)
24
13
  @current_lockfile = current_lockfile
25
- @outdated_gems = current_lockfile.entries.each_with_object({}) do |current_lockfile_entry, hash|
26
- name = current_lockfile_entry.name
27
- updated_lockfile_entry = updated_lockfile && updated_lockfile[name]
28
- next unless current_lockfile_entry.older_than?(updated_lockfile_entry)
29
-
30
- hash[name] = build_outdated_gem(current_lockfile_entry, updated_lockfile_entry, gemfile[name]&.groups)
31
- end.freeze
14
+ @withheld_gems = withheld_gems.freeze
15
+ @updatable_gems = updatable_gems.freeze
32
16
  end
33
17
 
34
- def [](gem_name)
35
- outdated_gems[gem_name]
18
+ def empty?
19
+ withheld_gems.empty? && updatable_gems.empty?
36
20
  end
37
21
 
38
- def updateable_gems
39
- @updateable_gems ||= outdated_gems.reject do |name, _|
40
- current_lockfile[name].exact_requirement?
41
- end.freeze
22
+ def all_gems
23
+ @all_gems ||= withheld_gems.merge(updatable_gems)
42
24
  end
43
25
 
44
26
  def expand_gems_with_exact_dependencies(*gem_names)
@@ -47,13 +29,13 @@ module BundleUpdateInteractive
47
29
  end
48
30
 
49
31
  def scan_for_vulnerabilities!
50
- return false if outdated_gems.empty?
32
+ return false if all_gems.empty?
51
33
 
52
34
  Bundler::Audit::Database.update!(quiet: true)
53
35
  audit_report = Bundler::Audit::Scanner.new.report
54
36
  vulnerable_gem_names = Set.new(audit_report.vulnerable_gems.map(&:name))
55
37
 
56
- outdated_gems.each do |name, gem|
38
+ all_gems.each do |name, gem|
57
39
  gem.vulnerable = (vulnerable_gem_names & [name, *current_lockfile[name].exact_dependencies]).any?
58
40
  end
59
41
  true
@@ -67,18 +49,5 @@ module BundleUpdateInteractive
67
49
  private
68
50
 
69
51
  attr_reader :current_lockfile
70
-
71
- def build_outdated_gem(current_lockfile_entry, updated_lockfile_entry, gemfile_groups)
72
- OutdatedGem.new(
73
- name: current_lockfile_entry.name,
74
- gemfile_groups: gemfile_groups,
75
- rubygems_source: updated_lockfile_entry.rubygems_source?,
76
- git_source_uri: updated_lockfile_entry.git_source_uri&.to_s,
77
- current_version: current_lockfile_entry.version.to_s,
78
- current_git_version: current_lockfile_entry.git_version&.strip,
79
- updated_version: updated_lockfile_entry.version.to_s,
80
- updated_git_version: updated_lockfile_entry.git_version&.strip
81
- )
82
- end
83
52
  end
84
53
  end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BundleUpdateInteractive
4
+ class Reporter
5
+ def initialize(groups: [])
6
+ @gemfile = Gemfile.parse
7
+ @current_lockfile = Lockfile.parse
8
+ @candidate_gems = current_lockfile.gems_exclusively_installed_by(gemfile: gemfile, groups: groups) if groups.any?
9
+ end
10
+
11
+ def generate_report
12
+ updatable_gems = find_updatable_gems
13
+ withheld_gems = find_withheld_gems(exclude: updatable_gems.keys)
14
+
15
+ Report.new(current_lockfile: current_lockfile, updatable_gems: updatable_gems, withheld_gems: withheld_gems)
16
+ end
17
+
18
+ private
19
+
20
+ attr_reader :gemfile, :current_lockfile, :candidate_gems
21
+
22
+ def find_updatable_gems
23
+ return {} if candidate_gems && candidate_gems.empty?
24
+
25
+ updated_lockfile = Lockfile.parse(BundlerCommands.read_updated_lockfile(*Array(candidate_gems)))
26
+ current_lockfile.entries.each_with_object({}) do |current_lockfile_entry, hash|
27
+ name = current_lockfile_entry.name
28
+ updated_lockfile_entry = updated_lockfile && updated_lockfile[name]
29
+ next unless current_lockfile_entry.older_than?(updated_lockfile_entry)
30
+ next if current_lockfile_entry.exact_requirement?
31
+
32
+ hash[name] = build_outdated_gem(name, updated_lockfile_entry.version, updated_lockfile_entry.git_version)
33
+ end
34
+ end
35
+
36
+ def build_outdated_gem(name, updated_version, updated_git_version)
37
+ current_lockfile_entry = current_lockfile[name]
38
+
39
+ OutdatedGem.new(
40
+ name: name,
41
+ gemfile_groups: gemfile[name]&.groups,
42
+ gemfile_requirement: gemfile[name]&.requirement&.to_s,
43
+ rubygems_source: current_lockfile_entry.rubygems_source?,
44
+ git_source_uri: current_lockfile_entry.git_source_uri&.to_s,
45
+ current_version: current_lockfile_entry.version.to_s,
46
+ current_git_version: current_lockfile_entry.git_version&.strip,
47
+ updated_version: updated_version.to_s,
48
+ updated_git_version: updated_git_version&.strip
49
+ )
50
+ end
51
+
52
+ def find_withheld_gems(exclude: [])
53
+ possibly_withheld = gemfile.dependencies.filter_map do |dep|
54
+ dep.name if dep.should_include? && !dep.requirement.none? # rubocop:disable Style/InverseMethods
55
+ end
56
+ possibly_withheld -= exclude
57
+ possibly_withheld &= candidate_gems unless candidate_gems.nil?
58
+
59
+ return {} if possibly_withheld.empty?
60
+
61
+ BundlerCommands.parse_outdated(*possibly_withheld).to_h do |name, newest|
62
+ [name, build_outdated_gem(name, newest, nil)]
63
+ end
64
+ end
65
+ end
66
+ end
@@ -10,10 +10,12 @@ module BundleUpdateInteractive
10
10
 
11
11
  @same_segments = new_segments.take_while.with_index { |seg, i| seg == old_segments[i] }
12
12
  @diff_segments = new_segments[same_segments.length..]
13
+
14
+ @changed = diff_segments.any? || old_segments.length != new_segments.length
13
15
  end
14
16
 
15
17
  def severity
16
- return nil if diff_segments.empty?
18
+ return nil unless @changed
17
19
 
18
20
  SEVERITIES[same_segments.length] || :patch
19
21
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module BundleUpdateInteractive
4
- VERSION = "0.5.0"
4
+ VERSION = "0.7.0"
5
5
  end
@@ -13,6 +13,7 @@ module BundleUpdateInteractive
13
13
  autoload :LockfileEntry, "bundle_update_interactive/lockfile_entry"
14
14
  autoload :OutdatedGem, "bundle_update_interactive/outdated_gem"
15
15
  autoload :Report, "bundle_update_interactive/report"
16
+ autoload :Reporter, "bundle_update_interactive/reporter"
16
17
  autoload :SemverChange, "bundle_update_interactive/semver_change"
17
18
  autoload :VERSION, "bundle_update_interactive/version"
18
19
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bundle_update_interactive
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matt Brictson
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2024-08-03 00:00:00.000000000 Z
11
+ date: 2024-08-14 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -38,6 +38,20 @@ dependencies:
38
38
  - - ">="
39
39
  - !ruby/object:Gem::Version
40
40
  version: 0.9.1
41
+ - !ruby/object:Gem::Dependency
42
+ name: launchy
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: 2.5.0
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: 2.5.0
41
55
  - !ruby/object:Gem::Dependency
42
56
  name: pastel
43
57
  requirement: !ruby/object:Gem::Requirement
@@ -108,6 +122,7 @@ files:
108
122
  - lib/bundle_update_interactive/lockfile_entry.rb
109
123
  - lib/bundle_update_interactive/outdated_gem.rb
110
124
  - lib/bundle_update_interactive/report.rb
125
+ - lib/bundle_update_interactive/reporter.rb
111
126
  - lib/bundle_update_interactive/semver_change.rb
112
127
  - lib/bundle_update_interactive/version.rb
113
128
  homepage: https://github.com/mattbrictson/bundle_update_interactive