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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4aabea84f997a26ce08fd78e561be889a990f16fde64956f55ea92249b2d13b0
4
- data.tar.gz: 360408860ba9184cfa6e09434092f2758b4bdced69a7d3873288b54157450e63
3
+ metadata.gz: 1d3cea2d193f4cf496417c5098ad4ff57b0a4b55bc7ee26664f72293c2fcbd06
4
+ data.tar.gz: 89d8dc8a3c5c65062531e175587d3f86f27ca9630679dac925593be231062e8c
5
5
  SHA512:
6
- metadata.gz: d4703291e60da15ad0109712fc07e6e4f5dbd35efeafd3e8e49f86ccdbddaad39b3f5faca751f80662f618abcb3f618d4c0d4ea5962a470ef5c417ef94112e08
7
- data.tar.gz: 68b4a7df9a8a0aef4879ff2ef1f101068b494baafef3ddc869385f7565cb4cdaf4afe42ea35c4966f2e090cf21461bdac021f3fcb64fd44ac1cadc00b29561dc
6
+ metadata.gz: aa2d115c1300f177cd4c9dc8c8e4ab2802baf700be3b7124a644f6889ba4138cafe3a6be16d37005f5c2aba95b803740084139171466ebf7ba126e22f82e7d9c
7
+ data.tar.gz: 1d0c8da737a56bcb21ad6798f6cf59c3fd6ad3384e2d423bb492b37ddf42ccb2c6231b6afebf2b2fc9dc8b7a44e748dd505910a0ceb6835b87006870ae3e68d9
data/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
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
+
17
+ ## 1.9.2 (2026-09-01)
18
+
19
+ - Fix `.praypkg` unpack for git and registry installs when the Ruby process forces UTF-8 internal encoding.
20
+ - Treat empty or corrupt `.pray/cache/registry` directories as not ready so the next install unpacks again.
21
+ - Reject archive members that escape the package root or exceed size limits; unpack through staging into cache.
22
+ - Resolve packages from a matching source namespace or sole source without an explicit `source:`.
23
+ - Move login sessions to the user Pray home with owner-only permissions and migrate legacy repository sessions.
24
+ - Reject manifest paths outside the project; require registry hashes and recheck cached package trees.
25
+ - Bound server requests, headers, connections, and timeouts; expose a readiness endpoint.
26
+ - Cap registry downloads at 64 MiB; unpack `.praypkg` tar members without system tar; reject absolute artifact URLs.
27
+
3
28
  ## 1.9.1 (2026-08-29)
4
29
 
5
30
  - Rewrite every matching `pray` line when a package constraint is updated, keeping indent and extra keywords.
data/bin/pray CHANGED
@@ -2,7 +2,6 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  Encoding.default_external = Encoding::UTF_8
5
- Encoding.default_internal = Encoding::UTF_8
6
5
 
7
6
  gemfile = File.expand_path("../Gemfile", __dir__)
8
7
  if File.exist?(gemfile)
data/lib/pray/archive.rb CHANGED
@@ -3,7 +3,7 @@
3
3
  require "json"
4
4
  require "open3"
5
5
  require "fileutils"
6
- require "tempfile"
6
+ require_relative "archive_unpack"
7
7
 
8
8
  module Pray
9
9
  module Archive
@@ -23,15 +23,20 @@ module Pray
23
23
  File.binwrite(destination, File.binread(File.join(package.root, file)))
24
24
  end
25
25
 
26
- tar_bytes, status = Open3.capture2("tar", "-cf", "-", "-C", staging, ".")
27
- raise Error.integrity("failed to build package tar archive") unless status.success?
26
+ with_binary_process_encoding do
27
+ tar_bytes, status = Open3.capture2(
28
+ {"COPYFILE_DISABLE" => "1"},
29
+ "tar", "-cf", "-", "-C", staging, "."
30
+ )
31
+ raise Error.integrity("failed to build package tar archive") unless status.success?
28
32
 
29
- zstd_bytes, status = Open3.capture2("zstd", "-q", "-c", stdin_data: tar_bytes)
30
- unless status.success?
31
- raise Error.unsupported("zstd is required to build package archives")
32
- end
33
+ zstd_bytes, status = Open3.capture2("zstd", "-q", "-c", stdin_data: tar_bytes)
34
+ unless status.success?
35
+ raise Error.unsupported("zstd is required to build package archives")
36
+ end
33
37
 
