gemkeeper 0.7.2 → 0.8.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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +18 -1
  3. data/README.md +11 -11
  4. data/lib/gemkeeper/bundler_mirror_configurator.rb +1 -1
  5. data/lib/gemkeeper/cli/commands/list.rb +2 -2
  6. data/lib/gemkeeper/cli/commands/server/start.rb +4 -4
  7. data/lib/gemkeeper/cli/commands/server/status.rb +3 -3
  8. data/lib/gemkeeper/cli/commands/server/stop.rb +3 -3
  9. data/lib/gemkeeper/cli/commands/sync.rb +1 -1
  10. data/lib/gemkeeper/compact_index_server/cache_meta.rb +34 -0
  11. data/lib/gemkeeper/compact_index_server/cache_store.rb +64 -0
  12. data/lib/gemkeeper/compact_index_server/gem_cache.rb +88 -0
  13. data/lib/gemkeeper/compact_index_server/gem_index.rb +78 -0
  14. data/lib/gemkeeper/compact_index_server/index_merger.rb +81 -0
  15. data/lib/gemkeeper/compact_index_server/response.rb +12 -0
  16. data/lib/gemkeeper/compact_index_server/response_builder.rb +63 -0
  17. data/lib/gemkeeper/compact_index_server/rubygems_client.rb +59 -0
  18. data/lib/gemkeeper/compact_index_server/spec_mapper.rb +38 -0
  19. data/lib/gemkeeper/compact_index_server/upload_handler.rb +36 -0
  20. data/lib/gemkeeper/compact_index_server/upstream_cache.rb +26 -0
  21. data/lib/gemkeeper/compact_index_server.rb +131 -0
  22. data/lib/gemkeeper/configuration.rb +1 -1
  23. data/lib/gemkeeper/gem_syncer.rb +53 -84
  24. data/lib/gemkeeper/gem_uploader.rb +26 -18
  25. data/lib/gemkeeper/rackup_process.rb +12 -7
  26. data/lib/gemkeeper/repo_fetcher.rb +80 -0
  27. data/lib/gemkeeper/server_manager.rb +1 -1
  28. data/lib/gemkeeper/version.rb +1 -1
  29. data/lib/gemkeeper.rb +2 -0
  30. data/specs/20260529-091429-replace-geminabox-compact-proxy/critique-consolidated-v-1.md +168 -0
  31. data/specs/20260529-091429-replace-geminabox-compact-proxy/critique-v-1-claude.md +124 -0
  32. data/specs/20260529-091429-replace-geminabox-compact-proxy/critique-v-1-codex.md +125 -0
  33. data/specs/20260529-091429-replace-geminabox-compact-proxy/critique-v-1-copilot.md +261 -0
  34. data/specs/20260529-091429-replace-geminabox-compact-proxy/spec.md +360 -0
  35. data/specs/20260529-131354-sync-serve-cache-contract/critique-consolidated-v-1.md +95 -0
  36. data/specs/20260529-131354-sync-serve-cache-contract/critique-v-1-claude.md +47 -0
  37. data/specs/20260529-131354-sync-serve-cache-contract/critique-v-1-codex.md +112 -0
  38. data/specs/20260529-131354-sync-serve-cache-contract/critique-v-1-copilot.md +169 -0
  39. data/specs/20260529-131354-sync-serve-cache-contract/implementation-summary.md +59 -0
  40. data/specs/20260529-131354-sync-serve-cache-contract/spec.md +169 -0
  41. metadata +38 -28
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "zlib"
5
+
6
+ require_relative "response"
7
+
8
+ module Gemkeeper
9
+ class CompactIndexServer
10
+ # HTTP client for RubyGems.org compact index endpoints.
11
+ # Speaks conditional GET (ETag) and gzip; raises UpstreamUnavailableError
12
+ # on any network failure so callers can fall back to cache.
13
+ class RubygemsClient
14
+ HOST = "rubygems.org"
15
+ OPEN_TIMEOUT = 5
16
+ READ_TIMEOUT = 10
17
+
18
+ # Returns :not_modified, or {status:, body:, etag:}.
19
+ def get(path, if_none_match = nil)
20
+ uri = URI("https://#{HOST}#{path}")
21
+ req = build_request(uri, if_none_match)
22
+
23
+ Net::HTTP.start(uri.host, uri.port, use_ssl: true,
24
+ open_timeout: OPEN_TIMEOUT,
25
+ read_timeout: READ_TIMEOUT) do |http|
26
+ interpret(http.request(req))
27
+ end
28
+ rescue Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::ETIMEDOUT,
29
+ Net::OpenTimeout, Net::ReadTimeout, SocketError, OpenSSL::SSL::SSLError => error
30
+ raise UpstreamUnavailableError, error.message
31
+ end
32
+
33
+ private
34
+
35
+ def build_request(uri, if_none_match)
36
+ req = Net::HTTP::Get.new(uri)
37
+ req["If-None-Match"] = if_none_match if if_none_match
38
+ req["Accept-Encoding"] = "gzip"
39
+ req
40
+ end
41
+
42
+ def interpret(res)
43
+ case res.code.to_i
44
+ when 304 then Response.new(304, nil, nil)
45
+ when 200 then Response.new(200, decompress(res.body.to_s, res["Content-Encoding"]), res["ETag"])
46
+ else Response.new(res.code.to_i, res.body.to_s, nil)
47
+ end
48
+ end
49
+
50
+ def decompress(body, encoding)
51
+ return body unless encoding&.include?("gzip")
52
+
53
+ Zlib::GzipReader.new(StringIO.new(body)).read
54
+ rescue Zlib::Error
55
+ body
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "compact_index"
4
+ require "digest"
5
+
6
+ module Gemkeeper
7
+ class CompactIndexServer
8
+ # Maps a Gem::Specification to the compact-index representations the index is
9
+ # built from: the on-disk gem filename and a CompactIndex::GemVersion entry.
10
+ module SpecMapper
11
+ module_function
12
+
13
+ def filename(spec)
14
+ platform = spec.platform.to_s
15
+ suffix = platform.empty? || platform == "ruby" ? "" : "-#{platform}"
16
+ "#{spec.name}-#{spec.version}#{suffix}.gem"
17
+ end
18
+
19
+ def gem_version(spec, gem_file)
20
+ CompactIndex::GemVersion.new(
21
+ spec.version.to_s,
22
+ spec.platform.to_s,
23
+ Digest::SHA256.file(gem_file).hexdigest,
24
+ nil,
25
+ dependencies(spec),
26
+ spec.required_ruby_version&.to_s,
27
+ spec.required_rubygems_version&.to_s
28
+ )
29
+ end
30
+
31
+ def dependencies(spec)
32
+ (spec.runtime_dependencies || []).map do |dep|
33
+ CompactIndex::Dependency.new(dep.name, dep.requirement.to_s)
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rubygems/package"
4
+ require "zlib"
5
+
6
+ module Gemkeeper
7
+ class CompactIndexServer
8
+ # Handles POST /upload: reads the multipart gem file, parses its spec,
9
+ # and adds it to the index. Maps failures to compact-index HTTP responses.
10
+ class UploadHandler
11
+ def initialize(index)
12
+ @index = index
13
+ end
14
+
15
+ def call(req)
16
+ upload = req.params["file"]
17
+ return text(400, "Missing file parameter") unless upload
18
+
19
+ text(201, "Uploaded #{add(upload[:tempfile].path)}")
20
+ rescue Errno::EEXIST
21
+ text(409, "Gem already exists")
22
+ rescue Gem::Exception, Gem::Package::FormatError, Zlib::Error, TypeError, ArgumentError => error
23
+ text(422, "Invalid gem: #{error.message}")
24
+ end
25
+
26
+ private
27
+
28
+ def add(tempfile_path)
29
+ spec = Gem::Package.new(tempfile_path).spec
30
+ @index.add(tempfile_path, spec)
31
+ end
32
+
33
+ def text(status, message) = [status, { "content-type" => "text/plain" }, [message]]
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "cache_store"
4
+ require_relative "gem_cache"
5
+ require_relative "index_merger"
6
+ require_relative "rubygems_client"
7
+
8
+ module Gemkeeper
9
+ class CompactIndexServer
10
+ # Composition root for upstream RubyGems.org caching. Wires a shared on-disk
11
+ # store and HTTP client into the merged-index and per-gem cache collaborators.
12
+ class UpstreamCache
13
+ def initialize(cache_dir)
14
+ store = CacheStore.new(File.join(cache_dir, "rubygems_cache"))
15
+ client = RubygemsClient.new
16
+ @merger = IndexMerger.new(store, client)
17
+ @gems = GemCache.new(store, client)
18
+ end
19
+
20
+ def merged_versions(private_gems) = @merger.versions(private_gems)
21
+ def merged_names(private_names) = @merger.names(private_names)
22
+ def info(gemname) = @gems.info(gemname)
23
+ def gem_binary(filename) = @gems.binary(filename)
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "compact_index"
4
+ require "digest"
5
+ require "rack"
6
+
7
+ require_relative "compact_index_server/cache_store"
8
+ require_relative "compact_index_server/gem_index"
9
+ require_relative "compact_index_server/response_builder"
10
+ require_relative "compact_index_server/upload_handler"
11
+ require_relative "compact_index_server/upstream_cache"
12
+
13
+ module Gemkeeper
14
+ # Rack application implementing the Bundler compact index protocol.
15
+ # Delegates private gem state to GemIndex and upstream caching to UpstreamCache.
16
+ class CompactIndexServer
17
+ VALID_NAME = /\A[a-zA-Z0-9._-]+\z/
18
+ RESOURCE_ROUTES = {
19
+ info: %r{\A/info/([^/]+)\z},
20
+ gem: %r{\A/gems/([^/]+\.gem)\z}
21
+ }.freeze
22
+ # Private-store presence check for `gemkeeper sync`; reads the index only,
23
+ # never the upstream proxy (unlike /info), so it can't be fooled by a public gem.
24
+ PRESENCE_ROUTE = %r{\A/gemkeeper/has/([^/]+)/([^/]+)\z}
25
+
26
+ def initialize(gems_path:, cache_dir:)
27
+ @index = GemIndex.new(File.join(gems_path, "gems"))
28
+ @cache = UpstreamCache.new(cache_dir)
29
+ @upload = UploadHandler.new(@index)
30
+ end
31
+
32
+ def call(env)
33
+ req = Rack::Request.new(env)
34
+ path = req.path_info
35
+ case [req.request_method, path]
36
+ in ["GET", "/"] then health
37
+ in ["GET", "/names"] then serve_names(req)
38
+ in ["GET", "/versions"] then serve_versions(req)
39
+ in ["POST", "/upload"] then @upload.call(req)
40
+ in ["GET", String => p] if p.start_with?("/gemkeeper/") then serve_presence(p)
41
+ in ["GET", _] then serve_resource(path, req)
42
+ else not_found
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ # ── Routing ──────────────────────────────────────────────────────────────
49
+
50
+ def serve_presence(path)
51
+ match = path.match(PRESENCE_ROUTE)
52
+ return not_found unless match
53
+
54
+ name, version = match.captures
55
+ return invalid_name unless VALID_NAME.match?(name) && VALID_NAME.match?(version)
56
+
57
+ @index.serves?(name, version) ? present : not_found
58
+ end
59
+
60
+ def serve_resource(path, req)
61
+ type, name = match_resource(path)
62
+ return not_found unless type
63
+ return invalid_name unless VALID_NAME.match?(name)
64
+
65
+ type == :info ? serve_info(name, req) : serve_gem_file(name)
66
+ end
67
+
68
+ def match_resource(path)
69
+ RESOURCE_ROUTES.each do |type, pattern|
70
+ match = path.match(pattern)
71
+ return [type, match[1]] if match
72
+ end
73
+ nil
74
+ end
75
+
76
+ # ── Serving ───────────────────────────────────────────────────────────────
77
+
78
+ def serve_names(req)
79
+ result = @cache.merged_names(@index.keys)
80
+ serve_index_file(result[:path], result[:etag], req)
81
+ end
82
+
83
+ def serve_versions(req)
84
+ result = @cache.merged_versions(@index.values)
85
+ serve_index_file(result[:path], result[:etag], req)
86
+ end
87
+
88
+ def serve_info(gemname, req)
89
+ gem = @index[gemname]
90
+ gem ? serve_private_info(gem, req) : serve_upstream_info(gemname, req)
91
+ rescue UpstreamUnavailableError
92
+ upstream_unavailable
93
+ end
94
+
95
+ def serve_private_info(gem, req)
96
+ body = CompactIndex.info(gem.versions)
97
+ ResponseBuilder.new(req).index(body, Digest::SHA256.hexdigest(body))
98
+ end
99
+
100
+ def serve_upstream_info(gemname, req)
101
+ result = @cache.info(gemname)
102
+ result ? ResponseBuilder.new(req).index(result[:body], result[:etag]) : not_found
103
+ end
104
+
105
+ def serve_gem_file(filename)
106
+ path = @index.gem_path(filename) || @cache.gem_binary(filename)
107
+ path ? ResponseBuilder.file(path) : not_found
108
+ rescue UpstreamUnavailableError
109
+ upstream_unavailable
110
+ end
111
+
112
+ # ── HTTP response helpers ─────────────────────────────────────────────────
113
+
114
+ def serve_index_file(file_path, etag, req)
115
+ return upstream_unavailable unless file_path && File.exist?(file_path)
116
+
117
+ ResponseBuilder.new(req).index(File.binread(file_path), etag)
118
+ end
119
+
120
+ def health = [200, { "content-type" => "text/plain" }, ["OK"]]
121
+ def present = [200, { "content-type" => "text/plain" }, ["present"]]
122
+ def not_found = [404, { "content-type" => "text/plain" }, ["Not Found"]]
123
+ def invalid_name = [400, { "content-type" => "text/plain" }, ["Invalid name"]]
124
+
125
+ def upstream_unavailable
126
+ [503, { "content-type" => "text/plain" },
127
+ ["Upstream unavailable and no local cache. " \
128
+ "Connect to the internet and run bundle install to warm the cache."]]
129
+ end
130
+ end
131
+ end
@@ -62,7 +62,7 @@ module Gemkeeper
62
62
  apply_config
