pray-cli 1.9.2 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 85c1a7462b69a935d6e9a83d1489c43a445e63190dcebb5481744de051bf562a
4
- data.tar.gz: 778142b6ea1ac16ae0762dc9015a30f20ca06cfb3abcb715fa37eea2620a05ff
3
+ metadata.gz: 1d3cea2d193f4cf496417c5098ad4ff57b0a4b55bc7ee26664f72293c2fcbd06
4
+ data.tar.gz: 89d8dc8a3c5c65062531e175587d3f86f27ca9630679dac925593be231062e8c
5
5
  SHA512:
6
- metadata.gz: 7199ad20e75fd83ad98c97d0bbf9f97ae4c6d91bc2e2a8dca0cf4b14339da30a7396d8b076e0f789d8f17f0334b2b10853c8298e429705ef99ccaccc02b88f09
7
- data.tar.gz: 19ea0fb475fdfeabc7ab7729e40d2228eba70be9f52becd2923d9b2c770a31fc146215aa2fa65b04c2e14733114c672f67a906e364841df027fedbcd9ca6f61f
6
+ metadata.gz: aa2d115c1300f177cd4c9dc8c8e4ab2802baf700be3b7124a644f6889ba4138cafe3a6be16d37005f5c2aba95b803740084139171466ebf7ba126e22f82e7d9c
7
+ data.tar.gz: 1d0c8da737a56bcb21ad6798f6cf59c3fd6ad3384e2d423bb492b37ddf42ccb2c6231b6afebf2b2fc9dc8b7a44e748dd505910a0ceb6835b87006870ae3e68d9
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## 1.10.0 (2026-09-02)
4
+
5
+ - Refuse to overwrite exclusive `file:` and `tree:` destinations that already exist with other bytes.
6
+ - Fail when a provisioned or compose destination is a symbolic link.
7
+ - Record provisioned leaves in `Prayfile.lock` and delete a dropped leaf only when on-disk bytes still match the locked hash.
8
+ - Keep the previous lock when a provisioned destination write fails, so retry still has its ownership record.
9
+ - Reject destination paths that start with `~`.
10
+ - Print every provisioned path in `pray plan`.
11
+ - BREAKING: Drop unused `render` fields `section_markers` and `line_endings`; reject them at parse.
12
+ - Inline a UTF-8 `file` export into `compose` as a marked span. Exclusive `file:` stays unmarked.
13
+ - Write the Agent context banner on `AGENTS.md` by default. Other compose destinations opt in with `header: true`.
14
+ - Fail compose of JSON, binary, or an unknown file type and name `file:` as the unmarked path.
15
+ - Warn when Prayfile uses deprecated `skills`, export type `skill`, or `spec.skills`; prefer `tree` / `folder`. These forms will be removed in version 2.
16
+
3
17
  ## 1.9.2 (2026-09-01)
4
18
 
5
19
  - Fix `.praypkg` unpack for git and registry installs when the Ruby process forces UTF-8 internal encoding.
@@ -107,8 +107,8 @@ module Pray
107
107
  rendered = Render.render_project(project)
108
108
  updated_lockfile = build_lockfile(project, rendered)
109
109
  merged_lockfile = merge_selected_package_update(previous_lockfile, updated_lockfile, name)
110
+ Render.write_rendered_targets(project, rendered, previous_lockfile)
110
111
  Pray.write_lockfile(lockfile_path, merged_lockfile)
111
- Render.write_rendered_targets(project, rendered)
112
112
  puts "Unlocked #{name}"
113
113
  end
114
114
 
@@ -38,7 +38,8 @@ module Pray
38
38
  rendered,
39
39
  project.packages,
40
40
  project.source_revisions,
41
- project.source_host_keys
41
+ project.source_host_keys,
42
+ project
42
43
  )
43
44
  end
