pray-cli 1.15.0 → 1.17.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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +17 -0
  3. data/lib/pray/apply_report.rb +49 -0
  4. data/lib/pray/cli/commands/init.rb +1 -20
  5. data/lib/pray/cli/commands/init_prayer.rb +76 -0
  6. data/lib/pray/cli/commands/init_prayer_manifest.rb +102 -0
  7. data/lib/pray/cli/commands/packages.rb +13 -2
  8. data/lib/pray/cli/commands/workflow.rb +5 -0
  9. data/lib/pray/cli/help.rb +52 -15
  10. data/lib/pray/cli/helpers.rb +13 -5
  11. data/lib/pray/cli/parse.rb +21 -1
  12. data/lib/pray/cli.rb +3 -1
  13. data/lib/pray/constraint.rb +17 -4
  14. data/lib/pray/dependency_graph.rb +43 -0
  15. data/lib/pray/destination.rb +32 -2
  16. data/lib/pray/distribution.rb +94 -0
  17. data/lib/pray/format_serialize.rb +1 -4
  18. data/lib/pray/git_sources.rb +11 -10
  19. data/lib/pray/local_prayer.rb +38 -0
  20. data/lib/pray/lockfile.rb +8 -2
  21. data/lib/pray/manifest.rb +6 -3
  22. data/lib/pray/manifest_json.rb +1 -0
  23. data/lib/pray/manifest_parser_blocks.rb +15 -15
  24. data/lib/pray/materialize.rb +8 -1
  25. data/lib/pray/package_spec.rb +25 -13
  26. data/lib/pray/package_spec_render.rb +4 -3
  27. data/lib/pray/package_spec_version.rb +33 -0
  28. data/lib/pray/plan.rb +2 -1
  29. data/lib/pray/publish.rb +27 -2
  30. data/lib/pray/registry.rb +12 -0
  31. data/lib/pray/registry_paths.rb +14 -0
  32. data/lib/pray/render.rb +6 -36
  33. data/lib/pray/render_managed.rb +64 -0
  34. data/lib/pray/render_patch.rb +63 -12
  35. data/lib/pray/resolve.rb +17 -50
  36. data/lib/pray/resolve_context.rb +7 -0
  37. data/lib/pray/resolve_deps.rb +18 -0
  38. data/lib/pray/resolve_local.rb +39 -0
  39. data/lib/pray/resolve_source.rb +5 -0
  40. data/lib/pray/resolve_tarball.rb +66 -0
  41. data/lib/pray/serve.rb +15 -4
  42. data/lib/pray/serve_range.rb +36 -0
  43. data/lib/pray/torrent_manifest.rb +78 -0
  44. data/lib/pray/upstream_drift.rb +42 -0
  45. data/lib/pray/upstream_merge.rb +25 -0
  46. data/lib/pray/upstream_refresh.rb +28 -2
  47. data/lib/pray/verify.rb +2 -2
  48. data/lib/pray/verify_locked_dest.rb +47 -0
  49. data/lib/pray/version.rb +2 -2
  50. data/lib/pray.rb +15 -1
  51. metadata +17 -1
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module Pray
6
+ module RegistryPaths
7
+ module_function
8
+
9
+ def local_registry_root(project_root, source_url)
10
+ path = source_url.delete_prefix("file://")
11
+ Pathname.new(path).absolute? ? path : File.expand_path(path, project_root)
12
+ end
13
+ end
14
+ end
data/lib/pray/render.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require "fileutils"
4
4
  require "pathname"
5
5
  require_relative "render_content"
6
+ require_relative "render_managed"
6
7
 
7
8
  module Pray
8
9
  RenderedTarget = Struct.new(:path, :content, :managed_spans) do
@@ -76,7 +77,7 @@ module Pray
76
77
 
77
78
  (target.entries || []).each do |entry|
78
79
  if entry.kind == "local"
79
- append_scoped_local(builder, project, entry.path, symbols)
80
+ append_scoped_local(builder, managed_spans, project, target, output, entry.path, symbols)
80
81
  else
81
82
  append_scoped_package(builder, managed_spans, project, target, output, entry.name, symbols)
82
83
  end
@@ -99,18 +100,17 @@ module Pray
99
100
  builder.append_empty_line