63
63
  end
64
64
 
65
- def geminabox_url
65
+ def server_url
66
66
  "http://localhost:#{port}"
67
67
  end
68
68
 
@@ -1,73 +1,45 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "rubygems/package"
4
+
3
5
  module Gemkeeper
4
- # Syncs a single gem: resolves version, checks cache, clones/pulls, builds, uploads.
6
+ # Syncs a single gem: checks the server, reuses a built artifact, or builds
7
+ # from source, then uploads. Repo acquisition is delegated to RepoFetcher.
5
8
  class GemSyncer
6
- AUTH_ERROR_PATTERNS = [
7
- /authentication failed/i,
8
- /could not read from remote repository/i,
9
- /permission denied \(publickey\)/i,
10
- /repository not found/i,
11
- /fatal: credential/i
12
- ].freeze
13
-
14
9
  def initialize(config, uploader, manifest:)
15
- @config = config
10
+ @config = config
16
11
  @uploader = uploader
17
- @manifest = manifest
12
+ @fetcher = RepoFetcher.new(manifest, config.repos_path)
18
13
  end
19
14
 
20
15
  def sync(gem_def)
21
- repo_url = resolve_repo(gem_def)
22
- version = resolve_version(gem_def)
23
- name = gem_def.name
24
- gems_path = @config.gems_path
25
-
26
- return :skipped if !gem_def.latest? && cached?(name, version, gems_path)
27
-
28
- puts "Syncing #{name} @ #{version}..."
29
- local_path = File.join(@config.repos_path, name)
30
- repo = fetch_repo(repo_url, local_path)
31
-
32
- if gem_def.latest?
33
- version = latest_version!(repo, name, gems_path, repo_url)
34
- return :skipped unless version
35
- else
36
- Output.step("Checking out #{version}...")
37
- repo.checkout_version(version)
38
- end
39
-
40
- build_and_upload(local_path, gems_path)
41
- :synced
16
+ gem_def.latest? ? sync_latest(gem_def) : sync_pinned(gem_def)
42
17
  end
