namespaced-gem 0.1.0.pre

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.
data/ISSUE.md ADDED
@@ -0,0 +1,198 @@
1
+ # gem.coop namespace servers missing legacy Marshal API endpoints
2
+
3
+ ## Summary
4
+
5
+ `beta.gem.coop` namespace servers implement only the **Compact Index** API
6
+ (used by Bundler) and the `/gems/` download endpoint, but not the **legacy
7
+ Marshal API** (`quick/Marshal.4.8/`). The
8
+ [namespaced-gem](https://gitlab.com/galtzo-floss/namespaced-gem) plugin works
9
+ around this by synthesizing `Gem::Specification` objects from Compact Index data
10
+ (via `ApiSpecPatch`), so **both `bundle install` and `gem install` now work**.
11
+
12
+ A secondary issue: the production `gem.coop` server returns **HTTP 200 with
13
+ body `"404"`** for namespace endpoints, instead of a proper HTTP 404 status
14
+ code.
15
+
16
+ ---
17
+
18
+ ## Context
19
+
20
+ The `namespaced-gem` RubyGems plugin enables gemspec dependencies to be declared
21
+ as full URIs (e.g. `spec.add_dependency "https://beta.gem.coop/@kaspth/oaken"`).
22
+ It patches both Bundler and RubyGems' native resolver to parse these URIs,
23
+ derive the namespace source URL, and resolve against it.
24
+
25
+ Each namespace (e.g. `https://beta.gem.coop/@kaspth/`) is treated as its own
26
+ **discrete gem server** — completely independent of the root server or any
27
+ other namespace.
28
+
29
+ ---
30
+
31
+ ## What works: Compact Index (Bundler)
32
+
33
+ The Compact Index endpoints are served correctly under namespace paths.
34
+ Bundler uses **only** these two endpoints, so `bundle install` / `bundle lock`
35
+ works today.
36
+
37
+ | Endpoint | URL | Status |
38
+ |---|---|---|
39
+ | `versions` | `https://beta.gem.coop/@kaspth/versions` | ✅ 200 — returns gem listing |
40
+ | `info/{gem}` | `https://beta.gem.coop/@kaspth/info/oaken` | ✅ 200 — returns per-version dependency data |
41
+
42
+ ---
43
+
44
+ ## What's missing: Legacy Marshal API (`gem install`)
45
+
46
+ When `gem install` resolves a dependency, it uses RubyGems' native resolver
47
+ (`Gem::DependencyInstaller` → `Gem::Resolver::InstallerSet`). The flow is:
48
+
49
+ 1. **Discovery** — `Gem::Source#dependency_resolver_set` probes
50
+ `{source}/versions`. Since that returns 200, it creates an `APISet` backed
51
+ by `{source}/info/`. This step **succeeds** — the gem and its versions are
52
+ found via the Compact Index.
53
+
54
+ 2. **Spec fetch** — `InstallerSet#add_always_install` calls
55
+ `APISpecification#spec`, which calls `Gem::Source#fetch_spec`. This method
56
+ constructs the URL:
57
+
58
+ ```
59
+ {source}/quick/Marshal.4.8/{gem}-{version}.gemspec.rz
60
+ ```
61
+
62
+ and expects a **deflated (`Zlib`) Marshal-serialized `Gem::Specification`**
63
+ in response. This endpoint **returns 404**.
64
+
65
+ 3. **Gem download** — After resolution, RubyGems downloads the `.gem` file
66
+ from:
67
+
68
+ ```
69
+ {source}/gems/{gem}-{version}.gem
70
+ ```
71
+
72
+ This endpoint also **returns 404**.
73
+
74
+ 4. **Crash** — The 404 HTML body is passed to `Zlib::Inflate.inflate`, which
75
+ raises `Zlib::DataError: incorrect header check`.
76
+
77
+ ### Endpoints that return 404 (all under the namespace path)
78
+
79
+ | Endpoint | URL | Expected response |
80
+ |---|---|---|
81
+ | `quick/Marshal.4.8/{gem}-{ver}.gemspec.rz` | `https://beta.gem.coop/@kaspth/quick/Marshal.4.8/oaken-2.5.1.gemspec.rz` | Deflated Marshal-serialized `Gem::Specification` |
82
+ | `gems/{gem}-{ver}.gem` | `https://beta.gem.coop/@kaspth/gems/oaken-2.5.1.gem` | The `.gem` file |
83
+ | `specs.4.8.gz` | `https://beta.gem.coop/@kaspth/specs.4.8.gz` | Gzipped Marshal array of all `[name, version, platform]` tuples |
84
+ | `latest_specs.4.8.gz` | `https://beta.gem.coop/@kaspth/latest_specs.4.8.gz` | Gzipped Marshal array of latest `[name, version, platform]` tuples |
85
+
86
+ **All of these must be served under the namespace path** (e.g.
87
+ `https://beta.gem.coop/@kaspth/quick/…`, not `https://beta.gem.coop/quick/…`),
88
+ because each namespace is its own discrete gem server.
89
+
90
+ The minimum required for `gem install` to work are:
91
+
92
+ 1. **`quick/Marshal.4.8/{gem}-{ver}.gemspec.rz`** — the gemspec, serialized
93
+ with `Marshal.dump` then compressed with `Zlib::Deflate.deflate`.
94
+ 2. **`gems/{gem}-{ver}.gem`** — the `.gem` package file for download.
95
+
96
+ ---
97
+
98
+ ## Reproduction
99
+
100
+ ### Direct install
101
+
102
+ ```bash
103
+ # Requires Ruby >= 3.2, RubyGems >= 4.0.5
104
+ gem install namespaced-gem # install the plugin first
105
+
106
+ gem install @kaspth/oaken # shorthand (defaults to https://beta.gem.coop)
107
+ # => ERROR: Zlib::DataError — incorrect header check
108
+
109
+ gem install https://beta.gem.coop/@kaspth/oaken # full URI
110
+ # => ERROR: Zlib::DataError — incorrect header check
111
+ ```
112
+
113
+ ### Transitive dependency (Use Case 1)
114
+
115
+ Even when the plugin is already installed, `gem install` of a gem whose gemspec
116
+ contains URI dependencies also fails:
117
+
118
+ ```bash
119
+ gem install namespaced-gem # plugin loaded on next boot
120
+
121
+ gem install my-gem # my-gem.gemspec has:
122
+ # spec.add_dependency "https://beta.gem.coop/@kaspth/oaken", "~> 1.0"
123
+ ```
124
+
125
+ The resolution phase succeeds — the `InstallerSetPatch#find_all` intercept
126
+ correctly remaps the URI dep and queries the compact index. But the
127
+ **installation phase** fails because RubyGems downloads `.gem` files from
128
+ `{source}/gems/{name}-{version}.gem`, which returns 404.
129
+
130
+ Additionally, there is a **chicken-and-egg problem** for first-time installs:
131
+ if `namespaced-gem` is listed as a dependency of `my-gem` (rather than being
132
+ pre-installed), the plugin is not loaded when `gem install my-gem` starts —
133
+ because RubyGems loads `rubygems_plugin.rb` files only from _already installed_
134
+ gems at boot. In this case, RubyGems encounters the URI dependency string
135
+ without the patches in place and tries to look it up as a literal gem name on
136
+ rubygems.org, failing immediately.
137
+
138
+ ### Full stack trace (direct install)
139
+
140
+ ```
141
+ ERROR: While executing gem ... (Zlib::DataError)
142
+ incorrect header check
143
+ .../rubygems/util.rb:47:in 'Zlib::Inflate.inflate'
144
+ .../rubygems/util.rb:47:in 'Gem::Util.inflate'
145
+ .../rubygems/source.rb:132:in 'Gem::Source#fetch_spec'
146
+ .../rubygems/resolver/api_specification.rb:93:in 'Gem::Resolver::APISpecification#spec'
147
+ .../rubygems/resolver/installer_set.rb:99:in 'Gem::Resolver::InstallerSet#add_always_install'
148
+ .../rubygems/dependency_installer.rb:243:in 'Gem::DependencyInstaller#resolve_dependencies'
149
+ .../rubygems/commands/install_command.rb:198:in 'Gem::Commands::InstallCommand#install_gem'
150
+ ...
151
+ ```
152
+
153
+ ---
154
+
155
+ ## Impact: which code paths work today
156
+
157
+ | Scenario | API used | Works? |
158
+ |---|---|---|
159
+ | `bundle lock` / `bundle install` with URI deps in gemspec | Compact Index only | ✅ |
160
+ | `bundler/inline` with a namespace source block | Compact Index only | ✅ |
161
+ | `gem install @kaspth/oaken` (direct, plugin pre-installed) | Compact Index + gem download | ✅ |
162
+ | `gem install my-gem` (transitive URI deps, plugin pre-installed) | Compact Index + gem download | ✅ |
163
+ | `gem install my-gem` (transitive URI deps, plugin NOT pre-installed) | N/A — plugin not loaded | ❌ |
164
+
165
+ All code paths now work when the `namespaced-gem` plugin is installed, thanks
166
+ to `ApiSpecPatch` (which synthesizes specs from Compact Index data, bypassing
167
+ the missing `quick/Marshal.4.8/` endpoint) and the namespace server's `/gems/`
168
+ download endpoint. The only remaining failure case is the cold-start scenario
169
+ where the plugin is not yet installed — RubyGems loads plugins only from
170
+ already-installed gems at boot.
171
+
172
+ ---
173
+
174
+ ## Separate issue: `gem.coop` (production) returns HTTP 200 with body `"404"`
175
+
176
+ The production server at `gem.coop` (not `beta.gem.coop`) returns **HTTP 200**
177
+ with a plain-text body of `"404"` for namespace endpoints:
178
+
179
+ ```
180
+ GET https://gem.coop/@kaspth/versions → 200, body: "404"
181
+ GET https://gem.coop/@kaspth/info/oaken → 200, body: "404"
182
+ ```
183
+
184
+ This is problematic because RubyGems interprets an HTTP 200 response as a
185
+ successful Compact Index reply. It creates an `APISet` and attempts to parse
186
+ the string `"404"` as version data, leading to confusing downstream failures.
187
+
188
+ These should return a proper **HTTP 404** status code so that RubyGems raises
189
+ `Gem::RemoteFetcher::FetchError` and falls back gracefully.
190
+
191
+ ---
192
+
193
+ ## Environment
194
+
195
+ - Ruby 4.0.1
196
+ - RubyGems 4.0.5+
197
+ - `namespaced-gem` (development, HEAD)
198
+ - Tested: 2026-03-07
data/README.md ADDED
@@ -0,0 +1,369 @@
1
+ # 🔌 namespaced-gem
2
+
3
+ A RubyGems plugin that enables gemspec dependencies to be declared as
4
+ full URIs, pointing to **namespaced gem sources** such as
5
+ [gem.coop namespaces](https://gem.coop/updates/5/).
6
+
7
+ This implements the ideas discussed in
8
+ [gem-coop/gem.coop#12](https://github.com/gem-coop/gem.coop/issues/12).
9
+
10
+ ---
11
+
12
+ ## The Problem
13
+
14
+ gem.coop's public beta introduced _namespaces_ — isolated gem registries per
15
+ user or organization:
16
+
17
+ ```
18
+ https://beta.gem.coop/@myspace # namespace index
19
+ https://beta.gem.coop/@myspace/my-gem # canonical gem URI
20
+ ```
21
+
22
+ Today, gemspecs declare dependencies as plain names:
23
+
24
+ ```ruby
25
+ spec.add_dependency "rack", "~> 3.0"
26
+ ```
27
+
28
+ There is no standard way to express _which gem server_ (and _which namespace_)
29
+ a dependency comes from inside the gemspec itself. The user must manually add
30
+ a `source` block to their Gemfile — which defeats the purpose of publishing a
31
+ self-describing gemspec.
32
+
33
+ The question this prototype asks: **can a gemspec declare its own source for a
34
+ dependency, using the dependency name string alone?**
35
+
36
+ ```ruby
37
+ spec.add_dependency "https://beta.gem.coop/@myspace/my-gem", "~> 1.0"
38
+
39
+ # or using a Package URL (purl-spec):
40
+ spec.add_dependency "pkg:gem/@myspace/my-gem?repository_url=https://beta.gem.coop", "~> 1.0"
41
+ ```
42
+
43
+ ---
44
+
45
+ ## Key Finding: RubyGems 4.0.5+ Happens to Allow URI Names
46
+
47
+ RubyGems 4.0.5 removed the old `Gem::Dependency::VALID_NAME_PATTERN`
48
+ restriction entirely — any String is now accepted as a dependency name:
49
+
50
+ ```ruby
51
+ dep = Gem::Dependency.new("https://beta.gem.coop/@ns/foo", "~> 1.0")
52
+ dep.name # => "https://beta.gem.coop/@ns/foo"
53
+ ```
54
+
55
+ However, **RubyGems has no idea what to do with a URI-named dependency.** It
56
+ will happily store the string, but `gem install` will try to look up that
57
+ literal name on rubygems.org — and fail. Neither RubyGems nor Bundler knows
58
+ how to extract the real gem name, derive the namespace source URL, or resolve
59
+ transitive dependencies that use URI names.
60
+
61
+ **This gem bridges that gap.** It teaches both RubyGems' resolver (`gem
62
+ install`) and Bundler's resolver (`bundle install`) how to parse URI dependency
63
+ names, route them to the correct namespace source, and remap transitive deps
64
+ on the fly.
65
+
66
+ ---
67
+
68
+ ## How It Works
69
+
70
+ This gem ships a `rubygems_plugin.rb` that is automatically loaded by RubyGems
71
+ at boot — before any gemspec is parsed.
72
+
73
+ ### 1. `Gem::Dependency` patch (`DependencyPatch`)
74
+
75
+ Prepends helper methods `#uri_gem?` and `#uri_dependency` onto
76
+ `Gem::Dependency`.
77
+
78
+ ```ruby
79
+ dep = Gem::Dependency.new("https://beta.gem.coop/@myspace/my-gem", "~> 1.0")
80
+ dep.uri_gem? # => true
81
+ dep.uri_dependency # => #<Namespaced::Gem::UriDependency gem_name="my-gem" source_url="https://beta.gem.coop/@myspace">
82
+ ```
83
+
84
+ ### 2. URI parser (`UriDependency`)
85
+
86
+ Parses a URI dependency name into its components:
87
+
88
+ | Part | Example |
89
+ |---------------|------------------------------------|
90
+ | `server_base` | `https://beta.gem.coop` |
91
+ | `namespace` | `@myspace` |
92
+ | `gem_name` | `my-gem` |
93
+ | `source_url` | `https://beta.gem.coop/@myspace` |
94
+
95
+ Supports three forms:
96
+ - **Full URI**: `https://beta.gem.coop/@myspace/my-gem`
97
+ - **Shorthand**: `@myspace/my-gem` (defaults to `https://beta.gem.coop`)
98
+ - **Package URL** ([purl-spec](https://github.com/package-url/purl-spec)):
99
+ - `pkg:gem/@myspace/my-gem` (namespace in path, default server)
100
+ - `pkg:gem/@myspace/my-gem?repository_url=https://beta.gem.coop` (explicit server)
101
+ - `pkg:gem/my-gem?repository_url=https://beta.gem.coop/@myspace` (namespace in qualifier)
102
+
103
+ All three forms resolve to the same internal representation and are
104
+ interchangeable anywhere a dependency name is accepted.
105
+
106
+ ### 3. Bundler DSL integration (`BundlerIntegration`)
107
+
108
+ Patches `Bundler::Dsl#gemspec` so that after standard gemspec processing, any
109
+ URI-named runtime dependencies automatically inject a `source` block:
110
+
111
+ ```ruby
112
+ # What the user writes in their Gemfile:
113
+ gemspec
114
+
115
+ # What this patch injects automatically for URI deps:
116
+ # source "https://beta.gem.coop/@myspace" do
117
+ # gem "my-gem", "~> 1.0"
118
+ # end
119
+ ```
120
+
121
+ This means the Gemfile needs **no manual source declarations** for URI deps
122
+ found in the gemspec.
123
+
124
+ ---
125
+
126
+ ## Usage
127
+
128
+ There are four ways to use `namespaced-gem` today, depending on your situation.
129
+
130
+ ### Use Case 1: Gem authors (primary)
131
+
132
+ Add `namespaced-gem` as a runtime dependency of your gem. It is published on
133
+ rubygems.org and acts as a bridge to gem.coop namespaces.
134
+
135
+ ```ruby
136
+ Gem::Specification.new do |spec|
137
+ spec.name = "my-gem"
138
+ spec.version = "1.0.0"
139
+
140
+ # This gem must be a runtime dependency so that its rubygems_plugin.rb
141
+ # is installed and loaded by RubyGems at boot — before any gemspec
142
+ # containing URI dependencies is evaluated.
143
+ spec.add_dependency "namespaced-gem"
144
+
145
+ # Traditional dependency from RubyGems.org:
146
+ spec.add_dependency "rack", "~> 3.0"
147
+
148
+ # Namespaced dependency from gem.coop (full URI):
149
+ spec.add_dependency "https://beta.gem.coop/@myspace/special-gem", "~> 0.5"
150
+
151
+ # Shorthand (defaults to beta.gem.coop):
152
+ spec.add_dependency "@myorg/internal-tool", ">= 2.0"
153
+
154
+ # Package URL (purl-spec):
155
+ spec.add_dependency "pkg:gem/@myorg/another-tool", ">= 1.0"
156
+ spec.add_dependency "pkg:gem/@myorg/extra?repository_url=https://beta.gem.coop", "~> 3.0"
157
+ end
158
+ ```
159
+
160
+ When a downstream user uses **Bundler** (the expected path), their Gemfile can
161
+ remain:
162
+
163
+ ```ruby
164
+ source "https://rubygems.org"
165
+ gemspec
166
+ ```
167
+
168
+ The Bundler integration automatically injects the correct `source` blocks for
169
+ any URI dependencies found in the gemspec. Bundler uses only the Compact Index
170
+ API, which gem.coop namespace servers already support.
171
+
172
+ Both `bundle install` and `gem install my-gem` work — see
173
+ [Use Case 4](#use-case-4-direct-gem-install-with-a-namespace) for the
174
+ `gem install` path.
175
+
176
+ ### Use Case 2: Application developers
177
+
178
+ If you are not publishing a gem but want to use URI-style dependencies in an
179
+ application, install `namespaced-gem` directly:
180
+
181
+ ```bash
182
+ gem install namespaced-gem
183
+ ```
184
+
185
+ Because `rubygems_plugin.rb` files are only loaded from **installed** gems, the
186
+ gem must be present in the gem path _before_ RubyGems evaluates any gemspec
187
+ that contains URI dependencies. In practice this means:
188
+
189
+ 1. Install the gem first: `gem install namespaced-gem`
190
+ 2. Then declare URI dependencies in your gemspec or Gemfile as usual.
191
+
192
+ > **Note:** Simply listing `gem "namespaced-gem"` in a Gemfile is _not
193
+ > sufficient_ on its own — Bundler evaluates the Gemfile (and its `gemspec`
194
+ > directive) before it installs gems, so the plugin would not yet be loaded.
195
+ > The gem must already be installed via `gem install` (or as a transitive
196
+ > dependency of another installed gem, as in Use Case 1).
197
+
198
+ ### Use Case 3: Global installation (enable namespace support Ruby-wide)
199
+
200
+ Install `namespaced-gem` once into your Ruby environment and every subsequent
201
+ `gem install` and `bundle install` in that Ruby will be able to resolve
202
+ URI-named dependencies — no per-project configuration needed.
203
+
204
+ ```bash
205
+ gem install namespaced-gem
206
+ ```
207
+
208
+ That's it. The `rubygems_plugin.rb` is now in the gem path and will be loaded
209
+ by RubyGems on every boot. From this point forward:
210
+
211
+ - `gem install some-gem` will automatically resolve any URI-named transitive
212
+ dependencies found in `some-gem`'s gemspec.
213
+ - `bundle install` in any project will automatically inject the correct
214
+ `source` blocks for URI dependencies found in gemspecs.
215
+
216
+ This is useful for CI images, Docker containers, or development machines where
217
+ you want namespace support available globally without requiring each gem or
218
+ project to explicitly depend on `namespaced-gem`.
219
+
220
+ ```dockerfile
221
+ # Example: Dockerfile
222
+ RUN gem install namespaced-gem
223
+ # All subsequent gem/bundle commands in this image now support URI deps.
224
+ ```
225
+
226
+ ```bash
227
+ # Example: CI setup step
228
+ gem install namespaced-gem
229
+ bundle install # URI deps in any gemspec are resolved automatically
230
+ ```
231
+
232
+ ### Use Case 4: Direct `gem install` with a namespace
233
+
234
+ Once `namespaced-gem` is installed, you can install namespaced gems directly:
235
+
236
+ ```bash
237
+ gem install namespaced-gem # one-time setup (if not already installed)
238
+
239
+ gem install @kaspth/oaken # shorthand (defaults to beta.gem.coop)
240
+ gem install https://beta.gem.coop/@kaspth/oaken # full URI
241
+ ```
242
+
243
+ This works because the plugin patches multiple layers of RubyGems' native
244
+ `gem install` pipeline:
245
+
246
+ 1. **`GemResolverPatch`** intercepts the resolver and routes URI-named
247
+ dependencies to the correct namespace source via the Compact Index
248
+ (`versions` / `info/` endpoints).
249
+ 2. **`ApiSpecPatch`** synthesizes a `Gem::Specification` from the Compact Index
250
+ data already fetched — bypassing the legacy `quick/Marshal.4.8/` endpoint
251
+ that namespace servers don't serve.
252
+ 3. **`DownloadPatch`** provides clear, actionable error messages if the
253
+ namespace server fails to serve the `.gem` file.
254
+
255
+ All three forms of URI dependency names are supported:
256
+
257
+ ```bash
258
+ gem install @kaspth/oaken
259
+ gem install https://beta.gem.coop/@kaspth/oaken
260
+ gem install "pkg:gem/@kaspth/oaken"
261
+ ```
262
+
263
+ ---
264
+
265
+ ## Architecture
266
+
267
+ ```
268
+ lib/
269
+ rubygems_plugin.rb # Loaded by RubyGems at boot (or hot-loaded during install)
270
+ namespaced/
271
+ gem.rb # Main module
272
+ gem/
273
+ version.rb
274
+ uri_dependency.rb # URI parser (value object)
275
+ namespace_source_registry.rb # Thread-safe registry of namespace source URLs
276
+ dependency_patch.rb # Gem::Dependency patch (helper methods)
277
+ api_spec_patch.rb # Gem::Resolver::APISpecification — Compact Index spec synthesis
278
+ download_patch.rb # Gem::Source#download — namespace download error handling
279
+ bundler_integration.rb # Bundler::Dsl#gemspec patch
280
+ bundler_resolver_patch.rb # Bundler::Definition / Resolver transitive dep handling
281
+ gem_resolver_patch.rb # Gem::RequestSet / InstallerSet for `gem install`
282
+ metadata_deps_hook.rb # Gem.done_installing hook for hot-load deferred deps
283
+ ```
284
+
285
+ ---
286
+
287
+ ## Known Limitations
288
+
289
+ 1. **Plugin must be installed before first use (application developers only).**
290
+ This gem works as a RubyGems plugin (`rubygems_plugin.rb`), which means it
291
+ must be _installed_ in the gem path so that RubyGems loads the plugin at
292
+ boot before any gemspec containing URI dependencies is evaluated. For gem
293
+ authors (Use Case 1), this happens automatically — when a user installs your
294
+ gem, `namespaced-gem` is installed as a transitive dependency and available
295
+ on the next boot. For global installations (Use Case 3), the plugin is
296
+ already in the gem path by definition. For application developers
297
+ (Use Case 2), the gem must be installed explicitly with
298
+ `gem install namespaced-gem` before running `bundle install`, because
299
+ Bundler evaluates the Gemfile before it installs gems. In Ruby 4.0+,
300
+ RubyGems auto-loads `bundler/setup` when it detects a Gemfile in the working
301
+ directory, and this happens _before_ `RUBYOPT` `-r` flags are processed —
302
+ so the plugin must already be in the gem path.
303
+
304
+ 2. **Gemspec linting:** Tools that validate gemspecs (e.g. `gem build`, `rake
305
+ release`) work fine because `SpecificationPolicy#validate_name` only
306
+ validates the gem's *own* name — it does not check dependency names.
307
+
308
+ 3. **`gem.coop` (production) returns HTTP 200 with body `"404"` for namespace
309
+ endpoints.** Namespace resolution only works against `beta.gem.coop`
310
+ currently. The production `gem.coop` server returns HTTP 200 with a
311
+ plain-text body of `"404"` for namespace paths, which RubyGems
312
+ misinterprets as valid Compact Index data. The shorthand default server is
313
+ `beta.gem.coop` for this reason. See [ISSUE.md](ISSUE.md) for details.
314
+
315
+ ---
316
+
317
+ ## Version Constraints
318
+
319
+ Version requirements work exactly as they always have — they are the second
320
+ argument to `add_dependency`, completely separate from the name:
321
+
322
+ ```ruby
323
+ spec.add_dependency "https://beta.gem.coop/@myspace/my-gem", "~> 1.0"
324
+ # ^^^^^^^^^^ URI name ^^^^^^^^^^^^^^^^^^ ^^^^^^^^
325
+ # version
326
+
327
+ spec.add_dependency "pkg:gem/@myspace/my-gem", "~> 1.0"
328
+ # ^^^^^^^ purl name ^^^^^^ ^^^^^^^^
329
+ # version
330
+ ```
331
+
332
+ All standard operators (`~>`, `>=`, `=`, etc.) are supported unchanged.
333
+
334
+ > **Note:** The purl spec allows a `@version` component in the name itself
335
+ > (e.g. `pkg:gem/@ns/foo@2.0`). If present it is **ignored** — version
336
+ > constraints always come from the second argument to `add_dependency`.
337
+
338
+ ---
339
+
340
+ ## Development
341
+
342
+ ```bash
343
+ bundle install
344
+ bundle exec rspec # unit + offline integration tests
345
+ bundle exec rspec --tag network # network integration tests (hits beta.gem.coop)
346
+ bundle exec rake # tests + rubocop
347
+ ```
348
+
349
+ The network integration tests resolve a real gem (`@kaspth/oaken`) from
350
+ `beta.gem.coop` and verify `bundle lock` produces a correct `Gemfile.lock`.
351
+ They are excluded from the default `rspec` run and must be opted into with
352
+ `--tag network`.
353
+
354
+ ---
355
+
356
+ ## Contributing
357
+
358
+ Bug reports and pull requests are welcome on GitLab at
359
+ <https://gitlab.com/galtzo-floss/namespaced-gem>.
360
+
361
+ This project is intended to be a safe, welcoming space for collaboration, and
362
+ contributors are expected to adhere to the
363
+ [code of conduct](https://gitlab.com/galtzo-floss/namespaced-gem/-/blob/main/CODE_OF_CONDUCT.md).
364
+
365
+ ## Code of Conduct
366
+
367
+ Everyone interacting in the namespaced-gem project's codebases, issue trackers,
368
+ chat rooms and mailing lists is expected to follow the
369
+ [code of conduct](https://gitlab.com/galtzo-floss/namespaced-gem/-/blob/main/CODE_OF_CONDUCT.md).
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
data/WORKAROUND.md ADDED
@@ -0,0 +1,43 @@
1
+ **STATUS: RESOLVED — Approach B was implemented.** `ApiSpecPatch` synthesizes specs from Compact Index data (bypassing the Marshal endpoint), `DownloadPatch` handles namespace download errors, and `beta.gem.coop` serves `/gems/` under namespace paths. Both `gem install @kaspth/oaken` and `bundle install` with URI deps now work.
2
+
3
+ ---
4
+
5
+ Plan: Two Approaches for gem install @kaspth/oaken with Compact Index-Only Servers
6
+ The namespaced-gem plugin already handles Bundler workflows, but gem install fails because RubyGems requires two legacy Marshal API endpoints (quick/Marshal.4.8/… and gems/…) that gem.coop namespace servers don't serve. Two approaches can bridge this gap: (A) delegate to Bundler's infrastructure, or (B) patch RubyGems' native classes to use the Compact Index.
7
+
8
+ CRITICAL INVARIANT — Namespaces Are Not Optional
9
+ A namespaced gem (e.g. @kaspth/oaken) and a non-namespaced gem with the same base name (e.g. oaken on rubygems.org) are COMPLETELY DIFFERENT, UNRELATED gems. The namespace is an integral part of the gem's identity. It MUST NEVER be stripped, ignored, or used to fall back to a non-namespaced gem. Each namespace server (e.g. https://beta.gem.coop/@kaspth/) is a discrete, independent gem source — exactly like a private gem server that happens to have a gem with a common name. Every code path in this plugin — resolution, spec fetching, downloading — must preserve the namespace and source URL end-to-end. If an operation fails against the namespace source, it must ERROR, not silently retry against the root server or rubygems.org.
10
+
11
+ Approach A: Delegate to Bundler from within the plugin
12
+ Concept: When gem install @kaspth/oaken is detected, intercept early in the gem install pipeline and hand off resolution + installation to Bundler, which already works with the Compact Index-only server. The Bundler-based installer MUST use the full namespace source URL (e.g. https://beta.gem.coop/@kaspth/) as the sole source — never the root server or rubygems.org.
13
+ Steps
14
+ Intercept in Gem::Commands::InstallCommand#install_gem — prepend a method in a new install_command_patch.rb that detects URI-named gem arguments (via UriDependency.uri?). For URI deps, bypass DependencyInstaller entirely and call a Bundler-based installer instead.
15
+ Create a Namespaced::Gem::BundlerGemInstaller helper class — this class constructs an in-memory Bundler definition (or a temp Gemfile string) with the right source block + gem declaration, invokes Bundler::Installer.install (or uses Bundler::Inline internals via Bundler::CLI::Install or gemfile() from bundler/inline), and installs the gem into the same Gem.dir RubyGems would use. The source block MUST be scoped to the namespace URL only — e.g. source("https://beta.gem.coop/@kaspth/") { gem "oaken" }.
16
+ Configure Bundler to install into the system gem path — set Bundler.settings[:path] to Gem.dir and Bundler.settings[:system] to true (or use BUNDLE_PATH/BUNDLE_SYSTEM_GEMS) so gems land where gem install users expect them — not in a vendor/bundle directory.
17
+ Map Bundler output back to RubyGems' InstallCommand expectations — after Bundler installs, report the installed spec(s) back to the install command's output format (gem name, version, summary line), so the user sees the same CLI output they'd expect from gem install.
18
+ Wire the patch in rubygems_plugin.rb — load and apply InstallCommandPatch alongside the existing patches.
19
+ Risks / Trade-offs
20
+ Heavy dependency on Bundler internals — Bundler's Definition, Installer, and settings APIs are not designed for one-shot single-gem installs; edge cases around lockfile generation, Bundler.root, and Bundler.reset! state will need careful handling.
21
+ bundler/inline is the easiest entry point but it calls Bundler.reset! and exit on failure, so using it in-process requires careful error handling or subprocess isolation.
22
+ Gem path mismatch — Bundler defaults to project-local paths; mapping back to Gem.dir needs explicit config. Version conflicts with already-installed gems must be considered.
23
+ Transitive deps — Bundler would resolve the full transitive tree automatically; this is a pro, but may surprise users who expect gem install to only install the named gem + immediate runtime deps.
24
+
25
+ Approach B: Patch RubyGems' native classes to use the Compact Index
26
+ Concept: Instead of delegating to Bundler, patch the two failing points in RubyGems' own pipeline — fetch_spec (which needs Marshal data) and gem download (which needs the correct URL) — to use the already-working Compact Index data and downloads served by the namespace server.
27
+ Steps
28
+ Patch Gem::Resolver::APISpecification#spec — in a new api_spec_patch.rb, prepend #spec so that for namespace sources (detected via UriDependency), instead of calling source.fetch_spec(tuple) (which hits the missing Marshal endpoint), construct a Gem::Specification from the Compact Index info/ data. The info/{gem} endpoint returns dependency names, version constraints, and platform data — enough to build a minimal Gem::Specification with name, version, platform, and dependencies populated. This is sufficient for the resolver.
29
+ Parse the Compact Index info/ response — create a Namespaced::Gem::CompactIndexClient utility that fetches {source_url}/info/{gem_name}, parses the Compact Index format (line-per-version: version deps_list|checksum,ruby:required_ruby,rubygems:required_rubygems), and returns structured data. Bundler already has Bundler::Fetcher::CompactIndex — consider extracting its parser or using the compact_index gem directly.
30
+ Patch the gem download URL — prepend Gem::RemoteFetcher#download (or Gem::Source#download) in a new download_patch.rb so that for namespace sources, the .gem download is attempted from {namespace_source}/gems/{gem}-{ver}.gem. The download MUST use the namespace source URL — never the root server URL. If the namespace server returns 404 for /gems/, that is a server bug to be reported (per ISSUE.md), NOT a reason to silently fall back to the root server. Stripping the namespace and downloading from the root would risk fetching a COMPLETELY DIFFERENT gem that happens to share the same base name.
31
+ Track namespace source metadata through the resolver — the existing InstallerSetPatch#find_all creates a Gem::Source for the namespace URL. The APISpecification objects returned carry a reference to that source. Ensure the download patch can identify these specs (e.g., by checking spec.source.uri against known namespace URLs stored in a registry) to know when to route the download through the namespace source.
32
+ Wire both patches in rubygems_plugin.rb — load ApiSpecPatch and DownloadPatch alongside existing patches, applied at boot.
33
+ Risks / Trade-offs
34
+ Compact Index info/ data is incomplete vs. a full Gem::Specification — fields like summary, description, authors, executables, extensions, metadata, post_install_message are NOT in the Compact Index. The synthesized spec is sufficient for resolution but not for installation (e.g., Gem::Installer needs extensions to compile C extensions). However, the actual .gem file contains a full embedded gemspec, so once downloaded the real spec takes over.
35
+ Namespace server MUST serve /gems/ under the namespace path — the namespace server is a discrete gem server and must serve all endpoints (including /gems/) under its namespace path. If it does not, that is a server-side bug. The plugin should surface a clear error (e.g., "Namespace server at {url} returned 404 for {gem}.gem — the server must serve /gems/ under the namespace path") rather than silently falling back to the root server.
36
+ Tighter coupling to RubyGems internals — Gem::Resolver::APISpecification#spec and Gem::Source#fetch_spec are internal APIs that may change across RubyGems versions. required_rubygems_version already gates to >= 4.0.5.
37
+ Simpler, lighter-weight — no Bundler dependency at runtime for gem install, no temp files, no state management.
38
+
39
+ Further Considerations
40
+ Hybrid approach? Approach B for spec synthesis is simpler for the happy path, while Approach A could serve as an alternative implementation strategy if the Compact Index data proves insufficient (e.g., gems with native extensions where the synthesized spec is too minimal). Consider implementing B first, with A as an alternative. In either case, a namespaced gem is ALWAYS resolved and downloaded from its namespace source — never from the root server or any other source.
41
+ Server-side fix is complementary — the ISSUE.md already requests gem.coop serve the Marshal endpoints AND the /gems/ endpoint under the namespace path. If/when the server adds the Marshal endpoints, the native RubyGems code path would work without any plugin patches for spec fetching. The patches should detect when the native path succeeds (i.e., the Marshal endpoint is available under the namespace) and defer to it. This is NOT a "fallback" to a different source — it is the same namespace source supporting more endpoint types. The namespace is always preserved.
42
+ NO FALLBACK TO ROOT OR NON-NAMESPACED SOURCES — To reiterate: if spec fetching or gem download fails against the namespace source, the correct behavior is to raise an error, not to retry against the root server or rubygems.org. The namespace identifies a completely different gem. Stripping it would be like resolving "acme-corp-utils" and silently downloading "utils" instead — a security and correctness violation.
43
+ The compact_index gem (used internally by Bundler) provides a battle-tested parser for the info/ format — should we add it as a runtime dependency, vendor a minimal parser, or reuse Bundler's CompactIndex::Client directly?