100
101
  end
101
102
  symbols = project.manifest.symbols || {}
103
+ managed_spans = []
102
104
  unbound_locals.each do |local|
103
105
  next if local.content.empty? && local.optional
104
106
 
105
107
  builder.append_line("### #{local.manifest_path}")
106
- builder.append_body(Substitute.substitute_pray_symbols(local.content, symbols))
107
- builder.append_empty_line
108
+ append_managed_local(builder, managed_spans, local, target, output, symbols)
108
109
  end
109
110
 
110
111
  builder.append_line("## Shared instructions")
111
112
  builder.append_empty_line
112
113
 
113
- managed_spans = []
114
114
  project.packages.each do |package|
115
115
  next unless Environment.package_matches_environment?(package.declaration.groups, project.environment)
116
116
  next unless Destination.package_bound_to_compose?(package.declaration, target)
@@ -133,13 +133,11 @@ module Pray
133
133
  builder.append_empty_line
134
134
  end
135
135
 
136
- def append_scoped_local(builder, project, path, symbols)
136
+ def append_scoped_local(builder, managed_spans, project, target, output, path, symbols)
137
137
  local = project.local_files.find { |candidate| candidate.manifest_path == path }
138
138
  return unless local
139
- return if local.content.empty? && local.optional
140
139
 
141
- builder.append_body(Substitute.substitute_pray_symbols(local.content, symbols))
142
- builder.append_empty_line
140
+ append_managed_local(builder, managed_spans, local, target, output, symbols)
143
141
  end
144
142
 
145
143
  def append_scoped_package(builder, managed_spans, project, target, output, package_name, symbols)
@@ -154,34 +152,6 @@ module Pray
154
152
  end
155
153
  end
156
154
 
157
- def append_managed_export(builder, managed_spans, package, export, target, output, symbols)
158
- body = package.export_bodies[export]
159
- unless body
160
- raise Error.integrity("compose cannot write binary export #{export}; use file: for unmarked bytes") if package.spec.exports[export]&.kind == "file"
161
- raise Error.render("package #{package.declaration.name} is missing cached export #{export}")
162
- end
163
-
164
- body = Substitute.substitute_pray_symbols(body, symbols)
165
- identifier = Hashing.marker_id("#{package.declaration.name}:#{export}:#{target.name}")
166
- open_line = builder.next_line_number
167
- builder.append_line("<!-- pray:#{identifier} -->")
168
- builder.append_body(body)
169
- close_line = builder.next_line_number
170
- builder.append_line("<!-- pray:#{identifier} -->")
171
- managed_spans << ManagedSpanRecord.new(
172
- id: identifier,
173
- target: output,
174
- open_line: open_line,
175
- close_line: close_line,
176
- ideal_checksum: Hashing.checksum_managed_span_content(body),
177
- package: package.declaration.name,
178
- export: export,
179
- source_checksum: package.source_checksum,
180
- silenced: false
181
- )
182
- builder.append_empty_line
183
- end
184
-
185
155
  def should_inline_export?(package, export_name)
186
156
  export = package.spec.exports[export_name]