43
18
 
44
19
  private
45
20
 
46
- # Explicit repo: in gemkeeper.yml wins, but warns on divergence from the manifest.
47
- # Otherwise the repo is resolved from the manifest by gem name.
48
- def resolve_repo(gem_def)
49
- manifest_repo = @manifest.repo_for(gem_def.name)
50
- return manifest_repo || missing_repo!(gem_def.name) unless gem_def.repo
21
+ # Pinned / from_lockfile: version is known without the repo, so check the
22
+ # server first, then reuse a local artifact, and only build as a last resort.
23
+ def sync_pinned(gem_def)
24
+ name = gem_def.name
25
+ version = resolve_version(gem_def)
26
+ return skip(name, version) if @uploader.serves?(name, version)
51
27
 
52
- warn_if_divergent(gem_def.name, gem_def.repo, manifest_repo)
53
- gem_def.repo
54
- end
28
+ artifact = File.join(@config.gems_path, "#{name}-#{version}.gem")
29
+ return reupload(artifact, name, version) if reusable_artifact?(artifact, name, version)
55
30
 
56
- def missing_repo!(name)
57
- unless File.exist?(@manifest.path)
58
- raise InvalidConfigError,
59
- "No manifest found at #{@manifest.path} — run 'gemkeeper manifest generate' to create one"
60
- end
61
-
62
- raise InvalidConfigError,
63
- "No repo configured for #{name.inspect} — add it to the manifest with 'gemkeeper manifest generate'"
31
+ build_and_upload(gem_def, version)
64
32
  end
