pray-cli 1.6.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 (71) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +47 -0
  3. data/LICENSE.md +21 -0
  4. data/README.md +125 -0
  5. data/SECURITY.md +28 -0
  6. data/bin/polyrun +108 -0
  7. data/bin/pray +26 -0
  8. data/lib/pray/archive.rb +68 -0
  9. data/lib/pray/auth_client.rb +97 -0
  10. data/lib/pray/cli/commands/auth.rb +42 -0
  11. data/lib/pray/cli/commands/distribution.rb +17 -0
  12. data/lib/pray/cli/commands/init.rb +62 -0
  13. data/lib/pray/cli/commands/meta.rb +15 -0
  14. data/lib/pray/cli/commands/packages.rb +107 -0
  15. data/lib/pray/cli/commands/trust.rb +73 -0
  16. data/lib/pray/cli/commands/workflow.rb +126 -0
  17. data/lib/pray/cli/help.rb +168 -0
  18. data/lib/pray/cli/helpers.rb +166 -0
  19. data/lib/pray/cli/parse.rb +136 -0
  20. data/lib/pray/cli/parse_auth.rb +91 -0
  21. data/lib/pray/cli/parse_trust.rb +152 -0
  22. data/lib/pray/cli/suggest.rb +57 -0
  23. data/lib/pray/cli.rb +117 -0
  24. data/lib/pray/confess.rb +113 -0
  25. data/lib/pray/config.rb +52 -0
  26. data/lib/pray/constraint.rb +80 -0
  27. data/lib/pray/destination.rb +158 -0
  28. data/lib/pray/dotenv.rb +46 -0
  29. data/lib/pray/environment.rb +41 -0
  30. data/lib/pray/error.rb +55 -0
  31. data/lib/pray/format_manifest.rb +255 -0
  32. data/lib/pray/format_serialize.rb +135 -0
  33. data/lib/pray/git_sources.rb +228 -0
  34. data/lib/pray/hashing.rb +59 -0
  35. data/lib/pray/invocation.rb +110 -0
  36. data/lib/pray/literal.rb +382 -0
  37. data/lib/pray/lockfile.rb +259 -0
  38. data/lib/pray/lockfile_serialize.rb +125 -0
  39. data/lib/pray/manifest.rb +126 -0
  40. data/lib/pray/manifest_formatter.rb +56 -0
  41. data/lib/pray/manifest_json.rb +128 -0
  42. data/lib/pray/manifest_parser.rb +152 -0
  43. data/lib/pray/manifest_parser_blocks.rb +216 -0
  44. data/lib/pray/manifest_parser_helpers.rb +208 -0
  45. data/lib/pray/materialize.rb +120 -0
  46. data/lib/pray/package_spec.rb +245 -0
  47. data/lib/pray/path_safety.rb +41 -0
  48. data/lib/pray/plan.rb +83 -0
  49. data/lib/pray/project_context.rb +67 -0
  50. data/lib/pray/publish.rb +162 -0
  51. data/lib/pray/registry.rb +355 -0
  52. data/lib/pray/render.rb +360 -0
  53. data/lib/pray/resolve.rb +396 -0
  54. data/lib/pray/resolve_context.rb +19 -0
  55. data/lib/pray/serve.rb +130 -0
  56. data/lib/pray/serve_federation.rb +109 -0
  57. data/lib/pray/session.rb +90 -0
  58. data/lib/pray/ssh_agent.rb +115 -0
  59. data/lib/pray/statement_surface.rb +262 -0
  60. data/lib/pray/substitute.rb +50 -0
  61. data/lib/pray/sync.rb +219 -0
  62. data/lib/pray/terminal.rb +30 -0
  63. data/lib/pray/trust.rb +201 -0
  64. data/lib/pray/trust_feed.rb +110 -0
  65. data/lib/pray/trust_ops.rb +202 -0
  66. data/lib/pray/verify.rb +257 -0
  67. data/lib/pray/version.rb +6 -0
  68. data/lib/pray-cli.rb +4 -0
  69. data/lib/pray.rb +46 -0
  70. data/pray-cli.gemspec +48 -0
  71. metadata +187 -0