187
157
  export.nil? || %w[fragment file].include?(export.kind)
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pray
4
+ module Render
5
+ module_function
6
+
7
+ def append_managed_local(builder, managed_spans, local, target, output, symbols)
8
+ return if local.content.empty? && local.optional
9
+
10
+ body = Substitute.substitute_pray_symbols(local.content, symbols)
11
+ append_managed_span(
12
+ builder,
13
+ managed_spans,
14
+ "local:#{local.manifest_path}:#{target.name}",
15
+ body,
16
+ output,
17
+ LOCAL_EMBED_PACKAGE,
18
+ local.manifest_path,
19
+ local.source_checksum
20
+ )
21
+ end
22
+
23
+ def append_managed_export(builder, managed_spans, package, export, target, output, symbols)
24
+ body = package.export_bodies[export]
25
+ unless body
26
+ raise Error.integrity("compose cannot write binary export #{export}; use file: for unmarked bytes") if package.spec.exports[export]&.kind == "file"
27
+ raise Error.render("package #{package.declaration.name} is missing cached export #{export}")
28
+ end
29
+
30
+ body = Substitute.substitute_pray_symbols(body, symbols)
31
+ append_managed_span(
32
+ builder,
33
+ managed_spans,
34
+ "#{package.declaration.name}:#{export}:#{target.name}",
35
+ body,
36
+ output,
37
+ package.declaration.name,
38
+ export,
39
+ package.source_checksum
40
+ )
41
+ end
42
+
43
+ def append_managed_span(builder, managed_spans, seed, body, output, package, export, source_checksum)
44
+ identifier = Hashing.marker_id(seed)
45
+ open_line = builder.next_line_number
46
+ builder.append_line("<!-- pray:#{identifier} -->")
47
+ builder.append_body(body)
48
+ close_line = builder.next_line_number
49
+ builder.append_line("<!-- pray:#{identifier} -->")
50
+ managed_spans << ManagedSpanRecord.new(
51
+ id: identifier,
52
+ target: output,
53
+ open_line: open_line,
54
+ close_line: close_line,
55
+ ideal_checksum: Hashing.checksum_managed_span_content(body),
56
+ package: package,
57
+ export: export,
58
+ source_checksum: source_checksum,
59
+ silenced: false
60
+ )
61
+ builder.append_empty_line
62
+ end
63
+ end
64
+ end
@@ -7,28 +7,74 @@ module Pray
7
7
  def patch_rendered_content(existing, fresh)
8
8
  existing_segments = split_segments(existing)
9
9
  fresh_segments = split_segments(fresh)
10
- fresh_managed = fresh_segments.filter_map do |segment|
11
- [segment[:id], segment[:body]] if segment[:kind] == :managed
12
- end.to_h
13
- overlap = existing_segments.any? do |segment|
14
- segment[:kind] == :managed && fresh_managed.key?(segment[:id])
15
- end
16
- return fresh unless overlap
10
+ fresh_managed = managed_bodies(fresh_segments)
11
+ return fresh unless overlapping_managed?(existing_segments, fresh_managed)
17
12
 
18
13
  used = {}
19
- output = existing_segments.map do |segment|
20
- next segment[:text] if segment[:kind] == :text
14
+ output = +""
15
+ remaining = prepend_new_leading_spans(
16
+ existing_segments,
17
+ fresh_segments,
18
+ managed_id_set(existing_segments),
19
+ used,
20
+ output
21
+ )
22
+ splice_existing_managed(remaining, fresh_managed, used, output)
23
+ append_unused_fresh_managed(fresh_segments, used, output)
24
+ output.end_with?("\n") ? output : "#{output}\n"
25
+ end
26
+
27
+ def managed_bodies(segments)
28
+ segments.filter_map { |segment| [segment[:id], segment[:body]] if segment[:kind] == :managed }.to_h
29
+ end
30
+
31
+ def overlapping_managed?(existing_segments, fresh_managed)
32
+ existing_segments.any? { |segment| segment[:kind] == :managed && fresh_managed.key?(segment[:id]) }
33
+ end
34
+
35
+ def managed_id_set(segments)
36
+ segments.filter_map { |segment| segment[:id] if segment[:kind] == :managed }.to_h { |id| [id, true] }
37
+ end
38
+
39
+ def splice_existing_managed(remaining, fresh_managed, used, output)
40
+ remaining.each do |segment|
41
+ if segment[:kind] == :text
42
+ output << segment[:text]
43
+ next
44
+ end
21
45
 
22
46
  used[segment[:id]] = true
23
- managed_segment(segment[:id], fresh_managed.fetch(segment[:id], segment[:body]))
24
- end.join
47
+ output << managed_segment(segment[:id], fresh_managed.fetch(segment[:id], segment[:body]))
48
+ end
49
+ end
50
+
51
+ def append_unused_fresh_managed(fresh_segments, used, output)
25
52
  fresh_segments.each do |segment|
26
53
  next unless segment[:kind] == :managed
27
54
  next if used[segment[:id]]
28
55
 
29
56
  output << managed_segment(segment[:id], segment[:body])
30
57
  end
