gemkeeper 0.1.0 → 0.2.1

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: 45860b370cd7f5370ab01069c44b9547208b64b0de895e1e2e1f625d87873fec
4
- data.tar.gz: 1989d795d04f3d368e5e26c419b6e8d10c59a2ae814a2d9cb28c1083d5cbf081
3
+ metadata.gz: 97154d493b89cc264c9de34cd0c15f3712d2e23a9757ec9fc749c436dee126d5
4
+ data.tar.gz: 4dba3a688a98ea40994b5979f5ef16df68ecad0cbc6fb5a8f2581abab40b0537
5
5
  SHA512:
6
- metadata.gz: 9e9ef5c779e1b6928562fbb39277f605aede5f46dc3d2d6937951b926a5fc296f2d530ffb5417548ab6155586e15a2e4eba990ad369fd7a2ec983999fb070bd5
7
- data.tar.gz: b83947d19960c2439d7abcb49b4aa64113c1d5333bb133ab63cf6ce71d81d62ca403145c752545c1f58e02ebfcfc6f14ff92eba03903192e1b6d321136a0fe97
6
+ metadata.gz: 4531fa55ac8d2f512ba602bee71752299b22e0243313ed1c3489bf6d5b9723f8d94d7a23da77a2fa2de8cd9587fac243f34fb899c3c11d43d5c6a77a05d99818
7
+ data.tar.gz: 85e9e466db67728d70ca25be6fec81a98717325021e5a8e38257e1b4d51fde54c91e9486e2284f0a81cd68f6bc74a9ba87d319e3c994e8567574e1de86c17c4d
data/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.2.1] - 2026-05-19
4
+
5
+ ### Fixed
6
+
7
+ - RuboCop offenses in sync command and executable (numeric predicate style, `delete_prefix`, parentheses, cyclomatic complexity).
8
+
9
+ ## [0.2.0] - 2026-05-19
10
+
11
+ ### Fixed
12
+
13
+ - `version: latest` now caches by the resolved gemspec version rather than the string "latest" — re-running `sync` is a no-op if the tip hasn't changed.
14
+ - Explicit versions accept both `v`-prefixed (`v1.2.3`) and bare semver (`1.2.3`) tag formats; the cache key is normalized to bare semver in both cases.
15
+ - `gemkeeper --help` and `gemkeeper server --help` now exit 0.
16
+ - Port validation raises a clear error at config load time rather than failing mid-command.
17
+ - Running the `gemkeeper` executable directly (without `bundle exec`) no longer raises a load error.
18
+
19
+ ### Changed
20
+
21
+ - Sync output uses ANSI colors (TTY only) to distinguish progress steps, skips, and failures.
22
+ - `sync` prints a summary line on completion: `Sync complete: 2 synced, 1 skipped (3 total)`.
23
+ - Version reading for `version: latest` follows `require_relative` in the gemspec and falls back to globbing `lib/**/version.rb`, covering the common pattern where the version lives in a separate constant file.
24
+
3
25
  ## [0.1.0] - 2026-01-29
4
26
 
5
27
  - Initial release
28
+
29
+ [Unreleased]: https://github.com/danhorst/gemkeeper/compare/v0.2.1...HEAD
30
+ [0.2.1]: https://github.com/danhorst/gemkeeper/compare/0.2.0...0.2.1
31
+ [0.2.0]: https://github.com/danhorst/gemkeeper/compare/0.1.0...0.2.0
data/README.md CHANGED
@@ -1,19 +1,256 @@
1
- # Gemkeeper
1
+ ![Gemkeeper](./img/gemkeeper.jpeg)
2
2
 
3
- An opinionated wrapper around [Gem in a Box][1] for local, sandboxed Ruby development.
3
+ This project is an opinionated wrapper around [Gem in a Box][1] for managing private gem dependencies in an offline development environment.
4
4
 
5
- ## Usage
5
+ ## Installation
6
6
 
7
- This project can be run directly on your machine or in a Docker container.
8
- For a local service, you'll need to install [Ruby][2], ideally with a [version manager][3].
7
+ ### Via RubyGems
9
8
 
10
- ## Colophon
9
+ ```bash
10
+ gem install gemkeeper
11
+ ```
11
12
 