34
- zstd_bytes
38
+ zstd_bytes
39
+ end
35
40
  end
36
41
  end
37
42
 
@@ -41,14 +46,7 @@ module Pray
41
46
  end
42
47
 
43
48
  def unpack_praypkg(artifact_bytes, output_directory)
44
- FileUtils.mkdir_p(output_directory)
45
- tar_bytes, status = Open3.capture2("zstd", "-d", "-q", "-c", stdin_data: artifact_bytes)
46
- unless status.success?
47
- raise Error.unsupported("zstd is required to unpack package archives")
48
- end
49
-
50
- _stdout, _stderr, status = Open3.capture3("tar", "-xf", "-", "-C", output_directory, stdin_data: tar_bytes)
51
- raise Error.integrity("failed to unpack package archive") unless status.success?
49
+ ArchiveUnpack.unpack_praypkg(artifact_bytes, output_directory)
52
50
  end
53
51
 
54
52
  def package_archive_path(package_name, version)
@@ -64,5 +62,9 @@ module Pray
64
62
  "exports" => package.selected_exports
65
63
  )
66
64
  end
65
+
66
+ def with_binary_process_encoding(&block)
67
+ ArchiveUnpack.with_binary_process_encoding(&block)
68
+ end
67
69
  end
68
70
  end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "fileutils"
5
+ require "find"
6
+ require_relative "path_safety"
7
+ require_relative "resource_limits"
8
+ require_relative "tar_validation"
9
+
10
+ module Pray
11
+ module ArchiveUnpack
12
+ module_function
13
+
14
+ def unpack_praypkg(artifact_bytes, output_directory)
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
+ tar_bytes = decompress_zstd(artifact_bytes)
22
+ if tar_bytes.bytesize > ResourceLimits::MAX_ARCHIVE_TOTAL_BYTES
23
+ raise Error.integrity(
24
+ "package archive exceeds #{ResourceLimits::MAX_ARCHIVE_TOTAL_BYTES} decompressed bytes"
25
+ )
26
+ end
27
+
28
+ TarValidation.extract!(tar_bytes, output_directory)
29
+ reject_unsafe_extracted_tree!(output_directory)
30
+ end
31
+
32
+ def with_binary_process_encoding
33
+ previous = Encoding.default_internal
34
+ verbose = $VERBOSE
35
+ $VERBOSE = nil
36
+ Encoding.default_internal = nil
37
+ yield
38
+ ensure
39
+ Encoding.default_internal = previous
40
+ $VERBOSE = verbose
41
+ end
42
+
43
+ def decompress_zstd(artifact_bytes)
44
+ with_binary_process_encoding do
45
+ tar_bytes, status = Open3.capture2("zstd", "-d", "-q", "-c", stdin_data: artifact_bytes)
46
+ unless status.success?
47
+ raise Error.unsupported("zstd is required to unpack package archives")
48
+ end
49
+
50
+ tar_bytes
51
+ end
52
+ end
53
+
54
+ def reject_unsafe_extracted_tree!(output_directory)
55
+ Find.find(output_directory) do |path|
56
+ next if path == output_directory
57
+ next if File.directory?(path)
58
+
59
+ if File.symlink?(path)
60
+ raise Error.integrity("unsupported package archive entry type")
61
+ end
62
+ unless PathSafety.path_under_root?(output_directory, path)
63
+ raise Error.integrity("package path escapes package root: #{path}")
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -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
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "resource_limits"
4
+
5
+ module Pray
6
+ module HttpBody
7
+ module_function
8
+
9
+ def reject_oversized_content_length!(content_length, max_bytes = ResourceLimits::MAX_HTTP_RESPONSE_BYTES)
10
+ return if content_length.nil?
11
+ return if content_length <= max_bytes
12
+
13
+ raise Error.resolution("HTTP response exceeds #{max_bytes} bytes")
14
+ end
15
+
16
+ def append_chunk!(body, chunk, max_bytes = ResourceLimits::MAX_HTTP_RESPONSE_BYTES)
17
+ body << chunk
18
+ return body if body.bytesize <= max_bytes
19
+
20
+ raise Error.resolution("HTTP response exceeds #{max_bytes} bytes")
21
+ end
22
+
23
+ def read_response!(response, max_bytes = ResourceLimits::MAX_HTTP_RESPONSE_BYTES)
24
+ length = response["content-length"]
25
+ parsed = length && Integer(length, exception: false)
26
+ reject_oversized_content_length!(parsed, max_bytes)
27
+ body = +"".b
28
+ response.read_body do |chunk|
29
+ append_chunk!(body, chunk, max_bytes)
30
+ end
31
+ body
32
+ end
33
+ end
34
+ 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
@@ -5,30 +5,29 @@ require_relative "manifest_formatter"
5
5
  require_relative "manifest_parser_helpers"