31
- output.end_with?("\n") ? output : "#{output}\n"
58
+ end
59
+
60
+ def prepend_new_leading_spans(existing_segments, fresh_segments, existing_ids, used, output)
61
+ missing = fresh_segments.any? { |segment| segment[:kind] == :managed && !existing_ids[segment[:id]] }
62
+ return existing_segments unless missing
63
+
64
+ shared_id = fresh_segments.find { |segment| segment[:kind] == :managed && existing_ids[segment[:id]] }&.[](:id)
65
+ return existing_segments unless shared_id
66
+
67
+ fresh_segments.each do |segment|
68
+ break if segment[:kind] == :managed && segment[:id] == shared_id
69
+
70
+ if segment[:kind] == :text
71
+ output << segment[:text]
72
+ else
73
+ used[segment[:id]] = true
74
+ output << managed_segment(segment[:id], segment[:body])
75
+ end
76
+ end
77
+ skip_until_managed(existing_segments, shared_id)
32
78
  end
33
79
 
34
80
  def relocate_managed_spans(content, spans)
@@ -68,6 +114,11 @@ module Pray
68
114
  segments
69
115
  end
70
116
 
117
+ def skip_until_managed(segments, identifier)
118
+ index = segments.index { |segment| segment[:kind] == :managed && segment[:id] == identifier }
119
+ index ? segments[index..] : segments
120
+ end
121
+
71
122
  def lines_of(content)
72
123
  content.lines(chomp: true)
73
124
  end
data/lib/pray/resolve.rb CHANGED
@@ -17,7 +17,7 @@ module Pray
17
17
  )
18
18
 
19
19
  ResolvedLocalFile = Struct.new(
20
- :path, :manifest_path, :content, :position, :optional
20
+ :path, :manifest_path, :content, :source_checksum, :position, :optional
21
21
  )
22
22
 
23
23
  module Resolve
@@ -92,12 +92,15 @@ module Pray
92
92
  local_files = []
93
93
  local_errors = []
94
94
  manifest.local.each do |local|
95
+ next unless Destination.local_compose_embed?(manifest, local)
95
96
  local_files << resolve_local_file(project_root, local)
96
97
  rescue Error => error
97
98
  local_errors << "local #{local.path}: #{error.message}"
98
99
  end
99
100
  raise Error.resolution(local_errors.join("\n")) unless local_errors.empty?
100
101
 
102
+ ResolveDeps.reject_dependency_cycles(packages)
103
+
101
104
  ResolvedProject.new(
102
105
  manifest_path: manifest_path,
103
106
  project_root: project_root,
@@ -144,15 +147,10 @@ module Pray
144
147
  GitRefresh.resolution_may_benefit_from_git_source_refresh?(error)
145
148
  end
146
149
 
147
- def missing_local_embed_guidance(path)
148
- "Prayfile lists `local \"#{path}\"` but the file does not exist. " \
149
- "Create the file or remove the entry from Prayfile, then run `pray install`."
150
- end
151
-
152
150
  def resolve_package(project_root, sources, git_sources, user_config, declaration, lockfile, offline: false, options: nil)
153
151
  options ||= ResolveOptions.new(offline: offline)
154
152
  root, registry_latest_version = resolve_package_root_with_metadata(
155
- project_root, sources, git_sources, user_config, declaration, lockfile, offline: options.offline
153
+ project_root, sources, git_sources, user_config, declaration, lockfile, options
156
154
  )
157
155
  spec_path = find_prayspec_file(root)
158
156
  spec_text = File.read(spec_path)
@@ -162,11 +160,7 @@ module Pray
162
160
  "package path #{root.inspect} declares #{spec.name.inspect}, expected #{declaration.name.inspect}"
163
161
  )
164
162
  end
165
- unless Constraint.version_satisfies(spec.version, declaration.constraint)
166
- raise Error.resolution(
167
- "package #{declaration.name} version #{spec.version} does not satisfy constraint #{declaration.constraint}"
168
- )
169
- end
163
+ spec.satisfy_constraint!(declaration.constraint)
170
164
 
171
165
  selected_exports = select_exports(declaration, spec)
172
166
  file_bytes = load_package_file_bytes(root, spec)
@@ -194,12 +188,16 @@ module Pray
194
188
  end
195
189
 
196
190
  def resolve_package_root_with_metadata(
197
- project_root, sources, git_sources, user_config, declaration, lockfile, offline: false
191
+ project_root, sources, git_sources, user_config, declaration, lockfile, options = nil
198
192
  )
