paquette 0.1.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 (38) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.md +10 -0
  3. data/README.md +90 -0
  4. data/lib/paquette/gem_server/directory_gem_repository.rb +154 -0
  5. data/lib/paquette/gem_server/gem_repacker.rb +170 -0
  6. data/lib/paquette/gem_server/gem_repository.rb +53 -0
  7. data/lib/paquette/gem_server/personalizer.rb +66 -0
  8. data/lib/paquette/gem_server/read_gated_repository.rb +74 -0
  9. data/lib/paquette/gem_server.rb +393 -0
  10. data/lib/paquette/npm_repacker.rb +153 -0
  11. data/lib/paquette/npm_server/directory_npm_repository.rb +127 -0
  12. data/lib/paquette/npm_server/gated_npm_repository.rb +66 -0
  13. data/lib/paquette/npm_server/npm_repository.rb +53 -0
  14. data/lib/paquette/npm_server.rb +98 -0
  15. data/lib/paquette/routes.rb +78 -0
  16. data/lib/paquette/subdomain_router.rb +59 -0
  17. data/lib/paquette/version.rb +3 -0
  18. data/lib/paquette.rb +12 -0
  19. data/test/fixtures/gems/minuscule_test/minuscule_test-0.1.0.gem +0 -0
  20. data/test/fixtures/gems/zip_kit/zip_kit-6.2.0.gem +0 -0
  21. data/test/fixtures/gems/zip_kit/zip_kit-6.2.1.gem +0 -0
  22. data/test/fixtures/npm/react-dropzone/react-dropzone-14.3.8.tgz +0 -0
  23. data/test/gem_server/bundler_install_test.rb +86 -0
  24. data/test/gem_server/directory_gem_repository_test.rb +208 -0
  25. data/test/gem_server/gem_repacker_test.rb +289 -0
  26. data/test/gem_server/personalizer_metadata_test.rb +49 -0
  27. data/test/gem_server/personalizer_test.rb +66 -0
  28. data/test/gem_server/read_gated_repository_test.rb +254 -0
  29. data/test/gem_server/repository_stack_test.rb +71 -0
  30. data/test/gem_server_test.rb +339 -0
  31. data/test/npm_repacker_sourcemap_test.rb +108 -0
  32. data/test/npm_repacker_test.rb +117 -0
  33. data/test/npm_server_test.rb +140 -0
  34. data/test/routes_test.rb +142 -0
  35. data/test/subdomain_router_integration_test.rb +126 -0
  36. data/test/subdomain_router_test.rb +125 -0
  37. data/test/test_helper.rb +12 -0
  38. metadata +117 -0
