bundle_update_interactive 0.1.0

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 9c9585ae537745fcca9df3d3c56bf670b9c89c76e6a8764f06cded19facf8f56
4
+ data.tar.gz: 25cda618302b78573e94cf4551d82cf05ac13dca1be5c70d1dd9f10f4e1cc78b
5
+ SHA512:
6
+ metadata.gz: 233ff8af81ceb4810b121bd8d603318b5ee8e966b0ef7f11ea3d4bd1c40f4c70d679fa2cc4ec34510d2a70ba0dc3d4dbf6ce2b7f7a9315dc328fdf10e4540238
7
+ data.tar.gz: 1d5d043af5822a8764696e8e1a959ba603400ef5b6ff63785fd7a10728ecb09df6f490b58a296870a98b3c7ecdc82cc8abb1e5a7e05e02408ea3a952a00084c2
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2024 Matt Brictson
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.
data/README.md ADDED
@@ -0,0 +1,54 @@
1
+ # bundle_update_interactive
2
+
3
+ [![Gem Version](https://img.shields.io/gem/v/bundle_update_interactive)](https://rubygems.org/gems/bundle_update_interactive)
4
+ [![Gem Downloads](https://img.shields.io/gem/dt/bundle_update_interactive)](https://www.ruby-toolbox.com/projects/bundle_update_interactive)
5
+ [![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/mattbrictson/bundle_update_interactive/ci.yml)](https://github.com/mattbrictson/bundle_update_interactive/actions/workflows/ci.yml)
6
+ [![Code Climate maintainability](https://img.shields.io/codeclimate/maintainability/mattbrictson/bundle_update_interactive)](https://codeclimate.com/github/mattbrictson/bundle_update_interactive)
7
+
8
+ This gem adds an `update-interactive` command to [Bundler](https://bundler.io).
9
+
10
+ https://github.com/user-attachments/assets/3ec11073-b365-4f92-be76-60c9ac73d1be
11
+
12
+ ---
13
+
14
+ - [Quick start](#quick-start)
15
+ - [Support](#support)
16
+ - [License](#license)
17
+ - [Code of conduct](#code-of-conduct)
18
+ - [Contribution guide](#contribution-guide)
19
+
20
+ ## Quick start
21
+
22
+ Install the gem:
23
+
24
+ ```
25
+ gem install bundle_update_interactive
26
+ ```
27
+
28
+ Now you can use:
29
+
30
+ ```
31
+ bundle update-interactive
32
+ ```
33
+
34
+ Or the shorthand:
35
+
36
+ ```
37
+ bundle ui
38
+ ```
39
+
40
+ ## Support
41
+
42
+ If you want to report a bug, or have ideas, feedback or questions about the gem, [let me know via GitHub issues](https://github.com/mattbrictson/bundle_update_interactive/issues/new) and I will do my best to provide a helpful answer. Happy hacking!
43
+
44
+ ## License
45
+
46
+ The gem is available as open source under the terms of the [MIT License](LICENSE.txt).
47
+
48
+ ## Code of conduct
49
+
50
+ Everyone interacting in this project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](CODE_OF_CONDUCT.md).
51
+
52
+ ## Contribution guide
53
+
54
+ Pull requests are welcome!
data/exe/bundler-ui ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundle_update_interactive"
5
+ BundleUpdateInteractive::CLI.start(ARGV)
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundle_update_interactive"
5
+ BundleUpdateInteractive::CLI.start(ARGV)
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shellwords"
4
+
5
+ module BundleUpdateInteractive
6
+ module BundlerCommands
7
+ class << self
8
+ def update_gems_conservatively(*gems)
9
+ system "bundle update --conservative #{gems.flatten.map(&:shellescape).join(' ')}"
10
+ end
11
+
12
+ def read_updated_lockfile
13
+ `bundle lock --print --update`.tap do
14
+ raise "bundle lock command failed" unless Process.last_status.success?
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "json"
5
+
6
+ GITHUB_PATTERN = %r{^(?:https?://)?github\.com/([^/]+/[^/]+)(?:\.git)?/?}.freeze
7
+ URI_KEYS = %w[source_code_uri homepage_uri bug_tracker_uri wiki_uri].freeze
8
+ FILE_PATTERN = /(?:changelog|changes|history|news|release)/.freeze
9
+ EXT_PATTERN = /(?:md|txt|rdoc)/.freeze
10
+
11
+ module BundleUpdateInteractive
12
+ class ChangelogLocator
13
+ # TODO: refactor
14
+ def find_changelog_uri(name:, version: nil) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
15
+ if version
16
+ response = Faraday.get("https://rubygems.org/api/v2/rubygems/#{name}/versions/#{version}.json")
17
+ version = nil unless response.success?
18
+ end
19
+
20
+ response = Faraday.get("https://rubygems.org/api/v1/gems/#{name}.json") if version.nil?
21
+
22
+ return nil unless response.success?
23
+
24
+ data = JSON.parse(response.body)
25
+
26
+ version ||= data["version"]
27
+ changelog_uri = data["changelog_uri"]
28
+ github_repo = guess_github_repo(data)
29
+
30
+ if changelog_uri.nil? && github_repo
31
+ file_list = Faraday.get("https://github.com/#{github_repo}")
32
+ if file_list.status == 301
33
+ github_repo = file_list.headers["Location"][GITHUB_PATTERN, 1]
34
+ file_list = Faraday.get(file_list.headers["Location"])
35
+ end
36
+ match = file_list.body.match(%r{/(#{github_repo}/blob/[^/]+/#{FILE_PATTERN}(?:\.#{EXT_PATTERN})?)"}i)
37
+ changelog_uri = "https://github.com/#{match[1]}" if match
38
+ end
39
+
40
+ if changelog_uri.nil? && github_repo
41
+ releases_uri = "https://github.com/#{github_repo}/releases"
42
+ changelog_uri = releases_uri if Faraday.head("#{releases_uri}/tag/v#{version}").success?
43
+ end
44
+
45
+ changelog_uri
46
+ end
47
+
48
+ private
49
+
50
+ def guess_github_repo(data)
51
+ data.values_at(*URI_KEYS).each do |uri|
52
+ return Regexp.last_match(1) if uri&.match(GITHUB_PATTERN)
53
+ end
54
+ nil
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pastel"
4
+ require "tty/prompt"
5
+ require "tty/screen"
6
+
7
+ class BundleUpdateInteractive::CLI
8
+ class MultiSelect
9
+ class List < TTY::Prompt::MultiList
10
+ def initialize(prompt, **options)
11
+ defaults = {
12
+ cycle: true,
13
+ help_color: :itself.to_proc,
14
+ per_page: [TTY::Prompt::Paginator::DEFAULT_PAGE_SIZE, TTY::Screen.height.to_i - 3].max,
15
+ quiet: true,
16
+ show_help: :always
17
+ }
18
+ super(prompt, **defaults.merge(options))
19
+ end
20
+
21
+ def selected_names
22
+ ""
23
+ end
24
+ end
25
+
26
+ def self.prompt_for_gems_to_update(outdated_gems)
27
+ table = Table.new(outdated_gems)
28
+ title = "#{outdated_gems.length} gems can be updated."
29
+ chosen = new(title: title, table: table).prompt
30
+ outdated_gems.slice(*chosen)
31
+ end
32
+
33
+ def initialize(title:, table:)
34
+ @title = title
35
+ @table = table
36
+ @tty_prompt = TTY::Prompt.new(
37
+ interrupt: lambda {
38
+ puts
39
+ exit(130)
40
+ }
41
+ )
42
+ @pastel = BundleUpdateInteractive.pastel
43
+ end
44
+
45
+ def prompt
46
+ choices = table.gem_names.to_h { |name| [table.render_gem(name), name] }
47
+ tty_prompt.invoke_select(List, title, choices, help: help)
48
+ end
49
+
50
+ private
51
+
52
+ attr_reader :pastel, :table, :tty_prompt, :title
53
+
54
+ def help
55
+ [
56
+ pastel.dim("\nPress <space> to select, ↑/↓ move, <ctrl-a> all, <ctrl-r> reverse, <enter> to finish."),
57
+ "\n ",
58
+ table.render_header
59
+ ].join
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "delegate"
4
+ require "pastel"
5
+
6
+ class BundleUpdateInteractive::CLI
7
+ class Row < SimpleDelegator
8
+ SEMVER_COLORS = {
9
+ major: :red,
10
+ minor: :yellow,
11
+ patch: :green
12
+ }.freeze
13
+
14
+ def initialize(outdated_gem)
15
+ super
16
+ @pastel = BundleUpdateInteractive.pastel
17
+ end
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
+ def formatted_gem_name
31
+ vulnerable? ? pastel.white.on_red(name) : apply_semver_highlight(name)
32
+ end
33
+
34
+ def formatted_current_version
35
+ [current_version.to_s, current_git_version].compact.join(" ")
36
+ end
37
+
38
+ def formatted_updated_version
39
+ version = semver_change.format { |part| apply_semver_highlight(part) }
40
+ git_version = apply_semver_highlight(updated_git_version)
41
+
42
+ [version, git_version].compact.join(" ")
43
+ end
44
+
45
+ def formatted_gemfile_groups
46
+ gemfile_groups&.map(&:inspect)&.join(", ")
47
+ end
48
+
49
+ def formatted_changelog_uri
50
+ pastel.blue(changelog_uri)
51
+ end
52
+
53
+ def apply_semver_highlight(value)
54
+ color = git_version_changed? ? :cyan : SEMVER_COLORS.fetch(semver_change.severity)
55
+ pastel.decorate(value, color)
56
+ end
57
+
58
+ private
59
+
60
+ attr_reader :pastel
61
+ end
62
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pastel"
4
+
5
+ class BundleUpdateInteractive::CLI
6
+ class Table
7
+ HEADERS = ["name", "from", nil, "to", "group", "url"].freeze
8
+
9
+ def initialize(outdated_gems)
10
+ @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) }
13
+ @column_widths = calculate_column_widths
14
+ end
15
+
16
+ def gem_names
17
+ rows.keys
18
+ end
19
+
20
+ def render_header
21
+ render_row(headers)
22
+ end
23
+
24
+ def render_gem(name)
25
+ row = rows.fetch(name)
26
+ render_row(row)
27
+ end
28
+
29
+ def render
30
+ lines = [render_header]
31
+ rows.each_key { |name| lines << render_gem(name) }
32
+ lines.join("\n")
33
+ end
34
+
35
+ private
36
+
37
+ attr_reader :column_widths, :pastel, :rows, :headers
38
+
39
+ def render_row(row)
40
+ row.zip(column_widths).map do |value, width|
41
+ padding = width && (" " * (width - pastel.strip(value).length))
42
+ "#{value}#{padding}"
43
+ end.join(" ")
44
+ end
45
+
46
+ def calculate_column_widths
47
+ rows_with_header = [headers, *rows.values]
48
+ Array.new(headers.length - 1) do |i|
49
+ rows_with_header.map { |values| pastel.strip(values[i]).length }.max
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler"
4
+
5
+ class BundleUpdateInteractive::CLI
6
+ module ThorExt
7
+ # Configures Thor to behave more like a typical CLI, with better help and error handling.
8
+ #
9
+ # - Passing -h or --help to a command will show help for that command.
10
+ # - Unrecognized options will be treated as errors (instead of being silently ignored).
11
+ # - Error messages will be printed in red to stderr, without stack trace.
12
+ # - Full stack traces can be enabled by setting the VERBOSE environment variable.
13
+ # - Errors will cause Thor to exit with a non-zero status.
14
+ #
15
+ # To take advantage of this behavior, your CLI should subclass Thor and extend this module.
16
+ #
17
+ # class CLI < Thor
18
+ # extend ThorExt::Start
19
+ # end
20
+ #
21
+ # Start your CLI with:
22
+ #
23
+ # CLI.start
24
+ #
25
+ # In tests, prevent Kernel.exit from being called when an error occurs, like this:
26
+ #
27
+ # CLI.start(args, exit_on_failure: false)
28
+ #
29
+ module Start
30
+ def self.extended(base)
31
+ super
32
+ base.check_unknown_options!
33
+ end
34
+
35
+ def start(given_args=ARGV, config={})
36
+ config[:shell] ||= Thor::Base.shell.new
37
+ handle_help_switches(given_args) do |args|
38
+ dispatch(nil, args, nil, config)
39
+ end
40
+ rescue Exception => e # rubocop:disable Lint/RescueException
41
+ handle_exception_on_start(e, config)
42
+ end
43
+
44
+ private
45
+
46
+ def handle_help_switches(given_args)
47
+ yield(given_args.dup)
48
+ rescue Thor::UnknownArgumentError => e
49
+ retry_with_args = []
50
+
51
+ if given_args.first == "help"
52
+ retry_with_args = ["help"] if given_args.length > 1
53
+ elsif e.unknown.intersect?(%w[-h --help])
54
+ retry_with_args = ["help", (given_args - e.unknown).first]
55
+ end
56
+ raise unless retry_with_args.any?
57
+
58
+ yield(retry_with_args)
59
+ end
60
+
61
+ def handle_exception_on_start(error, config)
62
+ case error
63
+ when Errno::EPIPE
64
+ # Ignore
65
+ when Thor::Error, Interrupt, Bundler::Dsl::DSLError
66
+ raise unless config.fetch(:exit_on_failure, true)
67
+
68
+ config[:shell]&.say_error(error.message, :red)
69
+ exit(false)
70
+ else
71
+ raise
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+
5
+ module BundleUpdateInteractive
6
+ class CLI < Thor
7
+ autoload :MultiSelect, "bundle_update_interactive/cli/multi_select"
8
+ autoload :Row, "bundle_update_interactive/cli/row"
9
+ autoload :Table, "bundle_update_interactive/cli/table"
10
+ autoload :ThorExt, "bundle_update_interactive/cli/thor_ext"
11
+
12
+ extend ThorExt::Start
13
+
14
+ default_command :ui
15
+ map %w[-v --version] => "version"
16
+
17
+ desc "version", "Display bundle_update_interactive version", hide: true
18
+ def version
19
+ say "bundle_update_interactive/#{VERSION} #{RUBY_DESCRIPTION}"
20
+ end
21
+
22
+ desc "ui", "Update Gemfile.lock interactively", hide: true
23
+ def ui # rubocop:disable Metrics/AbcSize
24
+ report = generate_report
25
+ say("No gems to update.") && return if report.updateable_gems.empty?
26
+
27
+ say
28
+ say legend
29
+ say
30
+ selected_gems = MultiSelect.prompt_for_gems_to_update(report.updateable_gems)
31
+ say("No gems to update.") && return if selected_gems.empty?
32
+
33
+ say "\nUpdating the following gems."
34
+ say
35
+ say Table.new(selected_gems).render
36
+ say
37
+ BundlerCommands.update_gems_conservatively(*selected_gems.keys)
38
+ end
39
+
40
+ private
41
+
42
+ def legend
43
+ pastel = BundleUpdateInteractive.pastel
44
+ <<~LEGEND
45
+ Color legend:
46
+ #{pastel.white.on_red('<inverse>')} Known security vulnerability
47
+ #{pastel.red('<red>')} Major update; likely to have breaking changes, high risk
48
+ #{pastel.yellow('<yellow>')} Minor update; changes and additions, moderate risk
49
+ #{pastel.green('<green>')} Patch update; bug fixes, low risk
50
+ #{pastel.cyan('<cyan>')} Possibly unreleased git commits; unknown risk
51
+ LEGEND
52
+ end
53
+
54
+ def generate_report
55
+ whisper "Resolving latest gem versions..."
56
+ report = Report.generate
57
+ updateable_gems = report.updateable_gems
58
+ return report if updateable_gems.empty?
59
+
60
+ whisper "Checking for security vulnerabilities..."
61
+ report.scan_for_vulnerabilities!
62
+
63
+ progress "Finding changelogs", updateable_gems.values, &:changelog_uri
64
+ report
65
+ end
66
+
67
+ def whisper(message)
68
+ $stderr.puts(message) # rubocop:disable Style/StderrPuts
69
+ end
70
+
71
+ def progress(message, items, &block)
72
+ $stderr.print(message)
73
+ items.each_slice([1, items.length / 12].max) do |slice|
74
+ slice.each(&block)
75
+ $stderr.print(".")
76
+ end
77
+ $stderr.print("\n")
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler"
4
+
5
+ module BundleUpdateInteractive
6
+ class Gemfile
7
+ def self.parse(path="Gemfile")
8
+ dsl = Bundler::Dsl.new
9
+ dsl.eval_gemfile(path)
10
+ dependencies = dsl.dependencies.to_h { |d| [d.name, d] }
11
+ new(dependencies)
12
+ end
13
+
14
+ def initialize(dependencies)
15
+ @dependencies = dependencies.freeze
16
+ end
17
+
18
+ def [](name)
19
+ @dependencies[name]
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler"
4
+
5
+ module BundleUpdateInteractive
6
+ class Lockfile
7
+ # TODO: refactor
8
+ def self.parse(lockfile_contents=File.read("Gemfile.lock")) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
9
+ parser = Bundler::LockfileParser.new(lockfile_contents)
10
+ specs_by_name = {}
11
+ exact = Set.new
12
+ exact_children = {}
13
+
14
+ parser.specs.each do |spec|
15
+ specs_by_name[spec.name] = spec
16
+
17
+ spec.dependencies.each do |dep|
18
+ next unless dep.requirement.exact?
19
+
20
+ exact << dep.name
21
+ (exact_children[spec.name] ||= []) << dep.name
22
+ end
23
+ end
24
+
25
+ entries = specs_by_name.transform_values do |spec|
26
+ exact_dependencies = Set.new
27
+ traversal = exact_children[spec.name]&.dup || []
28
+ until traversal.empty?
29
+ name = traversal.pop
30
+ next if exact_dependencies.include?(name)
31
+
32
+ exact_dependencies << name
33
+ traversal.push(*exact_children.fetch(name, []))
34
+ end
35
+
36
+ LockfileEntry.new(spec, exact_dependencies, exact.include?(spec.name))
37
+ end
38
+
39
+ new(entries)
40
+ end
41
+
42
+ def initialize(entries)
43
+ @entries = entries.freeze
44
+ end
45
+
46
+ def entries
47
+ @entries.values
48
+ end
49
+
50
+ def [](gem_name)
51
+ @entries[gem_name]
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BundleUpdateInteractive
4
+ class LockfileEntry
5
+ attr_reader :spec, :exact_dependencies
6
+
7
+ def initialize(spec, exact_dependencies, exact_dependency)
8
+ @spec = spec
9
+ @exact_dependencies = exact_dependencies
10
+ @exact_dependency = exact_dependency
11
+ end
12
+
13
+ def name
14
+ spec.name
15
+ end
16
+
17
+ def version
18
+ spec.version
19
+ end
20
+
21
+ def older_than?(updated_entry)
22
+ return false if updated_entry.nil?
23
+
24
+ if git_source? && updated_entry.git_source?
25
+ version <= updated_entry.version && git_version != updated_entry.git_version
26
+ else
27
+ version < updated_entry.version
28
+ end
29
+ end
30
+
31
+ def exact_dependency?
32
+ @exact_dependency
33
+ end
34
+
35
+ def git_version
36
+ spec.git_version&.strip
37
+ end
38
+
39
+ def git_source_uri
40
+ spec.source.uri if git_source?
41
+ end
42
+
43
+ def git_source?
44
+ !!git_version
45
+ end
46
+
47
+ def rubygems_source?
48
+ return false if git_source?
49
+
50
+ source = spec.source
51
+ source.respond_to?(:remotes) && source.remotes.map(&:to_s).include?("https://rubygems.org/")
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BundleUpdateInteractive
4
+ class OutdatedGem
5
+ attr_reader :current_lockfile_entry, :updated_lockfile_entry, :gemfile_groups
6
+ attr_writer :vulnerable
7
+
8
+ def initialize(current_lockfile_entry:, updated_lockfile_entry:, gemfile_groups:)
9
+ @current_lockfile_entry = current_lockfile_entry
10
+ @updated_lockfile_entry = updated_lockfile_entry
11
+ @gemfile_groups = gemfile_groups
12
+ @changelog_locator = ChangelogLocator.new
13
+ @vulnerable = nil
14
+ end
15
+
16
+ def name
17
+ current_lockfile_entry.name
18
+ end
19
+
20
+ def semver_change
21
+ @semver_change ||= SemverChange.new(current_version, updated_version)
22
+ end
23
+
24
+ def vulnerable?
25
+ @vulnerable
26
+ end
27
+
28
+ def changelog_uri
29
+ return @changelog_uri if defined?(@changelog_uri)
30
+
31
+ @changelog_uri =
32
+ if git_version_changed?
33
+ "https://github.com/#{github_repo}/compare/#{current_git_version}...#{updated_git_version}"
34
+ elsif updated_lockfile_entry.rubygems_source?
35
+ changelog_locator.find_changelog_uri(name: name, version: updated_version.to_s)
36
+ else
37
+ begin
38
+ Gem::Specification.find_by_name(name)&.homepage
39
+ rescue Gem::MissingSpecError
40
+ nil
41
+ end
42
+ end
43
+ end
44
+
45
+ def current_version
46
+ current_lockfile_entry.version
47
+ end
48
+
49
+ def updated_version
50
+ updated_lockfile_entry.version
51
+ end
52
+
53
+ def current_git_version
54
+ current_lockfile_entry.git_version
55
+ end
56
+
57
+ def updated_git_version
58
+ updated_lockfile_entry.git_version
59
+ end
60
+
61
+ def git_version_changed?
62
+ current_git_version && updated_git_version && current_git_version != updated_git_version
63
+ end
64
+
65
+ private
66
+
67
+ attr_reader :changelog_locator
68
+
69
+ def github_repo
70
+ return nil unless updated_git_version
71
+
72
+ updated_lockfile_entry.git_source_uri.to_s[%r{^(?:git@github.com:|https://github.com/)([^/]+/[^/]+?)(:?\.git)?(?:$|/)}i,
73
+ 1]
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler"
4
+ require "bundler/audit"
5
+ require "bundler/audit/scanner"
6
+ require "set"
7
+
8
+ module BundleUpdateInteractive
9
+ class Report
10
+ class << self
11
+ def generate
12
+ gemfile = Gemfile.parse
13
+ current_lockfile = Lockfile.parse
14
+ updated_lockfile = Lockfile.parse(BundlerCommands.read_updated_lockfile)
15
+
16
+ new(gemfile: gemfile, current_lockfile: current_lockfile, updated_lockfile: updated_lockfile)
17
+ end
18
+ end
19
+
20
+ attr_reader :outdated_gems
21
+
22
+ def initialize(gemfile:, current_lockfile:, updated_lockfile:)
23
+ @current_lockfile = current_lockfile
24
+ outdated_names = current_lockfile.entries.each_with_object([]) do |current_entry, arr|
25
+ updated_entry = updated_lockfile[current_entry.name]
26
+ arr << current_entry.name if current_entry.older_than?(updated_entry)
27
+ end
28
+ @outdated_gems ||= outdated_names.sort.each_with_object({}) do |name, hash|
29
+ hash[name] = OutdatedGem.new(
30
+ current_lockfile_entry: current_lockfile[name],
31
+ updated_lockfile_entry: updated_lockfile[name],
32
+ gemfile_groups: gemfile[name]&.groups
33
+ )
34
+ end.freeze
35
+ end
36
+
37
+ def [](gem_name)
38
+ outdated_gems[gem_name]
39
+ end
40
+
41
+ def updateable_gems
42
+ outdated_gems.reject { |_, gem| gem.current_lockfile_entry.exact_dependency? }
43
+ end
44
+
45
+ def expand_gems_with_exact_dependencies(*gem_names)
46
+ gem_names.flatten!
47
+ gem_names.flat_map { [_1, *outdated_gems[_1].current_lockfile_entry.exact_dependencies] }.uniq
48
+ end
49
+
50
+ def scan_for_vulnerabilities!
51
+ return false if outdated_gems.empty?
52
+
53
+ Bundler::Audit::Database.update!(quiet: true)
54
+ audit_report = Bundler::Audit::Scanner.new.report
55
+ vulnerable_gem_names = Set.new(audit_report.vulnerable_gems.map(&:name))
56
+
57
+ outdated_gems.each do |name, gem|
58
+ gem.vulnerable = vulnerable_gem_names.intersect?([name, *current_lockfile[name].exact_dependencies])
59
+ end
60
+ true
61
+ end
62
+
63
+ def bundle_update!(*gem_names)
64
+ expanded_names = expand_gems_with_exact_dependencies(*gem_names)
65
+ BundlerCommands.update_gems_conservatively(*expanded_names)
66
+ end
67
+
68
+ private
69
+
70
+ attr_reader :current_lockfile
71
+ end
72
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BundleUpdateInteractive
4
+ class SemverChange
5
+ SEVERITIES = %i[major minor patch].freeze
6
+
7
+ def initialize(old_version, new_version)
8
+ old_segments = old_version.to_s.split(".")
9
+ new_segments = new_version.to_s.split(".")
10
+
11
+ @same_segments = new_segments.take_while.with_index { |seg, i| seg == old_segments[i] }
12
+ @diff_segments = new_segments[same_segments.length..]
13
+ end
14
+
15
+ def severity
16
+ return nil if diff_segments.empty?
17
+
18
+ SEVERITIES[same_segments.length] || :patch
19
+ end
20
+
21
+ SEVERITIES.each do |level|
22
+ define_method(:"#{level}?") { severity == level }
23
+ end
24
+
25
+ def none?
26
+ severity.nil?
27
+ end
28
+
29
+ def any?
30
+ !!severity
31
+ end
32
+
33
+ def format
34
+ parts = []
35
+ parts << same_segments.join(".") if same_segments.any?
36
+ parts << yield(diff_segments.join(".")) if diff_segments.any?
37
+ parts.join(".")
38
+ end
39
+
40
+ private
41
+
42
+ attr_reader :same_segments, :diff_segments
43
+ end
44
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BundleUpdateInteractive
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pastel"
4
+
5
+ module BundleUpdateInteractive
6
+ autoload :BundlerCommands, "bundle_update_interactive/bundler_commands"
7
+ autoload :ChangelogLocator, "bundle_update_interactive/changelog_locator"
8
+ autoload :CLI, "bundle_update_interactive/cli"
9
+ autoload :Gemfile, "bundle_update_interactive/gemfile"
10
+ autoload :Lockfile, "bundle_update_interactive/lockfile"
11
+ autoload :LockfileEntry, "bundle_update_interactive/lockfile_entry"
12
+ autoload :OutdatedGem, "bundle_update_interactive/outdated_gem"
13
+ autoload :Report, "bundle_update_interactive/report"
14
+ autoload :SemverChange, "bundle_update_interactive/semver_change"
15
+ autoload :VERSION, "bundle_update_interactive/version"
16
+
17
+ class << self
18
+ attr_accessor :pastel
19
+ end
20
+
21
+ self.pastel = Pastel.new
22
+ end
metadata ADDED
@@ -0,0 +1,167 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: bundle_update_interactive
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Matt Brictson
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2024-07-11 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: bundler
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '2.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '2.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: bundler-audit
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: 0.9.1
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: 0.9.1
41
+ - !ruby/object:Gem::Dependency
42
+ name: faraday
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: 2.8.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.8.0
55
+ - !ruby/object:Gem::Dependency
56
+ name: pastel
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: 0.8.0
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: 0.8.0
69
+ - !ruby/object:Gem::Dependency
70
+ name: thor
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '1.2'
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '1.2'
83
+ - !ruby/object:Gem::Dependency
84
+ name: tty-prompt
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: 0.23.1
90
+ type: :runtime
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: 0.23.1
97
+ - !ruby/object:Gem::Dependency
98
+ name: tty-screen
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: 0.8.2
104
+ type: :runtime
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: 0.8.2
111
+ description:
112
+ email:
113
+ - opensource@mattbrictson.com
114
+ executables:
115
+ - bundler-ui
116
+ - bundler-update-interactive
117
+ extensions: []
118
+ extra_rdoc_files: []
119
+ files:
120
+ - LICENSE.txt
121
+ - README.md
122
+ - exe/bundler-ui
123
+ - exe/bundler-update-interactive
124
+ - lib/bundle_update_interactive.rb
125
+ - lib/bundle_update_interactive/bundler_commands.rb
126
+ - lib/bundle_update_interactive/changelog_locator.rb
127
+ - lib/bundle_update_interactive/cli.rb
128
+ - lib/bundle_update_interactive/cli/multi_select.rb
129
+ - lib/bundle_update_interactive/cli/row.rb
130
+ - lib/bundle_update_interactive/cli/table.rb
131
+ - lib/bundle_update_interactive/cli/thor_ext.rb
132
+ - lib/bundle_update_interactive/gemfile.rb
133
+ - lib/bundle_update_interactive/lockfile.rb
134
+ - lib/bundle_update_interactive/lockfile_entry.rb
135
+ - lib/bundle_update_interactive/outdated_gem.rb
136
+ - lib/bundle_update_interactive/report.rb
137
+ - lib/bundle_update_interactive/semver_change.rb
138
+ - lib/bundle_update_interactive/version.rb
139
+ homepage: https://github.com/mattbrictson/bundle_update_interactive
140
+ licenses:
141
+ - MIT
142
+ metadata:
143
+ bug_tracker_uri: https://github.com/mattbrictson/bundle_update_interactive/issues
144
+ changelog_uri: https://github.com/mattbrictson/bundle_update_interactive/releases
145
+ source_code_uri: https://github.com/mattbrictson/bundle_update_interactive
146
+ homepage_uri: https://github.com/mattbrictson/bundle_update_interactive
147
+ rubygems_mfa_required: 'true'
148
+ post_install_message:
149
+ rdoc_options: []
150
+ require_paths:
151
+ - lib
152
+ required_ruby_version: !ruby/object:Gem::Requirement
153
+ requirements:
154
+ - - ">="
155
+ - !ruby/object:Gem::Version
156
+ version: '2.7'
157
+ required_rubygems_version: !ruby/object:Gem::Requirement
158
+ requirements:
159
+ - - ">="
160
+ - !ruby/object:Gem::Version
161
+ version: '0'
162
+ requirements: []
163
+ rubygems_version: 3.5.11
164
+ signing_key:
165
+ specification_version: 4
166
+ summary: Adds an update-interactive command to Bundler
167
+ test_files: []