12
- This project has been augmented with [Claude Code][4].
13
- The instructions for all agents are all in `AGENTS.md`.
14
- There is a utility script (`bin/agent-setup`) that takes care of symlinking `AGENTS.md` to `CLAUDE.md`.
13
+ ### Via Homebrew (MacOS)
14
+
15
+ ```bash
16
+ brew tap danhorst/tap
17
+ brew install danhorst/tap/gemkeeper
18
+ ```
19
+
20
+ Forumla: [`danhorst/homebrew-tap`][2]
21
+
22
+ ## Workstation Setup
23
+
24
+ If you cannot reach your organization's private gem server, follow these steps to use gemkeeper as a local proxy.
25
+
26
+ **Prerequisites:** You must have HTTPS access to the internal gem repositories on GitHub.
27
+ Configure GitHub credentials before step 4 — see [GitHub authentication docs][3].
28
+
29
+ 1. Install gemkeeper:
30
+
31
+ ```bash
32
+ gem install gemkeeper
33
+ ```
34
+
35
+ 2. Install your org's gem manifest:
36
+
37
+ Your organization should provide a command or file that writes `~/.config/gemkeeper/manifest.yml`.
38
+ This manifest lists the internal gems and their GitHub URLs, and may include the private gem source URL used in step 6.
39
+
40
+ 3. Generate a `gemkeeper.yml` for your project:
41
+
42
+ ```bash
43
+ gemkeeper setup path/to/Gemfile.lock
44
+ ```
45
+
46
+ This reads the lockfile, cross-references the manifest, and writes a `gemkeeper.yml` in the current directory.
47
+ It also prints the `bundle config` command you will need in step 6.
48
+
49
+ 4. Build and cache the internal gems:
50
+
51
+ ```bash
52
+ gemkeeper sync
53
+ ```
54
+
55
+ 5. Start the local gem server:
56
+
57
+ ```bash
58
+ gemkeeper server start
59
+ ```
60
+
61
+ 6. Point Bundler at the local server using the command printed by step 3:
62
+
63
+ ```bash
64
+ bundle config set --local mirror.<your-private-gem-source-url> http://localhost:9292
65
+ ```
66
+
67
+ Replace `<your-private-gem-source-url>` with the gem source URL declared in your `Gemfile` (the one that requires VPN or private credentials).
68
+ The mirror approach redirects gem resolution to your local Geminabox without modifying the committed `Gemfile` or `Gemfile.lock`.
69
+ Public gems are proxied from RubyGems.org automatically.
70
+
71
+ ## Quick Start
72
+
73
+ 1. Create a configuration file at `~/.config/gemkeeper/config.yml`:
74
+
75
+ ```yaml
76
+ port: 9292
77
+ gems:
78
+ - repo: https://github.com/company/internal-gem
79
+ version: latest
80
+ ```
81
+
82
+ 2. Start the server:
83
+
84
+ ```bash
85
+ gemkeeper server start
86
+ ```
87
+
88
+ 3. Configure your Rails app to use the local gem server:
89
+
90
+ ```ruby
91
+ # Gemfile
92
+ source "http://localhost:9292" do
93
+ gem "internal-gem"
94
+ end
95
+ ```
96
+
97
+ 4. Sync your gems:
98
+
99
+ ```bash
100
+ gemkeeper sync
101
+ ```
102
+
103
+ ## Configuration
104
+
105
+ Gemkeeper looks for configuration files in these locations (in order):
106
+
107
+ 1. `./gemkeeper.yml` (current directory)
108
+ 2. `~/.config/gemkeeper/config.yml`
109
+ 3. `~/.gemkeeper.yml`
110
+ 4. `/usr/local/etc/gemkeeper.yml` (Homebrew on Intel)
111
+ 5. `/opt/homebrew/etc/gemkeeper.yml` (Homebrew on Apple Silicon)
112
+
113
+ ### Configuration Options
114
+
115
+ ```yaml
116
+ # Port for the Geminabox server (default: 9292)
117
+ port: 9292
118
+
119
+ # Where to clone gem repositories (default: ./cache/repos)
120
+ repos_path: ./cache/repos
121
+
122
+ # Where to store built gems (default: ./cache/gems)
123
+ gems_path: ./cache/gems
124
+
125
+ # PID file location (default: ./cache/gemkeeper.pid)
126
+ pid_file: ./cache/gemkeeper.pid
127
+
128
+ # List of gems to manage
129
+ gems:
130
+ # HTTPS is recommended — works without SSH key setup (alternative: git@github.com:company/gem-one.git)
131
+ - repo: https://github.com/company/gem-one
132
+ version: latest # Use the latest commit on main/master; cached by resolved gemspec version
133
+
134
+ - repo: https://github.com/company/gem-two
135
+ version: v1.2.3 # Use a specific tag; both v-prefixed and bare semver accepted
136
+
137
+ - repo: https://github.com/company/gem-two
138
+ version: from_lockfile # Read version from the nearest Gemfile.lock
139
+
140
+ - repo: https://github.com/company/ruby-gem-three
141
+ name: gem-three # Override the gem name (strips "ruby-" prefix by default)
142
+ ```
143
+
144
+ ## CLI Commands
145
+
146
+ ### Server Management
147
+
148
+ ```bash
149
+ # Start the server (daemonized)
150
+ gemkeeper server start
151
+
152
+ # Start in foreground (for services/debugging)
153
+ gemkeeper server start --foreground
154
+ gemkeeper server start -f
155
+
156
+ # Start on a specific port
157
+ gemkeeper server start --port 8080
158
+
159
+ # Stop the server
160
+ gemkeeper server stop
161
+
162
+ # Check server status
163
+ gemkeeper server status
164
+ ```
165
+
166
+ ### Project Setup
167
+
168
+ ```bash
169
+ # Generate gemkeeper.yml from a Gemfile.lock and org manifest
170
+ gemkeeper setup path/to/Gemfile.lock
171
+
172
+ # Use a custom manifest path
173
+ gemkeeper setup path/to/Gemfile.lock --manifest ~/.config/myorg/manifest.yml
174
+
175
+ # Overwrite existing gemkeeper.yml entirely
176
+ gemkeeper setup path/to/Gemfile.lock --force
177
+ ```
178
+
179
+ ### Gem Synchronization
180
+
181
+ ```bash
182
+ # Sync all configured gems
183
+ gemkeeper sync
184
+
185
+ # Sync a specific gem
186
+ gemkeeper sync internal-gem
187
+ ```
188
+
189
+ ### Other Commands
190
+
191
+ ```bash
192
+ # List cached gems
193
+ gemkeeper list
194
+
195
+ # Show version
196
+ gemkeeper version
197
+ ```
198
+
199
+ ### Global Options
200
+
201
+ All commands support:
202
+
203
+ ```bash
204
+ --config PATH # Use a specific config file
205
+ ```
206
+
207
+ ## Running as a Service
208
+
209
+ ### Homebrew Services (macOS)
210
+
211
+ If installed via Homebrew:
212
+
213
+ ```bash
214
+ # Start and enable at login
215
+ brew services start gemkeeper
216
+
217
+ # Stop the service
218
+ brew services stop gemkeeper
219
+
220
+ # Check status
221
+ brew services info gemkeeper
222
+ ```
223
+
224
+ ### Manual Background Mode
225
+
226
+ ```bash
227
+ # Start daemonized
228
+ gemkeeper server start
229
+
230
+ # Check if running
231
+ gemkeeper server status
232
+
233
+ # Stop
234
+ gemkeeper server stop
235
+ ```
236
+
237
+ ## How It Works
238
+
239
+ 1. **Clone/Pull**: Gemkeeper clones (or pulls) gem repositories to a local cache.
240
+ 2. **Build**: Builds `.gem` files from the source at the specified version/tag.
241
+ 3. **Upload**: Uploads built gems to a local Geminabox server.
242
+ 4. **Proxy**: Geminabox proxies public gems from RubyGems.org, so you only need one gem source.
243
+
244
+ This lets you use a combination of public and private gems from a single gem source.
245
+
246
+ ## Development
247
+
248
+ ```bash
249
+ bundle install
250
+ bundle exec rake test # Run tests
251
+ bundle exec rubocop # Run linter
252
+ ```
15
253
 