44
45
 
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pray
4
+ module ComposeDest
5
+ module_function
6
+
7
+ HTML_COMMENT_EXTENSIONS = %w[.md .markdown .html .htm].freeze
8
+ BINARY_EXTENSIONS = %w[
9
+ .png .jpg .jpeg .gif .webp .ico .pdf .zip .gz .tgz .tar .wasm .bin
10
+ .woff .woff2 .exe .dylib .so
11
+ ].freeze
12
+
13
+ def compose_writes_header?(target, output, project_header)
14
+ return target.header unless target.header.nil?
15
+
16
+ project_header && agents_markdown?(output)
17
+ end
18
+
19
+ def header_text(target, output, project_header)
20
+ return unless compose_writes_header?(target, output, project_header)
21
+
22
+ name = File.basename(output)
23
+ guidance = if agents_markdown?(output)
24
+ "Do not edit managed blocks in `#{name}` or provisioned files under `.agents/`."
25
+ else
26
+ "Do not edit managed blocks in `#{name}`."
27
+ end
28
+ <<~HEADER.rstrip
29
+ <!-- pray:0 ignore-comments -->
30
+
31
+ # Agent context
32
+
33
+ #{guidance}
34
+ To change shared guidance, update `Prayfile` and run `pray install`.
35
+ HEADER
36
+ end
37
+
38
+ def ensure_html_comment_dest!(output)
39
+ dest = output.tr("\\", "/")
40
+ extension = File.extname(output).downcase
41
+ return if HTML_COMMENT_EXTENSIONS.include?(extension)
42
+ if extension == ".json"
43
+ raise Error.render("compose cannot write JSON; use file: \"#{dest}\" for unmarked bytes")
44
+ end
45
+ if BINARY_EXTENSIONS.include?(extension)
46
+ raise Error.render(
47
+ "compose cannot write a binary file; use file: \"#{dest}\" for unmarked bytes"
48
+ )
49
+ end
50
+ raise Error.render(
51
+ "compose cannot write this file type; use file: \"#{dest}\" for unmarked bytes"
52
+ )
53
+ end
54
+
55
+ def agents_markdown?(output)
56
+ File.basename(output) == "AGENTS.md"
57
+ end
58
+ end
59
+ end
@@ -28,7 +28,12 @@ module Pray
28
28
  case target.mode
29
29
  when "compose"
30
30
  path = target.outputs.first || ""
31
- lines << %(compose "#{path}" do)
31
+ header = case target.header
32
+ when true then ", header: true"
33
+ when false then ", header: false"
34
+ else ""
35
+ end
36
+ lines << %(compose "#{path}"#{header} do)
32
37
  target.entries.each do |entry|
33
38
  lines << " #{format_destination_entry(entry, manifest)}"
34
39
  end
data/lib/pray/lockfile.rb CHANGED
@@ -20,14 +20,16 @@ module Pray
20
20
  :id, :target, :open_line, :close_line, :ideal_checksum, :package, :export,
21
21
  :source_checksum, :silenced
22
22
  )
23
+ ProvisionedFileRecord = Struct.new(:path, :content_hash, :package, :export)
23
24
 
24
25
  Lockfile = Struct.new(
25
26
  :prayfile_lock, :spec, :generated_by, :manifest_hash, :environment, :source, :package,
26
- :target, :managed_span
27
+ :target, :managed_span, :provisioned
27
28
  ) do
28
29
  def initialize(
29
30
  prayfile_lock: "1", spec: "0.1", generated_by: Pray::GENERATED_BY,
30
- manifest_hash: "", environment: nil, source: [], package: [], target: [], managed_span: []
31
+ manifest_hash: "", environment: nil, source: [], package: [], target: [], managed_span: [],
32
+ provisioned: []
31
33
  )
32
34
  super
33
35
  end
@@ -38,6 +40,7 @@ module Pray
38
40
  copy.package = package.sort_by { |entry| [entry.name, entry.source.to_s, entry.version] }
39
41
  copy.target = target.sort_by(&:name)
40
42
  copy.managed_span = managed_span.sort_by { |span| [span.target, span.open_line, span.id] }
43
+ copy.provisioned = provisioned.sort_by { |entry| [entry.path, entry.package] }
41
44
  end
42
45
  end
43
46
 
@@ -70,7 +73,8 @@ module Pray
70
73
  "source" => lockfile.source.map { |entry| source_to_hash(entry) },
71
74
  "package" => lockfile.package.map { |entry| package_to_hash(entry) },
72
75
  "target" => lockfile.target.map { |entry| {"name" => entry.name, "outputs" => entry.outputs} },
73
- "managed_span" => lockfile.managed_span.map { |entry| managed_span_to_hash(entry) }
76
+ "managed_span" => lockfile.managed_span.map { |entry| managed_span_to_hash(entry) },
77
+ "provisioned" => lockfile.provisioned.map { |entry| provisioned_to_hash(entry) }
74
78
  }
75
79
  end