65
33
 
66
- def warn_if_divergent(name, config_repo, manifest_repo)
67
- return unless manifest_repo && manifest_repo != config_repo
34
+ # latest: the version is only known after checkout, so the repo is always
35
+ # fetched; the server check then decides whether the resolved version uploads.
36
+ def sync_latest(gem_def)
37
+ repo, local_path = @fetcher.fetch(gem_def)
38
+ version = repo.current_version or
39
+ raise BuildError, "Could not read version from gemspec for #{gem_def.name}"
40
+ return skip(gem_def.name, version) if @uploader.serves?(gem_def.name, version)
68
41
 
69
- warn "Warning: repo for #{name} in gemkeeper.yml (#{config_repo}) " \
70
- "differs from manifest (#{manifest_repo}) — using gemkeeper.yml"
42
+ build_gem(local_path)
71
43
  end
72
44
 
73
45
  def resolve_version(gem_def)
@@ -84,49 +56,46 @@ module Gemkeeper
84
56
  versions[gem_def.name] or raise GitError, "#{gem_def.name} not found in #{lockfile_path}"
85
57
  end
86
58
 
87
- def cached?(name, version, gems_path)
88
- bare = version.delete_prefix("v")
89
- gem_file = File.join(gems_path, "#{name}-#{bare}.gem")
90
- return false unless File.exist?(gem_file)
59
+ def build_and_upload(gem_def, version)
60
+ repo, local_path = @fetcher.fetch(gem_def)
61
+ Output.step("Checking out #{version}...")
62
+ repo.checkout_version(version)
63
+ build_gem(local_path)
64
+ end
91
65
 
