pray-cli 1.25.0 → 1.26.1

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: 817f233c7933ea4baf101ff50028641a039e3970951e83a1bfdda61f1ec55412
4
- data.tar.gz: 43b0d03df5ee524eefc819d5984b2ac01fa05377ffbdb60621ce195113339c45
3
+ metadata.gz: 7a2524f61bfecb06bcdcf1656ed618e104d854a2d0386c791c3bf3989b9894d1
4
+ data.tar.gz: d20780c7dd5cb5a9f14448fcb793e2bb42007ec8c29db9a390fbe3d4c9dd27b5
5
5
  SHA512:
6
- metadata.gz: 6ae47d928e86b7beb3c3b4009e836fe0a53d2f620a54eb891576acb8854931b51508bda2fbd4fe33565aab97047691561dc2ce49e332068f6235e1be0db34ba2
7
- data.tar.gz: 16e3dfa34136071143a0adcb85184c341f655fbcef9a6319c0a8249bd90c86bb0f61ace49ad16da2470660c23240a70c5ec343d3b14fec563fcffac741fbf9a3
6
+ metadata.gz: 3f721c1a473ce6e07b726c6be47f6b98da4dcd83a6f6da6e3afcf6f80d18d50a7f47ed1b42f3bdde9b4922e16c05784ef5c32f69e1255cb7ae521f7f6ade8dbe
7
+ data.tar.gz: aeeaaade1743bf0b15511ec45704692fed58ed65b2f2f4ccca6d848481b23bd958bee3e433bb944632828ed029391f638c1ffaf31699bb73251763e8654f0c9b
data/CHANGELOG.md CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 1.26.1 (2026-11-02)
6
+
7
+ - Show optional package referrals separately in `pray explain`, exclude them from dependency cycles and trees, and validate their constraints when another declaration selects the target.
8
+ - Keep drift, render checks, planning, and writes consistent for managed files that contain local content.
9
+
10
+ ## 1.26.0 (2026-10-30)
11
+
12
+ - Add validated `related` references to prayspec parsing, rendering, and `pray explain`.
13
+ - Make `render --check`, frozen install, and `plan` compare destinations with the bytes the write path would produce. Remove unchanged managed spans retired by the previous lock while preserving edited or unknown spans for explicit review.
14
+ - Add `pray search` parity with the stable distribution-index contract. Search package names by substring through a declared source, local distribution root, or registry URL, and label catalog summaries as unverified claims in help.
15
+
5
16
  ## 1.25.0 (2026-10-26)
6
17
 
7
18
  - Bump the gem version to 1.25.0 with the coordinated language-registry cut.
data/README.md CHANGED
@@ -62,6 +62,7 @@ pray remove sample/base
62
62
  pray update
63
63
  pray unlock sample/base
64
64
  pray list
65
+ pray search base --source default
65
66
  pray tree
66
67
  pray explain sample/base
67
68
  pray outdated
@@ -84,7 +84,13 @@ module Pray
84
84
  end
85
85
  lines << "source: #{package_source_summary(package)}"
86
86
  lines << "exports: #{format_list(package.selected_exports)}"
87
- lines << "dependencies: #{format_list(package.spec.dependencies.map(&:name))}"
87
+ required_dependencies = package.spec.dependencies.reject(&:optional).map(&:name)
88
+ optional_referrals = package.spec.dependencies.select(&:optional).map do |dependency|
89
+ "#{dependency.name} (#{dependency.constraint})"
90
+ end
91
+ lines << "dependencies: #{format_list(required_dependencies)}"
92
+ lines << "optional referrals: #{format_list(optional_referrals)}"
93
+ lines << "related prayers: #{format_list(package.spec.related)}"
88
94
  lines << "tree hash: #{package.tree_hash}"
89
95
  lines << "artifact hash: #{package.artifact_hash}"
90
96
  if lockfile_package
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pray
4
+ module CLI
5
+ def search_command(query:, source:, root:, url:)
6
+ hits = if root
7
+ RegistrySearch.search_local_registry(root, query)
8
+ elsif url
9
+ RegistrySearch.search_http_registry(url, query)
10
+ else
11
+ search_declared_source(query, source)
12
+ end
13
+ if hits.empty?
14
+ puts "no packages matched"
15
+ else
16
+ hits.each { |hit| puts(hit.summary ? "#{hit.name} — #{hit.summary}" : hit.name) }
17
+ end
18
+ end
19
+
20
+ def search_declared_source(query, source_name)
21
+ manifest = Pray.parse_manifest(Pray.read_manifest_text(manifest_path))
22
+ source = source_name ? manifest.sources.find { |entry| entry.name == source_name } : searchable_source(manifest)
23
+ raise Error.resolution("unknown source: #{source_name}") if source_name && !source
24
+ unless source
25
+ raise Error.resolution("Prayfile has no searchable sources; pass --root PATH or --url URL")
26
+ end
27
+
28
+ case source.kind
29
+ when "path"
30
+ root = File.expand_path(source.url, File.dirname(manifest_path))
31
+ RegistrySearch.search_local_registry(root, query)
32
+ when "registry", "static index"
33
+ RegistrySearch.search_http_registry(source.url, query)
34
+ else
35
+ raise Error.unsupported("search does not support source kind #{source.kind}")
36
+ end
37
+ end
38
+
39
+ def searchable_source(manifest)
40
+ manifest.sources.find { |source| %w[registry static\ index].include?(source.kind) } || manifest.sources.first
41
+ end
42
+ end
43
+ end
@@ -29,7 +29,7 @@ module Pray
29
29
  if flags[:check]