76
80
 
@@ -111,6 +115,15 @@ module Pray
111
115
  }
112
116
  end
113
117
 
118
+ def provisioned_to_hash(entry)
119
+ {
120
+ "path" => entry.path,
121
+ "content_hash" => entry.content_hash,
122
+ "package" => entry.package,
123
+ "export" => entry.export
124
+ }
125
+ end
126
+
114
127
  def parse_lockfile(text)
115
128
  data = TomlRB.parse(text)
116
129
  from_hash(data)
@@ -147,8 +160,9 @@ module Pray
147
160
  left.equivalent_to?(right)
148
161
  end
149
162
 
150
- def build_lockfile(manifest_hash, environment, project_root, manifest_sources, manifest_targets, rendered, packages, source_revisions, source_host_keys)
151
- Lockfile.new(
163
+ def build_lockfile(manifest_hash, environment, project_root, manifest_sources, manifest_targets, rendered, packages, source_revisions, source_host_keys, project = nil)
164
+ rendered = Render.layout_rendered_targets(project, rendered) if project
165
+ lockfile = Lockfile.new(
152
166
  manifest_hash: manifest_hash,
153
167
  environment: environment,
154
168
  source: manifest_sources.map do |source|
@@ -176,7 +190,11 @@ module Pray
176
190
  end,
177
191
  target: manifest_targets.map { |target| LockedTarget.new(name: target.name, outputs: target.outputs) },
178
192
  managed_span: rendered.flat_map(&:managed_spans)
179
- ).canonicalized
193
+ )
194
+ if project
195
+ lockfile.provisioned = RenderDest.provisioned_records(project)
196
+ end
197
+ lockfile.canonicalized
180
198
  end
181
199
 
182
200
  def from_hash(data)
@@ -222,6 +240,14 @@ module Pray
222
240
  source_checksum: entry["source_checksum"],
223
241
  silenced: entry["silenced"]
224
242
  )
243
+ end,
244
+ provisioned: Array(data["provisioned"]).map do |entry|
245
+ ProvisionedFileRecord.new(
246
+ path: entry["path"],
247
+ content_hash: entry["content_hash"],
248
+ package: entry["package"],
249
+ export: entry["export"]
250
+ )
225
251
  end
226
252
  )
227
253
  end
@@ -22,6 +22,7 @@ module Pray
22
22
  lockfile.package.each { |entry| append_section(lines, format_package(entry)) }
23
23
  lockfile.target.each { |entry| append_section(lines, format_target(entry)) }
24
24
  lockfile.managed_span.each { |entry| append_section(lines, format_managed_span(entry)) }
25
+ lockfile.provisioned.each { |entry| append_section(lines, format_provisioned(entry)) }
25
26
 
26
27
  lines.pop while lines.last == ""
27
28
  "#{lines.join("\n")}\n"
@@ -94,6 +95,20 @@ module Pray
94
95
  lines
95
96
  end
96
97
 
98
+ def format_provisioned(entry)
99
+ lines = ["[[provisioned]]"]
100
+ append_scalars(
101
+ lines,
102
+ [
103
+ ["path", entry.path],
104
+ ["content_hash", entry.content_hash],
105
+ ["package", entry.package],
106
+ ["export", entry.export]
107
+ ]
108
+ )
109
+ lines
110
+ end
111
+
97
112
  def append_scalars(lines, entries)
98
113
  entries.each do |key, value|
99
114
  lines << case value
data/lib/pray/manifest.rb CHANGED
@@ -9,27 +9,25 @@ require_relative "path_safety"
9
9
 
10
10
  module Pray
11
11
  RenderPolicy = Struct.new(
12
- :mode, :conflict, :churn, :header, :section_markers, :line_endings
12
+ :mode, :conflict, :churn, :header
13
13
  ) do
14
14
  def self.default
15
15
  new(
16
16
  mode: "managed",
17
17
  conflict: "fail",
18
18
  churn: "minimal",
19
- header: true,
20
- section_markers: true,
21
- line_endings: "lf"
19
+ header: true
22
20
  )
23
21
  end
24
22
  end
25
23
 
26
24
  ManifestSource = Struct.new(:name, :kind, :url, :subdir, :rev, :tag)
27
25
  ManifestTarget = Struct.new(
28
- :name, :outputs, :skills, :commands, :rules, :max_bytes, :mode, :scoped, :entries
26
+ :name, :outputs, :skills, :commands, :rules, :max_bytes, :mode, :scoped, :entries, :header
29
27
  ) do