92
- Output.skip("Skipping #{name} @ #{bare} (already cached)")
93
- true
66
+ def build_gem(local_path)
67
+ Output.step("Building gem...")
68
+ gem_path = GemBuilder.new(local_path, @config.gems_path).build
69
+ upload(gem_path)
70
+ :synced
94
71
  end
95
72
 
96
- def fetch_repo(repo_url, local_path)
97
- repo = GitRepository.new(repo_url, local_path)
98
- Output.step("Fetching from #{repo_url}...")
99
- repo.clone_or_pull
100
- repo
101
- rescue GitError => git_error
102
- raise auth_error?(git_error) ? auth_failure_error(repo_url, git_error) : git_error
73
+ # Reuse a previously built artifact only if its embedded spec matches the
74
+ # requested gem — never upload the wrong file because a name collided.
75
+ def reusable_artifact?(path, name, version)
76
+ return false unless File.exist?(path)
77
+
78
+ spec = Gem::Package.new(path).spec
79
+ spec.name == name && spec.version.to_s == version
80
+ rescue StandardError
81
+ false
103
82
  end
104
83
 
105
- def latest_version!(repo, name, gems_path, repo_url)
106
- version = repo.current_version or
107
- raise BuildError, "Could not read version from gemspec in #{repo_url}"
108
- cached?(name, version, gems_path) ? nil : version
84
+ def reupload(path, name, version)
85
+ Output.step("Uploading cached #{name} @ #{version} (no rebuild)...")
86
+ upload(path)
87
+ :synced
109
88
  end
110
89
 
111
- def build_and_upload(local_path, gems_path)
112
- Output.step("Building gem...")
113
- gem_path = GemBuilder.new(local_path, gems_path).build
114
- Output.step("Uploading to Geminabox...")
115
- result = @uploader.upload(gem_path)
90
+ def upload(path)
91
+ result = @uploader.upload(path)
116
92
  Output.step(result[:message])
117
93
  Output.success(" Done!")