193
+ options ||= ResolveOptions.new
194
+ offline = options.offline
195
+ preferred_version = options.preferred_lock_version(lockfile, declaration.name)
199
196
  if (local_path = user_config.local.package[declaration.name])
200
197
  return [File.expand_path(local_path, project_root), nil]
201
198
  end
202
199
  return [File.expand_path(declaration.path, project_root), nil] if declaration.path
200
+ return [ResolveTarball.package_root(project_root, declaration.tarball, offline: offline), nil] if declaration.tarball
203
201
  source_name = ResolveSource.implied_source_name(declaration, sources)
204
202
  if source_name
205
203
  source = sources[source_name]
@@ -212,7 +210,7 @@ module Pray
212
210
  "local:#{source_name}",
213
211
  source_root,
214
212
  declaration,
215
- preferred_version: lockfile_preferred_version(lockfile, declaration.name),
213
+ preferred_version: preferred_version,
216
214
  offline: offline
217
215
  )
218
216
  return [resolved.root, resolved.registry_latest_version]
@@ -220,13 +218,14 @@ module Pray
220
218
 
221
219
  case source.kind
222
220
  when "path"
223
- return [File.join(project_root, source.url, declaration.name.tr("/", "-")), nil]
221
+ directory = LocalPrayer.path_source_package_directory(source_name, declaration.name)
222
+ return [File.join(project_root, source.url, directory), nil]
224
223
  when "registry", "static index"
225
224
  resolved = Registry.resolve_registry_package_root(
226
225
  project_root,
227
226
  source.url,
228
227
  declaration,
229
- preferred_version: lockfile_preferred_version(lockfile, declaration.name),
228
+ preferred_version: preferred_version,
230
229
  offline: offline
231
230
  )
232
231
  return [resolved.root, resolved.registry_latest_version]
@@ -245,7 +244,7 @@ module Pray
245
244
  source_key,
246
245
  distribution_root,
247
246
  declaration,
248
- preferred_version: lockfile_preferred_version(lockfile, declaration.name),
247
+ preferred_version: preferred_version,
249
248
  offline: offline
250
249
  )
251
250
  rescue Error => error
@@ -262,9 +261,7 @@ module Pray
262
261
  end
263
262
  end
264
263
 
265
- if declaration.git || declaration.tarball || declaration.oci
266
- raise Error.unsupported("remote sources are not implemented yet")
267
- end
264
+ raise Error.unsupported("remote sources are not implemented yet") if declaration.git || declaration.oci
268
265
 
269
266
  [File.join(project_root, declaration.name.tr("/", "-")), nil]
270
267
  end
@@ -273,36 +270,6 @@ module Pray
273
270
  resolve_package_root_with_metadata(project_root, sources, git_sources, user_config, declaration, lockfile).first
274
271
  end
275
272
 
276
- def lockfile_preferred_version(lockfile, package_name)
277
- return nil unless lockfile
278
-
279
- lockfile.package.find { |entry| entry.name == package_name }&.version
280
- end
281
-
282
- def resolve_local_file(project_root, declaration)
283
- path = File.join(project_root, declaration.path)
284
- unless File.exist?(path)
285
- if declaration.optional
286
- return ResolvedLocalFile.new(
287
- path: path,
288
- manifest_path: declaration.path,
289
- content: "",
290
- position: declaration.position,
291
- optional: true
292
- )
293
- end
294
- raise Error.resolution(missing_local_embed_guidance(declaration.path))
295
- end
296
-
297
- ResolvedLocalFile.new(
298
- path: path,
299
- manifest_path: declaration.path,
300
- content: Hashing.normalize_line_endings(File.read(path)),
301
- position: declaration.position,
302
- optional: declaration.optional
303
- )
304
- end
305
-
306
273
  def find_prayspec_file(root)
307
274
  files = Dir.children(root).filter_map do |entry|
308
275
  path = File.join(root, entry)
@@ -15,5 +15,12 @@ module Pray
15
15
  )
16
16
  super
17
17
  end
18
+
19
+ def preferred_lock_version(lockfile, package_name)
20
+ return nil unless lockfile
21
+ return nil if ignore_locked_versions || unlocked_packages.include?(package_name)
22
+
23
+ lockfile.package.find { |entry| entry.name == package_name }&.version
24
+ end
18
25
  end
