pray-cli 1.9.2 → 1.11.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.
@@ -71,5 +71,34 @@ module Pray
71
71
 
72
72
  path
73
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
83
+
84
+ def validate_registry_cache_identity!(package_name, version)
85
+ namespace, name, extra_segment = package_name.to_s.split("/", -1)
86
+ unless extra_segment.nil? && registry_cache_segment_safe?(namespace) && registry_cache_segment_safe?(name)
87
+ raise Error.integrity("invalid registry package name: #{package_name}")
88
+ end
89
+ unless registry_cache_segment_safe?(version)
90
+ raise Error.integrity("invalid registry package version: #{version}")
91
+ end
92
+
93
+ [namespace, name]
94
+ end
95
+
96
+ def registry_cache_segment_safe?(value)
97
+ value.is_a?(String) &&
98
+ !value.empty? &&
99
+ ![".", ".."].include?(value) &&
100
+ !value.match?(%r{[/\\\0]})
101
+ end
102
+ private_class_method :registry_cache_segment_safe?
74
103
  end
75
104
  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
@@ -47,8 +47,7 @@ module Pray
47
47
  project_root,
48
48
  source_url,
49
49
  declaration.name,
50
- selected.version,
51
- selected.artifact_hash
50
+ selected.version
52
51
  )
53
52
 
54
53
  if cache_ready?(cache_directory, selected)
@@ -91,8 +90,7 @@ module Pray
91
90
  project_root,
92
91
  source_key,
93
92
  declaration.name,
94
- selected.version,
95
- selected.artifact_hash
93
+ selected.version
96
94
  )
97
95
 
98
96
  if cache_ready?(cache_directory, selected)
@@ -238,15 +236,10 @@ module Pray
238
236
  )
239
237
  end
240
238
 
241
- def registry_cache_directory(project_root, source_key, package_name, version, artifact_hash)
242
- identifier = [
243
- source_key,
244
- package_name,
245
- version,
246
- artifact_hash || "no-artifact-hash"
247
- ].join(":")
248
- digest = Hashing.sha256_hex(identifier)[0, 16]
249
- File.join(project_root, ".pray", "cache", "registry", package_name.tr("/", "-"), version, digest)
239
+ def registry_cache_directory(project_root, source_key, package_name, version)
240
+ namespace, name = PathSafety.validate_registry_cache_identity!(package_name, version)
241
+ source_hash = Hashing.sha256_hex(source_key)[0, 16]
242
+ File.join(project_root, ".pray", "cache", "registry", namespace, name, version, source_hash)
250
243
  end
251
244
 
252
245
  def offline_package_error(package_name, version)
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
@@ -23,33 +24,29 @@ module Pray
23
24
  rendered
24
25
  end
25
26
 
26
- def write_rendered_targets(project, rendered)
27
+ def write_rendered_targets(project, rendered, previous_lockfile = nil)
27
28
  rendered.each do |target|
28
- PathSafety.validate_project_relative_path!(target.path)
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
- PathSafety.validate_project_relative_path!(file.path)
40
- destination = File.join(project.project_root, file.path)
41
- FileUtils.mkdir_p(File.dirname(destination))
42
- write_provisioned_file(file.source, destination, symbols)
43
- end
39
+ def materialize_provisioned_exports(project, previous_lockfile = nil)
40
+ RenderDest.materialize(project, previous_lockfile)
44
41
  end
45
42
 
46
- def write_provisioned_file(source, destination, symbols)
43
+ def expected_provisioned_bytes(source, symbols)
47
44
  bytes = File.binread(source)
48
- text = bytes.force_encoding(Encoding::UTF_8)
45
+ text = bytes.dup.force_encoding(Encoding::UTF_8)
49
46
  if text.valid_encoding?
50
- File.write(destination, Substitute.substitute_pray_symbols(text, symbols))
47
+ Substitute.substitute_pray_symbols(text, symbols)
51
48
  else
52
- File.binwrite(destination, bytes)
49
+ bytes
53
50
  end
54
51
  end
55
52
 
@@ -71,9 +68,10 @@ module Pray
71
68
  planned.sort_by(&:path).uniq(&:path)
72
69
  end
73
70
 
74
- PlannedProvisionedFile = Struct.new(:path, :source)
71
+ PlannedProvisionedFile = Struct.new(:path, :source, :package, :export, keyword_init: true)
75
72
 
76
73
  def render_target(project, target, output)
74
+ ComposeDest.ensure_html_comment_dest!(output)
77
75
  if target.scoped && target.mode == "compose"
78
76
  return render_scoped_compose(project, target, output)
79
77
  end
