bundle_update 0.0.1 → 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.
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "time"
6
+ require "English"
7
+
8
+ module BundleUpdate
9
+ # The `bundle_update` executable. Distinguishes its own subcommands
10
+ # (report/auth/status/version) from arguments meant for `bundle update`,
11
+ # and otherwise forwards everything verbatim and unmodified.
12
+ class CLI
13
+ SUBCOMMANDS = %w[report auth status version].freeze
14
+
15
+ def self.start(argv)
16
+ new(argv).run
17
+ end
18
+
19
+ def initialize(argv)
20
+ @argv = argv
21
+ end
22
+
23
+ def run
24
+ if @argv.first == "--"
25
+ run_bundle_update(@argv[1..] || [])
26
+ elsif SUBCOMMANDS.include?(@argv.first)
27
+ run_subcommand(@argv[0], @argv[1..] || [])
28
+ else
29
+ run_bundle_update(@argv)
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def run_subcommand(name, rest)
36
+ case name
37
+ when "report" then run_report
38
+ when "auth" then run_auth(rest)
39
+ when "status" then run_status
40
+ when "version" then run_version
41
+ end
42
+ end
43
+
44
+ def run_report
45
+ Runner.new(config: Config.new).refetch_latest
46
+ 0
47
+ end
48
+
49
+ def run_auth(rest)
50
+ key = rest.first
51
+ if key.nil? || key.empty?
52
+ warn "Usage: bundle_update auth KEY"
53
+ return 1
54
+ end
55
+
56
+ Config.save_api_key(key)
57
+ puts "API key saved (#{Config.mask(key)})."
58
+ 0
59
+ end
60
+
61
+ def run_status
62
+ config = Config.new
63
+ puts "bundle_update #{BundleUpdate::VERSION}"
64
+ puts "Project: #{config.project_name}"
65
+ puts "API URL: #{config.api_url}"
66
+ puts "API key: #{config.api_key? ? config.masked_api_key : "not configured"}"
67
+ puts "Disabled: #{config.disabled?}"
68
+ puts "API reachable: #{api_reachable?(config) ? "yes" : "no"}" if config.api_key?
69
+ 0
70
+ end
71
+
72
+ def run_version
73
+ puts "bundle_update #{BundleUpdate::VERSION}"
74
+ 0
75
+ end
76
+
77
+ # A bare connectivity check against the configured host — no report
78
+ # contract involved, so it can't be affected by API changes.
79
+ def api_reachable?(config)
80
+ uri = URI.parse(config.api_url)
81
+ http = Net::HTTP.new(uri.host, uri.port)
82
+ http.use_ssl = uri.scheme == "https"
83
+ http.open_timeout = 2
84
+ http.read_timeout = 2
85
+ http.start { true }
86
+ rescue StandardError
87
+ false
88
+ end
89
+
90
+ def run_bundle_update(bundler_args)
91
+ lockfile_path = File.join(Dir.pwd, "Gemfile.lock")
92
+ lockfile_before = read_lockfile(lockfile_path)
93
+
94
+ run_command("bundle", "update", *bundler_args)
95
+ code = exit_status
96
+ return code unless code.zero?
97
+
98
+ run_reporting_pipeline(lockfile_path, lockfile_before)
99
+ code
100
+ end
101
+
102
+ # Thin seams around Kernel#system / $? so specs can verify the exact
103
+ # bundler invocation and simulate its exit code without shelling out.
104
+ def run_command(*args)
105
+ system(*args)
106
+ end
107
+
108
+ def exit_status
109
+ $CHILD_STATUS&.exitstatus || 1
110
+ end
111
+
112
+ def run_reporting_pipeline(lockfile_path, lockfile_before)
113
+ lockfile_after = read_lockfile(lockfile_path)
114
+ Runner.new(config: Config.new).report(lockfile_before: lockfile_before, lockfile_after: lockfile_after, ran_at: Time.now)
115
+ rescue StandardError
116
+ # The reporting pipeline already fails open internally; this is an
117
+ # unreachable last resort so a bug here can never fail the bundle run.
118
+ nil
119
+ end
120
+
121
+ def read_lockfile(path)
122
+ File.exist?(path) ? File.read(path) : ""
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "fileutils"
5
+
6
+ module BundleUpdate
7
+ # Layered configuration: built-in defaults -> ~/.config/bundle_update/config.yml
8
+ # -> ./.bundle_update.yml (per-project) -> environment variables. Each layer
9
+ # overrides the previous one, key by key.
10
+ #
11
+ # API keys belong in a config file or an environment variable — never in the
12
+ # Gemfile, where they'd be committed to source control.
13
+ class Config
14
+ # TEMPORARY: pointed at the local dev backend (served via puma-dev, see
15
+ # ~/.puma-dev/bundleupdate) while bundleupdate.com isn't live yet.
16
+ # Revert to "https://bundleupdate.com" before shipping.
17
+ DEFAULT_API_URL = "http://bundleupdate.test"
18
+ DEFAULT_REPORTS_DIR = "bundle_update_reports"
19
+ DEFAULT_STATE_DIR = ".bundle_update"
20
+
21
+ ENV_KEYS = {
22
+ "BUNDLE_UPDATE_API_KEY" => :api_key,
23
+ "BUNDLE_UPDATE_API_URL" => :api_url,
24
+ "BUNDLE_UPDATE_DISABLED" => :disabled,
25
+ "BUNDLE_UPDATE_DEBUG" => :debug
26
+ }.freeze
27
+
28
+ TRUTHY = %w[1 true yes on].freeze
29
+
30
+ attr_reader :api_key, :api_url, :project_name, :git_remote_url, :reports_dir, :state_dir, :project_root
31
+
32
+ def self.user_config_path
33
+ File.join(Dir.home, ".config", "bundle_update", "config.yml")
34
+ end
35
+
36
+ def self.project_config_path(root)
37
+ File.join(root, ".bundle_update.yml")
38
+ end
39
+
40
+ def self.mask(key)
41
+ return nil if key.nil? || key.empty?
42
+
43
+ visible = key[0, [key.length - 4, 12].min.clamp(0, key.length)]
44
+ visible.empty? ? "…" : "#{visible}…"
45
+ end
46
+
47
+ def initialize(env: ENV, project_root: Dir.pwd, user_config_path: self.class.user_config_path)
48
+ @project_root = project_root
49
+ merged = [
50
+ defaults(project_root),
51
+ load_yaml(user_config_path),
52
+ load_yaml(self.class.project_config_path(project_root)),
53
+ from_env(env)
54
+ ].each_with_object({}) { |layer, acc| acc.merge!(layer) }
55
+
56
+ @api_key = merged[:api_key]
57
+ @api_url = merged[:api_url]
58
+ @project_name = merged[:project_name]
59
+ @git_remote_url = merged[:git_remote_url]
60
+ @reports_dir = merged[:reports_dir]
61
+ @state_dir = merged[:state_dir]
62
+ @disabled = truthy?(merged[:disabled])
63
+ @debug = truthy?(merged[:debug])
64
+ end
65
+
66
+ def api_key?
67
+ !api_key.to_s.empty?
68
+ end
69
+
70
+ def masked_api_key
71
+ self.class.mask(api_key)
72
+ end
73
+
74
+ def disabled?
75
+ @disabled
76
+ end
77
+
78
+ def debug?
79
+ @debug
80
+ end
81
+
82
+ def state_file_path
83
+ File.join(@project_root, state_dir, "last_run.json")
84
+ end
85
+
86
+ def debug_log_path
87
+ File.join(@project_root, state_dir, "debug.log")
88
+ end
89
+
90
+ # Persists the API key to the user-level config file so it applies across
91
+ # every project. Preserves any other keys already present in that file.
92
+ def self.save_api_key(key, path: user_config_path)
93
+ existing = File.exist?(path) ? (YAML.safe_load_file(path) || {}) : {}
94
+ existing["api_key"] = key
95
+ FileUtils.mkdir_p(File.dirname(path))
96
+ File.write(path, existing.to_yaml)
97
+ FileUtils.chmod(0o600, path)
98
+ end
99
+
100
+ private
101
+
102
+ def defaults(project_root)
103
+ {
104
+ api_key: nil,
105
+ api_url: DEFAULT_API_URL,
106
+ project_name: default_project_name(project_root),
107
+ git_remote_url: default_git_remote_url(project_root),
108
+ reports_dir: DEFAULT_REPORTS_DIR,
109
+ state_dir: DEFAULT_STATE_DIR,
110
+ disabled: false,
111
+ debug: false
112
+ }
113
+ end
114
+
115
+ def default_project_name(root)
116
+ remote = raw_git_remote(root)
117
+ remote ? remote.split("/").last.to_s.sub(/\.git\z/, "") : File.basename(File.expand_path(root))
118
+ end
119
+
120
+ def default_git_remote_url(root)
121
+ normalize_remote_url(raw_git_remote(root))
122
+ end
123
+
124
+ def raw_git_remote(root)
125
+ config_file = File.join(root, ".git", "config")
126
+ return nil unless File.exist?(config_file)
127
+
128
+ content = File.read(config_file)
129
+ match = content.match(/url\s*=\s*(\S+)/)
130
+ match && match[1]
131
+ rescue Errno::ENOENT, Errno::EACCES
132
+ nil
133
+ end
134
+
135
+ # Collapses SSH (git@host:path), ssh:// and https:// forms of the same
136
+ # remote into one comparable "host/path" identity, so the same repo
137
+ # cloned differently in different places still resolves to the same
138
+ # bundleupdate.com project.
139
+ def normalize_remote_url(url)
140
+ return nil if url.nil?
141
+
142
+ stripped = url.strip.sub(/\.git\z/, "").sub(%r{/\z}, "")
143
+ match = stripped.match(/\Agit@([^:]+):(.+)\z/) || stripped.match(%r{\A\w+://(?:[^@/]+@)?([^/]+)/(.+)\z})
144
+ match ? "#{match[1].downcase}/#{match[2]}" : stripped
145
+ end
146
+
147
+ def load_yaml(path)
148
+ return {} unless path && File.exist?(path)
149
+
150
+ data = YAML.safe_load_file(path) || {}
151
+ symbolize(data)
152
+ rescue Psych::SyntaxError
153
+ {}
154
+ end
155
+
156
+ def from_env(env)
157
+ ENV_KEYS.each_with_object({}) do |(env_key, config_key), hash|
158
+ hash[config_key] = env[env_key] if env.key?(env_key) && !env[env_key].nil?
159
+ end
160
+ end
161
+
162
+ def symbolize(hash)
163
+ hash.each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
164
+ end
165
+
166
+ def truthy?(value)
167
+ return value if [true, false].include?(value)
168
+
169
+ TRUTHY.include?(value.to_s.strip.downcase)
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BundleUpdate
4
+ # Computes the set of gem changes between two Snapshots.
5
+ class Diff
6
+ Update = Struct.new(:name, :from, :to, :severity, keyword_init: true)
7
+
8
+ SEVERITIES = %i[major minor patch other].freeze
9
+
10
+ attr_reader :updated, :added, :removed
11
+
12
+ def initialize(before, after)
13
+ @before = before
14
+ @after = after
15
+ @updated = []
16
+ @added = []
17
+ @removed = []
18
+ compute
19
+ end
20
+
21
+ def empty?
22
+ updated.empty? && added.empty? && removed.empty?
23
+ end
24
+
25
+ def counts_by_severity
26
+ SEVERITIES.to_h { |s| [s, 0] }.tap do |counts|
27
+ updated.each { |u| counts[u.severity] += 1 }
28
+ end
29
+ end
30
+
31
+ def total_changes
32
+ updated.size + added.size + removed.size
33
+ end
34
+
35
+ private
36
+
37
+ def compute
38
+ before_specs = @before.specs
39
+ after_specs = @after.specs
40
+
41
+ after_specs.each do |name, after_spec|
42
+ before_spec = before_specs[name]
43
+ if before_spec.nil?
44
+ @added << after_spec
45
+ elsif before_spec.version != after_spec.version
46
+ @updated << Update.new(
47
+ name: name,
48
+ from: before_spec.version,
49
+ to: after_spec.version,
50
+ severity: classify(before_spec.version, after_spec.version)
51
+ )
52
+ end
53
+ end
54
+
55
+ before_specs.each_key do |name|
56
+ @removed << before_specs[name] unless after_specs.key?(name)
57
+ end
58
+
59
+ @updated.sort_by!(&:name)
60
+ @added.sort_by!(&:name)
61
+ @removed.sort_by!(&:name)
62
+ end
63
+
64
+ # :major/:minor/:patch by comparing the first three Gem::Version segments.
65
+ # Prerelease versions and unparseable strings are always :other — segment
66
+ # comparison across a prerelease boundary (1.0.0 -> 1.0.0.rc1) doesn't map
67
+ # cleanly onto semver severity, so we refuse to guess.
68
+ def classify(from, to)
69
+ from_version = safe_version(from)
70
+ to_version = safe_version(to)
71
+ return :other unless comparable?(from_version, to_version)
72
+
73
+ severity_for(padded_segments(from_version), padded_segments(to_version))
74
+ end
75
+
76
+ def comparable?(from_version, to_version)
77
+ return false if from_version.nil? || to_version.nil?
78
+
79
+ !from_version.prerelease? && !to_version.prerelease?
80
+ end
81
+
82
+ def severity_for(from_segments, to_segments)
83
+ return :major if from_segments[0] != to_segments[0]
84
+ return :minor if from_segments[1] != to_segments[1]
85
+ return :patch if from_segments[2] != to_segments[2]
86
+
87
+ :other
88
+ end
89
+
90
+ def safe_version(str)
91
+ return nil if str.nil?
92
+
93
+ Gem::Version.new(str)
94
+ rescue ArgumentError
95
+ nil
96
+ end
97
+
98
+ def padded_segments(version)
99
+ segments = version.segments
100
+ [segments[0] || 0, segments[1] || 0, segments[2] || 0]
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BundleUpdate
4
+ # Renders the free-tier output for a Diff: a terminal summary and a
5
+ # markdown report. Pure rendering — writing either to disk is ReportWriter's
6
+ # job, so the same markdown can be reused as the fallback body when the API
7
+ # tier degrades to a 202/error response.
8
+ class LocalReport
9
+ SEVERITY_ORDER = %i[major minor patch other].freeze
10
+ SEVERITY_LABELS = { major: "Major", minor: "Minor", patch: "Patch", other: "Other" }.freeze
11
+ SEVERITY_COLORS = { major: 31, minor: 33, patch: 32, other: 36 }.freeze # red/yellow/green/cyan
12
+ BOLD = 1
13
+
14
+ def initialize(diff:, project_name:, ran_at: Time.now, no_color: nil)
15
+ @diff = diff
16
+ @project_name = project_name
17
+ @ran_at = ran_at
18
+ @no_color = no_color.nil? ? !ENV["NO_COLOR"].to_s.empty? : no_color
19
+ end
20
+
21
+ def terminal_summary
22
+ return "bundle_update: no changes to Gemfile.lock\n" if @diff.empty?
23
+
24
+ lines = [count_line(colored: true), ""]
25
+ lines.concat(table_lines)
26
+ if @diff.added.any?
27
+ lines << ""
28
+ lines << "Added: #{@diff.added.map(&:name).join(", ")}"
29
+ end
30
+ if @diff.removed.any?
31
+ lines << ""
32
+ lines << "Removed: #{@diff.removed.map(&:name).join(", ")}"
33
+ end
34
+ "#{lines.join("\n")}\n"
35
+ end
36
+
37
+ def markdown
38
+ <<~MARKDOWN
39
+ # bundle_update report — #{@project_name}
40
+
41
+ _#{@ran_at.strftime("%Y-%m-%d %H:%M %z")}_
42
+
43
+ ## Summary
44
+
45
+ #{summary_bullets.join("\n")}
46
+
47
+ ## Security
48
+
49
+ _Security advisory checks aren't available in the free tier. [Get a bundleupdate.com API key](https://www.bundleupdate.com) for CVE-aware changelog reports._
50
+
51
+ ## Changes
52
+
53
+ #{changes_markdown}
54
+ MARKDOWN
55
+ end
56
+
57
+ private
58
+
59
+ def summary_bullets
60
+ return ["- No changes."] if @diff.empty?
61
+
62
+ bullets = ["- #{count_line(colored: false)}"]
63
+ bullets << "- #{@diff.added.size} gem#{"s" unless @diff.added.size == 1} added" if @diff.added.any?
64
+ bullets << "- #{@diff.removed.size} gem#{"s" unless @diff.removed.size == 1} removed" if @diff.removed.any?
65
+ bullets
66
+ end
67
+
68
+ def changes_markdown
69
+ return "_No changes._" if @diff.empty?
70
+
71
+ sections = []
72
+ SEVERITY_ORDER.each do |severity|
73
+ group = @diff.updated.select { |u| u.severity == severity }
74
+ next if group.empty?
75
+
76
+ sections << update_table(SEVERITY_LABELS[severity], group)
77
+ end
78
+ sections << name_version_table("Added", @diff.added) if @diff.added.any?
79
+ sections << name_version_table("Removed", @diff.removed) if @diff.removed.any?
80
+ sections.join("\n\n")
81
+ end
82
+
83
+ def update_table(title, group)
84
+ rows = group.map { |u| "| #{u.name} | #{u.from} | #{u.to} |" }
85
+ ["### #{title}", "", "| Gem | From | To |", "|---|---|---|", *rows].join("\n")
86
+ end
87
+
88
+ def name_version_table(title, specs)
89
+ rows = specs.map { |s| "| #{s.name} | #{s.version} |" }
90
+ ["### #{title}", "", "| Gem | Version |", "|---|---|", *rows].join("\n")
91
+ end
92
+
93
+ def count_line(colored:)
94
+ counts = @diff.counts_by_severity
95
+ parts = SEVERITY_ORDER.filter_map { |sev| "#{counts[sev]} #{sev}" if counts[sev].positive? }
96
+ total = @diff.updated.size
97
+ noun = total == 1 ? "update" : "updates"
98
+ text = "#{parts.join(", ")} #{noun}"
99
+ colored ? colorize(text, BOLD) : text
100
+ end
101
+
102
+ def table_lines
103
+ lines = []
104
+ name_width = @diff.updated.map { |u| u.name.length }.max.to_i
105
+
106
+ SEVERITY_ORDER.each do |severity|
107
+ group = @diff.updated.select { |u| u.severity == severity }
108
+ next if group.empty?
109
+
110
+ lines << colorize(SEVERITY_LABELS[severity], SEVERITY_COLORS[severity])
111
+ group.each do |u|
112
+ lines << " #{u.name.ljust(name_width)} #{u.from} → #{u.to}"
113
+ end
114
+ end
115
+ lines
116
+ end
117
+
118
+ def colorize(text, code)
119
+ return text if @no_color
120
+
121
+ "\e[#{code}m#{text}\e[0m"
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module BundleUpdate
6
+ # Wires the reporting pipeline into Bundler's plugin hook system so it
7
+ # fires automatically on `bundle install`/`bundle update` without the CLI
8
+ # wrapper, once the plugin is installed via `bundle plugin install
9
+ # bundle_update`.
10
+ #
11
+ # See DECISIONS.md for the Bundler source investigation this is based on:
12
+ # both bundle install and bundle update route through
13
+ # Bundler::Installer.install, which fires GEM_BEFORE_INSTALL_ALL
14
+ # ("before-install-all") then GEM_AFTER_INSTALL_ALL ("after-install-all")
15
+ # around the same run() call that resolves the definition and (eventually)
16
+ # writes the lockfile — so both hooks fire for a plain `bundle install`
17
+ # too, and the empty-diff case is handled by Runner, not here.
18
+ module Plugin
19
+ BEFORE_EVENT = "before-install-all"
20
+ AFTER_EVENT = "after-install-all"
21
+
22
+ # Registers the hooks. Every failure mode — missing/renamed Bundler
23
+ # plugin API, hook registration errors, anything — is swallowed so a
24
+ # plugin bug can never break the host's bundle install/update. Called
25
+ # from plugins.rb at the gem root, which Bundler loads automatically
26
+ # once the plugin is installed.
27
+ def self.install!
28
+ return false unless hook_api_available?
29
+
30
+ state = RunState.new
31
+ Bundler::Plugin.add_hook(BEFORE_EVENT) { state.capture_before! }
32
+ Bundler::Plugin.add_hook(AFTER_EVENT) { state.capture_after_and_report! }
33
+ # Defensive fallback for Bundler versions/paths where the lockfile
34
+ # isn't rewritten yet when after-install-all fires: re-check once more
35
+ # at process exit. No-ops if capture_after_and_report! already ran.
36
+ at_exit { state.fallback_report! }
37
+ true
38
+ rescue StandardError
39
+ false
40
+ end
41
+
42
+ def self.hook_api_available?
43
+ defined?(Bundler::Plugin) &&
44
+ Bundler::Plugin.respond_to?(:add_hook) &&
45
+ defined?(Bundler::Plugin::Events) &&
46
+ Bundler::Plugin::Events.respond_to?(:defined_event?) &&
47
+ Bundler::Plugin::Events.defined_event?(BEFORE_EVENT) &&
48
+ Bundler::Plugin::Events.defined_event?(AFTER_EVENT)
49
+ rescue StandardError
50
+ false
51
+ end
52
+
53
+ # Captures the lockfile before/after around the two hooks and calls
54
+ # Runner exactly once. A plain instance (not a singleton) so each
55
+ # install! call — and each spec example — gets an isolated one.
56
+ class RunState
57
+ def initialize(lockfile_path: nil)
58
+ @lockfile_path = lockfile_path || default_lockfile_path
59
+ @before = nil
60
+ @reported = false
61
+ end
62
+
63
+ def capture_before!(*)
64
+ @before = read_lockfile
65
+ rescue StandardError
66
+ @before = nil
67
+ end
68
+
69
+ def capture_after_and_report!(*)
70
+ return if @reported || @before.nil?
71
+
72
+ report(resolved_lockfile_content)
73
+ rescue StandardError
74
+ nil
75
+ end
76
+
77
+ # Last-resort safety net: if after-install-all already reported, or we
78
+ # never captured a before state, this is a no-op.
79
+ def fallback_report!
80
+ return if @reported || @before.nil?
81
+
82
+ report(read_lockfile)
83
+ rescue StandardError
84
+ nil
85
+ end
86
+
87
+ private
88
+
89
+ def report(after_content)
90
+ return if after_content.nil?
91
+
92
+ @reported = true
93
+ Runner.new(config: Config.new).report(lockfile_before: @before, lockfile_after: after_content, ran_at: Time.now)
94
+ end
95
+
96
+ # The in-memory resolved definition reflects the new lockfile even on
97
+ # Bundler versions/paths where the file on disk hasn't been rewritten
98
+ # yet at hook time.
99
+ def resolved_lockfile_content
100
+ if defined?(Bundler) && Bundler.respond_to?(:definition)
101
+ Bundler.definition.to_lock
102
+ else
103
+ read_lockfile
104
+ end
105
+ rescue StandardError
106
+ read_lockfile
107
+ end
108
+
109
+ def read_lockfile
110
+ File.exist?(@lockfile_path) ? File.read(@lockfile_path) : nil
111
+ rescue StandardError
112
+ nil
113
+ end
114
+
115
+ def default_lockfile_path
116
+ if defined?(Bundler) && Bundler.respond_to?(:default_lockfile)
117
+ Bundler.default_lockfile.to_s
118
+ else
119
+ File.join(Dir.pwd, "Gemfile.lock")
120
+ end
121
+ rescue StandardError
122
+ File.join(Dir.pwd, "Gemfile.lock")
123
+ end
124
+ end
125
+ end
126
+ end