19
26
  end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pray
4
+ module ResolveDeps
5
+ module_function
6
+
7
+ def reject_dependency_cycles(packages)
8
+ edges = {}
9
+ packages.each do |package|
10
+ edges[package.declaration.name] = package.spec.dependencies.map(&:name)
11
+ end
12
+ cycle = DependencyGraph.find_dependency_cycle(edges)
13
+ return unless cycle
14
+
15
+ raise Error.resolution("dependency cycle detected: #{cycle.join(" -> ")}")
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pray
4
+ module Resolve
5
+ module_function
6
+
7
+ def missing_local_embed_guidance(path)
8
+ "Prayfile lists `local \"#{path}\"` but the file does not exist. " \
9
+ "Create the file or remove the entry from Prayfile, then run `pray install`."
10
+ end
11
+
12
+ def resolve_local_file(project_root, declaration)
13
+ path = File.join(project_root, declaration.path)
14
+ unless File.exist?(path)
15
+ if declaration.optional
16
+ return ResolvedLocalFile.new(
17
+ path: path,
18
+ manifest_path: declaration.path,
19
+ content: "",
20
+ source_checksum: Hashing.sha256_prefixed(""),
21
+ position: declaration.position,
22
+ optional: true
23
+ )
24
+ end
25
+ raise Error.resolution(missing_local_embed_guidance(declaration.path))
26
+ end
27
+
28
+ content = Hashing.normalize_line_endings(File.read(path))
29
+ ResolvedLocalFile.new(
30
+ path: path,
31
+ manifest_path: declaration.path,
32
+ content: content,
33
+ source_checksum: Hashing.sha256_prefixed(content),
34
+ position: declaration.position,
35
+ optional: declaration.optional
36
+ )
37
+ end
38
+ end
39
+ end
@@ -10,6 +10,11 @@ module Pray
10
10
  namespace = package_namespace(declaration.name)
11
11
  return namespace if namespace && sources.key?(namespace)
12
12
 
13
+ unless declaration.name.include?("/")
14
+ path_sources = sources.select { |_name, source| source.kind == "path" }
15
+ return path_sources.keys.first if path_sources.length == 1
16
+ end
17
+
13
18
  case sources.length
14
19
  when 0 then nil
15
20
  when 1 then sources.keys.first
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "pathname"
5
+ require_relative "archive_unpack"
6
+ require_relative "hashing"
7
+ require_relative "resource_limits"
8
+
9
+ module Pray
10
+ module ResolveTarball
11
+ module_function
12
+
13
+ def package_root(project_root, tarball, offline:)
14
+ artifact_bytes = read_tarball_bytes(project_root, tarball, offline: offline)
15
+ if artifact_bytes.bytesize > ResourceLimits::MAX_ARCHIVE_TOTAL_BYTES
16
+ raise Error.integrity(
17
+ "package archive exceeds #{ResourceLimits::MAX_ARCHIVE_TOTAL_BYTES} bytes"
18
+ )
19
+ end
20
+
21
+ cache_key = Hashing.sha256_prefixed(artifact_bytes).delete_prefix("sha256:")[0, 16]
22
+ cache_directory = File.join(project_root, ".pray", "cache", "tarball", cache_key)
23
+ if File.directory?(cache_directory)
24
+ begin
25
+ Resolve.find_prayspec_file(cache_directory)
26
+ return cache_directory
27
+ rescue Error
28
+ nil
29
+ end
30
+ end
31
+
32
+ staging_directory = "#{cache_directory}.staging"
33
+ FileUtils.rm_rf(staging_directory)
34
+ FileUtils.mkdir_p(staging_directory)
35
+ ArchiveUnpack.unpack_praypkg(artifact_bytes, staging_directory)
36
+ Resolve.find_prayspec_file(staging_directory)
37
+ FileUtils.mkdir_p(File.dirname(cache_directory))
38
+ FileUtils.rm_rf(cache_directory)
39
+ FileUtils.mv(staging_directory, cache_directory)
40
+ cache_directory
41
+ end
42
+
43
+ def read_tarball_bytes(project_root, tarball, offline:)
44
+ if tarball.start_with?("http://", "https://")
45
+ if offline
46
+ raise Error.resolution(
47
+ "tarball #{tarball} is not cached locally and offline mode is enabled"
48
+ )
49
+ end
50
+ return Registry.http_get(tarball)
51
+ end
52
+
53
+ path = local_tarball_path(project_root, tarball)
54
+ unless File.file?(path)
55
+ raise Error.resolution("tarball missing at #{path}")
56
+ end
57
+
58
+ File.binread(path)
59
+ end
60
+
61
+ def local_tarball_path(project_root, tarball)
62
+ path = tarball.delete_prefix("file://")
63
+ Pathname.new(path).absolute? ? path : File.expand_path(path, project_root)
64
+ end
65
+ end
66
+ end
data/lib/pray/serve.rb CHANGED
@@ -65,7 +65,7 @@ module Pray
65
65
  validate_body_length!(body_length)