@@ -0,0 +1,393 @@
1
+ require "json"
2
+ require "fileutils"
3
+ require "rubygems"
4
+ require "zlib"
5
+ require "stringio"
6
+ require "digest"
7
+ require "time"
8
+
9
+ require_relative "routes"
10
+ require_relative "gem_server/directory_gem_repository"
11
+ require_relative "gem_server/read_gated_repository"
12
+ require_relative "gem_server/personalizer"
13
+
14
+ module Paquette
15
+ class GemServer
16
+ @@routes = Routes.draw do |r|
17
+ # Root endpoint
18
+ r.get "/" do
19
+ text_ok("Paquette RubyGems Repository")
20
+ end
21
+
22
+ # API endpoints
23
+ r.post "/api/v1/gems" do
24
+ handle_push
25
+ end
26
+
27
+ r.delete "/api/v1/gems/yank" do
28
+ handle_yank
29
+ end
30
+
31
+ r.get "/api/v1/versions" do
32
+ handle_versions
33
+ end
34
+
35
+ r.get "/api/v1/names" do
36
+ handle_names
37
+ end
38
+
39
+ r.get "/api/v1/search.json" do |query: nil|
40
+ handle_search(query)
41
+ end
42
+
43
+ # Specs endpoints
44
+ r.get "/specs.4.8" do
45
+ handle_specs("4.8")
46
+ end
47
+
48
+ r.get "/specs.4.8.gz" do
49
+ handle_specs("4.8.gz")
50
+ end
51
+
52
+ r.get "/latest_specs.4.8" do
53
+ handle_latest_specs("4.8")
54
+ end
55
+
56
+ r.get "/latest_specs.4.8.gz" do
57
+ handle_latest_specs("4.8.gz")
58
+ end
59
+
60
+ # Compact index endpoints
61
+ r.get "/names" do
62
+ handle_compact_names
63
+ end
64
+
65
+ r.get "/versions" do
66
+ handle_compact_versions
67
+ end
68
+
69
+ # Dynamic endpoints with parameters
70
+ r.get "/info/:gem_name" do |gem_name:|
71
+ info_lines = @repository.compact_info(gem_name)
72
+
73
+ if info_lines.empty?
74
+ not_found("Not Found")
75
+ else
76
+ text_ok(info_lines.join("\n"))
77
+ end
78
+ end
79
+
80
+ r.get "/quick/Marshal.4.8/:gem_spec_name.gemspec.rz" do |gem_spec_name:|
81
+ # Parse gem name and version from the spec name (e.g., "zip_kit-6.3.2")
82
+ if (match = gem_spec_name.match(/^(.+?)-(\d+\.\d+\.\d+.*)$/))
83
+ gem_name, version = match[1], match[2]
84
+
85
+ if @repository.gem_exists?(gem_name, version)
86
+ spec = @repository.gem_spec(gem_name, version)
87
+ if spec
88
+ # Marshal the spec and compress it with raw deflate (not gzip)
89
+ marshaled_spec = Marshal.dump(spec)
90
+ compressed_spec = Zlib::Deflate.deflate(marshaled_spec)
91
+ [200, {"Content-Type" => "application/octet-stream"}, [compressed_spec]]
92
+ else
93
+ not_found("Spec not found")
94
+ end
95
+ else
96
+ not_found("Gem not found: #{gem_name}-#{version}")
97
+ end
98
+ else
99
+ not_found("Invalid gem spec name: #{gem_spec_name}")
100
+ end
101
+ end
102
+
103
+ r.get "/gems/:gem_filename" do |gem_filename:|
104
+ # Extract gem name and version from filename
105
+ if (match = gem_filename.match(/^(.+)-(\d+\.\d+\.\d+.*)\.gem$/))
106
+ gem_name, version = match[1], match[2]
107
+ return not_found("Gem not found or it is not within your license") unless @repository.gem_exists?(gem_name, version)
108
+
109
+ # gem_file_path now automatically returns personalized gem
110
+ gem_path = @repository.gem_file_path(gem_name, version)
111
+ clen = File.size(gem_path).to_s
112
+ hh = {"Content-Type" => "application/octet-stream", "Content-Length" => clen}
113
+
114
+ [200, hh, File.open(gem_path, "rb")]
115
+ else
116
+ not_found("Invalid gem filename")
117
+ end
118
+ end
119
+ end
120
+
121
+ # Build a gem server backed by `repository`. Reads, writes, and yanks
122
+ # all flow through this one object — whether writes are accepted depends
123
+ # on the wrapper chain the caller assembled. A ReadGatedRepository, for
124
+ # example, refuses add_gem and yank_gem; a bare DirectoryGemRepository
125
+ # accepts both. Callers typically construct the stack per-request so
126
+ # user-specific state (entitlements, license keys) is captured in
127
+ # plain closures rather than stored on the server.
128
+ def initialize(repository)
129
+ @repository = repository
130
+ end
131
+
132
+ def call(env)
133
+ request = Rack::Request.new(env)
134
+ route = @@routes.match(request)
135
+ return not_found("Not found") unless route
136
+
137
+ @request = request
138
+ @@routes.perform_action(route, self, request)
139
+ end
140
+
141
+ private
142
+
143
+ def handle_dependencies(request)
144
+ gems = request.params["gems"]
145
+
146
+ # Handle different parameter formats
147
+ if gems.is_a?(String)
148
+ # Split comma-separated gem names
149
+ gems = gems.split(",").map(&:strip)
150
+ elsif gems.nil?
151
+ gems = []
152
+ end
153
+
154
+ # If no gems specified, return empty array
155
+ if gems.empty?
156
+ return json_ok([])
157
+ end
158
+
159
+ dependencies = []
160
+ gems.each do |gem_name|
161
+ gem_versions = @repository.versions_for_gem(gem_name)
162
+ gem_versions.each do |version|
163
+ gem_dependencies = @repository.gem_dependencies(gem_name, version)
164
+ dependencies << {
165
+ name: gem_name,
166
+ number: version,
167
+ platform: "ruby",
168
+ dependencies: gem_dependencies
169
+ }
170
+ end
171
+ end
172
+
173
+ json_ok(dependencies)
174
+ end
175
+
176
+ def handle_dependencies_json(request)
177
+ handle_dependencies(request)
178
+ end
179
+
180
+ def handle_versions
181
+ versions = []
182
+ @repository.gem_versions.each do |name, version|
183
+ spec = @repository.gem_spec(name, version)
184
+ versions << {
185
+ name: name,
186
+ number: version,
187
+ platform: spec.platform.to_s,
188
+ authors: spec.authors,
189
+ info: spec.description || "",
190
+ homepage: spec.homepage || "",
191
+ description: spec.description || "",
192
+ summary: spec.summary || "",
193
+ metadata: spec.metadata || {}
194
+ }
195
+ end
196
+
197
+ json_ok(versions)
198
+ end
199
+
200
+ def handle_names
201
+ names = @repository.gem_names
202
+ json_ok(names)
203
+ end
204
+
205
+ def handle_push
206
+ gem_data = @request.body.read
207
+
208
+ spec = @repository.add_gem(gem_data)
209
+ text_ok("Successfully registered gem: #{spec.name}-#{spec.version}")
210
+ rescue ReadGatedRepository::WriteNotAllowed => e
211
+ [403, {"Content-Type" => "text/plain"}, [e.message]]
212
+ rescue DirectoryGemRepository::GemYanked => e
213
+ [403, {"Content-Type" => "text/plain"}, [e.message]]
214
+ rescue DirectoryGemRepository::GemAlreadyExists => e
215
+ [409, {"Content-Type" => "text/plain"}, [e.message]]
216
+ rescue DirectoryGemRepository::InvalidGem => e
217
+ bad_request(e.message)
218
+ end
219
+
220
+ def handle_yank
221
+ gem_name = @request.params["gem_name"]
222
+ version = @request.params["version"]
223
+
224
+ return bad_request("Missing gem_name") if gem_name.nil? || gem_name.empty?
225
+ return bad_request("Missing version") if version.nil? || version.empty?
226
+
227
+ @repository.yank_gem(gem_name, version)
228
+ text_ok("Successfully yanked gem: #{gem_name}-#{version}")
229
+ rescue ReadGatedRepository::WriteNotAllowed => e
230
+ [403, {"Content-Type" => "text/plain"}, [e.message]]
231
+ rescue DirectoryGemRepository::GemNotFound => e
232
+ not_found(e.message)
233
+ end
234
+
235
+ def handle_search(query)
236
+ query ||= ""
237
+ results = []
238
+
239
+ @repository.gem_versions.each do |name, version|
240
+ if name.include?(query)
241
+ results << {
242
+ name: name,
243
+ version: version,
244
+ platform: "ruby",
245
+ authors: ["Unknown"],
246
+ info: "Uploaded to Paquette"
247
+ }
248
+ end
249
+ end
250
+
251
+ json_ok(results)
252
+ end
253
+
254
+ def handle_specs(version)
255
+ # Generate specs in the format expected by Bundler
256
+ specs = generate_specs_array
257
+
258
+ # Use Marshal 4.8 format for compatibility with Bundler
259
+ specs_data = marshal_dump_4_8(specs)
260
+
261
+ # For .gz requests, compress the data
262
+ if version.include?(".gz")
263
+ specs_data = gzip_compress(specs_data)
264
+ [200, {"Content-Type" => "application/x-gzip"}, [specs_data]]
265
+ else
266
+ [200, {"Content-Type" => "application/octet-stream"}, [specs_data]]
267
+ end
268
+ end
269
+
270
+ def handle_compact_names
271
+ names = @repository.gem_names
272
+ text_ok(names.join("\n"))
273
+ end
274
+
275
+ def handle_compact_versions
276
+ # Group versions by gem name
277
+ gem_versions = {}
278
+ @repository.gem_versions.each do |name, version|
279
+ gem_versions[name] ||= []
280
+ gem_versions[name] << version
281
+ end
282
+
283
+ # Sort versions for each gem
284
+ gem_versions.each { |name, versions| versions.sort! }
285
+
286
+ # Generate the content in the official format
287
+ lines = []
288
+ lines << "created_at: #{Time.now.utc.iso8601}"
289
+ lines << "---"
290
+
291
+ # Add each gem with its versions and checksum
292
+ gem_versions.sort.each do |name, versions|
293
+ versions_str = versions.join(",")
294
+ # Create a checksum for this gem's versions
295
+ version_data = "#{name} #{versions.join(" ")}"
296
+ checksum = Digest::MD5.hexdigest(version_data)
297
+ lines << "#{name} #{versions_str} #{checksum}"
298
+ end
299
+
300
+ content = lines.join("\n")
301
+
302
+ # Calculate overall checksum for the entire content
303
+ overall_checksum = Digest::MD5.hexdigest(content)
304
+
305
+ [200, {"Content-Type" => "text/plain", "X-Checksum-Sha256" => overall_checksum}, [content]]
306
+ end
307
+
308
+ def generate_specs_array
309
+ # Generate specs array in the format expected by RubyGems/Bundler
310
+ # Each spec is [gem_name, version, platform]
311
+ # Use only basic Ruby types to ensure Marshal 4.8 compatibility
312
+ specs = []
313
+ @repository.gem_versions.each do |name, version|
314
+ specs << [name.to_s, version.to_s, "ruby"]
315
+ end
316
+ specs
317
+ end
318
+
319
+ def handle_latest_specs(version)
320
+ # Generate latest specs (only the latest version of each gem)
321
+ latest_specs = generate_latest_specs_array
322
+
323
+ # Use Marshal 4.8 format for compatibility with Bundler
324
+ specs_data = marshal_dump_4_8(latest_specs)
325
+
326
+ # For .gz requests, compress the data
327
+ if version.include?(".gz")
328
+ specs_data = gzip_compress(specs_data)
329
+ [200, {"Content-Type" => "application/x-gzip"}, [specs_data]]
330
+ else
331
+ [200, {"Content-Type" => "application/octet-stream"}, [specs_data]]
332
+ end
333
+ end
334
+
335
+ def generate_latest_specs_array
336
+ # Generate latest specs array - only the latest version of each gem
337
+ latest_versions = {}
338
+ @repository.gem_versions.each do |name, version|
339
+ if !latest_versions[name] || Gem::Version.new(version) > Gem::Version.new(latest_versions[name])
340
+ latest_versions[name] = version
341
+ end
342
+ end
343
+
344
+ specs = []
345
+ latest_versions.each do |name, version|
346
+ specs << [name.to_s, version.to_s, "ruby"]
347
+ end
348
+ specs
349
+ end
350
+
351
+ def marshal_dump_4_8(obj)
352
+ # Create Marshal data in format 4.8 for compatibility with Bundler
353
+ # Use only basic Ruby types to ensure compatibility
354
+ specs_array = obj.is_a?(Array) ? obj : []
355
+
356
+ # Simple Marshal.dump should work with basic types
357
+ Marshal.dump(specs_array)
358
+ end
359
+
360
+ def gzip_compress(data)
361
+ # Create proper gzip format with headers, checksums, etc.
362
+ StringIO.open do |io|
363
+ Zlib::GzipWriter.wrap(io) do |gz|
364
+ gz.write(data)
365
+ end
366
+ io.string
367
+ end
368
+ end
369
+
370
+ # Helper methods for common response patterns
371
+ private
372
+
373
+ def json_ok(data)
374
+ [200, {"Content-Type" => "application/json"}, [JSON.pretty_generate(data)]]
375
+ end
376
+
377
+ def text_ok(data)
378
+ [200, {"Content-Type" => "text/plain"}, [data]]
379
+ end
380
+
381
+ def not_found(message = "Not Found")
382
+ [404, {"Content-Type" => "text/plain"}, [message]]
383
+ end
384
+
385
+ def bad_request(message = "Bad Request")
386
+ [400, {"Content-Type" => "text/plain"}, [message]]
387
+ end
388
+
389
+ def server_error(message = "Internal Server Error")
390
+ [500, {"Content-Type" => "text/plain"}, [message]]
391
+ end
392
+ end
393
+ end
@@ -0,0 +1,153 @@
1
+ require "fileutils"
2
+ require "digest"
3
+ require "tmpdir"
4
+ require "tempfile"
5
+ require "sourcemap"
6
+ require "json"
7
+
8
+ module Paquette
9
+ class NpmRepacker
10
+ def self.repack(npm_path, &block)
11
+ new(npm_path).repack(&block)
12
+ end
13
+
14
+ def initialize(npm_path)
15
+ @npm_path = npm_path
16
+ @temp_dir = nil
17
+ @unpacked_package_dir = nil
18
+ end
19
+
20
+ def repack(&block)
21
+ raise ArgumentError, "Block required" unless block_given?
22
+ raise ArgumentError, "NPM package not found: #{@npm_path}" unless File.exist?(@npm_path)
23
+
24
+ unpack_package
25
+ process_js_files(&block)
26
+ new_package_path = repackage_package
27
+ cleanup
28
+ new_package_path
29
+ end
30
+
31
+ private
32
+
33
+ def unpack_package
34
+ @temp_dir = Dir.mktmpdir("npm_repacker")
35
+ @unpacked_package_dir = File.join(@temp_dir, "unpacked_package")
36
+ FileUtils.mkdir_p(@unpacked_package_dir)
37
+
38
+ # Extract the NPM package (tar.gz file)
39
+ result = system("tar -xzf #{@npm_path} -C #{@unpacked_package_dir}")
40
+ raise "Failed to unpack NPM package: #{@npm_path}" unless result
41
+
42
+ # Find the unpacked package directory - it should be the only directory in unpacked_package_dir
43
+ @package_dir = Dir.glob(File.join(@unpacked_package_dir, "*")).find { |path| File.directory?(path) }
44
+ raise "Could not find unpacked package directory" unless @package_dir
45
+ end
46
+
47
+ def process_js_files(&block)
48
+ # Process .ts, .js, .mjs, .jsx, .tsx files
49
+ extensions = %w[.ts .js .mjs .jsx .tsx]
50
+ extensions.each do |ext|
51
+ Dir.glob(File.join(@package_dir, "**", "*#{ext}")).each do |js_file|
52
+ process_js_file(js_file, &block)
53
+ end
54
+ end
55
+ end
56
+
57
+ def process_js_file(file_path, &block)
58
+ # Get the relative path within the package directory
59
+ relative_path = file_path.sub("#{@package_dir}/", "")
60
+
61
+ # Create a temporary file for output
62
+ temp_output = Tempfile.new("npm_repacker_output")
63
+
64
+ begin
65
+ # Open input file in binary mode for reading
66
+ File.open(file_path, "rb") do |input_file|
67
+ # Open output file in binary mode for writing
68
+ File.open(temp_output.path, "wb") do |output_file|
69
+ # Call the block with input file, output file, and relative path
70
+ yield(input_file, output_file, relative_path)
71
+ end
72
+ end
73
+
74
+ # Handle sourcemap updates if this is a JS file
75
+ if File.extname(file_path) == ".js"
76
+ update_sourcemap_offsets(file_path, temp_output.path)
77
+ end
78
+
79
+ # Replace the original file with the processed content
80
+ FileUtils.mv(temp_output.path, file_path)
81
+ ensure
82
+ temp_output.close
83
+ temp_output.unlink if File.exist?(temp_output.path)
84
+ end
85
+ end
86
+
87
+ def update_sourcemap_offsets(js_file_path, new_js_content_path)
88
+ # Look for sourcemap reference in the JS file
89
+ sourcemap_path = find_sourcemap_file(js_file_path)
90
+ return unless sourcemap_path
91
+
92
+ # Calculate the offset difference
93
+ original_content = File.read(js_file_path)
94
+ new_content = File.read(new_js_content_path)
95
+ offset_diff = new_content.length - original_content.length
96
+
97
+ return if offset_diff == 0 # No changes, no need to update sourcemap
98
+
99
+ # Update the sourcemap
100
+ update_sourcemap_file(sourcemap_path, offset_diff)
101
+ end
102
+
103
+ def find_sourcemap_file(js_file_path)
104
+ # Look for sourceMappingURL comment in the JS file
105
+ content = File.read(js_file_path)
106
+ if content =~ /\/\/# sourceMappingURL=(.+)/
107
+ sourcemap_filename = $1.strip
108
+ sourcemap_path = File.join(File.dirname(js_file_path), sourcemap_filename)
109
+ return sourcemap_path if File.exist?(sourcemap_path)
110
+ end
111
+ nil
112
+ end
113
+
114
+ def update_sourcemap_file(sourcemap_path, offset_diff)
115
+ # Read and parse the sourcemap
116
+ sourcemap_content = File.read(sourcemap_path)
117
+ JSON.parse(sourcemap_content)
118
+
119
+ # Sourcemap found and needs updating (debug output removed)
120
+
121
+ # For now, we'll just log the information. In a production system,
122
+ # you would need to implement proper sourcemap offset adjustment
123
+ # which requires understanding the specific sourcemap format and
124
+ # updating the mappings array accordingly.
125
+
126
+ # TODO: Implement proper sourcemap offset adjustment
127
+ # This would involve:
128
+ # 1. Parsing the mappings string
129
+ # 2. Adjusting the generated line/column positions
130
+ # 3. Re-encoding the mappings string
131
+ # 4. Updating the sourcemap JSON
132
+ rescue
133
+ # If sourcemap parsing fails, silently continue (debug output removed)
134
+ # The sourcemap update is not critical for the repacking process
135
+ end
136
+
137
+ def repackage_package
138
+ # Create a new NPM package
139
+ package_name = File.basename(@npm_path, ".tgz")
140
+ new_package_path = File.join(Dir.tmpdir, "#{package_name}-repacked.tgz")
141
+
142
+ # Create the new tar.gz file
143
+ result = system("cd #{@unpacked_package_dir} && tar -czf #{new_package_path} package/")
144
+ raise "Failed to repackage NPM package" unless result
145
+
146
+ new_package_path
147
+ end
148
+
149
+ def cleanup
150
+ FileUtils.rm_rf(@temp_dir) if @temp_dir && Dir.exist?(@temp_dir)
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,127 @@
1
+ require_relative "npm_repository"
2
+ require "json"
3
+
4
+ module Paquette
5
+ class NpmServer
6
+ # Repository implementation that reads NPM packages from a directory
7
+ class DirectoryNpmRepository < NpmRepository
8
+ def initialize(packages_dir)
9
+ @packages_dir = packages_dir
10
+ FileUtils.mkdir_p(@packages_dir)
11
+ end
12
+
13
+ def package_names
14
+ Dir.glob(File.join(@packages_dir, "*")).select { |path| File.directory?(path) }.map do |package_path|
15
+ File.basename(package_path)
16
+ end.sort
17
+ end
18
+
19
+ def package_versions
20
+ versions = []
21
+ package_names.each do |package_name|
22
+ versions_for_package(package_name).each do |version|
23
+ versions << [package_name, version]
24
+ end
25
+ end
26
+ versions.sort
27
+ end
28
+
29
+ def versions_for_package(package_name)
30
+ package_dir = File.join(@packages_dir, package_name)
31
+ return [] unless Dir.exist?(package_dir)
32
+
33
+ Dir.glob(File.join(package_dir, "*.tgz")).map do |tgz_path|
34
+ filename = File.basename(tgz_path, ".tgz")
35
+ if (match = filename.match(/^#{Regexp.escape(package_name)}-(\d+\.\d+\.\d+.*)$/))
36
+ match[1]
37
+ end
38
+ end.compact.sort
39
+ end
40
+
41
+ def package_file_path(package_name, version)
42
+ File.join(@packages_dir, package_name, "#{package_name}-#{version}.tgz")
43
+ end
44
+
45
+ def package_exists?(package_name, version)
46
+ File.exist?(package_file_path(package_name, version))
47
+ end
48
+
49
+ def package_info(package_name, version)
50
+ package_file = package_file_path(package_name, version)
51
+ return nil unless File.exist?(package_file)
52
+
53
+ # For now, return basic package info
54
+ # In a real implementation, you'd extract the package.json from the .tgz file
55
+ {
56
+ name: package_name,
57
+ version: version,
58
+ description: "Package #{package_name} version #{version}",
59
+ main: "index.js",
60
+ scripts: {},
61
+ dependencies: {},
62
+ devDependencies: {}
63
+ }
64
+ end
65
+
66
+ def package_dependencies(package_name, version)
67
+ info = package_info(package_name, version)
68
+ return {} unless info
69
+
70
+ (info[:dependencies] || {}).merge(info[:devDependencies] || {})
71
+ end
72
+
73
+ def package_metadata(package_name)
74
+ versions = versions_for_package(package_name)
75
+ return nil if versions.empty?
76
+
77
+ # Get the latest version
78
+ latest_version = versions.max_by { |v| Gem::Version.new(v) }
79
+
80
+ metadata = {
81
+ name: package_name,
82
+ versions: {},
83
+ "dist-tags": {
84
+ latest: latest_version
85
+ },
86
+ time: {},
87
+ maintainers: [],
88
+ description: "Package #{package_name}",
89
+ keywords: [],
90
+ license: "MIT",
91
+ repository: {},
92
+ bugs: {},
93
+ homepage: "",
94
+ readme: "",
95
+ readmeFilename: "README.md"
96
+ }
97
+
98
+ # Add version-specific metadata
99
+ versions.each do |version|
100
+ info = package_info(package_name, version)
101
+ next unless info
102
+
103
+ metadata[:versions][version] = {
104
+ name: package_name,
105
+ version: version,
106
+ description: info[:description],
107
+ main: info[:main],
108
+ scripts: info[:scripts] || {},
109
+ dependencies: info[:dependencies] || {},
110
+ devDependencies: info[:devDependencies] || {},
111
+ dist: {
112
+ shasum: "dummy-sha", # In real implementation, calculate actual shasum
113
+ tarball: "#{package_name}/#{package_name}-#{version}.tgz"
114
+ }
115
+ }
116
+
117
+ metadata[:time][version] = Time.now.iso8601
118
+ end
119
+
120
+ metadata[:time][:created] = Time.now.iso8601
121
+ metadata[:time][:modified] = Time.now.iso8601
122
+
123
+ metadata
124
+ end
125
+ end
126
+ end
127
+ end