gemcp 0.0.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +126 -0
- data/bin/gemcp +7 -0
- data/lib/gemcp/cache.rb +102 -0
- data/lib/gemcp/client.rb +180 -0
- data/lib/gemcp/config.rb +35 -0
- data/lib/gemcp/dependency_graph.rb +130 -0
- data/lib/gemcp/errors.rb +24 -0
- data/lib/gemcp/local_repository.rb +105 -0
- data/lib/gemcp/rate_limiter.rb +31 -0
- data/lib/gemcp/server.rb +46 -0
- data/lib/gemcp/tools/base.rb +57 -0
- data/lib/gemcp/tools/check_compatibility.rb +86 -0
- data/lib/gemcp/tools/compare_versions.rb +64 -0
- data/lib/gemcp/tools/dependency_graph.rb +40 -0
- data/lib/gemcp/tools/get_context.rb +39 -0
- data/lib/gemcp/tools/get_gem.rb +21 -0
- data/lib/gemcp/tools/get_owners.rb +21 -0
- data/lib/gemcp/tools/get_releases_between.rb +61 -0
- data/lib/gemcp/tools/get_version.rb +25 -0
- data/lib/gemcp/tools/list_versions.rb +31 -0
- data/lib/gemcp/tools/reverse_dependencies.rb +36 -0
- data/lib/gemcp/tools/search_gems.rb +27 -0
- data/lib/gemcp/version.rb +5 -0
- data/lib/gemcp.rb +16 -0
- metadata +128 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: b3fbb27e1ba6c5a3784a08a182ebf231f88ab2a4cefe032393c69f0629964e2e
|
|
4
|
+
data.tar.gz: 3a38ad87ee84faddd0422d9fe909ae8fd58ba0344842516d07f37df169a8a318
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 37310c5845ad72a4124a148b6401b76b06fc8e3ee5475a724b4822a76a39af976afba151877f5bb532833b9b5cee0522a0b6c373f95668d3376a2bcc7105ac62
|
|
7
|
+
data.tar.gz: 2dbc82a8d0ba76f31e09a0569f0aada7a1a50fc6c0d2a59b3e5ae8c3691af6ab78aa129b60a6ed820d315986baf83765ad2b3494a1d3eb61d18c861a0f5234a5
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Brandon Weaver
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# gemcp
|
|
2
|
+
|
|
3
|
+
MCP tools for RubyGems package intelligence. Ask about gems, versions, dependencies, and ownership. Get structured, provenance-tagged answers an agent can trust.
|
|
4
|
+
|
|
5
|
+
`gemcp` reads from installed gemspecs first, a persistent disk cache second, and the RubyGems.org API only when neither has what you need.
|
|
6
|
+
|
|
7
|
+
## Tools
|
|
8
|
+
|
|
9
|
+
| Tool | Purpose |
|
|
10
|
+
|---|---|
|
|
11
|
+
| `search_gems` | Search installed gems first, then RubyGems.org |
|
|
12
|
+
| `get_gem` | Current gem metadata, preferring an installed gemspec |
|
|
13
|
+
| `list_gem_versions` | Installed versions first, registry versions otherwise |
|
|
14
|
+
| `get_gem_version` | Exact local version/platform first, API v2 otherwise |
|
|
15
|
+
| `get_gem_owners` | Public owner/maintainer data from RubyGems.org |
|
|
16
|
+
| `get_dependency_graph` | Bounded exploratory graph with constraints on edges |
|
|
17
|
+
| `get_gem_context` | Aggregated context: metadata + recent versions + owners |
|
|
18
|
+
| `check_compatibility` | Detect dependency conflicts between two gem versions |
|
|
19
|
+
| `get_releases_between` | List all releases between two versions of a gem |
|
|
20
|
+
| `reverse_dependencies` | Find gems that depend on a given gem |
|
|
21
|
+
| `compare_versions` | Diff dependency requirements between two versions |
|
|
22
|
+
|
|
23
|
+
## Example questions
|
|
24
|
+
|
|
25
|
+
These are the kinds of questions gemcp helps an agent answer with authoritative data:
|
|
26
|
+
|
|
27
|
+
- "Is sidekiq still actively maintained? Who owns it on RubyGems?"
|
|
28
|
+
- "What Ruby version does nokogiri 1.18.8 require?"
|
|
29
|
+
- "Can authlogic 3.6 coexist with Rails 8?"
|
|
30
|
+
- "What changed in authlogic's dependencies between version 3.6.1 and 6.6.0?"
|
|
31
|
+
- "What gems depend on rack?"
|
|
32
|
+
- "Show me every release of rails between 7.0.0 and 8.0.0"
|
|
33
|
+
- "Compare puma and unicorn — versions, owners, dependency counts"
|
|
34
|
+
- "What are the runtime dependencies of rails 8.1?"
|
|
35
|
+
- "Build me a dependency graph for sidekiq at depth 2"
|
|
36
|
+
- "Find me gems for background job processing"
|
|
37
|
+
|
|
38
|
+
## Quick start
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
bundle install
|
|
42
|
+
bundle exec bin/gemcp
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Add to your MCP client config:
|
|
46
|
+
|
|
47
|
+
```json
|
|
48
|
+
{
|
|
49
|
+
"mcpServers": {
|
|
50
|
+
"gemcp": {
|
|
51
|
+
"command": "bundle",
|
|
52
|
+
"args": ["exec", "/absolute/path/bin/gemcp"]
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Or run over HTTP:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
bundle exec rackup -p 9292
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## How it resolves data
|
|
65
|
+
|
|
66
|
+
```text
|
|
67
|
+
installed gemspecs
|
|
68
|
+
↓ miss
|
|
69
|
+
fresh disk cache
|
|
70
|
+
↓ stale/miss
|
|
71
|
+
conditional RubyGems.org request (ETag / If-Modified-Since)
|
|
72
|
+
↓
|
|
73
|
+
refresh cache
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Local results include `metadata_source: "local"`. On network failure with a stale cache entry, the stale data is served rather than raising.
|
|
77
|
+
|
|
78
|
+
Search is conservative: when installed gems satisfy a query, no network call is made.
|
|
79
|
+
|
|
80
|
+
## Configuration
|
|
81
|
+
|
|
82
|
+
| Environment variable | Default | Meaning |
|
|
83
|
+
|---|---:|---|
|
|
84
|
+
| `GEMCP_LOCAL_FIRST` | `true` | Prefer installed gemspecs before network |
|
|
85
|
+
| `GEMCP_CACHE_DIR` | `~/.cache/gemcp` | Persistent JSON response cache |
|
|
86
|
+
| `GEMCP_CACHE_TTL` | `3600` | Cache freshness lifetime in seconds |
|
|
87
|
+
| `GEMCP_REQUESTS_PER_SECOND` | `2` | Outbound request rate limit |
|
|
88
|
+
| `RUBYGEMS_API_BASE_URL` | `https://rubygems.org` | API origin (useful for tests or mirrors) |
|
|
89
|
+
|
|
90
|
+
## Architecture
|
|
91
|
+
|
|
92
|
+
Tools receive a `Client` via constructor injection. Each tool wraps its operation in `dry-monads` (`Success`/`Failure`) and renders the result to MCP response format. No global state.
|
|
93
|
+
|
|
94
|
+
```text
|
|
95
|
+
Tool.new(client:) → with_errors { client.do_thing } → Success(data) / Failure(error:, message:) → render → MCP response
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
The cache uses atomic temp-file + rename writes. The rate limiter uses a Mutex to serialize sleeps between outbound requests.
|
|
99
|
+
|
|
100
|
+
## Dependency graph limitations
|
|
101
|
+
|
|
102
|
+
`get_dependency_graph` is exploratory, not authoritative. It follows declared dependencies using the latest release of each, preserving requirement constraints on edges. It does not consider lockfiles, Ruby versions, platforms, or source configuration. It is not a substitute for Bundler.
|
|
103
|
+
|
|
104
|
+
## Compatibility checking limitations
|
|
105
|
+
|
|
106
|
+
`check_compatibility` tests whether two gems' declared dependency constraints overlap by sampling candidate versions against both `Gem::Requirement` objects. It catches common conflicts (e.g., `< 5.0` vs `= 8.0.0`) but is not a full constraint solver. It does not resolve transitive dependencies.
|
|
107
|
+
|
|
108
|
+
## Trust model
|
|
109
|
+
|
|
110
|
+
Registry facts (versions, checksums, dependencies, ownership, yanked state) are structured and authoritative. README, changelog, and documentation content is untrusted prose and never overrides registry facts.
|
|
111
|
+
|
|
112
|
+
## Dependencies
|
|
113
|
+
|
|
114
|
+
- [`fast-mcp`](https://github.com/yjacquin/fast-mcp) — MCP server framework (instance-based tools, Rack transport)
|
|
115
|
+
- [`dry-monads`](https://github.com/dry-rb/dry-monads) — `Success`/`Failure` result types for tool error handling
|
|
116
|
+
- `rack` — HTTP transport
|
|
117
|
+
|
|
118
|
+
## Test
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
bundle exec rake
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## License
|
|
125
|
+
|
|
126
|
+
MIT
|
data/bin/gemcp
ADDED
data/lib/gemcp/cache.rb
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "json"
|
|
6
|
+
require "securerandom"
|
|
7
|
+
|
|
8
|
+
module GemCP
|
|
9
|
+
# Persistent JSON file cache with conditional-request metadata (ETag, Last-Modified).
|
|
10
|
+
# Uses atomic temp+rename writes to prevent partial reads on POSIX systems.
|
|
11
|
+
class Cache
|
|
12
|
+
# A single cached response with freshness metadata.
|
|
13
|
+
Entry = Data.define(:body, :etag, :last_modified, :stored_at) do
|
|
14
|
+
# @param ttl [Integer] freshness lifetime in seconds
|
|
15
|
+
# @param now [Time] current time (injectable for testing)
|
|
16
|
+
# @return [Boolean] whether this entry is younger than ttl seconds
|
|
17
|
+
def fresh?(ttl:, now: Time.now)
|
|
18
|
+
now.to_i - stored_at.to_i < ttl
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @param directory [String] path to the cache directory (created if missing)
|
|
23
|
+
def initialize(directory:)
|
|
24
|
+
@directory = directory
|
|
25
|
+
FileUtils.mkdir_p(directory)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @param key [String] cache key (typically a full URL)
|
|
29
|
+
# @return [Entry, nil] the cached entry, or nil if missing/corrupt
|
|
30
|
+
def read(key)
|
|
31
|
+
path = path_for(key)
|
|
32
|
+
return unless File.file?(path)
|
|
33
|
+
|
|
34
|
+
data = JSON.parse(File.read(path))
|
|
35
|
+
Entry.new(
|
|
36
|
+
body: data.fetch("body"),
|
|
37
|
+
etag: data["etag"],
|
|
38
|
+
last_modified: data["last_modified"],
|
|
39
|
+
stored_at: Time.at(data.fetch("stored_at"))
|
|
40
|
+
)
|
|
41
|
+
rescue JSON::ParserError, KeyError, Errno::ENOENT
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @param key [String] cache key
|
|
46
|
+
# @param body [Hash, Array] parsed JSON response body
|
|
47
|
+
# @param etag [String, nil] ETag header from the response
|
|
48
|
+
# @param last_modified [String, nil] Last-Modified header from the response
|
|
49
|
+
# @return [Entry] the newly written entry
|
|
50
|
+
def write(key, body:, etag: nil, last_modified: nil)
|
|
51
|
+
path = path_for(key)
|
|
52
|
+
temp = "#{path}.#{SecureRandom.hex(8)}.tmp"
|
|
53
|
+
now = Time.now
|
|
54
|
+
|
|
55
|
+
payload = {
|
|
56
|
+
body: body,
|
|
57
|
+
etag: etag,
|
|
58
|
+
last_modified: last_modified,
|
|
59
|
+
stored_at: now.to_i
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
File.write(temp, JSON.generate(payload))
|
|
63
|
+
File.rename(temp, path)
|
|
64
|
+
|
|
65
|
+
Entry.new(body: body, etag: etag, last_modified: last_modified, stored_at: now)
|
|
66
|
+
ensure
|
|
67
|
+
File.delete(temp) if temp && File.exist?(temp)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Re-writes an entry with a fresh timestamp, preserving body and headers.
|
|
71
|
+
#
|
|
72
|
+
# @param key [String] cache key
|
|
73
|
+
# @param entry [Entry] the stale entry to refresh
|
|
74
|
+
# @return [Entry] the refreshed entry
|
|
75
|
+
def touch(key, entry)
|
|
76
|
+
write(key, body: entry.body, etag: entry.etag, last_modified: entry.last_modified)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Artificially age a cache entry by the given number of seconds.
|
|
80
|
+
# Intended for testing staleness without coupling to internal storage format.
|
|
81
|
+
#
|
|
82
|
+
# @param key [String] cache key
|
|
83
|
+
# @param seconds [Integer] number of seconds to subtract from stored_at
|
|
84
|
+
# @return [void]
|
|
85
|
+
def backdate(key, seconds:)
|
|
86
|
+
path = path_for(key)
|
|
87
|
+
return unless File.file?(path)
|
|
88
|
+
|
|
89
|
+
data = JSON.parse(File.read(path))
|
|
90
|
+
data["stored_at"] = data["stored_at"] - seconds
|
|
91
|
+
File.write(path, JSON.generate(data))
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
# @param key [String]
|
|
97
|
+
# @return [String] filesystem path for this cache key
|
|
98
|
+
def path_for(key)
|
|
99
|
+
File.join(@directory, "#{Digest::SHA256.hexdigest(key)}.json")
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
data/lib/gemcp/client.rb
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module GemCP
|
|
8
|
+
# HTTP client for the RubyGems.org API with local-first resolution,
|
|
9
|
+
# conditional caching (ETag/If-Modified-Since), and rate limiting.
|
|
10
|
+
class Client
|
|
11
|
+
DEFAULT_BASE_URL = "https://rubygems.org"
|
|
12
|
+
USER_AGENT = "gemcp/#{VERSION} (+https://github.com/baweaver/gemcp)"
|
|
13
|
+
|
|
14
|
+
# @param base_url [String] RubyGems API origin
|
|
15
|
+
# @param open_timeout [Integer] TCP connect timeout in seconds
|
|
16
|
+
# @param read_timeout [Integer] response read timeout in seconds
|
|
17
|
+
# @param config [Config, nil] configuration (built from env if nil)
|
|
18
|
+
# @param local_repository [LocalRepository, nil]
|
|
19
|
+
# @param cache [Cache, nil]
|
|
20
|
+
# @param rate_limiter [RateLimiter, nil]
|
|
21
|
+
def initialize(
|
|
22
|
+
base_url: ENV.fetch("RUBYGEMS_API_BASE_URL", DEFAULT_BASE_URL),
|
|
23
|
+
open_timeout: 3,
|
|
24
|
+
read_timeout: 10,
|
|
25
|
+
config: nil,
|
|
26
|
+
local_repository: nil,
|
|
27
|
+
cache: nil,
|
|
28
|
+
rate_limiter: nil
|
|
29
|
+
)
|
|
30
|
+
@config = config || Config.new
|
|
31
|
+
@local_repository = local_repository || LocalRepository.new
|
|
32
|
+
@cache = cache || Cache.new(directory: @config.cache_dir)
|
|
33
|
+
@rate_limiter = rate_limiter || RateLimiter.new(requests_per_second: @config.requests_per_second)
|
|
34
|
+
@base_url = base_url.delete_suffix("/")
|
|
35
|
+
@open_timeout = open_timeout
|
|
36
|
+
@read_timeout = read_timeout
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Search for gems by name or description.
|
|
40
|
+
#
|
|
41
|
+
# @param query [String] search text
|
|
42
|
+
# @return [Array<Hash>] list of gem metadata hashes
|
|
43
|
+
def search(query)
|
|
44
|
+
local = @config.local_first ? @local_repository.search(query) : []
|
|
45
|
+
return local if local.any?
|
|
46
|
+
|
|
47
|
+
get_json("/api/v1/search.json", query: { query: query })
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Get metadata for the latest version of a gem.
|
|
51
|
+
#
|
|
52
|
+
# @param name [String] gem name
|
|
53
|
+
# @return [Hash] gem metadata including version, downloads, URIs, dependencies
|
|
54
|
+
# @raise [NotFound] if the gem does not exist
|
|
55
|
+
def gem(name)
|
|
56
|
+
local = @config.local_first ? @local_repository.gem(name) : nil
|
|
57
|
+
return local if local
|
|
58
|
+
|
|
59
|
+
get_json("/api/v1/gems/#{escape(name)}.json")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# List all published versions of a gem.
|
|
63
|
+
#
|
|
64
|
+
# @param name [String] gem name
|
|
65
|
+
# @return [Array<Hash>] version metadata ordered newest-first
|
|
66
|
+
# @raise [NotFound] if the gem does not exist
|
|
67
|
+
def versions(name)
|
|
68
|
+
local = @config.local_first ? @local_repository.versions(name) : []
|
|
69
|
+
return local if local.any?
|
|
70
|
+
|
|
71
|
+
get_json("/api/v1/versions/#{escape(name)}.json")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Get the latest version number for a gem.
|
|
75
|
+
#
|
|
76
|
+
# @param name [String] gem name
|
|
77
|
+
# @return [Hash] hash with "version" key
|
|
78
|
+
# @raise [NotFound] if the gem does not exist
|
|
79
|
+
def latest_version(name)
|
|
80
|
+
local = @config.local_first ? @local_repository.gem(name) : nil
|
|
81
|
+
return { "version" => local.fetch("version"), "metadata_source" => "local" } if local
|
|
82
|
+
|
|
83
|
+
get_json("/api/v1/versions/#{escape(name)}/latest.json")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Get metadata for a specific version and optional platform.
|
|
87
|
+
#
|
|
88
|
+
# @param name [String] gem name
|
|
89
|
+
# @param number [String] version number
|
|
90
|
+
# @param platform [String, nil] platform filter (e.g. "x86_64-linux")
|
|
91
|
+
# @return [Hash] version metadata
|
|
92
|
+
# @raise [NotFound] if the version does not exist
|
|
93
|
+
def version(name, number, platform: nil)
|
|
94
|
+
local = @config.local_first ? @local_repository.version(name, number, platform: platform) : nil
|
|
95
|
+
return local if local
|
|
96
|
+
|
|
97
|
+
query = platform ? { platform: platform } : nil
|
|
98
|
+
get_json("/api/v2/rubygems/#{escape(name)}/versions/#{escape(number)}.json", query: query)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Get public owners/maintainers for a gem.
|
|
102
|
+
#
|
|
103
|
+
# @param name [String] gem name
|
|
104
|
+
# @return [Array<Hash>] owner records with id, handle, role
|
|
105
|
+
# @raise [NotFound] if the gem does not exist
|
|
106
|
+
def owners(name)
|
|
107
|
+
get_json("/api/v1/gems/#{escape(name)}/owners.json")
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Get gems that depend on this gem (reverse dependencies).
|
|
111
|
+
#
|
|
112
|
+
# @param name [String] gem name
|
|
113
|
+
# @return [Array<String>] names of gems that declare a runtime dependency on this gem
|
|
114
|
+
# @raise [NotFound] if the gem does not exist
|
|
115
|
+
def reverse_dependencies(name)
|
|
116
|
+
get_json("/api/v1/gems/#{escape(name)}/reverse_dependencies.json")
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
# Fetch JSON from the RubyGems API with caching and conditional requests.
|
|
122
|
+
#
|
|
123
|
+
# @param path [String] API path (e.g. "/api/v1/gems/rails.json")
|
|
124
|
+
# @param query [Hash, nil] query string parameters
|
|
125
|
+
# @return [Hash, Array] parsed JSON response
|
|
126
|
+
# @raise [NotFound, UpstreamError, InvalidResponse]
|
|
127
|
+
def get_json(path, query: nil)
|
|
128
|
+
uri = URI("#{@base_url}#{path}")
|
|
129
|
+
uri.query = URI.encode_www_form(query) if query
|
|
130
|
+
key = uri.to_s
|
|
131
|
+
cached = @cache.read(key)
|
|
132
|
+
return cached.body if cached&.fresh?(ttl: @config.cache_ttl)
|
|
133
|
+
|
|
134
|
+
request = Net::HTTP::Get.new(uri)
|
|
135
|
+
request["Accept"] = "application/json"
|
|
136
|
+
request["User-Agent"] = USER_AGENT
|
|
137
|
+
request["If-None-Match"] = cached.etag if cached&.etag
|
|
138
|
+
request["If-Modified-Since"] = cached.last_modified if cached&.last_modified
|
|
139
|
+
|
|
140
|
+
@rate_limiter.wait
|
|
141
|
+
response = Net::HTTP.start(
|
|
142
|
+
uri.host,
|
|
143
|
+
uri.port,
|
|
144
|
+
use_ssl: uri.scheme == "https",
|
|
145
|
+
open_timeout: @open_timeout,
|
|
146
|
+
read_timeout: @read_timeout
|
|
147
|
+
) { |http| http.request(request) }
|
|
148
|
+
|
|
149
|
+
case response
|
|
150
|
+
when Net::HTTPNotModified
|
|
151
|
+
@cache.touch(key, cached).body
|
|
152
|
+
when Net::HTTPSuccess
|
|
153
|
+
body = JSON.parse(response.body)
|
|
154
|
+
@cache.write(
|
|
155
|
+
key,
|
|
156
|
+
body: body,
|
|
157
|
+
etag: response["ETag"],
|
|
158
|
+
last_modified: response["Last-Modified"]
|
|
159
|
+
).body
|
|
160
|
+
when Net::HTTPTooManyRequests
|
|
161
|
+
raise UpstreamError, "RubyGems.org rate limited gemcp; retry later"
|
|
162
|
+
when Net::HTTPNotFound
|
|
163
|
+
raise NotFound, "RubyGems resource not found: #{uri.path}"
|
|
164
|
+
else
|
|
165
|
+
raise UpstreamError, "RubyGems.org returned HTTP #{response.code} for #{uri.path}"
|
|
166
|
+
end
|
|
167
|
+
rescue JSON::ParserError => e
|
|
168
|
+
raise InvalidResponse, "RubyGems.org returned invalid JSON: #{e.message}"
|
|
169
|
+
rescue Timeout::Error, SocketError, SystemCallError => e
|
|
170
|
+
return cached.body if cached
|
|
171
|
+
raise UpstreamError, "RubyGems.org request failed: #{e.class}: #{e.message}"
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# @param value [String] value to URL-encode
|
|
175
|
+
# @return [String]
|
|
176
|
+
def escape(value)
|
|
177
|
+
URI.encode_www_form_component(value.to_s)
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
data/lib/gemcp/config.rb
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemCP
|
|
4
|
+
# Immutable configuration for a GemCP client. Reads from environment
|
|
5
|
+
# variables at construction time, with sensible defaults.
|
|
6
|
+
class Config
|
|
7
|
+
# @return [String] path to the persistent JSON cache directory
|
|
8
|
+
attr_reader :cache_dir
|
|
9
|
+
|
|
10
|
+
# @return [Integer] seconds before a cache entry is considered stale
|
|
11
|
+
attr_reader :cache_ttl
|
|
12
|
+
|
|
13
|
+
# @return [Float] maximum outbound requests per second
|
|
14
|
+
attr_reader :requests_per_second
|
|
15
|
+
|
|
16
|
+
# @return [Boolean] whether to prefer installed gemspecs over network
|
|
17
|
+
attr_reader :local_first
|
|
18
|
+
|
|
19
|
+
# @param cache_dir [String]
|
|
20
|
+
# @param cache_ttl [Integer]
|
|
21
|
+
# @param requests_per_second [Float]
|
|
22
|
+
# @param local_first [Boolean]
|
|
23
|
+
def initialize(
|
|
24
|
+
cache_dir: ENV.fetch("GEMCP_CACHE_DIR", File.join(Dir.home, ".cache", "gemcp")),
|
|
25
|
+
cache_ttl: Integer(ENV.fetch("GEMCP_CACHE_TTL", "3600")),
|
|
26
|
+
requests_per_second: Float(ENV.fetch("GEMCP_REQUESTS_PER_SECOND", "2")),
|
|
27
|
+
local_first: ENV.fetch("GEMCP_LOCAL_FIRST", "true") != "false"
|
|
28
|
+
)
|
|
29
|
+
@cache_dir = cache_dir
|
|
30
|
+
@cache_ttl = cache_ttl
|
|
31
|
+
@requests_per_second = requests_per_second
|
|
32
|
+
@local_first = local_first
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
module GemCP
|
|
6
|
+
# Builds a bounded exploratory dependency graph by traversing RubyGems metadata.
|
|
7
|
+
# This is NOT a lockfile resolver — it follows each dependency's latest release
|
|
8
|
+
# and preserves version constraints on edges for informational purposes only.
|
|
9
|
+
class DependencyGraph
|
|
10
|
+
MAX_DEPTH = 4
|
|
11
|
+
MAX_NODES = 100
|
|
12
|
+
|
|
13
|
+
# @param client [Client] the API client to fetch version metadata
|
|
14
|
+
def initialize(client:)
|
|
15
|
+
@client = client
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Build the dependency graph starting from a root gem.
|
|
19
|
+
#
|
|
20
|
+
# @param name [String] root gem name
|
|
21
|
+
# @param version [String, nil] root version (defaults to latest)
|
|
22
|
+
# @param platform [String, nil] platform filter for the root
|
|
23
|
+
# @param depth [Integer] traversal depth (clamped to 0..MAX_DEPTH)
|
|
24
|
+
# @param include_development [Boolean] whether to follow dev dependencies
|
|
25
|
+
# @return [Hash] graph with :root, :nodes, :edges, :truncated, :limits
|
|
26
|
+
def build(name:, version: nil, platform: nil, depth: 1, include_development: false)
|
|
27
|
+
depth = Integer(depth).clamp(0, MAX_DEPTH)
|
|
28
|
+
root_version = version || @client.latest_version(name).fetch("version")
|
|
29
|
+
state = { nodes: {}, edges: [], visited: Set.new, truncated: false }
|
|
30
|
+
|
|
31
|
+
visit(
|
|
32
|
+
name: name,
|
|
33
|
+
version: root_version,
|
|
34
|
+
platform: platform,
|
|
35
|
+
depth_remaining: depth,
|
|
36
|
+
include_development: include_development,
|
|
37
|
+
state: state
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
{
|
|
41
|
+
root: { name: name, version: root_version, platform: platform },
|
|
42
|
+
nodes: state[:nodes].values,
|
|
43
|
+
edges: state[:edges],
|
|
44
|
+
truncated: state[:truncated],
|
|
45
|
+
limits: { max_depth: MAX_DEPTH, max_nodes: MAX_NODES }
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
# @param name [String]
|
|
52
|
+
# @param version [String]
|
|
53
|
+
# @param platform [String, nil]
|
|
54
|
+
# @return [String] unique node identifier (e.g. "rails@8.0.0@ruby")
|
|
55
|
+
def node_key(name, version, platform)
|
|
56
|
+
[name, version, platform || "ruby"].join("@")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Recursively visit a node and its dependencies.
|
|
60
|
+
#
|
|
61
|
+
# @param name [String] gem name
|
|
62
|
+
# @param version [String] version to fetch
|
|
63
|
+
# @param platform [String, nil] platform filter
|
|
64
|
+
# @param depth_remaining [Integer] remaining traversal depth
|
|
65
|
+
# @param include_development [Boolean]
|
|
66
|
+
# @param state [Hash] mutable accumulator for nodes, edges, visited set
|
|
67
|
+
# @return [void]
|
|
68
|
+
def visit(name:, version:, platform:, depth_remaining:, include_development:, state:)
|
|
69
|
+
key = node_key(name, version, platform)
|
|
70
|
+
return if state[:visited].include?(key)
|
|
71
|
+
|
|
72
|
+
if state[:nodes].length >= MAX_NODES
|
|
73
|
+
state[:truncated] = true
|
|
74
|
+
return
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
state[:visited] << key
|
|
78
|
+
metadata = @client.version(name, version, platform: platform)
|
|
79
|
+
state[:nodes][key] = summarize(metadata)
|
|
80
|
+
return if depth_remaining.zero?
|
|
81
|
+
|
|
82
|
+
dependency_groups = ["runtime"]
|
|
83
|
+
dependency_groups << "development" if include_development
|
|
84
|
+
|
|
85
|
+
dependency_groups.each do |kind|
|
|
86
|
+
Array(metadata.dig("dependencies", kind)).each do |dependency|
|
|
87
|
+
dep_name = dependency.fetch("name")
|
|
88
|
+
edge = {
|
|
89
|
+
from: key,
|
|
90
|
+
to: dep_name,
|
|
91
|
+
requirements: dependency["requirements"],
|
|
92
|
+
kind: kind
|
|
93
|
+
}
|
|
94
|
+
state[:edges] << edge
|
|
95
|
+
|
|
96
|
+
child_version = @client.latest_version(dep_name).fetch("version")
|
|
97
|
+
visit(
|
|
98
|
+
name: dep_name,
|
|
99
|
+
version: child_version,
|
|
100
|
+
platform: nil,
|
|
101
|
+
depth_remaining: depth_remaining - 1,
|
|
102
|
+
include_development: include_development,
|
|
103
|
+
state: state
|
|
104
|
+
)
|
|
105
|
+
rescue NotFound, UpstreamError => e
|
|
106
|
+
edge[:unresolved] = true
|
|
107
|
+
edge[:error] = e.message
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# @param metadata [Hash] raw version metadata from the API
|
|
113
|
+
# @return [Hash] summarized node for the graph output
|
|
114
|
+
def summarize(metadata)
|
|
115
|
+
{
|
|
116
|
+
id: node_key(metadata["name"], metadata["version"], metadata["platform"]),
|
|
117
|
+
name: metadata["name"],
|
|
118
|
+
version: metadata["version"],
|
|
119
|
+
platform: metadata["platform"],
|
|
120
|
+
summary: metadata["summary"],
|
|
121
|
+
licenses: metadata["licenses"],
|
|
122
|
+
ruby_version: metadata["ruby_version"],
|
|
123
|
+
yanked: metadata["yanked"],
|
|
124
|
+
sha: metadata["sha"],
|
|
125
|
+
project_uri: metadata["project_uri"],
|
|
126
|
+
source_code_uri: metadata["source_code_uri"]
|
|
127
|
+
}
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
data/lib/gemcp/errors.rb
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GemCP
|
|
4
|
+
# Base error class for all GemCP exceptions.
|
|
5
|
+
class Error < StandardError
|
|
6
|
+
# @return [String] machine-readable error type for structured responses
|
|
7
|
+
def error_type = raise NotImplementedError
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# Raised when a gem, version, or resource is not found.
|
|
11
|
+
class NotFound < Error
|
|
12
|
+
def error_type = "NotFound"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Raised when RubyGems.org returns an unexpected status or is unreachable.
|
|
16
|
+
class UpstreamError < Error
|
|
17
|
+
def error_type = "UpstreamError"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Raised when RubyGems.org returns a body that cannot be parsed as JSON.
|
|
21
|
+
class InvalidResponse < Error
|
|
22
|
+
def error_type = "InvalidResponse"
|
|
23
|
+
end
|
|
24
|
+
end
|