pray-cli 1.9.1 → 1.10.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.
@@ -24,6 +24,25 @@ module Pray
24
24
  super
25
25
  end
26
26
 
27
+ def deprecation_warnings
28
+ keywords = []
29
+ keywords << "spec.skills" unless skills.nil? || skills.empty?
30
+ keywords << "skill" if (exports || {}).values.any? { |export| export.kind == "skill" }
31
+ Manifest.new(deprecated_keywords: keywords).deprecation_warnings
32
+ end
33
+
34
+ def self.warn_resolved_deprecations(packages)
35
+ seen = {}
36
+ packages.each do |package|
37
+ package.spec.deprecation_warnings.each do |warning|
38
+ next if seen[warning]
39
+
40
+ seen[warning] = true
41
+ warn(warning)
42
+ end
43
+ end
44
+ end
45
+
27
46
  def canonicalized
28
47
  dup.tap do |copy|
29
48
  copy.files = files.sort
@@ -37,5 +37,48 @@ module Pray
37
37
 
38
38
  cleaned
39
39
  end
40
+
41
+ def validate_archive_member_path!(path)
42
+ cleaned = path.to_s.delete_prefix("./")
43
+ if cleaned.empty? || Pathname.new(cleaned).absolute? || cleaned.start_with?("/")
44
+ raise Error.integrity("package path must be relative: #{path}")
45
+ end
46
+
47
+ cleaned.split("/").each do |part|
48
+ next if part.empty? || part == "."
49
+
50
+ if part == ".." || part.include?("\0")
51
+ raise Error.integrity("package path escapes package root: #{path}")
52
+ end
53
+ end
54
+
55
+ cleaned
56
+ end
57
+
58
+ def validate_project_relative_path!(value)
59
+ path = value.to_s.strip
60
+ windows_absolute = path.match?(/\A(?:[A-Za-z]:[\\\/]|[\\\/]{2})/)
61
+ if path.empty? || Pathname.new(path).absolute? || windows_absolute
62
+ raise Error.manifest("project path must be repository-relative: #{value}")
63
+ end
64
+ parts = path.tr("\\", "/").split("/")
65
+ if parts.include?("..") || path.include?("\0")
66
+ raise Error.manifest("project path escapes repository root: #{value}")
67
+ end
68
+ if parts.all? { |part| part.empty? || part == "." }
69
+ raise Error.manifest("project path must be repository-relative: #{value}")
70
+ end
71
+
72
+ path
73
+ end
74
+
75
+ def validate_destination_path!(value)
76
+ path = value.to_s.strip
77
+ if path.start_with?("~")
78
+ raise Error.manifest("project path must be repository-relative: #{value}")
79
+ end
80
+
81
+ validate_project_relative_path!(value)
82
+ end
40
83
  end
41
84
  end
data/lib/pray/plan.rb CHANGED
@@ -15,7 +15,9 @@ module Pray
15
15
  package_lines: package_summary_lines(previous_lockfile, lockfile, project),
16
16
  lockfile: lockfile_change_status(lockfile_path, lockfile),
17
17
  targets: rendered.map { |target| target_change(project, target) },
18
- provisioned: Render.planned_provisioned_files(project).map { |file| provisioned_change(project, file) },
18
+ provisioned: Render.planned_provisioned_files(project).map do |file|
19
+ provisioned_change(project, file, previous_lockfile)
20
+ end,
19
21
  warnings: []
20
22
  )
21
23
  end
@@ -68,16 +70,8 @@ module Pray
68
70
  [target.path, change]
69
71
  end
70
72
 
71
- def provisioned_change(project, file)
72
- destination = File.join(project.project_root, file.path)
73
- change = if !File.exist?(destination)
74
- "write"
75
- elsif FileUtils.compare_file(destination, file.source)
76
- "unchanged"
77
- else
78
- "update"
79
- end
80
- [file.path, change]
73
+ def provisioned_change(project, file, previous_lockfile)
74
+ [file.path, RenderDest.destination_status(project, file, previous_lockfile).to_s]
81
75
  end
82
76
  end
83
77
  end
data/lib/pray/registry.rb CHANGED
@@ -6,6 +6,7 @@ require "uri"
6
6
  require "fileutils"
7
7
  require "pathname"
8
8
  require_relative "path_safety"
9
+ require_relative "http_body"
9
10
 
10
11
  module Pray
11
12
  RegistryPackageVersion = Struct.new(
@@ -33,12 +34,15 @@ module Pray
33
34
  )
34
35
 