30
28
  def initialize(
31
29
  name:, outputs: [], skills: [], commands: [], rules: [], max_bytes: nil,
32
- mode: "legacy", scoped: false, entries: []
30
+ mode: "legacy", scoped: false, entries: [], header: nil
33
31
  )
34
32
  super
35
33
  end
@@ -73,7 +71,7 @@ module Pray
73
71
  end
74
72
 
75
73
  def note_deprecated_keyword(keyword)
76
- return unless %w[target output agent].include?(keyword)
74
+ return unless %w[target output agent skills].include?(keyword)
77
75
  self.deprecated_keywords ||= []
78
76
  deprecated_keywords << keyword unless deprecated_keywords.include?(keyword)
79
77
  end
@@ -82,7 +80,10 @@ module Pray
82
80
  replacements = {
83
81
  "target" => "compose` / `tree",
84
82
  "output" => "compose",
85
- "agent" => "pray"
83
+ "agent" => "pray",
84
+ "skills" => "tree` / `folder",
85
+ "skill" => "folder",
86
+ "spec.skills" => "a folder export"
86
87
  }
87
88
  (deprecated_keywords || []).filter_map do |keyword|
88
89
  replacement = replacements[keyword]
@@ -127,12 +128,12 @@ module Pray
127
128
  def validate_manifest_paths!(manifest)
128
129
  manifest.targets.each do |target|
129
130
  (target.outputs + target.skills + target.commands + target.rules).each do |path|
130
- PathSafety.validate_project_relative_path!(path)
131
+ PathSafety.validate_destination_path!(path)
131
132
  end
132
133
  end
133
134
  manifest.packages.each do |package|
134
135
  PathSafety.validate_project_relative_path!(package.path) if package.path
135
- PathSafety.validate_project_relative_path!(package.file) if package.file
136
+ PathSafety.validate_destination_path!(package.file) if package.file
136
137
  end
137
138
  manifest.local.each { |local| PathSafety.validate_project_relative_path!(local.path) }
138
139
  end
@@ -34,7 +34,7 @@ module Pray
34
34
  end
35
35
 
36
36
  def target_fields(target)
37
- {
37
+ fields = {
38
38
  "name" => target.name,
39
39
  "outputs" => target.outputs,
40
40
  "skills" => target.skills,
@@ -45,6 +45,8 @@ module Pray
45
45
  "scoped" => target.scoped || false,
46
46
  "entries" => (target.entries || []).map { |entry| entry_fields(entry) }
47
47
  }
48
+ fields["header"] = target.header unless target.header.nil?
49
+ fields
48
50
  end
49
51
 
50
52
  def entry_fields(entry)
@@ -93,9 +95,7 @@ module Pray
93
95
  "mode" => render.mode,
94
96
  "conflict" => render.conflict,
95
97
  "churn" => render.churn,
96
- "header" => render.header,
97
- "section_markers" => render.section_markers,
98
- "line_endings" => render.line_endings
98
+ "header" => render.header
99
99
  }
100
100
  end
101
101
 
@@ -104,6 +104,7 @@ module Pray
104
104
  if statement.start_with?("folder ", "skills ") && !statement.end_with?(" do")
105
105
  raise Error.parse("manifest", "top-level folder/skills must use a tree block")
106
106
  end
107
+ manifest.note_deprecated_keyword("skills") if statement.start_with?("skills ")
107
108
  parse_destination_block(manifest, rest, "tree")
108
109
  end
109
110
 
@@ -55,11 +55,13 @@ module Pray
55
55
  end
56
56
 
57
57
  header = rest.sub(/\s*do\z/, "").strip
58
- values, = parse_call(header)
58
+ values, keywords = parse_call(header)
59
59
  raise Error.parse("manifest", "destination missing path") if values.empty?
60
60
 
61
61
  path = string_from_value(values.first)
62
- manifest.targets << Destination.new_destination_target(mode, path)
62
+ target = Destination.new_destination_target(mode, path)
63
+ target.header = destination_header_keyword(mode, keywords)
64
+ manifest.targets << target
63
65
  index = manifest.targets.length - 1
64
66
  parse_destination_body(manifest, index, mode)
65
67
  end
@@ -207,6 +209,7 @@ module Pray
207
209
  raise Error.manifest("target index out of range") unless target