@@ -0,0 +1,396 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pray
4
+ ResolvedProject = Struct.new(
5
+ :manifest_path, :project_root, :manifest, :manifest_hash, :packages,
6
+ :local_files, :source_revisions, :source_host_keys, :environment
7
+ ) do
8
+ def lockfile_hash
9
+ manifest_hash
10
+ end
11
+ end
12
+
13
+ ResolvedPackage = Struct.new(
14
+ :declaration, :root, :spec, :tree_hash, :artifact_hash, :artifact,
15
+ :selected_exports, :source_checksum, :export_bodies, :skill_files,
16
+ :signer_fingerprint, :registry_latest_version
17
+ )
18
+
19
+ ResolvedLocalFile = Struct.new(
20
+ :path, :manifest_path, :content, :position, :optional
21
+ )
22
+
23
+ module Resolve
24
+ module_function
25
+
26
+ def project_root_from_manifest(manifest_path)
27
+ parent = File.dirname(manifest_path)
28
+ (parent.empty? || parent == ".") ? "." : parent
29
+ end
30
+
31
+ def canonical_project_root(manifest_path)
32
+ root = project_root_from_manifest(manifest_path)
33
+ return Pathname(root).cleanpath.to_s if Pathname(root).absolute?
34
+
35
+ File.expand_path(root)
36
+ end
37
+
38
+ def resolve_project(manifest_path, offline: false, refresh: false, environment: nil)
39
+ options = ResolveOptions.new(offline: offline, refresh: refresh, environment: environment)
40
+ resolve_project_with_options(manifest_path, options)
41
+ end
42
+
43
+ def resolve_project_with_options(manifest_path, options = ResolveOptions.new)
44
+ project_root = canonical_project_root(manifest_path)
45
+ resolve_project_in_context(manifest_path, project_root, options)
46
+ end
47
+
48
+ def resolve_project_in_context(manifest_path, project_root, options = ResolveOptions.new)
49
+ user_config = Config.load_user_config
50
+ lockfile_path = File.join(project_root, "Prayfile.lock")
51
+ lockfile_hints = File.exist?(lockfile_path) ? Pray.read_lockfile(lockfile_path) : nil
52
+ manifest_text = Pray.read_manifest_text(manifest_path)
53
+ manifest = Pray.parse_manifest(manifest_text)
54
+ manifest.deprecation_warnings.each { |warning| warn(warning) }
55
+ Environment.validate_environment(manifest, options.environment)
56
+ manifest_hash = manifest.manifest_hash
57
+ sources = source_map(manifest.sources)
58
+ git_sources = GitSources.prepare_git_sources(
59
+ project_root,
60
+ manifest.sources,
61
+ lockfile_hints,
62
+ refresh: options.refresh || options.refresh_source_revisions
63
+ )
64
+ source_revisions = git_sources.transform_values(&:revision)
65
+ source_host_keys = Trust.prepare_source_host_keys(manifest.sources)
66
+
67
+ packages = []
68
+ seen = {}
69
+ errors = []
70
+ manifest.packages.each do |declaration|
71
+ package = resolve_package(
72
+ project_root,
73
+ sources,
74
+ git_sources,
75
+ user_config,
76
+ declaration,
77
+ lockfile_hints,
78
+ offline: options.offline
79
+ )
80
+ if seen[package.declaration.name]
81
+ raise Error.resolution("duplicate package declaration: #{package.declaration.name}")
82
+ end
83
+
84
+ seen[package.declaration.name] = true
85
+ packages << package
86
+ rescue Error => error
87
+ errors << "#{declaration.name}: #{error.message}"
88
+ end
89
+ raise Error.resolution(errors.join("\n")) unless errors.empty?
90
+
91
+ local_files = []
92
+ local_errors = []
93
+ manifest.local.each do |local|
94
+ local_files << resolve_local_file(project_root, local)
95
+ rescue Error => error
96
+ local_errors << "local #{local.path}: #{error.message}"
97
+ end
98
+ raise Error.resolution(local_errors.join("\n")) unless local_errors.empty?
99
+
100
+ ResolvedProject.new(
101
+ manifest_path: manifest_path,
102
+ project_root: project_root,
103
+ manifest: manifest,
104
+ manifest_hash: manifest_hash,
105
+ packages: packages,
106
+ local_files: local_files,
107
+ source_revisions: source_revisions,
108
+ source_host_keys: source_host_keys,
109
+ environment: options.environment
110
+ )
111
+ end
112
+
113
+ def resolve_project_with_git_refresh_fallback(
114
+ manifest_path,
115
+ offline: false,
116
+ refresh: false,
117
+ allow_git_refresh_fallback: false,
118
+ environment: nil
119
+ )
120
+ options = ResolveOptions.new(offline: offline, refresh: refresh, environment: environment)
121
+ resolve_project_with_options(manifest_path, options)
122
+ rescue Error => error
123
+ if allow_git_refresh_fallback &&
124
+ !offline &&
125
+ !refresh &&
126
+ resolution_may_benefit_from_git_source_refresh?(error)
127
+ refreshed = ResolveOptions.new(offline: offline, refresh: true, environment: environment)
128
+ resolve_project_with_options(manifest_path, refreshed)
129
+ else
130
+ raise
131
+ end
132
+ end
133
+
134
+ def resolution_may_benefit_from_git_source_refresh?(error)
135
+ error.category == :resolution && error.message.include?("no registry version")
136
+ end
137
+
138
+ def missing_local_embed_guidance(path)
139
+ "Prayfile lists `local \"#{path}\"` but the file does not exist. " \
140
+ "Create the file or remove the entry from Prayfile, then run `pray install`."
141
+ end
142
+
143
+ def resolve_package(project_root, sources, git_sources, user_config, declaration, lockfile, offline: false)
144
+ root, registry_latest_version = resolve_package_root_with_metadata(
145
+ project_root, sources, git_sources, user_config, declaration, lockfile, offline: offline
146
+ )
147
+ spec_path = find_prayspec_file(root)
148
+ spec_text = File.read(spec_path)
149
+ spec = Pray.parse_package_spec(spec_text).canonicalized
150
+ if spec.name != declaration.name
151
+ raise Error.resolution(
152
+ "package path #{root.inspect} declares #{spec.name.inspect}, expected #{declaration.name.inspect}"
153
+ )
154
+ end
155
+ unless Constraint.version_satisfies(spec.version, declaration.constraint)
156
+ raise Error.resolution(
157
+ "package #{declaration.name} version #{spec.version} does not satisfy constraint #{declaration.constraint}"
158
+ )
159
+ end
160
+
161
+ selected_exports = select_exports(declaration, spec)
162
+ file_bytes = load_package_file_bytes(root, spec)
163
+ tree_hash = PackageSpec.tree_hash_from_file_bytes(file_bytes)
164
+ export_bodies = load_export_bodies(file_bytes, spec, selected_exports)
165
+ skill_files = build_skill_file_index(spec)
166
+
167
+ ResolvedPackage.new(
168
+ declaration: declaration,
169
+ root: root,
170
+ spec: spec,
171
+ tree_hash: tree_hash,
172
+ artifact_hash: tree_hash,
173
+ artifact: "path:#{File.dirname(spec_path)}",
174
+ selected_exports: selected_exports,
175
+ source_checksum: tree_hash,
176
+ export_bodies: export_bodies,
177
+ skill_files: skill_files,
178
+ signer_fingerprint: nil,
179
+ registry_latest_version: registry_latest_version
180
+ )
181
+ end
182
+
183
+ def resolve_package_root_with_metadata(
184
+ project_root, sources, git_sources, user_config, declaration, lockfile, offline: false
185
+ )
186
+ if (local_path = user_config.local.package[declaration.name])
187
+ return [File.expand_path(local_path, project_root), nil]
188
+ end
189
+ return [File.expand_path(declaration.path, project_root), nil] if declaration.path
190
+
191
+ if declaration.source
192
+ source = sources[declaration.source]
193
+ raise Error.resolution("unknown source: #{declaration.source}") unless source
194
+
195
+ if (local_path = user_config.local.source[declaration.source])
196
+ source_root = File.expand_path(local_path, project_root)
197
+ resolved = Registry.resolve_local_registry_package_root(
198
+ project_root,
199
+ "local:#{declaration.source}",
200
+ source_root,
201
+ declaration,
202
+ preferred_version: lockfile_preferred_version(lockfile, declaration.name),
203
+ offline: offline
204
+ )
205
+ return [resolved.root, resolved.registry_latest_version]
206
+ end
207
+
208
+ case source.kind
209
+ when "path"
210
+ return [File.join(project_root, source.url, declaration.name.tr("/", "-")), nil]
211
+ when "registry", "static index"
212
+ resolved = Registry.resolve_registry_package_root(
213
+ project_root,
214
+ source.url,
215
+ declaration,
216
+ preferred_version: lockfile_preferred_version(lockfile, declaration.name),
217
+ offline: offline
218
+ )
219
+ return [resolved.root, resolved.registry_latest_version]
220
+ when "pray_ssh"
221
+ raise Error.unsupported("pray_ssh sources are not implemented yet in pray-cli Ruby")
222
+ when "git"
223
+ checkout = git_sources[source.name]
224
+ raise Error.resolution("git source #{source.name} was not prepared") unless checkout
225
+
226
+ clone_url = source.url.delete_prefix("git+")
227
+ distribution_root = GitSources.resolve_distribution_root(checkout.cache_directory, checkout.subdir)
228
+ source_key = checkout.revision.to_s.empty? ? clone_url : "#{clone_url}@#{checkout.revision}"
229
+ resolved = Registry.resolve_local_registry_package_root(
230
+ project_root,
231
+ source_key,
232
+ distribution_root,
233
+ declaration,
234
+ preferred_version: lockfile_preferred_version(lockfile, declaration.name),
235
+ offline: offline
236
+ )
237
+ return [resolved.root, resolved.registry_latest_version]
238
+ else
239
+ raise Error.unsupported("source kind #{source.kind} not implemented yet")
240
+ end
241
+ end
242
+
243
+ if declaration.git || declaration.tarball || declaration.oci
244
+ raise Error.unsupported("remote sources are not implemented yet")
245
+ end
246
+
247
+ [File.join(project_root, declaration.name.tr("/", "-")), nil]
248
+ end
249
+
250
+ def resolve_package_root(project_root, sources, git_sources, user_config, declaration, lockfile = nil)
251
+ resolve_package_root_with_metadata(project_root, sources, git_sources, user_config, declaration, lockfile).first
252
+ end
253
+
254
+ def lockfile_preferred_version(lockfile, package_name)
255
+ return nil unless lockfile
256
+
257
+ lockfile.package.find { |entry| entry.name == package_name }&.version
258
+ end
259
+
260
+ def resolve_local_file(project_root, declaration)
261
+ path = File.join(project_root, declaration.path)
262
+ unless File.exist?(path)
263
+ if declaration.optional
264
+ return ResolvedLocalFile.new(
265
+ path: path,
266
+ manifest_path: declaration.path,
267
+ content: "",
268
+ position: declaration.position,
269
+ optional: true
270
+ )
271
+ end
272
+ raise Error.resolution(missing_local_embed_guidance(declaration.path))
273
+ end
274
+
275
+ ResolvedLocalFile.new(
276
+ path: path,
277
+ manifest_path: declaration.path,
278
+ content: Hashing.normalize_line_endings(File.read(path)),
279
+ position: declaration.position,
280
+ optional: declaration.optional
281
+ )
282
+ end
283
+
284
+ def find_prayspec_file(root)
285
+ files = Dir.children(root).filter_map do |entry|
286
+ path = File.join(root, entry)
287
+ (File.file?(path) && File.extname(entry) == ".prayspec") ? path : nil
288
+ end
289
+ case files.length
290
+ when 1 then files.first
291
+ when 0 then raise Error.resolution("no prayspec file found in #{root.inspect}")
292
+ else raise Error.resolution("multiple prayspec files found in #{root.inspect}")
293
+ end
294
+ end
295
+
296
+ def source_map(sources)
297
+ sources.to_h { |source| [source.name, source] }
298
+ end
299
+
300
+ def select_exports(declaration, spec)
301
+ unless declaration.exports.empty?
302
+ declaration.exports.each do |export|
303
+ unless spec.exports.key?(export)
304
+ raise Error.resolution("package #{declaration.name} does not export #{export}")
305
+ end
306
+ end
307
+ return declaration.exports
308
+ end
309
+
310
+ roles = declaration.roles || []
311
+ return spec.exports.keys.sort if roles.empty? && declaration.file.nil?
312
+
313
+ effective_roles = roles.dup
314
+ effective_roles << "file" if declaration.file && !effective_roles.include?("file")
315
+
316
+ selected = []
317
+ effective_roles.each do |role|
318
+ compatible = spec.exports.filter_map do |name, export|
319
+ name if Destination.export_kind_matches_role?(export.kind, role)
320
+ end
321
+ case compatible.length
322
+ when 1
323
+ selected << compatible.first unless selected.include?(compatible.first)
324
+ when 0
325
+ raise Error.resolution(
326
+ "package #{declaration.name} has no export compatible with #{role}"
327
+ )
328
+ else
329
+ raise Error.resolution(
330
+ "package #{declaration.name} has multiple exports compatible with #{role}; set export: \"name\""
331
+ )
332
+ end
333
+ end
334
+ selected
335
+ end
336
+
337
+ def load_package_file_bytes(root, spec)
338
+ file_bytes = {}
339
+ spec.files.each do |file|
340
+ path = File.join(root, file)
341
+ raise Error.integrity("package file missing: #{file}") unless File.exist?(path)
342
+ raise Error.integrity("package file is a directory: #{file}") if File.directory?(path)
343
+
344
+ file_bytes[file] = File.binread(path)
345
+ end
346
+ file_bytes
347
+ end
348
+
349
+ def load_export_bodies(file_bytes, spec, selected_exports)
350
+ export_bodies = {}
351
+ selected_exports.each do |export_name|
352
+ entry = spec.exports[export_name]
353
+ raise Error.resolution("package #{spec.name} is missing export #{export_name}") unless entry
354
+ next unless entry.kind == "fragment"
355
+
356
+ bytes = file_bytes[entry.path]
357
+ raise Error.integrity("package file missing for export #{export_name}: #{entry.path}") unless bytes
358
+
359
+ export_bodies[export_name] = Hashing.normalize_line_endings(bytes.force_encoding(Encoding::UTF_8))
360
+ end
361
+ export_bodies
362
+ end
363
+
364
+ def build_skill_file_index(spec)
365
+ index = {}
366
+ spec.exports.each do |export_name, export|
367
+ next unless %w[folder skill].include?(export.kind)
368
+
369
+ folder_prefix = export.path.delete_suffix("/")
370
+ files = indexed_files_under_prefix(spec.files, folder_prefix)
371
+ index[export_name] = files unless files.empty?
372
+ end
373
+ spec.skills.each do |skill_name, skill|
374
+ next if index.key?(skill_name)
375
+
376
+ skill_prefix = skill.path.delete_suffix("/")
377
+ files = indexed_files_under_prefix(spec.files, skill_prefix)
378
+ index[skill_name] = files unless files.empty?
379
+ end
380
+ index
381
+ end
382
+
383
+ def indexed_files_under_prefix(files, prefix)
384
+ files.filter_map { |file| skill_relative_file(file, prefix) }
385
+ end
386
+
387
+ def skill_relative_file(file, skill_prefix)
388
+ return nil unless file.start_with?(skill_prefix)
389
+
390
+ relative = file.delete_prefix(skill_prefix).delete_prefix("/")
391
+ return nil if relative.empty? || file == skill_prefix
392
+
393
+ relative
394
+ end
395
+ end
396
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pray
4
+ ResolveOptions = Struct.new(
5
+ :offline, :refresh, :unlocked_packages, :refresh_source_revisions,
6
+ :ignore_locked_versions, :environment
7
+ ) do
8
+ def initialize(
9
+ offline: false,
10
+ refresh: false,
11
+ unlocked_packages: Set.new,
12
+ refresh_source_revisions: false,
13
+ ignore_locked_versions: false,
14
+ environment: nil
15
+ )
16
+ super
17
+ end
18
+ end
19
+ end
data/lib/pray/serve.rb ADDED
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "fileutils"
5
+ require "pathname"
6
+ require "json"
7
+ require_relative "path_safety"
8
+ require_relative "serve_federation"
9
+
10
+ module Pray
11
+ module Serve
12
+ DEFAULT_MAX_CONNECTIONS = 8
13
+
14
+ module_function
15
+
16
+ def run_server(root:, host: "127.0.0.1", port: 7429, max_connections: DEFAULT_MAX_CONNECTIONS)
17
+ root = File.expand_path(root)
18
+ server = TCPServer.new(host, port)
19
+ connection_slots = SizedQueue.new(max_connections)
20
+ max_connections.times { connection_slots << true }
21
+ puts "Serving #{root} on http://#{host}:#{port}"
22
+
23
+ loop do
24
+ socket = server.accept
25
+ begin
26
+ connection_slots.pop(true)
27
+ rescue ThreadError
28
+ socket.print(service_unavailable)
29
+ socket.close
30
+ next
31
+ end
32
+
33
+ Thread.new { serve_connection(root, socket, connection_slots) }
34
+ rescue Interrupt
35
+ break
36
+ end
37
+ ensure
38
+ server&.close
39
+ end
40
+
41
+ def serve_connection(root, socket, connection_slots)
42
+ handle_connection(root, socket)
43
+ ensure
44
+ connection_slots << true unless connection_slots.closed?
45
+ socket.close unless socket.closed?
46
+ end
47
+
48
+ def handle_connection(root, socket)
49
+ request_line = socket.gets
50
+ return unless request_line
51
+
52
+ method, path, = request_line.split
53
+ headers = read_headers(socket)
54
+ body_length = headers["content-length"].to_i
55
+ body = body_length.positive? ? socket.read(body_length) : ""
56
+
57
+ response = dispatch_request(root, method, path, body)
58
+ socket.print(response)
59
+ end
60
+
61
+ def read_headers(socket)
62
+ headers = {}
63
+ loop do
64
+ line = socket.gets
65
+ break if line.nil? || line.strip.empty?
66
+
67
+ name, value = line.split(":", 2)
68
+ headers[name.strip.downcase] = value.strip if name && value
69
+ end
70
+ headers
71
+ end
72
+
73
+ def dispatch_request(root, method, path, body = "")
74
+ path = path.split("?", 2).first
75
+
76
+ case [method, path]
77
+ when ["GET", "/.well-known/pray-federation.json"]
78
+ return ServeFederation.discovery_response(root)
79
+ when ["GET", "/v1/sync/index"]
80
+ return ServeFederation.index_response(root)
81
+ when ["POST", "/v1/confessions"]
82
+ return ServeFederation.append_confession(root, body)
83
+ end
84
+
85
+ if method == "GET" && path.start_with?("/v1/sync/package/")
86
+ return ServeFederation.package_response(root, path)
87
+ end
88
+
89
+ return not_found unless method == "GET"
90
+
91
+ if path == "/"
92
+ return html_response("<h1>Pray distribution</h1><p>Root: #{root}</p>")
93
+ end
94
+
95
+ file_path = PathSafety.join_under_root(root, path.delete_prefix("/"))
96
+ return not_found unless file_path
97
+ return not_found unless File.file?(file_path)
98
+
99
+ content_type = content_type_for(file_path)
100
+ file_body = File.binread(file_path)
101
+ ok_response(content_type, file_body)
102
+ end
103
+
104
+ def content_type_for(path)
105
+ case File.extname(path)
106
+ when ".json" then "application/json"
107
+ when ".praypkg" then "application/octet-stream"
108
+ else "text/plain"
109
+ end
110
+ end
111
+
112
+ def ok_response(content_type, body)
113
+ "HTTP/1.1 200 OK\r\nContent-Type: #{content_type}\r\nContent-Length: #{body.bytesize}\r\nConnection: close\r\n\r\n#{body}"
114
+ end
115
+
116
+ def html_response(body)
117
+ ok_response("text/html", body)
118
+ end
119
+
120
+ def not_found
121
+ body = "not found"
122
+ "HTTP/1.1 404 Not Found\r\nContent-Type: text/plain\r\nContent-Length: #{body.bytesize}\r\nConnection: close\r\n\r\n#{body}"
123
+ end
124
+
125
+ def service_unavailable
126
+ body = "too many connections"
127
+ "HTTP/1.1 503 Service Unavailable\r\nContent-Type: text/plain\r\nContent-Length: #{body.bytesize}\r\nConnection: close\r\n\r\n#{body}"
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Pray
6
+ module ServeFederation
7
+ module_function
8
+
9
+ def discovery_response(root)
10
+ peers = read_peers(root)
11
+ body = {
12
+ "spec" => "pray-federation-v1",
13
+ "server" => {
14
+ "name" => "pray",
15
+ "version" => VERSION,
16
+ "capabilities" => ["static_registry", "federation"]
17
+ },
18
+ "sync" => {
19
+ "index_url" => "/v1/sync/index",
20
+ "package_url" => "/v1/sync/package/{name}",
21
+ "artifact_url" => "/v1/artifacts/{package}/{version}/{artifact}",
22
+ "since_param" => "since"
23
+ },
24
+ "peers" => peers
25
+ }
26
+ Serve.ok_response("application/json", JSON.pretty_generate(body))
27
+ end
28
+
29
+ def index_response(root)
30
+ index = Publish.load_registry_index(root)
31
+ packages = index.packages.filter_map do |name|
32
+ metadata = Publish.load_registry_package_metadata(
33
+ Publish.registry_metadata_path(root, name), name
34
+ )
35
+ next if metadata.versions.empty?
36
+
37
+ updated_at = metadata.versions.map { |version| version.published_at.to_s }.max || "0"
38
+ {
39
+ "name" => name,
40
+ "updated_at" => updated_at,
41
+ "url" => "/v1/sync/package/#{name}"
42
+ }
43
+ end
44
+ body = {
45
+ "spec" => "prayfile-distribution-1",
46
+ "sync_version" => 0,
47
+ "packages" => packages
48
+ }
49
+ Serve.ok_response("application/json", JSON.pretty_generate(body))
50
+ end
51
+
52
+ def package_response(root, path)
53
+ name = path.delete_prefix("/v1/sync/package/")
54
+ metadata_path = Publish.registry_metadata_path(root, name)
55
+ return Serve.not_found unless File.file?(metadata_path)
56
+
57
+ metadata = Publish.load_registry_package_metadata(metadata_path, name)
58
+ body = {
59
+ "name" => metadata.name,
60
+ "updated_at" => metadata.versions.map { |version| version.published_at.to_s }.max || "0",
61
+ "versions" => metadata.versions.map { |version| transport_version(version) }
62
+ }
63
+ Serve.ok_response("application/json", JSON.pretty_generate(body))
64
+ end
65
+
66
+ def append_confession(root, body)
67
+ FileUtils.mkdir_p(File.join(root, "v1"))
68
+ path = File.join(root, "v1", "confessions.jsonl")
69
+ File.open(path, "a") { |file| file.puts(body.to_s.b) }
70
+ Serve.ok_response("application/json", JSON.generate({"status" => "ok"}))
71
+ end
72
+
73
+ def transport_version(version)
74
+ hash = {
75
+ "version" => version.version,
76
+ "artifact" => version.artifact,
77
+ "artifact_hash" => version.artifact_hash.to_s,
78
+ "tree_hash" => version.tree_hash.to_s,
79
+ "yanked" => version.yanked,
80
+ "targets" => version.targets,
81
+ "exports" => version.exports,
82
+ "published_at" => version.published_at.to_s
83
+ }
84
+ if version.signer || version.signer_fingerprint
85
+ hash["publisher"] = {
86
+ "id" => version.signer.to_s,
87
+ "key_fingerprint" => version.signer_fingerprint.to_s
88
+ }
89
+ end
90
+ if version.signature
91
+ hash["signature"] = {
92
+ "public_key" => version.signer.to_s,
93
+ "signature" => version.signature,
94
+ "value" => version.signature
95
+ }
96
+ end
97
+ hash
98
+ end
99
+
100
+ def read_peers(root)
101
+ path = File.join(root, "v1", "peers.json")
102
+ return [] unless File.file?(path)
103
+
104
+ Array(JSON.parse(File.read(path)))
105
+ rescue JSON::ParserError
106
+ []
107
+ end
108
+ end
109
+ end