30
30
  ensure_rendered_outputs_current(project, rendered)
31
31
  else
32
- Render.write_rendered_targets(project, rendered)
32
+ Render.write_rendered_targets(project, rendered, project.previous_lockfile)
33
33
  end
34
34
  end
35
35
 
data/lib/pray/cli/help.rb CHANGED
@@ -36,6 +36,7 @@ module Pray
36
36
 
37
37
  INSPECT_COMMANDS = [
38
38
  "list list declared packages",
39
+ "search <query> [--source|--root|--url] find packages in a distribution index",
39
40
  "outdated [--remote] show constraint vs resolved versions",
40
41
  "explain <package> show why a package was selected",
41
42
  "tree print the dependency tree"
@@ -102,7 +103,7 @@ module Pray
102
103
 
103
104
  Without --check, render writes dest. It does not write Prayfile.lock.
104
105
  Use pray plan to preview a write.
105
- --check writes nothing. It fails unless dest already matches a fresh render from current inputs.
106
+ --check writes nothing. It fails when render would change dest.
106
107
  TEXT
107
108
  "format" => <<~TEXT.strip,
108
109
  rewrite Prayfile to recommended destination DSL
@@ -187,6 +188,7 @@ module Pray
187
188
  Subcommands: list, show, add-key, remove-key, set-signed, set-allow, import-repo, import-registry, check
188
189
  TEXT
189
190
  "list" => "list declared packages\n\nUsage: pray list",
191
+ "search" => "search a distribution index for package names\n\nUsage: pray search <query> [--source NAME | --root PATH | --url URL]\n\nMatches package names by case-insensitive substring. Summaries are unverified catalog claims.",
190
192
  "outdated" => <<~TEXT.strip,
191
193
  show constraint vs resolved versions
192
194
 
@@ -59,8 +59,8 @@ module Pray
59
59
  def ensure_rendered_outputs_current(project, rendered)
60
60
  rendered.each do |target|
61
61
  path = File.join(project.project_root, target.path)
62
- on_disk = File.read(path)
63
- if on_disk != target.content
62
+ retired = RenderReconcile.retired_span_checksums(project.previous_lockfile, target)
63
+ unless RenderReconcile.current?(path, target.path, target.content, retired)
64
64
  raise Error.render("#{path} is stale; rerun pray install to regenerate it or pray plan to inspect the diff")
65
65
  end
66
66
  end
@@ -152,7 +152,7 @@ module Pray
152
152
  lines << "#{indent}#{package.declaration.name} #{package.spec.recorded_version}"
153
153
  return unless ancestry.add?(package.declaration.name)
154
154
 
155
- package.spec.dependencies.each do |dependency|
155
+ package.spec.dependencies.reject(&:optional).each do |dependency|
156
156
  resolved = package_map[dependency.name]
157
157
  if resolved
158
158
  if ancestry.include?(resolved.declaration.name)
@@ -64,6 +64,7 @@ module Pray
64
64
  when "serve" then [:serve, parse_serve_arguments(arguments)]
65
65
  when "confess" then [:confess, parse_confess_arguments(arguments)]
66
66
  when "list" then [:list]
67
+ when "search" then [:search, parse_search_arguments(arguments)]
67
68
  when "outdated" then [:outdated, arguments]
68
69
  when "explain" then [:explain, arguments.shift]
69
70
  when "vendor" then [:vendor]
@@ -121,6 +122,32 @@ module Pray
121
122
  {unused: unused}
122
123
  end
123
124
 
125
+ def parse_search_arguments(arguments)
126
+ options = {query: nil, source: nil, root: nil, url: nil}
127
+ while (argument = arguments.shift)
128
+ case argument
129
+ when "--source" then options[:source] = search_option_value(arguments, argument)
130
+ when "--root" then options[:root] = search_option_value(arguments, argument)
131
+ when "--url" then options[:url] = search_option_value(arguments, argument)
132
+ when /^--/ then raise Error.usage("unknown search flag: #{argument}")
133
+ else
134
+ raise Error.usage("unexpected search argument: #{argument}") if options[:query]
135
+
136
+ options[:query] = argument
137
+ end
138
+ end
139
+ raise Error.usage("search requires a query") unless options[:query]
140
+
141
+ options
142
+ end
143
+
144
+ def search_option_value(arguments, flag)
145
+ value = arguments.shift
146
+ raise Error.usage("#{flag} requires a value") unless value && !value.start_with?("--")
147
+
148
+ value
149
+ end
150
+
124
151
  def parse_add_arguments(arguments)
125
152
  name = arguments.shift
126
153
  raise Error.unsupported("add requires a package name") unless name
data/lib/pray/cli.rb CHANGED
@@ -14,6 +14,7 @@ require_relative "cli/commands/init_prayer"
14
14
  require_relative "cli/commands/workflow"
15
15
  require_relative "cli/commands/update_latest"
16
16
  require_relative "cli/commands/packages"
17
+ require_relative "cli/commands/search"
17
18
  require_relative "cli/commands/distribution"
18
19
  require_relative "cli/commands/trust"
19
20
  require_relative "cli/commands/auth"
@@ -119,6 +120,7 @@ module Pray
119
120
  in [:confess, confess_args] then confess_command(**confess_args)
120
121
  in [:sync, sync_args] then sync_command(**sync_args)
121
122
  in [:list] then list_command
123
+ in [:search, search_args] then search_command(**search_args)
122
124
  in [:outdated, arguments] then outdated_command(arguments)
123
125
  in [:explain, name] then explain_command(name)
124
126
  in [:vendor] then raise Error.unsupported("vendor is not implemented yet in pray-cli Ruby")
data/lib/pray/lockfile.rb CHANGED
@@ -169,7 +169,7 @@ module Pray
169
169
  end
170
170
 
171
171
  def build_lockfile(manifest_hash, environment, project_root, manifest_sources, manifest_targets, rendered, packages, source_revisions, source_host_keys, project = nil)
172
- rendered = Render.layout_rendered_targets(project, rendered) if project
172
+ rendered = Render.layout_rendered_targets(project, rendered, project.previous_lockfile) if project
173
173
  lockfile = Lockfile.new(
174
174
  manifest_hash: manifest_hash,
175
175
  environment: environment,
@@ -90,8 +90,8 @@ module Pray
90
90
  if frozen
91
91
  rendered.each do |target|
92
92
  path = File.join(project.project_root, target.path)
93
- on_disk = File.read(path)
94
- if on_disk != target.content
93
+ retired = RenderReconcile.retired_span_checksums(existing, target)
94
+ unless RenderReconcile.current?(path, target.path, target.content, retired)
95
95
  raise Error.render(
96
96
  "#{path} is stale; rerun install to regenerate it or plan to inspect the diff"
97
97
  )
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pray
4
+ module PackageRelation
5
+ module_function
6
+
7
+ def canonicalize(references)
8
+ Array(references).map(&:strip).uniq.sort.tap do |canonical|
9
+ invalid = canonical.find { |reference| !valid?(reference) }
10
+ raise Error.parse("prayspec", "invalid related prayer reference") if invalid
11
+ end
12
+ end
13
+
14
+ def valid?(reference)
15
+ return false if reference.empty? || reference.match?(/[\\[:cntrl:]]/)
16
+ if reference.match?(/\A[A-Za-z][A-Za-z0-9+.-]*:.+\z/)
17
+ return !uri_has_userinfo?(reference)
18
+ end
19
+ if reference.start_with?("/")
20
+ path = locator_path(reference)
21
+ return path.length > 1 && !path.start_with?("//")
22
+ end
23
+ if reference.start_with?("./", "../")
24
+ return locator_path(reference).split("/").any? do |segment|
25
+ !segment.empty? && ![".", ".."].include?(segment)
26
+ end
27
+ end
28
+
29
+ return false if reference.match?(/[?#]/)
30
+
31
+ segments = reference.split("/", -1)
32
+ segments.length == 2 && segments.none? { |segment| segment.empty? || [".", ".."].include?(segment) }
33
+ end
34
+
35
+ def locator_path(reference)
36
+ reference.split(/[?#]/, 2).first
37
+ end
38
+
39
+ def uri_has_userinfo?(reference)
40
+ after_scheme = reference.split("://", 2)[1]
41
+ after_scheme && after_scheme.split(%r{[/?#]}, 2).first.include?("@")
42
+ end
43
+ end
44
+ end
@@ -14,13 +14,13 @@ module Pray
14
14
  PackageSpec = Struct.new(
15
15
  :name, :version, :summary, :description, :authors, :maintainers, :license, :homepage,
16
16
  :source_code_uri, :changelog_uri, :prayfile_version, :files, :exports,
17
- :skills, :templates, :adapters, :targets, :dependencies, :metadata, :upstream
17
+ :skills, :templates, :adapters, :targets, :related, :dependencies, :metadata, :upstream
18
18
  ) do
19
19
  def initialize(
20
20
  name: "", version: "", summary: nil, description: nil, authors: [], maintainers: [], license: nil,
21
21
  homepage: nil, source_code_uri: nil, changelog_uri: nil, prayfile_version: nil,
22
22
  files: [], exports: {}, skills: {}, templates: {}, adapters: {}, targets: [],
23
- dependencies: [], metadata: {}, upstream: nil
23
+ related: [], dependencies: [], metadata: {}, upstream: nil
24
24
  )
25
25
  super
26
26
  end
@@ -50,6 +50,7 @@ module Pray
50
50
  copy.authors = Array(authors).sort
51
51
  copy.maintainers = Array(maintainers).sort
52
52
  copy.targets = Array(targets).sort
53
+ copy.related = PackageRelation.canonicalize(related)
53
54
  copy.dependencies = dependencies.sort_by { |dependency| [dependency.name, dependency.constraint, dependency.optional] }
54
55
  end
55
56
  end
@@ -258,6 +259,7 @@ module Pray
258
259
  when "maintainers" then spec.maintainers = array_of_strings(value)
259
260
  when "files" then spec.files = array_of_strings(value)
260
261
  when "targets" then spec.targets = array_of_strings(value)
262
+ when "related" then spec.related = array_of_strings(value)
261
263
  when "exports" then spec.exports = parse_exports(value)
262
264
  when "skills" then spec.skills = parse_skills(value)
263
265
  when "templates" then spec.templates = parse_templates(value)
@@ -51,6 +51,9 @@ module Pray
51
51
  push_named_paths(lines, "templates", spec.templates)
52
52
  push_string_map(lines, "adapters", spec.adapters)
53
53
  push_array(lines, "targets", spec.targets) unless spec.targets.nil? || spec.targets.empty?
54
+ unless spec.related.nil? || spec.related.empty?
55
+ push_array(lines, "related", spec.related)
56
+ end
54
57
  end
55
58
 
56
59
  def push_relation_fields(lines, spec)
@@ -21,8 +21,10 @@ module Pray
21
21
  end
22
22
 
23
23
  def reject_unsafe_package_name!(package_name)
24
- if package_name.nil? || package_name.empty? || package_name.include?("\0") || package_name.include?("\\") ||
25
- package_name.include?("..")
24
+ segments = package_name&.split("/", -1)
25
+ if segments&.length != 2 || segments.any? do |segment|
26
+ segment.empty? || [".", ".."].include?(segment) || segment.include?("\0") || segment.include?("\\")
27
+ end
26
28
  raise Error.resolution("invalid package name: #{package_name.inspect}")
27
29
  end
28
30
 
data/lib/pray/plan.rb CHANGED
@@ -16,7 +16,7 @@ module Pray
16
16
  package_lines: ApplyReport.local_summary_lines(previous_lockfile, project) +
17
17
  package_summary_lines(previous_lockfile, lockfile, project),
18
18
  lockfile: lockfile_change_status(previous_lockfile, lockfile),
19
- targets: rendered.map { |target| target_change(project, target) },
19
+ targets: rendered.map { |target| target_change(project, target, previous_lockfile) },
20
20
  provisioned: destinations.map { |file, status| [file.path, status.to_s] },
21
21
  warnings: []
22
22
  )
@@ -57,11 +57,12 @@ module Pray
57
57
  Pray.lockfiles_equivalent?(lockfile, existing) ? "unchanged" : "update"
58
58
  end
59
59
 
60
- def target_change(project, target)
60
+ def target_change(project, target, previous_lockfile)
61
61
  path = File.join(project.project_root, target.path)
62
+ retired = RenderReconcile.retired_span_checksums(previous_lockfile, target)
62
63
  change = if !File.exist?(path)
63
64
  "write"
64
- elsif RenderDest.read_regular_bytes(path, target.path) == target.content.b
65
+ elsif RenderReconcile.current?(path, target.path, target.content, retired)
65
66
  "unchanged"
66
67
  else
67
68
  "update"
data/lib/pray/registry.rb CHANGED
@@ -276,7 +276,7 @@ module Pray
276
276
 
277
277
  def http_get(url)
278
278
  uri = URI(url)
279
- bounded_http_exchange(uri, Net::HTTP::Get.new(uri), "HTTP request failed for #{url}")
279
+ bounded_http_exchange(uri, Net::HTTP::Get.new(uri), "HTTP request failed for #{UrlRedaction.redact_userinfo(url)}")
280
280
  end
281
281
 
282
282
  def http_put(url, content_type, body)
@@ -284,7 +284,7 @@ module Pray
284
284
  request = Net::HTTP::Put.new(uri)
285
285
  request["Content-Type"] = content_type
286
286
  request.body = body
287
- bounded_http_exchange(uri, request, "HTTP upload failed for #{url}")
287
+ bounded_http_exchange(uri, request, "HTTP upload failed for #{UrlRedaction.redact_userinfo(url)}")
288
288
  end
289
289
 
290
290
  def http_post(url, content_type, body)
@@ -292,7 +292,7 @@ module Pray
292
292
  request = Net::HTTP::Post.new(uri)
293
293
  request["Content-Type"] = content_type
294
294
  request.body = body
295
- bounded_http_exchange(uri, request, "HTTP request failed for #{url}")
295
+ bounded_http_exchange(uri, request, "HTTP request failed for #{UrlRedaction.redact_userinfo(url)}")
296
296
  end
297
297
 
298
298
  def bounded_http_exchange(uri, request, failure_message)
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Pray
6
+ RegistrySearchHit = Struct.new(:name, :summary, keyword_init: true)
7
+
8
+ module RegistrySearch
9
+ module_function
10
+
11
+ def search_local_registry(root, query, include_summary: true)
12
+ index = parse_index(File.read(File.join(root, "v1", "index.json"), encoding: "UTF-8"))
13
+ search_index_names(index, query).map do |name|
14
+ metadata_path = Registry.registry_metadata_path(root, name)
15
+ metadata = Registry.parse_metadata(File.read(metadata_path, encoding: "UTF-8")) if include_summary && File.file?(metadata_path)
16
+ search_hit(name, metadata)
17
+ end
18
+ end
19
+
20
+ def search_http_registry(url, query, include_summary: true)
21
+ index = parse_index(Registry.http_get(Registry.join_url(url, "v1/index.json")))
22
+ search_index_names(index, query).map do |name|
23
+ PathSafety.reject_unsafe_package_name!(name)
24
+ metadata = fetch_http_metadata(url, name) if include_summary
25
+ search_hit(name, metadata)
26
+ end
27
+ end
28
+
29
+ def search_index_names(index, query)
30
+ needle = query.strip.downcase
31
+ index.fetch("packages").select do |name|
32
+ raise Error.parse("registry index", "package names must be strings") unless name.is_a?(String)
33
+
34
+ PathSafety.reject_unsafe_package_name!(name)
35
+ name.downcase.include?(needle)
36
+ end.sort
37
+ end
38
+
39
+ def latest_non_yanked_summary(metadata)
40
+ return unless metadata
41
+
42
+ latest = metadata.versions.reject(&:yanked).max_by { |version| Gem::Version.new(version.version) }
43
+ summary = latest&.derived_metadata&.fetch("summary", nil).to_s.strip
44
+ summary unless summary.empty?
45
+ end
46
+
47
+ def parse_index(text)
48
+ index = JSON.parse(text)
49
+ unless index["spec"] == "prayfile-distribution-1" && index["packages"].is_a?(Array)
50
+ raise Error.parse("registry index", "expected prayfile-distribution-1 with a packages array")
51
+ end
52
+ index
53
+ rescue JSON::ParserError => error
54
+ raise Error.parse("registry index", error.message)
55
+ end
56
+
57
+ def fetch_http_metadata(url, name)
58
+ path = "v1/packages/#{name}.json"
59
+ Registry.parse_metadata(Registry.http_get(Registry.join_url(url, path)))
60
+ rescue Error => error
61
+ raise unless error.category == :resolution
62
+
63
+ nil
64
+ end
65
+
66
+ def search_hit(name, metadata)
67
+ if metadata && metadata.name != name
68
+ raise Error.integrity("registry metadata name mismatch for #{name}")
69
+ end
70
+ RegistrySearchHit.new(name: name, summary: latest_non_yanked_summary(metadata))
71
+ end
72
+ end
73
+ end
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
- require "pathname"
5
4
 
6
5
  module Pray
7
6
  module RenderDest
@@ -9,13 +8,15 @@ module Pray
9
8
 
10
9
  def write_rendered_targets(project, rendered, previous_lockfile = nil)
11
10
  validate_destinations!(project, previous_lockfile)
11
+ RenderReconcile.reject_managed_span_conflicts!(project, rendered, previous_lockfile) if previous_lockfile
12
12
  rendered.each do |target|
13
13
  PathSafety.validate_destination_path!(target.path)
14
14
  ensure_safe_destination_ancestors!(project.project_root, target.path, target.path)
15
15
  path = File.join(project.project_root, target.path)
16
16
  FileUtils.mkdir_p(File.dirname(path))
17
17
  ensure_safe_destination_ancestors!(project.project_root, target.path, target.path)
18
- write_rendered_content(path, target.path, target.content)
18
+ retired = RenderReconcile.retired_span_checksums(previous_lockfile, target)
19
+ write_rendered_content(path, target.path, target.content, retired)
19
20
  end
20
21
  materialize(project, previous_lockfile)
21
22
  end
@@ -147,7 +148,7 @@ module Pray
147
148
  end
148
149
  end
149
150
 
150
- def layout_rendered_content(path, display, fresh)
151
+ def layout_rendered_content(path, display, fresh, retired = {})
151
152
  kind = destination_kind(path)
152
153
  return fresh if kind == :missing
153
154
 
@@ -155,15 +156,15 @@ module Pray
155
156
  unless kind == :regular
156
157
  raise Error.render("refusing to write `#{display}`; destination is not a regular file")
157
158
  end
158
- RenderPatch.patch_rendered_content(decode_utf8(read_regular_bytes(path, display), display), fresh)
159
+ RenderReconcile.reconcile_content(decode_utf8(read_regular_bytes(path, display), display), fresh, retired)
159
160
  end
160
161
 
161
- def write_rendered_content(path, display, fresh)
162
+ def write_rendered_content(path, display, fresh, retired = {})
162
163
  return create_bytes(path, display, fresh) if destination_kind(path) == :missing
163
164
 
164
165
  open_regular(path, display, File::RDWR) do |file|
165
166
  existing = decode_utf8(read_destination_bytes(file, display), display)
166
- content = RenderPatch.patch_rendered_content(existing, fresh)
167
+ content = RenderReconcile.reconcile_content(existing, fresh, retired)
167
168
  return if Transaction.replace(path, existing.b, content.b)
168
169
 
169
170
  file.rewind
@@ -4,12 +4,13 @@ module Pray
4
4
  module Render
5
5
  module_function
6
6
 
7
- def layout_rendered_targets(project, rendered)
7
+ def layout_rendered_targets(project, rendered, previous_lockfile = project.previous_lockfile)
8
8
  rendered.map do |target|
9
9
  PathSafety.validate_destination_path!(target.path)
10
10
  RenderDest.ensure_safe_destination_ancestors!(project.project_root, target.path, target.path)
11
11
  path = File.join(project.project_root, target.path)
12
- content = RenderDest.layout_rendered_content(path, target.path, target.content)
12
+ retired = RenderReconcile.retired_span_checksums(previous_lockfile, target)
13
+ content = RenderDest.layout_rendered_content(path, target.path, target.content, retired)
13
14
  RenderedTarget.new(
14
15
  path: target.path,
15
16
  content: content,
@@ -4,11 +4,11 @@ module Pray
4
4
  module RenderPatch
5
5
  module_function
6
6
 
7
- def patch_rendered_content(existing, fresh)
8
- existing_segments = split_segments(existing)
7
+ def patch_rendered_content(existing, fresh, retired = {})
9
8
  fresh_segments = split_segments(fresh)
10
9
  fresh_managed = managed_bodies(fresh_segments)
11
- return fresh unless overlapping_managed?(existing_segments, fresh_managed)
10
+ existing_segments = drop_unchanged_retired(split_segments(existing), fresh_managed, retired)
11
+ return preserve_unmatched_managed(fresh, existing_segments, fresh_managed) unless overlapping_managed?(existing_segments, fresh_managed)
12
12
 
13
13
  used = {}
14
14
  output = +""
@@ -24,6 +24,28 @@ module Pray
24
24
  output.end_with?("\n") ? output : "#{output}\n"
25
25
  end
26
26
 
27
+ def drop_unchanged_retired(segments, fresh_managed, retired)
28
+ segments.reject do |segment|
29
+ next false unless segment[:kind] == :managed
30
+ next false if fresh_managed.key?(segment[:id])
31
+
32
+ expected = retired[segment[:id]]
33
+ expected && Hashing.checksum_managed_span_content(segment[:body]) == expected
34
+ end
35
+ end
36
+
37
+ def preserve_unmatched_managed(fresh, segments, fresh_managed)
38
+ unmatched = segments.select do |segment|
39
+ segment[:kind] == :managed && !fresh_managed.key?(segment[:id])
40
+ end
41
+ return fresh if unmatched.empty?
42
+
43
+ output = fresh.dup
44
+ output << "\n" unless output.empty? || output.end_with?("\n")
45
+ unmatched.each { |segment| output << managed_segment(segment[:id], segment[:body]) }
46
+ output
47
+ end
48
+
27
49
  def managed_bodies(segments)
28
50
  segments.filter_map { |segment| [segment[:id], segment[:body]] if segment[:kind] == :managed }.to_h
29
51
  end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pray
4
+ module RenderReconcile
5
+ module_function
6
+
7
+ def reconcile_content(existing, fresh, retired = {})
8
+ RenderPatch.patch_rendered_content(existing, fresh, retired)
9
+ end
10
+
11
+ def current?(path, display, fresh, retired = {})
12
+ kind = RenderDest.destination_kind(path)
13
+ return false if kind == :missing
14
+
15
+ RenderDest.fail_if_symlink!(path, display)
16
+ unless kind == :regular
17
+ raise Error.render("refusing to read `#{display}`; destination is not a regular file")
18
+ end
19
+ existing = RenderDest.decode_utf8(RenderDest.read_regular_bytes(path, display), display)
20
+ existing == reconcile_content(existing, fresh, retired)
21
+ end
22
+
23
+ def retired_span_checksums(previous_lockfile, target)
24
+ current_ids = target.managed_spans.to_h { |span| [span.id, true] }
25
+ Array(previous_lockfile&.managed_span).filter_map do |span|
26
+ next unless span.target == target.path
27
+ next if current_ids[span.id]
28
+
29
+ [span.id, span.ideal_checksum]
30
+ end.to_h
31
+ end
32
+
33
+ def reject_managed_span_conflicts!(project, rendered, previous_lockfile)
34
+ active_by_target = rendered.to_h do |target|
35
+ [target.path, target.managed_spans.to_h { |span| [span.id, true] }]
36
+ end
37
+ Array(previous_lockfile.managed_span).group_by(&:target).each do |target_path, spans|
38
+ active = active_by_target[target_path]
39
+ next unless active
40
+
41
+ PathSafety.validate_destination_path!(target_path)
42
+ RenderDest.ensure_safe_destination_ancestors!(project.project_root, target_path, target_path)
43
+ path = File.join(project.project_root, target_path)
44
+ next unless File.file?(path)
45
+
46
+ content = RenderDest.decode_utf8(RenderDest.read_regular_bytes(path, target_path), target_path)
47
+ bodies = RenderPatch.managed_bodies(RenderPatch.split_segments(content))
48
+ spans.each do |span|
49
+ next unless active[span.id] && bodies.key?(span.id)
50
+ next if Hashing.checksum_managed_span_content(bodies[span.id]) == span.ideal_checksum
51
+
52
+ raise Error.render(
53
+ "conflict: managed span `#{span.id}` in `#{target_path}` was edited " \
54
+ "(`#{span.package}::#{span.export}`)"
55
+ )
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
data/lib/pray/resolve.rb CHANGED
@@ -100,6 +100,7 @@ module Pray
100
100
  raise Error.resolution(local_errors.join("\n")) unless local_errors.empty?
101
101
 
102
102
  ResolveDeps.reject_dependency_cycles(packages)
103
+ ResolveDeps.validate_optional_referrals(packages)
103
104
  source_revisions = git_sources.revisions
104
105
 
105
106
  ResolvedProject.new(
@@ -7,12 +7,29 @@ module Pray
7
7
  def reject_dependency_cycles(packages)
8
8
  edges = {}
9
9
  packages.each do |package|
10
- edges[package.declaration.name] = package.spec.dependencies.map(&:name)
10
+ edges[package.declaration.name] = package.spec.dependencies.reject(&:optional).map(&:name)
11
11
  end
12
12
  cycle = DependencyGraph.find_dependency_cycle(edges)
13
13
  return unless cycle
14
14
 
15
15
  raise Error.resolution("dependency cycle detected: #{cycle.join(" -> ")}")
16
16
  end
17
+
18
+ def validate_optional_referrals(packages)
19
+ packages_by_name = packages.to_h { |package| [package.declaration.name, package] }
20
+ packages.each do |package|
21
+ package.spec.dependencies.select(&:optional).each do |dependency|
22
+ target = packages_by_name[dependency.name]
23
+ next unless target
24
+
25
+ target.spec.satisfy_constraint!(dependency.constraint)
26
+ rescue Error
27
+ raise Error.resolution(
28
+ "optional referral #{package.declaration.name} -> #{dependency.name} requires " \
29
+ "#{dependency.constraint}, but selected version is #{target.spec.recorded_version}"
30
+ )
31
+ end
32
+ end
33
+ end
17
34
  end
18
35
  end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Pray
6
+ module UrlRedaction
7
+ module_function
8
+
9
+ def redact_userinfo(url)
10
+ uri = URI(url)
11
+ return url unless uri.user || uri.password
12
+
13
+ uri.user = "***"
14
+ uri.password = nil
15
+ uri.to_s
16
+ rescue URI::InvalidURIError
17
+ url
18
+ end
19
+ end
20
+ end
data/lib/pray/verify.rb CHANGED
@@ -36,11 +36,16 @@ module Pray
36
36
 
37
37
  def drift_project(project, lockfile)
38
38
  report, rendered_targets, fresh_targets = collect_verification_report(project, lockfile)
39
- fresh_targets.each do |path, fresh_content|
40
- normalized_fresh = Hashing.normalize_line_endings(fresh_content)
39
+ fresh_targets.each do |path, fresh_target|
41
40
  on_disk = rendered_targets[path]
42
- on_disk = Hashing.normalize_line_endings(on_disk) if on_disk
43
- unless on_disk == normalized_fresh
41
+ retired = RenderReconcile.retired_span_checksums(lockfile, fresh_target)
42
+ expected = if on_disk
43
+ RenderReconcile.reconcile_content(on_disk, fresh_target.content, retired)
44
+ else
45
+ fresh_target.content
46
+ end
47
+ normalized_on_disk = Hashing.normalize_line_endings(on_disk) if on_disk
48
+ unless normalized_on_disk == Hashing.normalize_line_endings(expected)
44
49
  report.findings << VerificationFinding.new(
45
50
  kind: "renderer_drift",
46
51
  message: "#{path} differs from fresh render"
@@ -64,7 +69,7 @@ module Pray
64
69
  def collect_verification_report(project, lockfile)
65
70
  report = VerificationReport.new
66
71
  rendered_targets = {}
67
- fresh_targets = Render.render_project(project).to_h { |target| [target.path, target.content] }
72
+ fresh_targets = Render.render_project(project).to_h { |target| [target.path, target] }
68
73
 
69
74
  if project.manifest_hash != lockfile.manifest_hash
70
75
  report.findings << VerificationFinding.new(
@@ -104,6 +109,7 @@ module Pray
104
109
  end
105
110
 
106
111
  target_spans = lockfile.managed_span.group_by(&:target)
112
+ fresh_targets.each_key { |target_path| target_spans[target_path] ||= [] }
107
113
  target_spans.each do |target_path, spans|
108
114
  absolute_path = File.join(project.project_root, target_path)
109
115
  unless File.exist?(absolute_path)
@@ -135,7 +141,7 @@ module Pray
135
141
  end
136
142
  end
137
143
  end
138
- fresh_lines = fresh_targets[target_path]&.lines(chomp: true)
144
+ fresh_lines = fresh_targets[target_path]&.content&.lines(chomp: true)
139
145
  summary = VerifyPosition.summarize_position_drift(
140
146
  target_path,
141
147
  spans,
@@ -177,7 +183,7 @@ module Pray
177
183
 
178
184
  VerificationFinding.new(
179
185
  kind: "orphan_marker",
180
- message: "`#{target_path}` contains marker `#{marker_id}` that is not tracked in `Prayfile.lock`. Remove the marker or run `pray install` to reconcile."
186
+ message: "`#{target_path}` contains marker `#{marker_id}` that is not tracked in `Prayfile.lock`. Inspect and remove the block explicitly."
181
187
  )
182
188
  end
183
189
  end
data/lib/pray/version.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pray
4
- VERSION = "1.25.0"
5
- GENERATED_BY = "pray 1.25.0"
4
+ VERSION = "1.26.1"
5
+ GENERATED_BY = "pray 1.26.1"
6
6
  end
data/lib/pray.rb CHANGED
@@ -15,6 +15,7 @@ require_relative "pray/destination"
15
15
  require_relative "pray/manifest"
16
16
  require_relative "pray/format_serialize"
17
17
  require_relative "pray/format_manifest"
18
+ require_relative "pray/package_relation"
18
19
  require_relative "pray/package_spec"
19
20
  require_relative "pray/package_spec_paths"
20
21
  require_relative "pray/package_spec_version"
@@ -52,6 +53,7 @@ require_relative "pray/render_content"
52
53
  require_relative "pray/render_managed"
53
54
  require_relative "pray/render"
54
55
  require_relative "pray/render_dest"
56
+ require_relative "pray/render_reconcile"
55
57
  require_relative "pray/transaction"
56
58
  require_relative "pray/render_layout"
57
59
  require_relative "pray/verify_position"
@@ -60,9 +62,11 @@ require_relative "pray/verify"
60
62
  require_relative "pray/verify_locked_dest"
61
63
  require_relative "pray/resource_limits"
62
64
  require_relative "pray/http_body"
65
+ require_relative "pray/url_redaction"
63
66
  require_relative "pray/registry_install"
64
67
  require_relative "pray/registry_integrity"
65
68
  require_relative "pray/registry"
69
+ require_relative "pray/registry_search"
66
70
  require_relative "pray/derived_metadata"
67
71
  require_relative "pray/archive_unpack"
68
72
  require_relative "pray/archive"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pray-cli
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.25.0
4
+ version: 1.26.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrei Makarov
@@ -115,6 +115,7 @@ files:
115
115
  - lib/pray/cli/commands/init_prayer_manifest.rb
116
116
  - lib/pray/cli/commands/meta.rb
117
117
  - lib/pray/cli/commands/packages.rb
118
+ - lib/pray/cli/commands/search.rb
118
119
  - lib/pray/cli/commands/trust.rb
119
120
  - lib/pray/cli/commands/update_latest.rb
120
121
  - lib/pray/cli/commands/workflow.rb
@@ -161,6 +162,7 @@ files:
161
162
  - lib/pray/manifest_parser_helpers.rb
162
163
  - lib/pray/manifest_parser_publish.rb
163
164
  - lib/pray/materialize.rb
165
+ - lib/pray/package_relation.rb
164
166
  - lib/pray/package_spec.rb
165
167
  - lib/pray/package_spec_paths.rb
166
168
  - lib/pray/package_spec_render.rb
@@ -176,6 +178,7 @@ files:
176
178
  - lib/pray/registry_install.rb
177
179
  - lib/pray/registry_integrity.rb
178
180
  - lib/pray/registry_paths.rb
181
+ - lib/pray/registry_search.rb
179
182
  - lib/pray/render.rb
180
183
  - lib/pray/render_content.rb
181
184
  - lib/pray/render_dest.rb
@@ -183,6 +186,7 @@ files:
183
186
  - lib/pray/render_managed.rb
184
187
  - lib/pray/render_patch.rb
185
188
  - lib/pray/render_project.rb
189
+ - lib/pray/render_reconcile.rb
186
190
  - lib/pray/resolve.rb
187
191
  - lib/pray/resolve_context.rb
188
192
  - lib/pray/resolve_deps.rb
@@ -216,6 +220,7 @@ files:
216
220
  - lib/pray/upstream_merge.rb
217
221
  - lib/pray/upstream_plan.rb
218
222
  - lib/pray/upstream_refresh.rb
223
+ - lib/pray/url_redaction.rb
219
224
  - lib/pray/verify.rb
220
225
  - lib/pray/verify_locked_dest.rb
221
226
  - lib/pray/verify_position.rb