208
210
 
209
211
  manifest.note_deprecated_keyword("output") if statement.start_with?("output ")
212
+ manifest.note_deprecated_keyword("skills") if statement.start_with?("skills ")
210
213
  apply_target_statement(target, statement)
211
214
  end
212
215
  raise Error.parse("manifest", "missing 'end' for target block")
@@ -170,23 +170,52 @@ module Pray
170
170
 
171
171
  def parse_render_policy(rest)
172
172
  _, keywords = parse_call(rest)
173
+ keywords.each_key do |key|
174
+ unless %w[mode conflict churn header].include?(key)
175
+ raise Error.parse("manifest", "render does not accept #{key}")
176
+ end
177
+ end
173
178
  conflict = keywords["conflict"]&.as_string || "fail"
174
179
  unless conflict == "fail"
175
180
  raise Error.unsupported(
176
181
  "render conflict :#{conflict} is not implemented; only :fail is supported"
177
182
  )
178
183
  end
184
+ mode = keywords["mode"]&.as_string || "managed"
185
+ unless mode == "managed"
186
+ raise Error.unsupported("render mode :#{mode} is not implemented")
187
+ end
188
+ churn = keywords["churn"]&.as_string || "minimal"
189
+ unless churn == "minimal"
190
+ raise Error.unsupported("render churn :#{churn} is not implemented")
191
+ end
179
192
 
180
193
  RenderPolicy.new(
181
- mode: keywords["mode"]&.as_string || "managed",
194
+ mode: mode,
182
195
  conflict: conflict,
183
- churn: keywords["churn"]&.as_string || "minimal",
184
- header: keyword_bool(keywords, "header", true),
185
- section_markers: keyword_bool(keywords, "section_markers", true),
186
- line_endings: keywords["line_endings"]&.as_string || "lf"
196
+ churn: churn,
197
+ header: keyword_bool(keywords, "header", true)
187
198
  )
188
199
  end
189
200
 
201
+ def destination_header_keyword(mode, keywords)
202
+ label = (mode == "compose") ? "compose" : "tree"
203
+ keywords.each_key do |key|
204
+ unless key == "header"
205
+ raise Error.parse("manifest", "#{label} does not accept #{key}")
206
+ end
207
+ end
208
+ return if keywords["header"].nil?
209
+ unless mode == "compose"
210
+ raise Error.parse("manifest", "#{label} does not accept header")
211
+ end
212
+
213
+ value = keywords["header"].as_bool
214
+ raise Error.parse("manifest", "header must be true or false") if value.nil?
215
+
216
+ value
217
+ end
218
+
190
219
  def keyword_bool(keywords, key, default)
191
220
  value = keywords[key]
192
221
  return default if value.nil?
@@ -40,6 +40,7 @@ module Pray
40
40
  end
41
41
  rendered = Render.render_project(project)
42
42
  lockfile_path = default_lockfile_path(project.project_root)
43
+ previous_lockfile = File.exist?(lockfile_path) ? Pray.read_lockfile(lockfile_path) : nil
43
44
  next_lockfile = LockfileIO.build_lockfile(
44
45
  project.manifest_hash,
45
46
  project.environment,
@@ -49,7 +50,8 @@ module Pray
49
50
  rendered,
50
51
  project.packages,
51
52
  project.source_revisions,
52
- project.source_host_keys
53
+ project.source_host_keys,
54
+ project
53
55
  )
54
56
 
55
57
  if locked
@@ -61,7 +63,7 @@ module Pray
61
63
  raise Error.verify("lockfile needs update; rerun install to refresh Prayfile.lock")
62
64
  end
63
65
  unless frozen
64
- Render.write_rendered_targets(project, rendered)
66
+ Render.write_rendered_targets(project, rendered, existing)
65
67
  end
66
68
  if frozen
67
69
  rendered.each do |target|
@@ -89,8 +91,8 @@ module Pray
89
91
  end
90
92
  end
91
93
 
94
+ Render.write_rendered_targets(project, rendered, previous_lockfile)
92
95
  Pray.write_lockfile_if_changed(lockfile_path, next_lockfile)
93
- Render.write_rendered_targets(project, rendered)
94
96
  end
95
97
 
96
98
  def default_manifest_path(working_directory = Dir.pwd)
@@ -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
@@ -71,5 +71,14 @@ 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
74
83
  end
75
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