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 +4 -4
- data/CHANGELOG.md +14 -0
- data/lib/pray/cli/commands/workflow.rb +1 -1
- data/lib/pray/cli/helpers.rb +2 -1
- data/lib/pray/compose_dest.rb +59 -0
- data/lib/pray/format_serialize.rb +6 -1
- data/lib/pray/lockfile.rb +32 -6
- data/lib/pray/lockfile_serialize.rb +15 -0
- data/lib/pray/manifest.rb +11 -10
- data/lib/pray/manifest_json.rb +4 -4
- data/lib/pray/manifest_parser.rb +1 -0
- data/lib/pray/manifest_parser_blocks.rb +5 -2
- data/lib/pray/manifest_parser_helpers.rb +34 -5
- data/lib/pray/materialize.rb +5 -3
- data/lib/pray/package_spec.rb +19 -0
- data/lib/pray/path_safety.rb +9 -0
- data/lib/pray/plan.rb +5 -11
- data/lib/pray/render.rb +43 -65
- data/lib/pray/render_content.rb +36 -0
- data/lib/pray/render_dest.rb +215 -0
- data/lib/pray/render_layout.rb +21 -0
- data/lib/pray/render_patch.rb +107 -0
- data/lib/pray/resolve.rb +5 -48
- data/lib/pray/resolve_exports.rb +79 -0
- data/lib/pray/verify.rb +2 -0
- data/lib/pray/verify_provisioned.rb +55 -0
- data/lib/pray/version.rb +2 -2
- data/lib/pray.rb +6 -0
- metadata +8 -1
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
47
|
+
Substitute.substitute_pray_symbols(text, symbols)
|
|
51
48
|
else
|
|
52
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pray
|
|
4
|
+
module RenderPatch
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def patch_rendered_content(existing, fresh)
|
|
8
|
+
existing_segments = split_segments(existing)
|
|
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
|
|
17
|
+
|
|
18
|
+
used = {}
|
|
19
|
+
output = existing_segments.map do |segment|
|
|
20
|
+
next segment[:text] if segment[:kind] == :text
|
|
21
|
+
|
|
22
|
+
used[segment[:id]] = true
|
|
23
|
+
managed_segment(segment[:id], fresh_managed.fetch(segment[:id], segment[:body]))
|
|
24
|
+
end.join
|
|
25
|
+
fresh_segments.each do |segment|
|
|
26
|
+
next unless segment[:kind] == :managed
|
|
27
|
+
next if used[segment[:id]]
|
|
28
|
+
|
|
29
|
+
output << managed_segment(segment[:id], segment[:body])
|
|
30
|
+
end
|
|
31
|
+
output.end_with?("\n") ? output : "#{output}\n"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def relocate_managed_spans(content, spans)
|
|
35
|
+
positions = marker_positions(lines_of(content))
|
|
36
|
+
spans.map do |span|
|
|
37
|
+
position = positions[span.id]
|
|
38
|
+
next span unless position
|
|
39
|
+
|
|
40
|
+
span.dup.tap do |relocated|
|
|
41
|
+
relocated.open_line = position[0]
|
|
42
|
+
relocated.close_line = position[1]
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def split_segments(content)
|
|
48
|
+
lines = lines_of(content)
|
|
49
|
+
segments = []
|
|
50
|
+
text = +""
|
|
51
|
+
index = 0
|
|
52
|
+
while index < lines.length
|
|
53
|
+
identifier = marker_id(lines[index])
|
|
54
|
+
close = find_closing_marker(lines, index + 1, identifier) if identifier
|
|
55
|
+
if identifier && close
|
|
56
|
+
segments << {kind: :text, text: text} unless text.empty?
|
|
57
|
+
text = +""
|
|
58
|
+
body_lines = lines[(index + 1)...close]
|
|
59
|
+
body = body_lines.empty? ? "" : "#{body_lines.join("\n")}\n"
|
|
60
|
+
segments << {kind: :managed, id: identifier, body: body}
|
|
61
|
+
index = close + 1
|
|
62
|
+
else
|
|
63
|
+
text << "#{lines[index]}\n"
|
|
64
|
+
index += 1
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
segments << {kind: :text, text: text} unless text.empty?
|
|
68
|
+
segments
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def lines_of(content)
|
|
72
|
+
content.lines(chomp: true)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def find_closing_marker(lines, start, identifier)
|
|
76
|
+
(start...lines.length).find { |index| marker_id(lines[index]) == identifier }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def marker_positions(lines)
|
|
80
|
+
positions = {}
|
|
81
|
+
active = nil
|
|
82
|
+
lines.each_with_index do |line, index|
|
|
83
|
+
identifier = marker_id(line)
|
|
84
|
+
next unless identifier&.match?(/\A[a-z0-9]+\z/)
|
|
85
|
+
|
|
86
|
+
if active.nil?
|
|
87
|
+
active = [identifier, index + 1]
|
|
88
|
+
elsif active[0] == identifier
|
|
89
|
+
positions[identifier] = [active[1], index + 1]
|
|
90
|
+
active = nil
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
positions
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def marker_id(line)
|
|
97
|
+
match = line.strip.match(/\A<!-- pray:(.+) -->\z/)
|
|
98
|
+
identifier = match && match[1]
|
|
99
|
+
identifier unless identifier == "0 ignore-comments"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def managed_segment(identifier, body)
|
|
103
|
+
content = body.empty? ? "" : "#{body.sub(/\n+\z/, "")}\n"
|
|
104
|
+
"<!-- pray:#{identifier} -->\n#{content}<!-- pray:#{identifier} -->\n"
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
data/lib/pray/resolve.rb
CHANGED
|
@@ -87,6 +87,7 @@ module Pray
|
|
|
87
87
|
errors << "#{declaration.name}: #{error.message}"
|
|
88
88
|
end
|
|
89
89
|
raise Error.resolution(errors.join("\n")) unless errors.empty?
|
|
90
|
+
PackageSpec.warn_resolved_deprecations(packages)
|
|
90
91
|
|
|
91
92
|
local_files = []
|
|
92
93
|
local_errors = []
|
|
@@ -298,40 +299,11 @@ module Pray
|
|
|
298
299
|
end
|
|
299
300
|
|
|
300
301
|
def select_exports(declaration, spec)
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
unless spec.exports.key?(export)
|
|
304
|
-
raise Error.resolution("package #{declaration.name} does not export #{export}")
|
|
305
|
-
end
|
|
306
|
-
end
|
|
307
|
-
return declaration.exports
|
|
308
|
-
end
|
|
309
|
-
|
|
310
|
-
roles = declaration.roles || []
|
|
311
|
-
return spec.exports.keys.sort if roles.empty? && declaration.file.nil?
|
|
312
|
-
|
|
313
|
-
effective_roles = roles.dup
|
|
314
|
-
effective_roles << "file" if declaration.file && !effective_roles.include?("file")
|
|
302
|
+
ResolveExports.select_exports(declaration, spec)
|
|
303
|
+
end
|
|
315
304
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
compatible = spec.exports.filter_map do |name, export|
|
|
319
|
-
name if Destination.export_kind_matches_role?(export.kind, role)
|
|
320
|
-
end
|
|
321
|
-
case compatible.length
|
|
322
|
-
when 1
|
|
323
|
-
selected << compatible.first unless selected.include?(compatible.first)
|
|
324
|
-
when 0
|
|
325
|
-
raise Error.resolution(
|
|
326
|
-
"package #{declaration.name} has no export compatible with #{role}"
|
|
327
|
-
)
|
|
328
|
-
else
|
|
329
|
-
raise Error.resolution(
|
|
330
|
-
"package #{declaration.name} has multiple exports compatible with #{role}; set export: \"name\""
|
|
331
|
-
)
|
|
332
|
-
end
|
|
333
|
-
end
|
|
334
|
-
selected
|
|
305
|
+
def load_export_bodies(file_bytes, spec, selected_exports)
|
|
306
|
+
ResolveExports.load_export_bodies(file_bytes, spec, selected_exports)
|
|
335
307
|
end
|
|
336
308
|
|
|
337
309
|
def load_package_file_bytes(root, spec)
|
|
@@ -346,21 +318,6 @@ module Pray
|
|
|
346
318
|
file_bytes
|
|
347
319
|
end
|
|
348
320
|
|
|
349
|
-
def load_export_bodies(file_bytes, spec, selected_exports)
|
|
350
|
-
export_bodies = {}
|
|
351
|
-
selected_exports.each do |export_name|
|
|
352
|
-
entry = spec.exports[export_name]
|
|
353
|
-
raise Error.resolution("package #{spec.name} is missing export #{export_name}") unless entry
|
|
354
|
-
next unless entry.kind == "fragment"
|
|
355
|
-
|
|
356
|
-
bytes = file_bytes[entry.path]
|
|
357
|
-
raise Error.integrity("package file missing for export #{export_name}: #{entry.path}") unless bytes
|
|
358
|
-
|
|
359
|
-
export_bodies[export_name] = Hashing.normalize_line_endings(bytes.force_encoding(Encoding::UTF_8))
|
|
360
|
-
end
|
|
361
|
-
export_bodies
|
|
362
|
-
end
|
|
363
|
-
|
|
364
321
|
def build_skill_file_index(spec)
|
|
365
322
|
index = {}
|
|
366
323
|
spec.exports.each do |export_name, export|
|