6
6
  require_relative "manifest_parser_blocks"
7
7
  require_relative "manifest_parser"
8
+ require_relative "path_safety"
8
9
 
9
10
  module Pray
10
11
  RenderPolicy = Struct.new(
11
- :mode, :conflict, :churn, :header, :section_markers, :line_endings
12
+ :mode, :conflict, :churn, :header
12
13
  ) do
13
14
  def self.default
14
15
  new(
15
16
  mode: "managed",
16
17
  conflict: "fail",
17
18
  churn: "minimal",
18
- header: true,
19
- section_markers: true,
20
- line_endings: "lf"
19
+ header: true
21
20
  )
22
21
  end
23
22
  end
24
23
 
25
24
  ManifestSource = Struct.new(:name, :kind, :url, :subdir, :rev, :tag)
26
25
  ManifestTarget = Struct.new(
27
- :name, :outputs, :skills, :commands, :rules, :max_bytes, :mode, :scoped, :entries
26
+ :name, :outputs, :skills, :commands, :rules, :max_bytes, :mode, :scoped, :entries, :header
28
27
  ) do
29
28
  def initialize(
30
29
  name:, outputs: [], skills: [], commands: [], rules: [], max_bytes: nil,
31
- mode: "legacy", scoped: false, entries: []
30
+ mode: "legacy", scoped: false, entries: [], header: nil
32
31
  )
33
32
  super
34
33
  end
@@ -72,7 +71,7 @@ module Pray
72
71
  end
73
72
 
74
73
  def note_deprecated_keyword(keyword)
75
- return unless %w[target output agent].include?(keyword)
74
+ return unless %w[target output agent skills].include?(keyword)
76
75
  self.deprecated_keywords ||= []
77
76
  deprecated_keywords << keyword unless deprecated_keywords.include?(keyword)
78
77
  end
@@ -81,7 +80,10 @@ module Pray
81
80
  replacements = {
82
81
  "target" => "compose` / `tree",
83
82
  "output" => "compose",
84
- "agent" => "pray"
83
+ "agent" => "pray",
84
+ "skills" => "tree` / `folder",
85
+ "skill" => "folder",
86
+ "spec.skills" => "a folder export"
85
87
  }
86
88
  (deprecated_keywords || []).filter_map do |keyword|
87
89
  replacement = replacements[keyword]
@@ -118,7 +120,22 @@ module Pray
118
120
 
119
121
  def parse_manifest(text)
120
122
  lines = Literal.prepare_parser_lines(text)
121
- BlockParser.new(lines).parse_root
123
+ manifest = BlockParser.new(lines).parse_root
124
+ validate_manifest_paths!(manifest)
125
+ manifest
126
+ end
127
+
128
+ def validate_manifest_paths!(manifest)
129
+ manifest.targets.each do |target|
130
+ (target.outputs + target.skills + target.commands + target.rules).each do |path|
131
+ PathSafety.validate_destination_path!(path)
132
+ end
133
+ end
134
+ manifest.packages.each do |package|
135
+ PathSafety.validate_project_relative_path!(package.path) if package.path
136
+ PathSafety.validate_destination_path!(package.file) if package.file
137
+ end
138
+ manifest.local.each { |local| PathSafety.validate_project_relative_path!(local.path) }
122
139
  end
123
140
  end
124
141
 
@@ -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)