bundle_update_interactive 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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: []