118
94
  end
119
95
 
120
- def auth_error?(error)
121
- AUTH_ERROR_PATTERNS.any? { |pat| error.message.match?(pat) }
122
- end
123
-
124
- def auth_failure_error(repo_url, original_error)
125
- GitError.new(
126
- "Git authentication failed for #{repo_url}.\n" \
127
- "#{original_error.message}\n" \
128
- "Configure GitHub credentials: https://docs.github.com/en/authentication"
129
- )
96
+ def skip(name, version)
97
+ Output.skip("Skipping #{name} @ #{version} (already on server)")
98
+ :skipped
130
99
  end
131
100
  end
132
101
  end
@@ -4,12 +4,12 @@ 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
+ # Encapsulates the Gemkeeper server's upload HTTP API so callers never construct multipart requests directly.
8
8
  class GemUploader
9
- attr_reader :geminabox_url
9
+ attr_reader :server_url
10
10
 
11
- def initialize(geminabox_url)
12
- @geminabox_url = geminabox_url
11
+ def initialize(server_url)
12
+ @server_url = server_url
13
13
  end
14
14
 
15
15
  def upload(gem_path)
@@ -27,9 +27,20 @@ module Gemkeeper
27
27
 
28
28
  handle_response(response, gem_path)
29
29
  rescue Faraday::ConnectionFailed, Faraday::TimeoutError
30
- raise ServerNotReachableError,
31
- "Geminabox server is not reachable at #{@geminabox_url} — " \
32
- "run 'gemkeeper server start' or check 'gemkeeper server status'"
30
+ not_reachable!
31
+ end
32
+
33
+ # True when the running server's private store already serves name@version.
34
+ # Hits the private-store endpoint, never /info, so a public gem can't fool it.
35
+ def serves?(name, version)
36
+ status = connection.get("/gemkeeper/has/#{name}/#{version}").status
37
+ case status
38
+ when 200 then true
39
+ when 404 then false
40
+ else raise ServerError, "Unexpected status #{status} checking #{name} #{version} on #{@server_url}"
41
+ end
42
+ rescue Faraday::ConnectionFailed, Faraday::TimeoutError
43
+ not_reachable!
33
44
  end
34
45
 
35
46
  def reachable?
@@ -40,22 +51,19 @@ module Gemkeeper
40
51
  end
41
52
 
42
53
  def list_gems
43
- response = connection.get("/api/v1/gems.json")
44
-
45
- body = response.body
46
- raise UploadError, "Failed to list gems: #{response.status} #{body}" unless response.success?
47
-
48
- JSON.parse(body)
49
- rescue JSON::ParserError => parse_error
50
- raise UploadError, "Invalid JSON response: #{parse_error.message}"
51
- rescue Faraday::Error => connection_error
52
- raise UploadError, "Connection error: #{connection_error.message}"
54
+ raise NotImplementedError, "list_gems is not supported by CompactIndexServer; use gemkeeper list instead"
53
55
  end
54
56
 
55
57
  private
56
58
 
59
+ def not_reachable!
60
+ raise ServerNotReachableError,
61
+ "Gemkeeper server is not reachable at #{@server_url} — " \
62
+ "run 'gemkeeper server start' or check 'gemkeeper server status'"
63
+ end
64
+
57
65
  def connection
58
- @connection ||= Faraday.new(url: @geminabox_url) do |f|
66
+ @connection ||= Faraday.new(url: @server_url) do |f|
59
67
  f.request :multipart
60
68
  f.request :url_encoded
61
69
  f.adapter Faraday::Adapter::NetHttp
@@ -34,24 +34,29 @@ module Gemkeeper
34
34
 
35
35
  def config_ru_content
36
36
  gems_path = @config.gems_path
37
+ cache_dir = @config.cache_dir
37
38
  <<~RUBY
38
39
  # frozen_string_literal: true
39
40
  # Auto-generated by Gemkeeper
40
41
 
41
- require "geminabox"
42
+ require "gemkeeper/compact_index_server"
42
43
 
43
- Geminabox.data = #{gems_path.inspect}
44
- Geminabox.rubygems_proxy = true
45
-
46
- run Geminabox::Server
44
+ run Gemkeeper::CompactIndexServer.new(
45
+ gems_path: #{gems_path.inspect},
46
+ cache_dir: #{cache_dir.inspect}
47
+ )
47
48
  RUBY
