gemkeeper 0.2.1 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 97154d493b89cc264c9de34cd0c15f3712d2e23a9757ec9fc749c436dee126d5
4
- data.tar.gz: 4dba3a688a98ea40994b5979f5ef16df68ecad0cbc6fb5a8f2581abab40b0537
3
+ metadata.gz: f8a541f4777f6bff36eab8be7c1c7382e6e8354dd20dbe8b0e47c1d2a2d168fd
4
+ data.tar.gz: 112afe0590f5582455935123ec0e35bd52e7faaf8f6811ba7ef5924df0732e73
5
5
  SHA512:
6
- metadata.gz: 4531fa55ac8d2f512ba602bee71752299b22e0243313ed1c3489bf6d5b9723f8d94d7a23da77a2fa2de8cd9587fac243f34fb899c3c11d43d5c6a77a05d99818
7
- data.tar.gz: 85e9e466db67728d70ca25be6fec81a98717325021e5a8e38257e1b4d51fde54c91e9486e2284f0a81cd68f6bc74a9ba87d319e3c994e8567574e1de86c17c4d
6
+ metadata.gz: 3607617a5dc756210591b2ab71bb2ebe435e63ba458cd91ab112f6d98e4f1cf429939fc6261719a3ad5496ce98372ad668341418c5578add309d9e9f5d87201d
7
+ data.tar.gz: 9021ae8ed7015325522bb616b36e3729f6d4dd307a6017a226e48bf328f765d1134a92171b1ceacad33a582d5c0e1fd61c6269e89f32d4612703561cb45daeb2
data/.reek.yml ADDED
@@ -0,0 +1,37 @@
1
+ ---
2
+ # Reek skips test files — Minitest patterns (setup instance vars, accumulating test methods,
3
+ # calling the same method before/after) are idiomatic there, not smells.
4
+ exclude_paths:
5
+ - test
6
+
7
+ detectors:
8
+ IrresponsibleModule:
9
+ exclude:
10
+ # Pure namespace modules — no behaviour to document
11
+ - Gemkeeper
12
+ - Gemkeeper::CLI
13
+ - Gemkeeper::CLI::Commands
14
+ - Gemkeeper::CLI::Commands::Server
15
+ # One-liner exception subclasses — their names are the documentation
16
+ - Gemkeeper::Error
17
+ - Gemkeeper::ConfigurationError
18
+ - Gemkeeper::ConfigFileNotFoundError
19
+ - Gemkeeper::InvalidConfigError
20
+ - Gemkeeper::ManifestNotFoundError
21
+ - Gemkeeper::GitError
22
+ - Gemkeeper::CloneError
23
+ - Gemkeeper::CheckoutError
24
+ - Gemkeeper::BuildError
25
+ - Gemkeeper::GemspecNotFoundError
26
+ - Gemkeeper::UploadError
27
+ - Gemkeeper::ServerError
28
+ - Gemkeeper::ServerAlreadyRunningError
29
+ - Gemkeeper::ServerNotRunningError
30
+ # CLI command classes carry a dry-cli `desc` as their documentation
31
+ - Gemkeeper::CLI::Commands::List
32
+ - Gemkeeper::CLI::Commands::Setup
33
+ - Gemkeeper::CLI::Commands::Sync
34
+ - Gemkeeper::CLI::Commands::Version
35
+ - Gemkeeper::CLI::Commands::Server::Start
36
+ - Gemkeeper::CLI::Commands::Server::Stop
37
+ - Gemkeeper::CLI::Commands::Server::Status
data/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.3.0] - 2026-05-27
4
+
5
+ ### Added
6
+
7
+ - `gemkeeper setup --global` writes the config to the system-wide location used by the Homebrew service (`/opt/homebrew/etc/gemkeeper.yml` on Apple Silicon, `/usr/local/etc/gemkeeper.yml` on Intel, `~/.config/gemkeeper/config.yml` as a fallback) rather than the current project directory.
8
+ Data paths (`repos_path`, `gems_path`) are written as absolute paths under the corresponding `var` directory so the daemon finds them regardless of working directory.
9
+ Use this flag when running gemkeeper as a shared `brew services` daemon instead of a per-project process.
10
+ - `--global` and `--config` are mutually exclusive; passing both exits with an error.
11
+
3
12
  ## [0.2.1] - 2026-05-19