66
66
  body = body_length.positive? ? socket.read(body_length) : ""
67
67
 
68
- response = dispatch_request(root, method, path, body)
68
+ response = dispatch_request(root, method, path, body, headers)
69
69
  socket.print(response)
70
70
  end
71
71
 
@@ -94,7 +94,7 @@ module Pray
94
94
  )
95
95
  end
96
96
 
97
- def dispatch_request(root, method, path, body = "")
97
+ def dispatch_request(root, method, path, body = "", headers = {})
98
98
  path = path.split("?", 2).first
99
99
 
100
100
  case [method, path]
@@ -125,7 +125,8 @@ module Pray
125
125
 
126
126
  content_type = content_type_for(file_path)
127
127
  file_body = File.binread(file_path)
128
- ok_response(content_type, file_body)
128
+ status, ranged_body, content_range = ServeRange.apply(200, file_body, headers["range"])
129
+ ranged_response(status, content_type, ranged_body, content_range)
129
130
  end
130
131
 
131
132
  def content_type_for(path)
@@ -137,7 +138,17 @@ module Pray
137
138
  end
138
139
 
139
140
  def ok_response(content_type, body)
140
- "HTTP/1.1 200 OK\r\nContent-Type: #{content_type}\r\nContent-Length: #{body.bytesize}\r\nConnection: close\r\n\r\n#{body}"
141
+ ranged_response(200, content_type, body, nil)
142
+ end
143
+
144
+ def ranged_response(status, content_type, body, content_range)
145
+ reason = {
146
+ 200 => "OK",
147
+ 206 => "Partial Content",
148
+ 416 => "Range Not Satisfiable"
149
+ }.fetch(status, "OK")
150
+ range_header = content_range ? "Content-Range: #{content_range}\r\n" : ""
151
+ "HTTP/1.1 #{status} #{reason}\r\nContent-Type: #{content_type}\r\n#{range_header}Content-Length: #{body.bytesize}\r\nConnection: close\r\n\r\n#{body}"
141
152
  end
142
153
 
143
154
  def html_response(body)
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pray
4
+ module ServeRange
5
+ module_function
6
+
7
+ def apply(status, body, range_header)
8
+ return [status, body, nil] if range_header.nil? || range_header.empty?
9
+ return [status, body, nil] unless status == 200 && !body.empty?
10
+
11
+ start_offset, end_offset = parse_byte_range(range_header, body.bytesize)
12
+ unless start_offset
13
+ return [416, "".b, "bytes */#{body.bytesize}"]
14
+ end
15
+
16
+ [
17
+ 206,
18
+ body.b[start_offset..end_offset],
19
+ "bytes #{start_offset}-#{end_offset}/#{body.bytesize}"
20
+ ]
21
+ end
22
+
23
+ def parse_byte_range(header, total)
24
+ range = header.delete_prefix("bytes=")
25
+ start_text, end_text = range.split("-", 2)
26
+ return unless start_text && end_text
27
+
28
+ start_offset = Integer(start_text, exception: false)
29
+ end_offset = Integer(end_text, exception: false)
30
+ return if start_offset.nil? || end_offset.nil?
31
+ return unless start_offset <= end_offset && end_offset < total
32
+
33
+ [start_offset, end_offset]
34
+ end
35
+ end
36
+ end