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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +54 -0
- data/exe/bundler-ui +5 -0
- data/exe/bundler-update-interactive +5 -0
- data/lib/bundle_update_interactive/bundler_commands.rb +19 -0
- data/lib/bundle_update_interactive/changelog_locator.rb +57 -0
- data/lib/bundle_update_interactive/cli/multi_select.rb +62 -0
- data/lib/bundle_update_interactive/cli/row.rb +62 -0
- data/lib/bundle_update_interactive/cli/table.rb +53 -0
- data/lib/bundle_update_interactive/cli/thor_ext.rb +76 -0
- data/lib/bundle_update_interactive/cli.rb +80 -0
- data/lib/bundle_update_interactive/gemfile.rb +22 -0
- data/lib/bundle_update_interactive/lockfile.rb +54 -0
- data/lib/bundle_update_interactive/lockfile_entry.rb +54 -0
- data/lib/bundle_update_interactive/outdated_gem.rb +76 -0
- data/lib/bundle_update_interactive/report.rb +72 -0
- data/lib/bundle_update_interactive/semver_change.rb +44 -0
- data/lib/bundle_update_interactive/version.rb +5 -0
- data/lib/bundle_update_interactive.rb +22 -0
- metadata +167 -0
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
|
+
[](https://rubygems.org/gems/bundle_update_interactive)
|
4
|
+
[](https://www.ruby-toolbox.com/projects/bundle_update_interactive)
|
5
|
+
[](https://github.com/mattbrictson/bundle_update_interactive/actions/workflows/ci.yml)
|
6
|
+
[](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,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,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: []
|