16
254
  [1]: https://github.com/geminabox/geminabox
17
- [2]: https://www.ruby-lang.org/en/
18
- [3]: https://www.ruby-lang.org/en/documentation/installation/#managers
19
- [4]: https://claude.com/product/claude-code
255
+ [2]: https://github.com/danhorst/homebrew-tap
256
+ [3]: https://docs.github.com/en/authentication
data/exe/gemkeeper CHANGED
@@ -1,7 +1,13 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
5
+
4
6
  require "gemkeeper"
5
7
  require "gemkeeper/cli"
6
8
 
7
- Dry::CLI.new(Gemkeeper::CLI).call
9
+ begin
10
+ Dry::CLI.new(Gemkeeper::CLI).call
11
+ rescue SystemExit => e
12
+ exit(ARGV.include?("--help") || ARGV.include?("-h") ? 0 : e.status)
13
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Gemkeeper
6
+ module CLI
7
+ module Commands
8
+ class Setup < Dry::CLI::Command
9
+ desc "Generate gemkeeper.yml from a Gemfile.lock and org manifest"
10
+
11
+ argument :lockfile_path, type: :string, required: true,
12
+ desc: "Path to the project's Gemfile.lock"
13
+ option :manifest, type: :string,
14
+ desc: "Path to gem manifest (default: ~/.config/gemkeeper/manifest.yml)"
15
+ option :config, type: :string, desc: "Path to write gemkeeper.yml (default: ./gemkeeper.yml)"
16
+ option :force, type: :boolean, default: false,
17
+ desc: "Overwrite existing gemkeeper.yml entirely"
18
+
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)
22
+
23
+ manifest = ManifestReader.load(manifest_path)
24
+ lockfile_versions = LockfileParser.parse(lockfile_path)
25
+
26
+ matched = match_gems(manifest, lockfile_versions)
27
+
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}"
32
+ exit 1
33
+ end
34
+
35
+ private
36
+
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
65
+
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}"
74
+ end
75
+
76
+ def load_existing_config(path)
77
+ return nil unless File.exist?(path)
78
+
79
+ YAML.safe_load_file(path, permitted_classes: [], symbolize_names: false) || {}
80
+ end
81
+
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)
116
+ end
117
+
118
+ def print_bundler_instructions(config_path, manifest)
119
+ config = load_existing_config(config_path) || {}
120
+ port = config.fetch("port", Configuration::DEFAULT_PORT)
121
+ local_url = "http://localhost:#{port}"
122
+ source_url = manifest.source_url
123
+ puts ""
124
+ puts "To point Bundler at your local Geminabox, run:"
125
+ if source_url
126
+ puts " bundle config set --local mirror.#{source_url} #{local_url}"
127
+ else
128
+ puts " bundle config set --local mirror.<your-private-gem-source-url> #{local_url}"
129
+ puts " (Replace <your-private-gem-source-url> with the gem source URL from your Gemfile)"
130
+ end
131
+ end
132
+ end
133
+ end
134
+
135
+ register "setup", Commands::Setup
136
+ end
137
+ end
@@ -11,73 +11,148 @@ module Gemkeeper
11
11
 