4
13
 
5
14
  ### Fixed
@@ -26,6 +35,7 @@
26
35
 
27
36
  - Initial release
28
37
 
29
- [Unreleased]: https://github.com/danhorst/gemkeeper/compare/v0.2.1...HEAD
38
+ [Unreleased]: https://github.com/danhorst/gemkeeper/compare/v0.3.0...HEAD
39
+ [0.3.0]: https://github.com/danhorst/gemkeeper/compare/0.2.1...0.3.0
30
40
  [0.2.1]: https://github.com/danhorst/gemkeeper/compare/0.2.0...0.2.1
31
41
  [0.2.0]: https://github.com/danhorst/gemkeeper/compare/0.1.0...0.2.0
data/README.md CHANGED
@@ -174,8 +174,15 @@ gemkeeper setup path/to/Gemfile.lock --manifest ~/.config/myorg/manifest.yml
174
174
 
175
175
  # Overwrite existing gemkeeper.yml entirely
176
176
  gemkeeper setup path/to/Gemfile.lock --force
177
+
178
+ # Write to the global Homebrew service config instead of the current directory
179
+ gemkeeper setup path/to/Gemfile.lock --global
177
180
  ```
178
181
 
182
+ `--global` targets the system-wide config used by `brew services` — `/opt/homebrew/etc/gemkeeper.yml` on Apple Silicon or `/usr/local/etc/gemkeeper.yml` on Intel.
183
+ It sets `repos_path` and `gems_path` as absolute paths under the corresponding `var` directory so the daemon finds them regardless of which directory you run commands from.
184
+ `--global` and `--config` are mutually exclusive.
185
+
179
186
  ### Gem Synchronization
180
187
 
181
188
  ```bash
@@ -208,7 +215,18 @@ All commands support:
208
215
 
209
216
  ### Homebrew Services (macOS)
210
217
 
211
- If installed via Homebrew:
218
+ If installed via Homebrew, gemkeeper can run as a shared system daemon — one server, all projects.
219
+
220
+ **Configure the service** from any project that has a `Gemfile.lock`:
221
+
222
+ ```bash
223
+ gemkeeper setup path/to/Gemfile.lock --global
224
+ ```
225
+
226
+ This writes `/opt/homebrew/etc/gemkeeper.yml` (Apple Silicon) or `/usr/local/etc/gemkeeper.yml` (Intel) with absolute data paths.
227
+ Run it again from any other project to merge its gems into the shared config.
228
+
229
+ **Manage the daemon:**
212
230
 