48
49
  end
49
50
 
50
51
  def build_rackup_cmd(*extra)
51
- [rackup_bin, @config.config_ru_path, "--host", "127.0.0.1",
52
+ [rackup_bin, "-I", lib_dir, @config.config_ru_path, "--host", "127.0.0.1",
52
53
  "-p", @config.port.to_s, "-s", "puma", *extra]
53
54
  end
54
55
 
56
+ # The gem's own lib/, so the spawned rackup process can require gemkeeper
57
+ # even when running from source (it does not inherit the exe's $LOAD_PATH).
58
+ def lib_dir = File.expand_path("..", __dir__)
59
+
55
60
  def rackup_bin
56
61
  gem_home = ENV.fetch("GEM_HOME", nil)
57
62
  return "rackup" unless gem_home
@@ -66,7 +71,7 @@ module Gemkeeper
66
71
  end
67
72
 
68
73
  def wait_for_server
69
- ServerReadinessProbe.new(@config.geminabox_url).wait
74
+ ServerReadinessProbe.new(@config.server_url).wait
70
75
  end
71
76
  end
72
77
  end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gemkeeper
4
+ # Resolves a gem's source repo URL (manifest lookup, with an optional
5
+ # gemkeeper.yml override) and clones or pulls it, mapping git authentication
6
+ # failures to actionable guidance.
7
+ class RepoFetcher
8
+ AUTH_ERROR_PATTERNS = [
9
+ /authentication failed/i,
10
+ /could not read from remote repository/i,
11
+ /permission denied \(publickey\)/i,
12
+ /repository not found/i,
13
+ /fatal: credential/i
14
+ ].freeze
15
+
16
+ def initialize(manifest, repos_path)
17
+ @manifest = manifest
18
+ @repos_path = repos_path
19
+ end
20
+
21
+ # Resolves the repo URL, clones/pulls it, and returns [GitRepository, local_path].
22
+ def fetch(gem_def)
23
+ local_path = File.join(@repos_path, gem_def.name)
24
+ [clone_or_pull(resolve(gem_def), local_path), local_path]
25
+ end
26
+
27
+ # Explicit repo: in gemkeeper.yml wins, but warns on divergence from the
28
+ # manifest. Otherwise the repo is resolved from the manifest by gem name.
29
+ def resolve(gem_def)
30
+ name = gem_def.name
31
+ override = gem_def.repo
32
+ manifest_repo = @manifest.repo_for(name)
33
+ return manifest_repo || missing_repo!(name) unless override
34
+
35
+ warn_if_divergent(name, override, manifest_repo)
36
+ override
37
+ end
38
+
39
+ private
40
+
41
+ def missing_repo!(name)
42
+ path = @manifest.path
43
+ unless File.exist?(path)
44
+ raise InvalidConfigError,
45
+ "No manifest found at #{path} — run 'gemkeeper manifest generate' to create one"
46
+ end
47
+
48
+ raise InvalidConfigError,
49
+ "No repo configured for #{name.inspect} — add it to the manifest with 'gemkeeper manifest generate'"
50
+ end
51
+
52
+ def warn_if_divergent(name, override, manifest_repo)
53
+ return unless manifest_repo && manifest_repo != override
54
+
55
+ warn "Warning: repo for #{name} in gemkeeper.yml (#{override}) " \
56
+ "differs from manifest (#{manifest_repo}) — using gemkeeper.yml"
57
+ end
58
+
59
+ def clone_or_pull(repo_url, local_path)
60
+ repo = GitRepository.new(repo_url, local_path)
61
+ Output.step("Fetching from #{repo_url}...")
62
+ repo.clone_or_pull
63
+ repo
64
+ rescue GitError => git_error
65
+ raise auth_error?(git_error) ? auth_failure_error(repo_url, git_error) : git_error
66
+ end
67
+
68
+ def auth_error?(error)
69
+ AUTH_ERROR_PATTERNS.any? { |pat| error.message.match?(pat) }
70
+ end
71
+
72
+ def auth_failure_error(repo_url, original_error)
73
+ GitError.new(
74
+ "Git authentication failed for #{repo_url}.\n" \
75
+ "#{original_error.message}\n" \
76
+ "Configure GitHub credentials: https://docs.github.com/en/authentication"
77
+ )
78
+ end
79
+ end
80
+ end