12
12
  def call(gem_name: nil, **options)
13
13
  config = Configuration.load(options[:config])
14
+ gems_to_sync = select_gems(config, gem_name)
15
+ uploader = GemUploader.new(config.geminabox_url)
16
+ counts, failures = run_sync(gems_to_sync, config, uploader)
17
+ report_results(counts, failures, gems_to_sync.size)
18
+ end
19
+
20
+ private
14
21
 
15
- if config.gems.empty?
22
+ def select_gems(config, gem_name)
23
+ all_gems = config.gems
24
+ if all_gems.empty?
16
25
  warn "No gems configured. Add gems to your gemkeeper.yml file."
17
26
  exit 1
18
27
  end
19
28
 
20
- gems_to_sync = if gem_name
21
- config.gems.select { |g| g.name == gem_name }
22
- else
23
- config.gems
24
- end
29
+ gems = gem_name ? all_gems.select { |gem| gem.name == gem_name } : all_gems
25
30
 
26
- if gems_to_sync.empty?
31
+ if gems.empty?
27
32
  warn "No matching gem found: #{gem_name}"
28
33
  exit 1
29
34
  end
30
35
 
31
- uploader = GemUploader.new(config.geminabox_url)
36
+ gems
37
+ end
32
38
 
39
+ def run_sync(gems_to_sync, config, uploader)
40
+ counts = { synced: 0, skipped: 0 }
41
+ failures = []
33
42
  gems_to_sync.each do |gem_def|
34
- sync_gem(gem_def, config, uploader)
43
+ result = sync_gem(gem_def, config, uploader)
44
+ counts[result] += 1
45
+ rescue Error => e
46
+ failures << { name: gem_def.name, message: e.message }
35
47
  end
36
- rescue Error => e
37
- warn "Error: #{e.message}"
38
- exit 1
48
+ [counts, failures]
39
49
  end
40
50
 
41
- private
51
+ def report_results(counts, failures, total)
52
+ 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
+ puts "\nSync complete: #{parts.join(", ")} (#{total} total)"
57
+
58
+ return if failures.empty?
59
+
60
+ warn "\nSync completed with #{failures.size} failure(s):"
61
+ failures.each { |f| warn " #{f[:name]}: #{f[:message]}" }
62
+ exit 1
63
+ end
42
64
 
43
65
  def sync_gem(gem_def, config, uploader)
44
- puts "Syncing #{gem_def.name}..."
66
+ name = gem_def.name
67
+ repo_url = gem_def.repo
68
+ gems_path = config.gems_path
69
+ version = resolve_version(gem_def)
45
70
 