213
231
  ```bash
214
232
  # Start and enable at login
@@ -11,11 +11,12 @@ module Gemkeeper
11
11
  def call(**options)
12
12
  config = Configuration.load(options[:config])
13
13
 
14
- gem_files = Dir.glob(File.join(config.gems_path, "gems", "*.gem"))
14
+ gems_path = config.gems_path
15
+ gem_files = Dir.glob(File.join(gems_path, "gems", "*.gem"))
15
16
 
16
17
  if gem_files.empty?
17
18
  puts "No gems cached in Geminabox"
18
- puts " Gems directory: #{config.gems_path}"
19
+ puts " Gems directory: #{gems_path}"
19
20
  return
20
21
  end
21
22
 
@@ -13,25 +13,27 @@ module Gemkeeper
13
13
  desc: "Run in foreground (don't daemonize)"
14
14
 
15
15
  def call(**options)
16
+ port = options[:port]
16
17
  config = Configuration.load(options[:config])
17
- config = override_port(config, options[:port]) if options[:port]
18
+ config = override_port(config, port) if port
18
19
 
19
20
  manager = ServerManager.new(config)
21
+ url = config.geminabox_url
20
22
 
21
23
  if options[:foreground]
22
- puts "Starting Geminabox server at #{config.geminabox_url}"
24
+ puts "Starting Geminabox server at #{url}"
23
25
  puts "Press Ctrl+C to stop"
24
26
  manager.start_foreground
25
27
  else
26
28
  manager.start
27
- puts "Geminabox server started at #{config.geminabox_url}"
29
+ puts "Geminabox server started at #{url}"
28
30
  puts "PID: #{File.read(config.pid_file).strip}"
29
31
  end
30
- rescue ServerAlreadyRunningError => e
31
- warn "Error: #{e.message}"
32
+ rescue ServerAlreadyRunningError => error
33
+ warn "Error: #{error.message}"
32
34
  exit 1
33
- rescue ServerError => e
34
- warn "Error starting server: #{e.message}"
35
+ rescue ServerError => error
36
+ warn "Error starting server: #{error.message}"
35
37
  exit 1
36
38
  rescue Interrupt
37
39
  puts "\nShutting down..."
@@ -15,11 +15,11 @@ module Gemkeeper
15
15
  manager.stop
16
16
 
17
17
  puts "Geminabox server stopped"
18
- rescue ServerNotRunningError => e
19
- warn "Error: #{e.message}"
18
+ rescue ServerNotRunningError => error
19
+ warn "Error: #{error.message}"
20
20
  exit 1
21
- rescue ServerError => e
22
- warn "Error stopping server: #{e.message}"
21
+ rescue ServerError => error
22
+ warn "Error stopping server: #{error.message}"
23
23
  exit 1
24
24
  end
25
25
  end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "yaml"
4
-
5
3
  module Gemkeeper
6
4
  module CLI
7
5
  module Commands
@@ -13,110 +11,51 @@ module Gemkeeper
13
11
  option :manifest, type: :string,
14
12
  desc: "Path to gem manifest (default: ~/.config/gemkeeper/manifest.yml)"
15
13
  option :config, type: :string, desc: "Path to write gemkeeper.yml (default: ./gemkeeper.yml)"
14
+ option :global, type: :boolean, default: false,
15
+ desc: "Write to the global service config (for use with brew services)"
16
16
  option :force, type: :boolean, default: false,
17
17
  desc: "Overwrite existing gemkeeper.yml entirely"
18
18
 
19
19
  def call(lockfile_path:, **options)
20
- manifest_path = options[:manifest] || ManifestReader::DEFAULT_PATH
21
- output_path = options[:config] || File.join(Dir.pwd, Configuration::DEFAULT_CONFIG_FILENAME)
20
+ validate_options!(options)
22
21
 
23
- manifest = ManifestReader.load(manifest_path)
22
+ output_path = resolve_output_path(options)
23
+ manifest = ManifestReader.load(options[:manifest] || ManifestReader::DEFAULT_PATH)
24
24
  lockfile_versions = LockfileParser.parse(lockfile_path)
25
+ global_output_path = options[:global] ? output_path : nil
25
26
 
26
- matched = match_gems(manifest, lockfile_versions)
27
+ config = ConfigGenerator.new(manifest:, lockfile_versions:)
28
+ .build(output_path, force: options[:force], global_output_path:)
27
29
 
28
- write_config(matched, output_path, force: options[:force])
29
- print_bundler_instructions(output_path, manifest)
30
- rescue ManifestNotFoundError => e
31
- warn "Error: #{e.message}"
30
+ File.write(output_path, config.to_yaml)
31
+ puts "Wrote #{output_path}"
32
+ print_bundler_instructions(config, manifest)
33
+ rescue ManifestNotFoundError => error
34
+ warn "Error: #{error.message}"
32
35
  exit 1
33
36
  end
34
37
 
35
38
  private
36
39
 
37
- def match_gems(manifest, lockfile_versions)
38
- matched = manifest.gems.filter_map do |gem_entry|
39
- name = gem_entry[:name]
40
- { name: name, repo: gem_entry[:repo] } if lockfile_versions.key?(name)
41
- end
42
-
43
- warn_unmatched_internals(manifest, lockfile_versions)
44
- matched
45
- end
46
-
47
- def warn_unmatched_internals(manifest, lockfile_versions)
48
- lockfile_versions.each_key do |gem_name|
49
- next if manifest.find_by_name(gem_name)
50
-
51
- gem_prefix = gem_name.split("-").first
52
- next unless manifest.gem_names.any? { |manifest_name| manifest_name.split("-").first == gem_prefix }
53
-
54
- warn "Warning: #{gem_name} matches an internal name pattern but is not in the manifest — skipping"
55
- end
56
- end
57
-
58
- def write_config(matched_gems, output_path, force:)
59
- existing = load_existing_config(output_path) unless force
60
- existing ||= {}
61
-
62
- gem_entries = matched_gems.map do |gem_entry|
63
- { "repo" => gem_entry[:repo], "version" => "from_lockfile" }
64
- end
40
+ def validate_options!(options)
41
+ return unless options[:global] && options[:config]
65
42
 
66
- config = if force || existing.empty?
67
- build_fresh_config(gem_entries)
68
- else
69
- merge_config(existing, gem_entries, matched_gems)
70
- end
71
-
72
- File.write(output_path, config.to_yaml)
73
- puts "Wrote #{output_path}"
43
+ warn "Error: --global and --config are mutually exclusive"
44
+ exit 1
74
45
  end
75
46
 
76
- def load_existing_config(path)
77
- return nil unless File.exist?(path)
47
+ def resolve_output_path(options)
48
+ return options[:config] || File.join(Dir.pwd, Configuration::DEFAULT_CONFIG_FILENAME) unless options[:global]
78
49
 
79
- YAML.safe_load_file(path, permitted_classes: [], symbolize_names: false) || {}
50
+ Configuration.resolve_global_path || no_global_path!
80
51
  end
81
52
 
82
- def build_fresh_config(gem_entries)
83
- {
84
- "port" => Configuration::DEFAULT_PORT,
85
- "repos_path" => "./cache/repos",
86
- "gems_path" => "./cache/gems",
87
- "gems" => gem_entries
88
- }
89
- end
90
-
91
- def merge_config(existing, _new_gem_entries, matched_gems)
92
- existing_gems = existing["gems"] || []
93
- matched_names = matched_gems.map { |gem_entry| gem_entry[:name] }
94
-
95
- # Build a lookup for new entries by repo
96
- new_by_name = matched_gems.to_h do |gem_entry|
97
- entry_name = gem_entry[:name]
98
- [entry_name, { "repo" => gem_entry[:repo], "version" => "from_lockfile" }]
99
- end
100
-
101
- # Update existing entries for matched gems, keep others untouched
102
- updated = existing_gems.map do |entry|
103
- repo = entry["repo"].to_s
104
- name = File.basename(repo, ".git").sub(/^ruby-/, "")
105
- if matched_names.include?(name)
106
- new_by_name.delete(name).merge(entry.except("version")).merge("version" => "from_lockfile")
107
- else
108
- entry
109
- end
110
- end
111
-
112
- # Append any matched gems not already in the config
113
- updated += new_by_name.values
114
-
115
- existing.merge("gems" => updated)
53
+ def no_global_path!
54
+ warn "Error: no writable global config path found — install Homebrew or create ~/.config/gemkeeper/"
55
+ exit 1
116
56
  end
117
57
 
118
- def print_bundler_instructions(config_path, manifest)
119
- config = load_existing_config(config_path) || {}
58
+ def print_bundler_instructions(config, manifest)
120
59
  port = config.fetch("port", Configuration::DEFAULT_PORT)
121
60
  local_url = "http://localhost:#{port}"
122
61
  source_url = manifest.source_url
@@ -42,22 +42,25 @@ module Gemkeeper
42
42
  gems_to_sync.each do |gem_def|
43
43
  result = sync_gem(gem_def, config, uploader)
44
44
  counts[result] += 1
45
- rescue Error => e
46
- failures << { name: gem_def.name, message: e.message }
45
+ rescue Error => error
46
+ failures << { name: gem_def.name, message: error.message }
47
47
  end
48
48
  [counts, failures]
49
49
  end
50
50
 
51
51
  def report_results(counts, failures, total)
52
+ synced = counts[:synced]
53
+ skipped = counts[:skipped]
54
+ failure_count = failures.size
52
55
  parts = []
53
- parts << Output.colorize("#{counts[:synced]} synced", :green) if counts[:synced].positive?
54
- parts << Output.colorize("#{counts[:skipped]} skipped", :yellow) if counts[:skipped].positive?
55
- parts << Output.colorize("#{failures.size} failed", :red) if failures.any?
56
+ parts << Output.colorize("#{synced} synced", :green) if synced.positive?
57
+ parts << Output.colorize("#{skipped} skipped", :yellow) if skipped.positive?
58
+ parts << Output.colorize("#{failure_count} failed", :red) if failures.any?
56
59
  puts "\nSync complete: #{parts.join(", ")} (#{total} total)"
57
60
 
58
61
  return if failures.empty?
59
62
 
60
- warn "\nSync completed with #{failures.size} failure(s):"
63
+ warn "\nSync completed with #{failure_count} failure(s):"
61
64
  failures.each { |f| warn " #{f[:name]}: #{f[:message]}" }
62
65
  exit 1
63
66
  end
@@ -68,7 +71,8 @@ module Gemkeeper
68
71
  gems_path = config.gems_path
69
72
  version = resolve_version(gem_def)
70
73
 
71
- return :skipped if !gem_def.latest? && cached?(name, version, gems_path)
74
+ latest = gem_def.latest?
75
+ return :skipped if !latest && cached?(name, version, gems_path)
72
76
 
73
77
  puts "Syncing #{name} @ #{version}..."
74
78
 
@@ -80,14 +84,14 @@ module Gemkeeper
80
84
 
81
85
  checkout_gem_version(repo, version)
82
86
 
83
- if gem_def.latest?
87
+ if latest
84
88
  version = repo.current_version or
85
89
  raise BuildError, "Could not read version from gemspec in #{repo_url}"
86
90
  return :skipped if cached?(name, version, gems_path)
87
91
  end
88
92
 
89
93
  Output.step("Building gem...")
90
- gem_path = GemBuilder.new(local_path, config.gems_path).build
94
+ gem_path = GemBuilder.new(local_path, gems_path).build
91
95
 
92
96
  Output.step("Uploading to Geminabox...")
93
97
  result = uploader.upload(gem_path)
@@ -127,8 +131,8 @@ module Gemkeeper
127
131
 
128
132
  def fetch_repo(repo, repo_url)
129
133
  repo.clone_or_pull
130
- rescue GitError => e
131
- raise auth_error?(e) ? auth_failure_error(repo_url, e) : e
134
+ rescue GitError => git_error
135
+ raise auth_error?(git_error) ? auth_failure_error(repo_url, git_error) : git_error
132
136
  end
133
137
 
134
138
  def checkout_gem_version(repo, version)
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Gemkeeper
6
+ # Isolates merge/build logic from CLI commands so setup-adjacent features share a single path.
7
+ class ConfigGenerator
8
+ def initialize(manifest:, lockfile_versions:)
9
+ @manifest = manifest
10
+ @lockfile_versions = lockfile_versions
11
+ end
12
+
13
+ def build(output_path, force:, global_output_path: nil)
14
+ existing = force ? {} : (load_existing(output_path) || {})
15
+ matched = matched_gems
16
+
17
+ existing.empty? ? build_fresh(matched, global_output_path:) : merge(existing, matched)
18
+ end
19
+
20
+ private
21
+
22
+ def matched_gems
23
+ matched = @manifest.gems.filter_map do |gem_entry|
24
+ name = gem_entry[:name]
25
+ { name:, repo: gem_entry[:repo] } if @lockfile_versions.key?(name)
26
+ end
27
+ warn_unmatched
28
+ matched
29
+ end
30
+
31
+ def warn_unmatched
32
+ @lockfile_versions.each_key do |gem_name|
33
+ next if @manifest.find_by_name(gem_name)
34
+
35
+ prefix = gem_name.split("-").first
36
+ next unless @manifest.gem_names.any? { |n| n.split("-").first == prefix }
37
+
38
+ warn "Warning: #{gem_name} matches an internal name pattern but is not in the manifest — skipping"
39
+ end
40
+ end
41
+
42
+ def load_existing(path)
43
+ return nil unless File.exist?(path)
44
+
45
+ YAML.safe_load_file(path, permitted_classes: [], symbolize_names: false) || {}
46
+ end
47
+
48
+ def build_fresh(matched, global_output_path: nil)
49
+ repos_path, gems_path = data_paths_for(global_output_path)
50
+ gem_entries = matched.map { |g| { "repo" => g[:repo], "version" => "from_lockfile" } }
51
+ { "port" => Configuration::DEFAULT_PORT, "repos_path" => repos_path,
52
+ "gems_path" => gems_path, "gems" => gem_entries }
53
+ end
54
+
55
+ def data_paths_for(global_output_path)
56
+ return ["./cache/repos", "./cache/gems"] unless global_output_path
57
+
58
+ data_dir = Configuration.global_data_dir(global_output_path)
59
+ [File.join(data_dir, "repos"), File.join(data_dir, "gems")]
60
+ end
61
+
62
+ def merge(existing, matched)
63
+ existing_gems = existing["gems"] || []
64
+ new_by_name = matched.to_h { |g| [g[:name], { "repo" => g[:repo], "version" => "from_lockfile" }] }
65
+
66
+ updated = existing_gems.map do |entry|
67
+ name = File.basename(entry["repo"].to_s, ".git").sub(/^ruby-/, "")
68
+ if new_by_name.key?(name)
69
+ new_by_name.delete(name).merge(entry.except("version")).merge("version" => "from_lockfile")
70
+ else
71
+ entry
72
+ end
73
+ end
74
+
75
+ updated += new_by_name.values
76
+ existing.merge("gems" => updated)
77
+ end
78
+ end
79
+ end
@@ -4,6 +4,7 @@ require "yaml"
4
4
  require "fileutils"
5
5
 
6
6
  module Gemkeeper
7
+ # Single source of truth for resolved paths and settings; callers never touch raw YAML keys.
7
8
  class Configuration
8
9
  DEFAULT_PORT = 9292
9
10
  DEFAULT_CONFIG_FILENAME = "gemkeeper.yml"
@@ -17,6 +18,33 @@ module Gemkeeper
17
18
  -> { "/opt/homebrew/etc/gemkeeper.yml" } # Homebrew (Apple Silicon)
18
19
  ].freeze
19
20
 
21
+ # Candidate paths for the global service config, in priority order
22
+ GLOBAL_CONFIG_PATHS = [
23
+ -> { "/opt/homebrew/etc/gemkeeper.yml" },
24
+ -> { "/usr/local/etc/gemkeeper.yml" },
25
+ -> { File.expand_path("~/.config/gemkeeper/config.yml") }
26
+ ].freeze
27
+
28
+ def self.global_config_paths
29
+ override = ENV.fetch("GEMKEEPER_GLOBAL_CONFIG", nil)
30
+ return [override] if override
31
+
32
+ GLOBAL_CONFIG_PATHS.map(&:call)
33
+ end
34
+
35
+ def self.resolve_global_path
36
+ global_config_paths.find { |path| File.directory?(File.dirname(path)) }
37
+ end
38
+
39
+ def self.global_data_dir(config_path)
40
+ config_dir = File.dirname(File.expand_path(config_path))
41
+ if config_dir.end_with?("/etc")
42
+ File.join(File.dirname(config_dir), "var", "gemkeeper")
43
+ else
44
+ config_dir
45
+ end
46
+ end
47
+
20
48
  attr_reader :port, :repos_path, :gems_path, :pid_file, :gems
21
49
 
22
50
  def self.load(config_path = nil)
@@ -67,8 +95,8 @@ module Gemkeeper
67
95
 
68
96
  begin
69
97
  YAML.safe_load_file(@config_path, permitted_classes: [], symbolize_names: true) || {}
70
- rescue Psych::SyntaxError => e
71
- raise InvalidConfigError, "Invalid YAML in #{@config_path}: #{e.message}"
98
+ rescue Psych::SyntaxError => yaml_error
99
+ raise InvalidConfigError, "Invalid YAML in #{@config_path}: #{yaml_error.message}"
72
100
  end
73
101
  end
74
102
 
@@ -84,6 +112,7 @@ module Gemkeeper
84
112
  FileUtils.mkdir_p(@gems_path)
85
113
  end
86
114
 
115
+ # Keeps per-gem config strongly typed so callers get validated attributes instead of raw hash access.
87
116
  class GemDefinition
88
117
  VALID_VERSION_PATTERN = /\A[a-zA-Z0-9._-]+\z/
89
118
  RESERVED_VERSIONS = %w[latest from_lockfile].freeze
@@ -4,6 +4,7 @@ require "open3"
4
4
  require "fileutils"
5
5
 
6
6
  module Gemkeeper
7
+ # Shells out to `gem build` in an isolated working directory; decoupled so tests can stub it.
7
8
  class GemBuilder
8
9
  attr_reader :repo_path, :output_dir
9
10
 
@@ -4,6 +4,7 @@ require "faraday"
4
4
  require "faraday/multipart"
5
5
 
6
6
  module Gemkeeper
7
+ # Encapsulates Geminabox's HTTP API so callers never construct multipart requests directly.
7
8
  class GemUploader
8
9
  attr_reader :geminabox_url
9
10
 
@@ -30,13 +31,14 @@ module Gemkeeper
30
31
  def list_gems
31
32
  response = connection.get("/api/v1/gems.json")
32
33
 
33
- raise UploadError, "Failed to list gems: #{response.status} #{response.body}" unless response.success?
34
+ body = response.body
35
+ raise UploadError, "Failed to list gems: #{response.status} #{body}" unless response.success?
34
36
 
35
- JSON.parse(response.body)
36
- rescue JSON::ParserError => e
37
- raise UploadError, "Invalid JSON response: #{e.message}"
38
- rescue Faraday::Error => e
39
- raise UploadError, "Connection error: #{e.message}"
37
+ JSON.parse(body)
38
+ rescue JSON::ParserError => parse_error
39
+ raise UploadError, "Invalid JSON response: #{parse_error.message}"
40
+ rescue Faraday::Error => connection_error
41
+ raise UploadError, "Connection error: #{connection_error.message}"
40
42
  end
41
43
 
42
44
  private
@@ -50,16 +52,18 @@ module Gemkeeper
50
52
  end
51
53
 
52
54
  def handle_response(response, gem_path)
53
- case response.status
55
+ status = response.status
56
+ gem_name = File.basename(gem_path)
57
+ case status
54
58
  when 200, 201, 302
55
- { success: true, message: "Uploaded #{File.basename(gem_path)}" }
59
+ { success: true, message: "Uploaded #{gem_name}" }
56
60
  when 409
57
- { success: true, message: "#{File.basename(gem_path)} already exists", skipped: true }
61
+ { success: true, message: "#{gem_name} already exists", skipped: true }
58
62
  else
59
- raise UploadError, "Upload failed (#{response.status}): #{response.body}"
63
+ raise UploadError, "Upload failed (#{status}): #{response.body}"
60
64
  end
61
- rescue Faraday::Error => e
62
- raise UploadError, "Connection error: #{e.message}"
65
+ rescue Faraday::Error => connection_error
66
+ raise UploadError, "Connection error: #{connection_error.message}"
63
67
  end
64
68
  end
65
69
  end
@@ -4,6 +4,7 @@ require "open3"
4
4
  require "fileutils"
5
5
 
6
6
  module Gemkeeper
7
+ # Centralizes safe ref validation; prevents command injection via untrusted version strings.
7
8
  class GitRepository
8
9
  attr_reader :repo_url, :local_path
9
10
 
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Gemkeeper
4
+ # Locates the nearest Gemfile.lock by walking up; callers don't need to know the search algorithm.
4
5
  class LockfileParser
5
6
  LOCKFILE_NAME = "Gemfile.lock"
6
7
 
@@ -38,13 +39,14 @@ module Gemkeeper
38
39
  in_gem_specs = false
39
40
 
40
41
  content.each_line do |line|
41
- if line.strip == "GEM"
42
+ stripped = line.strip
43
+ if stripped == "GEM"
42
44
  in_gem_specs = true
43
45
  next
44
46
  end
45
47
 
46
48
  # A new top-level section (no leading spaces) ends the GEM block
47
- in_gem_specs = false if in_gem_specs && line =~ /\A[A-Z]/ && line.strip != "GEM"
49
+ in_gem_specs = false if in_gem_specs && line =~ /\A[A-Z]/ && stripped != "GEM"
48
50
 
49
51
  next unless in_gem_specs
50
52
 
@@ -3,6 +3,7 @@
3
3
  require "yaml"
4
4
 
5
5
  module Gemkeeper
6
+ # Decouples manifest format from sync/setup commands that need the eligible gem list.
6
7
  class ManifestReader
7
8
  DEFAULT_PATH = File.expand_path("~/.config/gemkeeper/manifest.yml")
8
9
 
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Gemkeeper
4
+ # Centralizes ANSI gating so color codes never appear in test output or pipes.
4
5
  module Output
5
6
  COLORS = {
6
7
  green: "\e[32m",
@@ -4,6 +4,7 @@ require "open3"
4
4
  require "fileutils"
5
5
 
6
6
  module Gemkeeper
7
+ # Owns the rackup/puma lifecycle so CLI commands delegate process management here.
7
8
  class ServerManager
8
9
  attr_reader :config
9
10
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Gemkeeper
4
- VERSION = "0.2.1"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/gemkeeper.rb CHANGED
@@ -6,6 +6,7 @@ require_relative "gemkeeper/output"
6
6
  require_relative "gemkeeper/configuration"
7
7
  require_relative "gemkeeper/lockfile_parser"
8
8
  require_relative "gemkeeper/manifest_reader"
9
+ require_relative "gemkeeper/config_generator"
9
10
  require_relative "gemkeeper/git_repository"
10
11
  require_relative "gemkeeper/gem_builder"
11
12
  require_relative "gemkeeper/gem_uploader"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gemkeeper
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dan Brubaker Horst
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-05-19 00:00:00.000000000 Z
11
+ date: 2026-05-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: dry-cli
@@ -89,6 +89,7 @@ executables:
89
89
  extensions: []
90
90
  extra_rdoc_files: []
91
91
  files:
92
+ - ".reek.yml"
92
93
  - CHANGELOG.md
93
94
  - LICENSE
94
95
  - README.md
@@ -102,6 +103,7 @@ files:
102
103
  - lib/gemkeeper/cli/commands/setup.rb
103
104
  - lib/gemkeeper/cli/commands/sync.rb
104
105
  - lib/gemkeeper/cli/commands/version.rb
106
+ - lib/gemkeeper/config_generator.rb
105
107
  - lib/gemkeeper/configuration.rb
106
108
  - lib/gemkeeper/errors.rb
107
109
  - lib/gemkeeper/gem_builder.rb