35
36
  module Registry
37
+ extend RegistryIntegrity
38
+
36
39
  module_function
37
40
 
38
41
  def resolve_registry_package_root(project_root, source_url, declaration, preferred_version: nil, offline: false)
39
42
  metadata = fetch_package_metadata(source_url, declaration.name)
40
43
  registry_latest_version = registry_latest_version_label(metadata)
41
44
  selected = select_package_version(metadata, declaration.constraint, preferred_version)
45
+ require_integrity_fields!(declaration.name, selected)
42
46
  cache_directory = registry_cache_directory(
43
47
  project_root,
44
48
  source_url,
@@ -57,11 +61,10 @@ module Pray
57
61
 
58
62
  raise Error.resolution(offline_package_error(declaration.name, selected.version)) if offline
59
63
 
60
- FileUtils.rm_rf(cache_directory) if File.exist?(cache_directory)
61
- FileUtils.mkdir_p(cache_directory)
62
-
63
64
  artifact_bytes = read_artifact_bytes(source_url, selected.artifact)
64
- validate_and_unpack(cache_directory, declaration, selected, artifact_bytes, source_url: source_url)
65
+ RegistryInstall.install_artifact_to_cache(
66
+ cache_directory, declaration, selected, artifact_bytes, source_url: source_url
67
+ )
65
68
 
66
69
  RegistryPackageResolution.new(
67
70
  root: cache_directory,
@@ -83,6 +86,7 @@ module Pray
83
86
  metadata = parse_metadata(File.read(metadata_path, encoding: "UTF-8"))
84
87
  registry_latest_version = registry_latest_version_label(metadata)
85
88
  selected = select_package_version(metadata, declaration.constraint, preferred_version)
89
+ require_integrity_fields!(declaration.name, selected)
86
90
  cache_directory = registry_cache_directory(
87
91
  project_root,
88
92
  source_key,
@@ -101,11 +105,10 @@ module Pray
101
105
 
102
106
  raise Error.resolution(offline_package_error(declaration.name, selected.version)) if offline
103
107
 
104
- FileUtils.rm_rf(cache_directory) if File.exist?(cache_directory)
105
- FileUtils.mkdir_p(cache_directory)
106
-
107
108
  artifact_bytes = read_local_artifact_bytes(source_root, selected.artifact)
108
- validate_and_unpack(cache_directory, declaration, selected, artifact_bytes, source_url: source_root)
109
+ RegistryInstall.install_artifact_to_cache(
110
+ cache_directory, declaration, selected, artifact_bytes, source_url: source_root
111
+ )
109
112
 
110
113
  RegistryPackageResolution.new(
111
114
  root: cache_directory,
@@ -177,51 +180,13 @@ module Pray
177
180
  Gem::Version.new(left) <=> Gem::Version.new(right)
178
181
  end
179
182
 
180
- def validate_and_unpack(cache_directory, declaration, selected, artifact_bytes, source_url: nil)
181
- if selected.artifact_hash
182
- artifact_hash = Hashing.sha256_prefixed(artifact_bytes)
183
- if artifact_hash != selected.artifact_hash
184
- raise Error.integrity(
185
- "package artifact hash mismatch for #{declaration.name} #{selected.version}"
186
- )
187
- end
188
- end
189
-
190
- verify_registry_signature!(declaration, selected, artifact_bytes)
191
- Trust.verify_publisher_fingerprint!(source_url, selected) if source_url
192
-
193
- Archive.unpack_praypkg(artifact_bytes, cache_directory)
194
- spec_path = Resolve.find_prayspec_file(cache_directory)
195
- spec = Pray.parse_package_spec(File.read(spec_path)).canonicalized
196
- if spec.name != declaration.name
197
- raise Error.resolution(
198
- "package path #{cache_directory.inspect} declares #{spec.name.inspect}, expected #{declaration.name.inspect}"
199
- )
200
- end
201
- if spec.version != selected.version
202
- raise Error.resolution(
203
- "package #{declaration.name} version #{spec.version} does not match registry version #{selected.version}"
204
- )
205
- end
206
- if selected.tree_hash
207
- actual_tree_hash = spec.tree_hash_for_root(cache_directory)
208
- if actual_tree_hash != selected.tree_hash
209
- raise Error.integrity(
210
- "package tree hash mismatch for #{declaration.name} #{selected.version}"
211
- )
212
- end
213
- end
214
- end
215
-
216
183
  def read_local_artifact_bytes(source_root, artifact)
217
- if artifact.start_with?("http://", "https://")
218
- return http_get(artifact)
219
- end
220
184
  if artifact.start_with?("file://")
221
185
  path = PathSafety.join_under_root(source_root, artifact.delete_prefix("file://"))
222
186
  raise Error.resolution("package artifact path escapes distribution root") unless path
223
187
  return File.binread(path)
224
188
  end
189
+ reject_absolute_artifact!(artifact)
225
190
 
226
191
  path = PathSafety.join_under_root(source_root, artifact)
227
192
  raise Error.resolution("package artifact path escapes distribution root") unless path
@@ -233,17 +198,14 @@ module Pray
233
198
  def read_artifact_bytes(source_url, artifact)
234
199
  return read_local_artifact_bytes(source_url, artifact) if local_source?(source_url)
235
200
 
201
+ reject_absolute_artifact!(artifact)
236
202
  http_get(join_url(source_url, artifact))
237
203
  end
238
204
 
239
- def cache_ready?(cache_directory, selected)
240
- return false unless File.directory?(cache_directory)
205
+ def reject_absolute_artifact!(artifact)
206
+ return unless artifact.match?(%r{\A[a-z][a-z0-9+.-]*:}i)
241
207
 
242
- spec_path = Resolve.find_prayspec_file(cache_directory)
243
- spec = Pray.parse_package_spec(File.read(spec_path)).canonicalized
244
- spec.version == selected.version
245
- rescue Errno::ENOENT, Errno::ENOTDIR, SystemCallError
246
- false
208
+ raise Error.integrity("remote artifact path must be relative: #{artifact}")
247
209
  end
248
210
 
249
211
  def registry_metadata_path(source_root, package_name)
@@ -301,12 +263,7 @@ module Pray
301
263
 
302
264
  def http_get(url)
303
265
  uri = URI(url)
304
- response = http_request(uri) { |http| http.get(uri.request_uri) }
305
- unless response.is_a?(Net::HTTPSuccess)
306
- raise Error.resolution("HTTP request failed for #{url}: #{response.code}")
307
- end
308
-
309
- response.body
266
+ bounded_http_exchange(uri, Net::HTTP::Get.new(uri), "HTTP request failed for #{url}")
310
267
  end
311
268
 
312
269
  def http_put(url, content_type, body)
@@ -314,12 +271,7 @@ module Pray
314
271
  request = Net::HTTP::Put.new(uri)
315
272
  request["Content-Type"] = content_type
316
273
  request.body = body
317
- response = http_request(uri) { |http| http.request(request) }
318
- unless response.is_a?(Net::HTTPSuccess)
319
- raise Error.resolution("HTTP upload failed for #{url}: #{response.code}")
320
- end
321
-
322
- response.body
274
+ bounded_http_exchange(uri, request, "HTTP upload failed for #{url}")
323
275
  end
324
276
 
325
277
  def http_post(url, content_type, body)
@@ -327,12 +279,23 @@ module Pray
327
279
  request = Net::HTTP::Post.new(uri)
328
280
  request["Content-Type"] = content_type
329
281
  request.body = body
330
- response = http_request(uri) { |http| http.request(request) }
331
- unless response.is_a?(Net::HTTPSuccess)
332
- raise Error.resolution("HTTP request failed for #{url}: #{response.code}")
282
+ bounded_http_exchange(uri, request, "HTTP request failed for #{url}")
283
+ end
284
+
285
+ def bounded_http_exchange(uri, request, failure_message)
286
+ body = nil
287
+ code = nil
288
+ success = false
289
+ http_request(uri) do |http|
290
+ http.request(request) do |response|
291
+ success = response.is_a?(Net::HTTPSuccess)
292
+ code = response.code
293
+ body = HttpBody.read_response!(response)
294
+ end
333
295
  end
296
+ raise Error.resolution("#{failure_message}: #{code}") unless success
334
297
 
335
- response.body
298
+ body
336
299
  end
337
300
 
338
301
  def http_request(uri)
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Pray
6
+ module RegistryInstall
7
+ module_function
8
+
9
+ def install_artifact_to_cache(cache_directory, declaration, selected, artifact_bytes, source_url: nil)
10
+ staging_directory = "#{cache_directory}.staging"
11
+ FileUtils.rm_rf(staging_directory)
12
+ FileUtils.mkdir_p(staging_directory)
13
+ unpacked_directory = File.join(staging_directory, "unpacked")
14
+ FileUtils.mkdir_p(unpacked_directory)
15
+
16
+ begin
17
+ Registry.validate_and_unpack(
18
+ unpacked_directory, declaration, selected, artifact_bytes, source_url: source_url
19
+ )
20
+ FileUtils.rm_rf(cache_directory) if File.exist?(cache_directory)
21
+ FileUtils.mv(unpacked_directory, cache_directory)
22
+ rescue
23
+ FileUtils.rm_rf(staging_directory)
24
+ raise
25
+ else
26
+ FileUtils.rm_rf(staging_directory)
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pray
4
+ module RegistryIntegrity
5
+ def validate_and_unpack(cache_directory, declaration, selected, artifact_bytes, source_url: nil)
6
+ require_integrity_fields!(declaration.name, selected)
7
+ artifact_hash = Hashing.sha256_prefixed(artifact_bytes)
8
+ if artifact_hash != selected.artifact_hash
9
+ raise Error.integrity(
10
+ "package artifact hash mismatch for #{declaration.name} #{selected.version}"
11
+ )
12
+ end
13
+
14
+ verify_registry_signature!(declaration, selected, artifact_bytes)
15
+ Trust.verify_publisher_fingerprint!(source_url, selected) if source_url
16
+ Archive.unpack_praypkg(artifact_bytes, cache_directory)
17
+ spec_path = Resolve.find_prayspec_file(cache_directory)
18
+ spec = Pray.parse_package_spec(File.read(spec_path)).canonicalized
19
+ validate_package_identity!(cache_directory, declaration, selected, spec)
20
+ actual_tree_hash = spec.tree_hash_for_root(cache_directory)
21
+ if actual_tree_hash != selected.tree_hash
22
+ raise Error.integrity(
23
+ "package tree hash mismatch for #{declaration.name} #{selected.version}"
24
+ )
25
+ end
26
+ end
27
+
28
+ def require_integrity_fields!(package_name, selected)
29
+ if selected.artifact_hash.to_s.empty?
30
+ raise Error.integrity("package #{package_name} #{selected.version} is missing artifact_hash")
31
+ end
32
+ if selected.tree_hash.to_s.empty?
33
+ raise Error.integrity("package #{package_name} #{selected.version} is missing tree_hash")
34
+ end
35
+ end
36
+
37
+ def cache_ready?(cache_directory, selected)
38
+ return false unless File.directory?(cache_directory)
39
+
40
+ spec_path = Resolve.find_prayspec_file(cache_directory)
41
+ spec = Pray.parse_package_spec(File.read(spec_path)).canonicalized
42
+ spec.version == selected.version && !selected.tree_hash.to_s.empty? &&
43
+ spec.tree_hash_for_root(cache_directory) == selected.tree_hash
44
+ rescue Error, SystemCallError
45
+ false
46
+ end
47
+
48
+ private
49
+
50
+ def validate_package_identity!(cache_directory, declaration, selected, spec)
51
+ if spec.name != declaration.name
52
+ raise Error.resolution(
53
+ "package path #{cache_directory.inspect} declares #{spec.name.inspect}, expected #{declaration.name.inspect}"
54
+ )
55
+ end
56
+ return if spec.version == selected.version
57
+
58
+ raise Error.resolution(
59
+ "package #{declaration.name} version #{spec.version} does not match registry version #{selected.version}"
60
+ )
61
+ end
62
+ end
63
+ end
data/lib/pray/render.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "fileutils"
4
4
  require "pathname"
5
+ require_relative "render_content"
5
6
 
6
7
  module Pray
7
8
  RenderedTarget = Struct.new(:path, :content, :managed_spans) do
@@ -18,37 +19,34 @@ module Pray
18
19
  project.manifest.targets.each do |target|
19
20
  output = target.outputs.first
20
21
  next unless output
21
-
22
22
  rendered << render_target(project, target, output)
23
23
  end
24
24
  rendered
25
25
  end
26
26
 
27
- def write_rendered_targets(project, rendered)
27
+ def write_rendered_targets(project, rendered, previous_lockfile = nil)
28
28
  rendered.each do |target|
29
+ PathSafety.validate_destination_path!(target.path)
30
+ RenderDest.ensure_safe_destination_ancestors!(project.project_root, target.path, target.path)
29
31
  path = File.join(project.project_root, target.path)
30
32
  FileUtils.mkdir_p(File.dirname(path))
31
- File.write(path, target.content)
33
+ RenderDest.ensure_safe_destination_ancestors!(project.project_root, target.path, target.path)
34
+ RenderDest.write_rendered_content(path, target.path, target.content)
32
35
  end
33
- materialize_provisioned_exports(project)
36
+ materialize_provisioned_exports(project, previous_lockfile)
34
37
  end
35
38
 
36
- def materialize_provisioned_exports(project)
37
- symbols = project.manifest.symbols || {}
38
- planned_provisioned_files(project).each do |file|
39
- destination = File.join(project.project_root, file.path)
40
- FileUtils.mkdir_p(File.dirname(destination))
41
- write_provisioned_file(file.source, destination, symbols)
42
- end
39
+ def materialize_provisioned_exports(project, previous_lockfile = nil)
40
+ RenderDest.materialize(project, previous_lockfile)
43
41
  end
44
42
 
45
- def write_provisioned_file(source, destination, symbols)
43
+ def expected_provisioned_bytes(source, symbols)
46
44
  bytes = File.binread(source)
47
- text = bytes.force_encoding(Encoding::UTF_8)
45
+ text = bytes.dup.force_encoding(Encoding::UTF_8)
48
46
  if text.valid_encoding?
49
- File.write(destination, Substitute.substitute_pray_symbols(text, symbols))
47
+ Substitute.substitute_pray_symbols(text, symbols)
50
48
  else
51
- File.binwrite(destination, bytes)
49
+ bytes
52
50
  end
53
51
  end
54
52
 
@@ -70,19 +68,19 @@ module Pray
70
68
  planned.sort_by(&:path).uniq(&:path)
71
69
  end
72
70
 
73
- PlannedProvisionedFile = Struct.new(:path, :source)
71
+ PlannedProvisionedFile = Struct.new(:path, :source, :package, :export, keyword_init: true)
74
72
 
75
73
  def render_target(project, target, output)
74
+ ComposeDest.ensure_html_comment_dest!(output)
76
75
  if target.scoped && target.mode == "compose"
77
76
  return render_scoped_compose(project, target, output)
78
77
  end
79
-
80
78
  render_legacy_compose(project, target, output)
81
79
  end
82
80
 
83
81
  def render_scoped_compose(project, target, output)
84
82
  builder = ContentBuilder.new
85
- append_header(builder, project, output)
83
+ append_header(builder, project, target, output)
86
84
  symbols = project.manifest.symbols || {}
87
85
  managed_spans = []
88
86
 
@@ -99,7 +97,7 @@ module Pray
99
97
 
100
98
  def render_legacy_compose(project, target, output)
101
99
  builder = ContentBuilder.new
102
- append_header(builder, project, output)
100
+ append_header(builder, project, target, output)
103
101
 
104
102
  unbound_locals = project.local_files.select do |local|
105
103
  declaration = project.manifest.local.find { |entry| entry.path == local.manifest_path }
@@ -137,16 +135,11 @@ module Pray
137
135
  RenderedTarget.new(path: output, content: builder.finish, managed_spans: managed_spans)
138
136
  end
139
137
 
140
- def append_header(builder, project, output)
141
- return unless project.manifest.render.header
138
+ def append_header(builder, project, target, output)
139
+ text = ComposeDest.header_text(target, output, project.manifest.render.header)
140
+ return unless text
142
141
 
143
- output_name = File.basename(output)
144
- builder.append_line("<!-- pray:0 ignore-comments -->")
145
- builder.append_empty_line
146
- builder.append_line("# Agent context")
147
- builder.append_empty_line
148
- builder.append_line("Do not edit managed blocks in `#{output_name}` or provisioned files under `.agents/`.")
149
- builder.append_line("To change shared guidance, update `Prayfile` and run `pray install`.")
142
+ builder.append_body(text)
150
143
  builder.append_empty_line
151
144
  end
152
145
 
@@ -173,7 +166,10 @@ module Pray
173
166
 
174
167
  def append_managed_export(builder, managed_spans, package, export, target, output, symbols)
175
168
  body = package.export_bodies[export]
176
- raise Error.render("package #{package.declaration.name} is missing cached export #{export}") unless body
169
+ unless body
170
+ raise Error.integrity("compose cannot write binary export #{export}; use file: for unmarked bytes") if package.spec.exports[export]&.kind == "file"
171
+ raise Error.render("package #{package.declaration.name} is missing cached export #{export}")
172
+ end
177
173
 
178
174
  body = Substitute.substitute_pray_symbols(body, symbols)
179
175
  identifier = Hashing.marker_id("#{package.declaration.name}:#{export}:#{target.name}")
@@ -198,7 +194,7 @@ module Pray
198
194
 
199
195
  def should_inline_export?(package, export_name)
200
196
  export = package.spec.exports[export_name]
201
- export.nil? || export.kind == "fragment"
197
+ export.nil? || %w[fragment file].include?(export.kind)
202
198
  end
203
199
 
204
200
  def collect_exact_file_bindings(project, planned)
@@ -215,7 +211,12 @@ module Pray
215
211
  source = File.join(package.root, export.path)
216
212
  raise Error.render("file export source missing: #{source}") unless File.file?(source)
217
213
 
218
- planned << PlannedProvisionedFile.new(path: destination, source: source)
214
+ planned << PlannedProvisionedFile.new(
215
+ path: destination,
216
+ source: source,
217
+ package: package.declaration.name,
218
+ export: export_name
219
+ )
219
220
  matched = true
220
221
  break
221
222
  end
@@ -241,6 +242,8 @@ module Pray
241
242
  skill_files,
242
243
  [],
243
244
  [],
245
+ package.declaration.name,
246
+ skill_name,
244
247
  planned
245
248
  )
246
249
  end
@@ -274,6 +277,8 @@ module Pray
274
277
  indexed_files,
275
278
  export.only || [],
276
279
  export.except || [],
280
+ package.declaration.name,
281
+ export_name,
277
282
  planned
278
283
  )
279
284
  when "file"
@@ -285,7 +290,9 @@ module Pray
285
290
  destination = File.join(destination_root, export_name, File.basename(source))
286
291
  planned << PlannedProvisionedFile.new(
287
292
  path: relative_project_path(project, destination),
288
- source: source
293
+ source: source,
294
+ package: package.declaration.name,
295
+ export: export_name
289
296
  )
290
297
  end
291
298
  end
@@ -295,7 +302,7 @@ module Pray
295
302
  File.basename(export_path.delete_suffix("/")).empty? ? export_name : File.basename(export_path.delete_suffix("/"))
296
303
  end
297
304
 
298
- def collect_tree_files(project, source_root, destination_root, relative_files, only, except, planned)
305
+ def collect_tree_files(project, source_root, destination_root, relative_files, only, except, package_name, export_name, planned)
299
306
  raise Error.render("folder source directory missing: #{source_root}") unless File.directory?(source_root)
300
307
  raise Error.render("no files listed in package manifest for #{source_root}") if relative_files.empty?
301
308
 
@@ -310,7 +317,9 @@ module Pray
310
317
  destination = File.join(destination_root, relative)
311
318
  planned << PlannedProvisionedFile.new(
312
319
  path: relative_project_path(project, destination),
313
- source: source
320
+ source: source,
321
+ package: package_name,
322
+ export: export_name
314
323
  )
315
324
  matched = true
316
325
  end
@@ -325,36 +334,5 @@ module Pray
325
334
  rescue ArgumentError
326
335
  absolute
327
336
  end
328
-
329
- class ContentBuilder
330
- def initialize
331
- @content = +""
332
- end
333
-
334
- def next_line_number
335
- @content.count("\n") + 1
336
- end
337
-
338
- def append_line(line)
339
- @content << line << "\n"
340
- end
341
-
342
- def append_empty_line
343
- @content << "\n"
344
- end
345
-
346
- def append_body(body)
347
- trimmed = body.sub(/\n+\z/, "")
348
- return if trimmed.empty?
349
-
350
- trimmed.each_line(chomp: true) { |line| append_line(line) }
351
- end
352
-
353
- def finish
354
- @content.sub(/\n\n+\z/, "\n")
355
- @content << "\n" unless @content.end_with?("\n")
356
- @content
357
- end
358
- end
359
337
  end
360
338
  end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pray
4
+ module Render
5
+ class ContentBuilder
6
+ def initialize
7
+ @content = +""
8
+ end
9
+
10
+ def next_line_number
11
+ @content.count("\n") + 1
12
+ end
13
+
14
+ def append_line(line)
15
+ @content << line << "\n"
16
+ end
17
+
18
+ def append_empty_line
19
+ @content << "\n"
20
+ end
21
+
22
+ def append_body(body)
23
+ trimmed = body.sub(/\n+\z/, "")
24
+ return if trimmed.empty?
25
+
26
+ trimmed.each_line(chomp: true) { |line| append_line(line) }
27
+ end
28
+
29
+ def finish
30
+ @content.sub(/\n\n+\z/, "\n")
31
+ @content << "\n" unless @content.end_with?("\n")
32
+ @content
33
+ end
34
+ end
35
+ end
36
+ end