46
- # Clone or pull the repository
47
- local_path = File.join(config.repos_path, gem_def.name)
48
- repo = GitRepository.new(gem_def.repo, local_path)
71
+ return :skipped if !gem_def.latest? && cached?(name, version, gems_path)
49
72
 
50
- puts " Fetching from #{gem_def.repo}..."
51
- repo.clone_or_pull
73
+ puts "Syncing #{name} @ #{version}..."
74
+
75
+ local_path = File.join(config.repos_path, name)
76
+ repo = GitRepository.new(repo_url, local_path)
77
+
78
+ Output.step("Fetching from #{repo_url}...")
79
+ fetch_repo(repo, repo_url)
80
+
81
+ checkout_gem_version(repo, version)
52
82
 
53
- # Checkout the specified version
54
- puts " Checking out #{gem_def.version}..."
55
- repo.checkout_version(gem_def.version)
83
+ if gem_def.latest?
84
+ version = repo.current_version or
85
+ raise BuildError, "Could not read version from gemspec in #{repo_url}"
86
+ return :skipped if cached?(name, version, gems_path)
87
+ end
56
88
 
57
- # Build the gem
58
- puts " Building gem..."
59
- builder = GemBuilder.new(local_path)
60
- gem_path = builder.build
89
+ Output.step("Building gem...")
90
+ gem_path = GemBuilder.new(local_path, config.gems_path).build
61
91
 
62
- # Upload to Geminabox
63
- puts " Uploading to Geminabox..."
92
+ Output.step("Uploading to Geminabox...")
64
93
  result = uploader.upload(gem_path)
94
+ Output.step(result[:message])
95
+ Output.success(" Done!")
96
+ :synced
97
+ end
98
+
99
+ def resolve_version(gem_def)
100
+ return gem_def.version unless gem_def.from_lockfile?
101
+
102
+ name = gem_def.name
103
+ lockfile_path = LockfileParser.find
104
+ unless lockfile_path
105
+ raise GitError,
106
+ "version: from_lockfile for #{name} — no Gemfile.lock found in " \
107
+ "#{Dir.pwd} or any parent directory"
108
+ end
109
+
110
+ versions = LockfileParser.parse(lockfile_path)
111
+ version = versions[name]
112
+ raise GitError, "#{name} not found in #{lockfile_path}" unless version
65
113
 
66
- puts " #{result[:message]}"
114
+ version
115
+ end
67
116
 
68
- # Clean up the built gem file
69
- FileUtils.rm_f(gem_path)
117
+ def cached?(name, version, gems_path)
118
+ bare_version = version.delete_prefix("v")
119
+ gem_file = File.join(gems_path, "gems", "#{name}-#{bare_version}.gem")
120
+ if File.exist?(gem_file)
121
+ Output.skip("Skipping #{name} @ #{bare_version} (already cached)")
122
+ true
123
+ else
124
+ false
125
+ end
126
+ end
70
127
 
71
- puts " Done!"
128
+ def fetch_repo(repo, repo_url)
129
+ repo.clone_or_pull
72
130
  rescue GitError => e
73
- warn " Git error: #{e.message}"
74
- raise
75
- rescue BuildError => e
76
- warn " Build error: #{e.message}"
77
- raise
78
- rescue UploadError => e
79
- warn " Upload error: #{e.message}"
80
- raise
131
+ raise auth_error?(e) ? auth_failure_error(repo_url, e) : e
132
+ end
133
+
134
+ def checkout_gem_version(repo, version)
135
+ Output.step("Checking out #{version}...")
136
+ repo.checkout_version(version)
137
+ end
138
+
139
+ def auth_error?(error)
140
+ auth_patterns = [
141
+ /authentication failed/i,
142
+ /could not read from remote repository/i,
143
+ /permission denied \(publickey\)/i,
144
+ /repository not found/i,
145
+ /fatal: credential/i
146
+ ]
147
+ auth_patterns.any? { |pat| error.message.match?(pat) }
148
+ end
149
+
150
+ def auth_failure_error(repo_url, original_error)
151
+ GitError.new(
152
+ "Git authentication failed for #{repo_url}.\n" \
153
+ "#{original_error.message}\n" \
154
+ "Configure GitHub credentials: https://docs.github.com/en/authentication"
155
+ )
81
156
  end
82
157
  end
83
158
  end
data/lib/gemkeeper/cli.rb CHANGED
@@ -13,6 +13,7 @@ module Gemkeeper
13
13
  end
14
14
 
15
15
  require_relative "cli/commands/version"
16
+ require_relative "cli/commands/setup"
16
17
  require_relative "cli/commands/sync"
17
18
  require_relative "cli/commands/list"
18
19
  require_relative "cli/commands/server/start"