@@ -82,7 +80,7 @@ module Pray
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
@@ -0,0 +1,215 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "pathname"
5
+
6
+ module Pray
7
+ module RenderDest
8
+ module_function
9
+
10
+ def materialize(project, previous_lockfile = nil)
11
+ planned = Render.planned_provisioned_files(project)
12
+ previous = previous_map(previous_lockfile)
13
+ planned_paths = planned.map(&:path).to_h { |path| [path, true] }
14
+ planned.each { |file| write_leaf(project, file, previous) }
15
+ prune_dropped(project, previous_lockfile, planned_paths) if previous_lockfile
16
+ end
17
+
18
+ def provisioned_records(project)
19
+ symbols = project.manifest.symbols || {}
20
+ Render.planned_provisioned_files(project).map do |file|
21
+ expected = Render.expected_provisioned_bytes(file.source, symbols)
22
+ ProvisionedFileRecord.new(
23
+ path: file.path.to_s.tr("\\", "/"),
24
+ content_hash: Hashing.sha256_prefixed(expected.b),
25
+ package: file.package,
26
+ export: file.export
27
+ )
28
+ end
29
+ end
30
+
31
+ def fail_if_symlink!(path, display)
32
+ return unless File.symlink?(path)
33
+
34
+ raise Error.render("refusing to write `#{display}` because it is a symbolic link")
35
+ end
36
+
37
+ def previous_map(lockfile)
38
+ return {} unless lockfile
39
+
40
+ Array(lockfile.provisioned).to_h { |record| [record.path, record] }
41
+ end
42
+
43
+ def destination_status(project, file, previous_lockfile = nil)
44
+ PathSafety.validate_destination_path!(file.path)
45
+ ensure_safe_destination_ancestors!(project.project_root, file.path, file.path)
46
+ destination = File.join(project.project_root, file.path)
47
+ expected = Render.expected_provisioned_bytes(file.source, project.manifest.symbols || {})
48
+ record = previous_map(previous_lockfile)[file.path.to_s.tr("\\", "/")]
49
+ classify_destination(destination, file.path, expected, record)
50
+ end
51
+
52
+ def write_leaf(project, file, previous)
53
+ PathSafety.validate_destination_path!(file.path)
54
+ ensure_safe_destination_ancestors!(project.project_root, file.path, file.path)
55
+ destination = File.join(project.project_root, file.path)
56
+ expected = Render.expected_provisioned_bytes(file.source, project.manifest.symbols || {})
57
+ record = previous[file.path.to_s.tr("\\", "/")]
58
+ status = classify_destination(destination, file.path, expected, record)
59
+ if status == :write
60
+ FileUtils.mkdir_p(File.dirname(destination))
61
+ ensure_safe_destination_ancestors!(project.project_root, file.path, file.path)
62
+ return create_bytes(destination, file.path, expected)
63
+ end
64
+ return if status == :unchanged
65
+
66
+ unless record
67
+ raise Error.render("missing lock ownership for `#{file.path}`")
68
+ end
69
+
70
+ ensure_safe_destination_ancestors!(project.project_root, file.path, file.path)
71
+ update_bytes(destination, file.path, expected, record.content_hash)
72
+ end
73
+
74
+ def classify_destination(destination, display, expected, record)
75
+ kind = destination_kind(destination)
76
+ return :write if kind == :missing
77
+
78
+ fail_if_symlink!(destination, display)
79
+ unless kind == :regular
80
+ raise Error.render("refusing to write `#{display}`; destination is not a regular file")
81
+ end
82
+ on_disk = read_regular_bytes(destination, display)
83
+ return :unchanged if on_disk.b == expected.b
84
+ return :update if record && Hashing.sha256_prefixed(on_disk) == record.content_hash
85
+
86
+ if record
87
+ raise Error.render("refusing to overwrite `#{display}`; it was provisioned and then edited")
88
+ end
89
+
90
+ raise Error.render(
91
+ "refusing to overwrite `#{display}`; it already exists and is not the expected provisioned file"
92
+ )
93
+ end
94
+
95
+ def prune_dropped(project, previous, planned_paths)
96
+ Array(previous.provisioned).each do |record|
97
+ PathSafety.validate_destination_path!(record.path)
98
+ next if planned_paths[record.path]
99
+
100
+ destination = File.join(project.project_root, record.path)
101
+ ensure_safe_destination_ancestors!(project.project_root, record.path, record.path)
102
+ next unless destination_kind(destination) == :regular
103
+
104
+ on_disk = read_regular_bytes(destination, record.path)
105
+ if Hashing.sha256_prefixed(on_disk) == record.content_hash
106
+ ensure_safe_destination_ancestors!(project.project_root, record.path, record.path)
107
+ File.delete(destination)
108
+ end
109
+ end
110
+ end
111
+
112
+ def layout_rendered_content(path, display, fresh)
113
+ kind = destination_kind(path)
114
+ return fresh if kind == :missing
115
+
116
+ fail_if_symlink!(path, display)
117
+ unless kind == :regular
118
+ raise Error.render("refusing to write `#{display}`; destination is not a regular file")
119
+ end
120
+ RenderPatch.patch_rendered_content(decode_utf8(read_regular_bytes(path, display), display), fresh)
121
+ end
122
+
123
+ def write_rendered_content(path, display, fresh)
124
+ return create_bytes(path, display, fresh) if destination_kind(path) == :missing
125
+
126
+ open_regular(path, display, File::RDWR) do |file|
127
+ existing = decode_utf8(file.read, display)
128
+ content = RenderPatch.patch_rendered_content(existing, fresh)
129
+ file.rewind
130
+ file.truncate(0)
131
+ file.write(content)
132
+ end
133
+ end
134
+
135
+ def ensure_safe_destination_ancestors!(project_root, relative_path, display)
136
+ parent = File.dirname(relative_path.to_s.tr("\\", "/"))
137
+ return if parent == "."
138
+
139
+ current = project_root
140
+ parent.split("/").each do |component|
141
+ next if component.empty? || component == "."
142
+
143
+ current = File.join(current, component)
144
+ metadata = File.lstat(current)
145
+ if metadata.symlink?
146
+ raise Error.render(
147
+ "refusing to write `#{display}` because a destination parent is a symbolic link"
148
+ )
149
+ end
150
+ unless metadata.directory?
151
+ raise Error.render(
152
+ "refusing to write `#{display}`; a destination parent is not a directory"
153
+ )
154
+ end
155
+ rescue Errno::ENOENT
156
+ next
157
+ end
158
+ end
159
+
160
+ def create_bytes(path, display, bytes)
161
+ open_path(path, display, File::WRONLY | File::CREAT | File::EXCL) do |file|
162
+ file.write(bytes)
163
+ end
164
+ end
165
+
166
+ def update_bytes(path, display, bytes, authorized_hash)
167
+ open_regular(path, display, File::RDWR) do |file|
168
+ on_disk = file.read
169
+ return if on_disk.b == bytes.b
170
+
171
+ unless Hashing.sha256_prefixed(on_disk) == authorized_hash
172
+ raise Error.render("refusing to overwrite `#{display}`; it was provisioned and then edited")
173
+ end
174
+ file.rewind
175
+ file.truncate(0)
176
+ file.write(bytes)
177
+ end
178
+ end
179
+
180
+ def read_regular_bytes(path, display)
181
+ open_regular(path, display, File::RDONLY) { |file| file.read }
182
+ end
183
+
184
+ def decode_utf8(bytes, display)
185
+ text = bytes.dup.force_encoding(Encoding::UTF_8)
186
+ return text if text.valid_encoding?
187
+
188
+ raise Error.render("rendered destination `#{display}` is not valid UTF-8")
189
+ end
190
+
191
+ def open_regular(path, display, flags)
192
+ open_path(path, display, flags) do |file|
193
+ unless file.stat.file?
194
+ raise Error.render("refusing to write `#{display}`; destination is not a regular file")
195
+ end
196
+ yield file
197
+ end
198
+ end
199
+
200
+ def open_path(path, display, flags)
201
+ no_follow = File.const_defined?(:NOFOLLOW) ? File::NOFOLLOW : 0
202
+ File.open(path, flags | no_follow) { |file| yield file }
203
+ rescue Errno::ELOOP
204
+ raise Error.render("refusing to write `#{display}` because it is a symbolic link")
205
+ end
206
+
207
+ def destination_kind(path)
208
+ return :symlink if File.symlink?(path)
209
+ return :missing unless File.exist?(path)
210
+ return :regular if File.file?(path)
211
+
212
+ :other
213
+ end
214
+ end
215
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pray
4
+ module Render
5
+ module_function
6
+
7
+ def layout_rendered_targets(project, rendered)
8
+ rendered.map do |target|
9
+ PathSafety.validate_destination_path!(target.path)
10
+ RenderDest.ensure_safe_destination_ancestors!(project.project_root, target.path, target.path)
11
+ path = File.join(project.project_root, target.path)
12
+ content = RenderDest.layout_rendered_content(path, target.path, target.content)
13
+ RenderedTarget.new(
14
+ path: target.path,
15
+ content: content,
16
+ managed_spans: RenderPatch.relocate_managed_spans(content, target.managed_spans)
17
+ )
18
+ end
19
+ end
20
+ end
21
+ end