pray-cli 1.6.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.
Files changed (71) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +47 -0
  3. data/LICENSE.md +21 -0
  4. data/README.md +125 -0
  5. data/SECURITY.md +28 -0
  6. data/bin/polyrun +108 -0
  7. data/bin/pray +26 -0
  8. data/lib/pray/archive.rb +68 -0
  9. data/lib/pray/auth_client.rb +97 -0
  10. data/lib/pray/cli/commands/auth.rb +42 -0
  11. data/lib/pray/cli/commands/distribution.rb +17 -0
  12. data/lib/pray/cli/commands/init.rb +62 -0
  13. data/lib/pray/cli/commands/meta.rb +15 -0
  14. data/lib/pray/cli/commands/packages.rb +107 -0
  15. data/lib/pray/cli/commands/trust.rb +73 -0
  16. data/lib/pray/cli/commands/workflow.rb +126 -0
  17. data/lib/pray/cli/help.rb +168 -0
  18. data/lib/pray/cli/helpers.rb +166 -0
  19. data/lib/pray/cli/parse.rb +136 -0
  20. data/lib/pray/cli/parse_auth.rb +91 -0
  21. data/lib/pray/cli/parse_trust.rb +152 -0
  22. data/lib/pray/cli/suggest.rb +57 -0
  23. data/lib/pray/cli.rb +117 -0
  24. data/lib/pray/confess.rb +113 -0
  25. data/lib/pray/config.rb +52 -0
  26. data/lib/pray/constraint.rb +80 -0
  27. data/lib/pray/destination.rb +158 -0
  28. data/lib/pray/dotenv.rb +46 -0
  29. data/lib/pray/environment.rb +41 -0
  30. data/lib/pray/error.rb +55 -0
  31. data/lib/pray/format_manifest.rb +255 -0
  32. data/lib/pray/format_serialize.rb +135 -0
  33. data/lib/pray/git_sources.rb +228 -0
  34. data/lib/pray/hashing.rb +59 -0
  35. data/lib/pray/invocation.rb +110 -0
  36. data/lib/pray/literal.rb +382 -0
  37. data/lib/pray/lockfile.rb +259 -0
  38. data/lib/pray/lockfile_serialize.rb +125 -0
  39. data/lib/pray/manifest.rb +126 -0
  40. data/lib/pray/manifest_formatter.rb +56 -0
  41. data/lib/pray/manifest_json.rb +128 -0
  42. data/lib/pray/manifest_parser.rb +152 -0
  43. data/lib/pray/manifest_parser_blocks.rb +216 -0
  44. data/lib/pray/manifest_parser_helpers.rb +208 -0
  45. data/lib/pray/materialize.rb +120 -0
  46. data/lib/pray/package_spec.rb +245 -0
  47. data/lib/pray/path_safety.rb +41 -0
  48. data/lib/pray/plan.rb +83 -0
  49. data/lib/pray/project_context.rb +67 -0
  50. data/lib/pray/publish.rb +162 -0
  51. data/lib/pray/registry.rb +355 -0
  52. data/lib/pray/render.rb +360 -0
  53. data/lib/pray/resolve.rb +396 -0
  54. data/lib/pray/resolve_context.rb +19 -0
  55. data/lib/pray/serve.rb +130 -0
  56. data/lib/pray/serve_federation.rb +109 -0
  57. data/lib/pray/session.rb +90 -0
  58. data/lib/pray/ssh_agent.rb +115 -0
  59. data/lib/pray/statement_surface.rb +262 -0
  60. data/lib/pray/substitute.rb +50 -0
  61. data/lib/pray/sync.rb +219 -0
  62. data/lib/pray/terminal.rb +30 -0
  63. data/lib/pray/trust.rb +201 -0
  64. data/lib/pray/trust_feed.rb +110 -0
  65. data/lib/pray/trust_ops.rb +202 -0
  66. data/lib/pray/verify.rb +257 -0
  67. data/lib/pray/version.rb +6 -0
  68. data/lib/pray-cli.rb +4 -0
  69. data/lib/pray.rb +46 -0
  70. data/pray-cli.gemspec +48 -0
  71. metadata +187 -0
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Pray
6
+ module CLI
7
+ def add_command(name:, constraint: nil, path: nil)
8
+ manifest_path_value = manifest_path
9
+ manifest_text = Pray.read_manifest_text(manifest_path_value)
10
+ manifest = Pray.parse_manifest(manifest_text)
11
+ if manifest.packages.any? { |package| package.name == name }
12
+ raise Error.manifest("package #{name} already exists")
13
+ end
14
+
15
+ declaration = Pray.format_package_declaration(
16
+ ManifestPackage.new(name: name, constraint: constraint || "*", path: path)
17
+ )
18
+ File.write(manifest_path_value, insert_manifest_statement(manifest_text, declaration))
19
+ end
20
+
21
+ def remove_command(name)
22
+ raise Error.manifest("remove requires a package name") unless name
23
+
24
+ manifest_path_value = manifest_path
25
+ manifest_text = Pray.read_manifest_text(manifest_path_value)
26
+ manifest = Pray.parse_manifest(manifest_text)
27
+ unless manifest.packages.any? { |package| package.name == name }
28
+ raise Error.manifest("package #{name} not found")
29
+ end
30
+
31
+ File.write(manifest_path_value, remove_manifest_statement(manifest_text, name))
32
+ install_command({locked: false, frozen: false, offline: false})
33
+ end
34
+
35
+ def list_command
36
+ project = resolve_current_project
37
+ lines = ["Package list"]
38
+ project.packages.each do |package|
39
+ lines << "#{package.declaration.name} #{package.spec.version} source=#{package_source_summary(package)} exports=#{format_list(package.selected_exports)}"
40
+ end
41
+ puts lines.join("\n")
42
+ end
43
+
44
+ def tree_command
45
+ project = resolve_current_project
46
+ package_map = project.packages.to_h { |package| [package.declaration.name, package] }
47
+ lines = ["Dependency tree"]
48
+ project.packages.each do |package|
49
+ render_tree_node(package, package_map, 0, Set.new, lines)
50
+ end
51
+ puts lines.join("\n")
52
+ end
53
+
54
+ def package_command
55
+ project = resolve_current_project
56
+ project.packages.each do |package|
57
+ output_path = Archive.package_archive_path(package.declaration.name, package.spec.version)
58
+ Archive.write_package_archive(package, output_path)
59
+ end
60
+ end
61
+
62
+ def explain_command(name)
63
+ raise Error.resolution("explain requires a package name") unless name
64
+
65
+ project = resolve_current_project
66
+ package = project.packages.find { |entry| entry.declaration.name == name }
67
+ raise Error.resolution("package #{name} not found") unless package
68
+
69
+ lockfile = File.exist?(lockfile_path) ? Pray.read_lockfile(lockfile_path) : nil
70
+ lockfile_package = lockfile&.package&.find { |entry| entry.name == name }
71
+
72
+ lines = ["Package explanation"]
73
+ lines << "name: #{package.declaration.name}"
74
+ lines << "constraint: #{package.declaration.constraint}"
75
+ lines << "resolved version: #{package.spec.version}"
76
+ if package.registry_latest_version
77
+ lines << "registry latest: #{package.registry_latest_version}"
78
+ end
79
+ lines << "source: #{package_source_summary(package)}"
80
+ lines << "exports: #{format_list(package.selected_exports)}"
81
+ lines << "dependencies: #{format_list(package.spec.dependencies.map(&:name))}"
82
+ lines << "tree hash: #{package.tree_hash}"
83
+ lines << "artifact hash: #{package.artifact_hash}"
84
+ if lockfile_package
85
+ lines << "lockfile version: #{lockfile_package.version}"
86
+ lines << "lockfile path: #{lockfile_package.path}"
87
+ lines << "lockfile exports: #{format_list(lockfile_package.exports)}"
88
+ else
89
+ lines << "lockfile record: missing"
90
+ end
91
+ puts lines.join("\n")
92
+ end
93
+
94
+ def outdated_command(_arguments)
95
+ previous_lockfile = File.exist?(lockfile_path) ? Pray.read_lockfile(lockfile_path) : nil
96
+ project = resolve_current_project
97
+ rendered = Render.render_project(project)
98
+ latest_lockfile = build_lockfile(project, rendered)
99
+ puts "Outdated packages"
100
+ if previous_lockfile && Pray.lockfiles_equivalent?(latest_lockfile, previous_lockfile)
101
+ puts "All packages up to date"
102
+ else
103
+ Plan.package_summary_lines(previous_lockfile, latest_lockfile, project).each { |line| puts line }
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pray
4
+ module CLI
5
+ def trust_list_command(scope: :all, source_url: nil)
6
+ puts Trust.list_policy(scope: scope, source_url: source_url)
7
+ end
8
+
9
+ def trust_show_command
10
+ puts Trust.show_policy_toml
11
+ end
12
+
13
+ def trust_add_key_command(key, match_prefix: nil)
14
+ Trust.add_allowed_signing_key(key, match_prefix: match_prefix)
15
+ end
16
+
17
+ def trust_remove_key_command(key, match_prefix: nil)
18
+ Trust.remove_allowed_signing_key(key, match_prefix: match_prefix)
19
+ end
20
+
21
+ def trust_set_signed_command(match_prefix:, enabled:)
22
+ Trust.set_require_signed_commit(match_prefix, enabled)
23
+ end
24
+
25
+ def trust_set_allow_command(match_prefix:, allow:)
26
+ Trust.set_allow(match_prefix, allow)
27
+ end
28
+
29
+ def trust_import_repo_command(source_url, match_prefix: nil)
30
+ clone_url = source_url.delete_prefix("git+")
31
+ repository = GitSources.git_source_cache_directory(Dir.pwd, clone_url)
32
+ unless File.directory?(File.join(repository, ".git"))
33
+ raise Error.resolution(
34
+ "no cached git repository for #{clone_url} at #{repository}"
35
+ )
36
+ end
37
+
38
+ added = Trust.import_repo(
39
+ clone_url, repository, match_prefix: match_prefix || clone_url
40
+ )
41
+ puts "imported #{added} key(s) from #{repository}"
42
+ end
43
+
44
+ def trust_import_registry_command(source_url, match_prefix: nil, include_host_key: false)
45
+ result = Trust.import_registry(
46
+ source_url, match_prefix: match_prefix, include_host_key: include_host_key
47
+ )
48
+ puts "imported #{result[:publishers_added]} publisher fingerprint(s) and " \
49
+ "#{result[:host_keys_added]} host key(s) for #{match_prefix || source_url}"
50
+ end
51
+
52
+ def trust_check_command(source = nil)
53
+ source_description, hits = Trust.check_compromised(source)
54
+ if hits.empty?
55
+ puts "no compromised trusted signing keys detected (checked against #{source_description})"
56
+ return
57
+ end
58
+
59
+ hits.each do |key, scopes, matches|
60
+ puts "[compromised] #{key}"
61
+ puts " scopes: #{scopes.join(", ")}"
62
+ matches.each do |entry|
63
+ puts " reason: #{entry.reason}" if entry.reason
64
+ puts " reference: #{entry.reference}" if entry.reference
65
+ puts " reported_at: #{entry.reported_at}" if entry.reported_at
66
+ end
67
+ end
68
+ raise Error.integrity(
69
+ "found #{hits.length} compromised trusted key(s) in #{source_description}"
70
+ )
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Pray
6
+ module CLI
7
+ def install_command(flags)
8
+ Pray.materialize_project(
9
+ manifest_path: manifest_path,
10
+ frozen: flags[:frozen],
11
+ locked: flags[:locked],
12
+ offline: flags[:offline],
13
+ refresh: flags[:refresh]
14
+ )
15
+ end
16
+
17
+ def render_command(flags)
18
+ project = resolve_current_project
19
+ rendered = Render.render_project(project)
20
+ if flags[:check]
21
+ ensure_rendered_outputs_current(project, rendered)
22
+ else
23
+ Render.write_rendered_targets(project, rendered)
24
+ end
25
+ end
26
+
27
+ def verify_command(flags)
28
+ project = resolve_current_project
29
+ lockfile = ensure_existing_lockfile(lockfile_path)
30
+ report = Verify.verify_project(project, lockfile, strict: flags[:strict])
31
+ puts format_verification_report(report) unless report.clean?
32
+ end
33
+
34
+ def drift_command(flags)
35
+ project = resolve_current_project
36
+ lockfile = ensure_existing_lockfile(lockfile_path)
37
+ report = if flags[:semantic]
38
+ Verify.inspect_project(project, lockfile)
39
+ else
40
+ Verify.drift_project(project, lockfile)
41
+ end
42
+ puts format_drift_report(report) unless report.clean?
43
+ end
44
+
45
+ def format_command
46
+ path = manifest_path
47
+ original = Pray.read_manifest_text(path)
48
+ manifest = Pray.parse_manifest(original)
49
+ project = begin
50
+ resolve_current_project(ResolveOptions.new(offline: true))
51
+ rescue Error
52
+ resolve_current_project
53
+ end
54
+ hints = FormatManifest.classify_format_hints(project)
55
+ formatted = FormatManifest.format_recommended(manifest, hints)
56
+ File.write(path, formatted) if formatted != original
57
+
58
+ return unless File.exist?(lockfile_path)
59
+
60
+ lockfile = Pray.read_lockfile(lockfile_path)
61
+ lockfile.target.each do |target|
62
+ target.outputs.each do |output|
63
+ next unless File.exist?(output)
64
+
65
+ original_output = File.read(output)
66
+ formatted_output = format_marker_comments(Hashing.normalize_line_endings(original_output))
67
+ File.write(output, formatted_output) if formatted_output != original_output
68
+ end
69
+ end
70
+ end
71
+
72
+ def plan_command(_arguments)
73
+ project = Invocation.resolve_current_project_with_git_refresh_fallback(
74
+ ResolveOptions.new,
75
+ allow_git_refresh_fallback: true
76
+ )
77
+ rendered = Render.render_project(project)
78
+ lockfile = build_lockfile(project, rendered)
79
+ previous_lockfile = File.exist?(lockfile_path) ? Pray.read_lockfile(lockfile_path) : nil
80
+ preview = Plan.build_materialization_preview(
81
+ project, rendered, lockfile, lockfile_path, previous_lockfile
82
+ )
83
+ Plan.print_materialization_report(preview, :plan)
84
+ end
85
+
86
+ def update_command(arguments)
87
+ package = arguments.reject { |argument| argument.start_with?("--") }.first
88
+ offline = arguments.include?("--offline")
89
+ if package
90
+ manifest = Pray.parse_manifest(Pray.read_manifest_text(manifest_path))
91
+ unless manifest.packages.any? { |entry| entry.name == package }
92
+ raise Error.manifest("package #{package} not found")
93
+ end
94
+ end
95
+ install_command({locked: false, frozen: false, offline: offline, refresh: true})
96
+ end
97
+
98
+ def unlock_command(name)
99
+ raise Error.manifest("unlock requires a package name") unless name
100
+
101
+ project = resolve_current_project
102
+ unless project.manifest.packages.any? { |entry| entry.name == name }
103
+ raise Error.manifest("package #{name} not found")
104
+ end
105
+
106
+ previous_lockfile = Pray.read_lockfile(lockfile_path)
107
+ rendered = Render.render_project(project)
108
+ updated_lockfile = build_lockfile(project, rendered)
109
+ merged_lockfile = merge_selected_package_update(previous_lockfile, updated_lockfile, name)
110
+ Pray.write_lockfile(lockfile_path, merged_lockfile)
111
+ Render.write_rendered_targets(project, rendered)
112
+ puts "Unlocked #{name}"
113
+ end
114
+
115
+ def merge_selected_package_update(previous, updated, selected_package)
116
+ merged = updated.dup
117
+ merged.package = updated.package.map do |package|
118
+ next package if package.name == selected_package
119
+
120
+ previous_package = previous.package.find { |entry| entry.name == package.name }
121
+ previous_package ? package.dup.tap { |copy| copy.version = previous_package.version } : package
122
+ end
123
+ merged
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pray
4
+ module CLI
5
+ module Help
6
+ DOCS_URL = "https://github.com/kiskolabs/pray"
7
+
8
+ WORKFLOW_COMMANDS = [
9
+ "install [--locked|--frozen|--offline] resolve, render, and write Prayfile.lock",
10
+ "plan [--remote] preview materialization changes",
11
+ "apply apply the current plan",
12
+ "verify [--strict] check rendered output against the lockfile",
13
+ "drift [--semantic] compare lockfile to current resolution",
14
+ "render [--check] render targets without updating the lockfile",
15
+ "format, fmt rewrite Prayfile to recommended destination DSL"
16
+ ].freeze
17
+
18
+ PACKAGE_COMMANDS = [
19
+ "add <name> [constraint] [--path PATH]",
20
+ "remove <name>",
21
+ "update [package] [--major] [--latest] [--dry-run] [--json]",
22
+ "unlock <package>",
23
+ "vendor copy resolved packages locally",
24
+ "clean remove local cache and vendor trees"
25
+ ].freeze
26
+
27
+ DISTRIBUTION_COMMANDS = [
28
+ "publish --root PATH [--server URL ...]",
29
+ "login --server URL --email EMAIL",
30
+ "serve [--root PATH] [--host HOST] [--port PORT] [--stdio]",
31
+ "sync [--root PATH] [--peer URL ...]",
32
+ "confess <package> | --from-lock SPAN_ID [--accepted|--rejected]"
33
+ ].freeze
34
+
35
+ TRUST_COMMANDS = [
36
+ "trust list|show|add-key|remove-key|set-signed|set-allow|import-repo|import-registry|check"
37
+ ].freeze
38
+
39
+ INSPECT_COMMANDS = [
40
+ "list list declared packages",
41
+ "outdated [--remote] show constraint vs resolved versions",
42
+ "explain <package> show why a package was selected",
43
+ "tree print the dependency tree"
44
+ ].freeze
45
+
46
+ META_COMMANDS = [
47
+ "init [--targets tool_a,tool_b]",
48
+ "prayer init scaffold a prayer package",
49
+ "repo init scaffold a distribution root",
50
+ "manifest print canonical Prayfile JSON",
51
+ "package build a distributable prayer archive",
52
+ "version | -V | --version"
53
+ ].freeze
54
+
55
+ COMMAND_HELP = {
56
+ "install" => <<~TEXT.strip,
57
+ install — resolve packages, render targets, and update Prayfile.lock
58
+
59
+ Usage: pray install [--locked|--frozen|--offline]
60
+
61
+ --locked require an existing lockfile
62
+ --frozen require lockfile to match Prayfile exactly
63
+ --offline use cache only
64
+ TEXT
65
+ "verify" => <<~TEXT.strip,
66
+ verify — check rendered files against Prayfile.lock
67
+
68
+ Usage: pray verify [--strict]
69
+
70
+ Without --strict, orphan-marker warnings print to stderr but exit 0.
71
+ With --strict, any finding fails with exit code 6.
72
+ TEXT
73
+ "drift" => <<~TEXT.strip,
74
+ drift — report differences between lockfile and current resolution
75
+
76
+ Usage: pray drift [--semantic]
77
+
78
+ Exits with code 6 when drift is found.
79
+ TEXT
80
+ "update" => <<~TEXT.strip,
81
+ update — refresh package versions within constraints
82
+
83
+ Usage: pray update [package] [--major] [--latest] [--dry-run] [--json]
84
+ TEXT
85
+ "plan" => <<~TEXT.strip,
86
+ plan — preview install/apply changes
87
+
88
+ Usage: pray plan [--remote]
89
+ TEXT
90
+ "apply" => "apply — materialize the current resolution plan\n\nUsage: pray apply",
91
+ "trust" => <<~TEXT.strip,
92
+ trust — manage client trust policy for remote sources
93
+
94
+ Usage: pray trust <subcommand>
95
+
96
+ Subcommands: list, show, add-key, remove-key, set-signed, set-allow, import-repo, import-registry, check
97
+ TEXT
98
+ "init" => <<~TEXT.strip,
99
+ init — create a starter Prayfile
100
+
101
+ Usage: pray init [--targets tool_a,tool_b]
102
+ TEXT
103
+ "add" => <<~TEXT.strip,
104
+ add — declare a package in Prayfile
105
+
106
+ Usage: pray add <name> [constraint] [--path PATH]
107
+ TEXT
108
+ "publish" => <<~TEXT.strip,
109
+ publish — upload packages to a registry or local root
110
+
111
+ Usage: pray publish --root PATH [--server URL ...]
112
+ TEXT
113
+ "serve" => <<~TEXT.strip
114
+ serve — run a local registry server
115
+
116
+ Usage: pray serve [--root PATH] [--host HOST] [--port PORT] [--stdio]
117
+ TEXT
118
+ }.freeze
119
+
120
+ module_function
121
+
122
+ def print_concise_help
123
+ puts "pray — reproducible inference input for projects"
124
+ puts
125
+ puts "Declare shared instructions in Prayfile, lock versions, and render tool-specific output."
126
+ puts
127
+ puts "Getting started:"
128
+ puts " pray init"
129
+ puts " pray install"
130
+ puts " pray plan"
131
+ puts " pray apply"
132
+ puts " pray verify"
133
+ puts
134
+ print_command_groups
135
+ puts
136
+ puts "Run `pray help <command>` or `pray <command> --help` for one command."
137
+ puts "Documentation: #{DOCS_URL}"
138
+ puts "Exit codes: 0 success; 2 usage/parse; 3 resolution; 4 integrity; 5 render; 6 verify; 8 unsupported."
139
+ end
140
+
141
+ def print_command_help(command)
142
+ text = COMMAND_HELP[command]
143
+ return false unless text
144
+
145
+ puts text
146
+ puts
147
+ puts "Documentation: #{DOCS_URL}"
148
+ true
149
+ end
150
+
151
+ def print_command_groups
152
+ puts "Workflow:"
153
+ WORKFLOW_COMMANDS.each { |line| puts " #{line}" }
154
+ puts "Packages:"
155
+ PACKAGE_COMMANDS.each { |line| puts " #{line}" }
156
+ puts "Distribution:"
157
+ DISTRIBUTION_COMMANDS.each { |line| puts " #{line}" }
158
+ puts "Trust:"
159
+ TRUST_COMMANDS.each { |line| puts " #{line}" }
160
+ puts "Inspect:"
161
+ INSPECT_COMMANDS.each { |line| puts " #{line}" }
162
+ puts "Meta:"
163
+ META_COMMANDS.each { |line| puts " #{line}" }
164
+ puts "Global flags: --no-input (disable prompts), --rm (ephemeral home), --trust [--global]"
165
+ end
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Pray
6
+ module CLI
7
+ def manifest_path
8
+ Invocation.manifest_path
9
+ end
10
+
11
+ def lockfile_path
12
+ Invocation.lockfile_path
13
+ end
14
+
15
+ def project_root
16
+ Invocation.project_root
17
+ end
18
+
19
+ def resolve_current_project(options = ResolveOptions.new)
20
+ Invocation.resolve_current_project(options)
21
+ end
22
+
23
+ def default_output_for_target(target)
24
+ case target
25
+ when "tool_a" then "INSTRUCTIONS"
26
+ when "tool_b" then "TOOL_B"
27
+ else target.upcase
28
+ end
29
+ end
30
+
31
+ def build_lockfile(project, rendered)
32
+ LockfileIO.build_lockfile(
33
+ project.manifest_hash,
34
+ project.environment,
35
+ project.project_root,
36
+ project.manifest.sources,
37
+ project.manifest.targets,
38
+ rendered,
39
+ project.packages,
40
+ project.source_revisions,
41
+ project.source_host_keys
42
+ )
43
+ end
44
+
45
+ def ensure_existing_lockfile(path)
46
+ raise Error.verify("missing Prayfile.lock; run pray install first") unless File.exist?(path)
47
+
48
+ Pray.read_lockfile(path)
49
+ end
50
+
51
+ def ensure_lockfile_current(project, rendered, existing)
52
+ current = build_lockfile(project, rendered)
53
+ return if Pray.lockfiles_equivalent?(current, existing)
54
+
55
+ raise Error.verify("lockfile needs update; rerun pray install to refresh Prayfile.lock")
56
+ end
57
+
58
+ def ensure_rendered_outputs_current(project, rendered)
59
+ rendered.each do |target|
60
+ path = File.join(project.project_root, target.path)
61
+ on_disk = File.read(path)
62
+ if on_disk != target.content
63
+ raise Error.render("#{path} is stale; rerun pray install to regenerate it or pray plan to inspect the diff")
64
+ end
65
+ end
66
+ end
67
+
68
+ def insert_manifest_statement(text, statement)
69
+ lines = text.lines.map(&:chomp)
70
+ insertion_index = lines.index do |line|
71
+ trimmed = line.lstrip
72
+ trimmed.start_with?("local ", "render ")
73
+ end || lines.length
74
+ lines.insert(insertion_index, statement)
75
+ output = lines.join("\n")
76
+ output += "\n" unless output.end_with?("\n")
77
+ output
78
+ end
79
+
80
+ def remove_manifest_statement(text, name)
81
+ lines = text.lines.map(&:chomp)
82
+ package_prefix = "agent \"#{name}\""
83
+ alternate_prefix = "agent '#{name}'"
84
+ index = lines.index do |line|
85
+ trimmed = line.lstrip
86
+ trimmed.start_with?(package_prefix, alternate_prefix)
87
+ end
88
+ if index
89
+ lines.delete_at(index)
90
+ lines.delete_at(index) if index < lines.length && lines[index].strip.empty?
91
+ lines.delete_at(index - 1) if index.positive? && lines[index - 1].strip.empty?
92
+ end
93
+ output = lines.join("\n")
94
+ output += "\n" unless output.end_with?("\n")
95
+ output
96
+ end
97
+
98
+ def format_marker_comments(text)
99
+ lines = text.split("\n", -1).map do |line|
100
+ canonical_marker_line(line) || line
101
+ end
102
+ output = lines.join("\n")
103
+ output += "\n" unless output.end_with?("\n")
104
+ output
105
+ end
106
+
107
+ def canonical_marker_line(line)
108
+ trimmed = line.strip
109
+ remainder = trimmed.delete_prefix("<!--").strip
110
+ return nil unless remainder.start_with?("pray:")
111
+
112
+ content = remainder.delete_prefix("pray:").strip.delete_suffix("-->").strip
113
+ return "<!-- pray:0 ignore-comments -->" if content == "0 ignore-comments"
114
+ return "<!-- pray:#{content} -->" if content.match?(/\A[a-z0-9]+\z/)
115
+
116
+ nil
117
+ end
118
+
119
+ def package_source_summary(package)
120
+ if package.declaration.path
121
+ "path:#{package.declaration.path}"
122
+ elsif package.declaration.source
123
+ "source:#{package.declaration.source}"
124
+ else
125
+ "root:#{package.root}"
126
+ end
127
+ end
128
+
129
+ def format_list(values)
130
+ values.empty? ? "none" : values.join(", ")
131
+ end
132
+
133
+ def format_verification_report(report)
134
+ Verify.format_verification_report(report)
135
+ end
136
+
137
+ def format_drift_report(report)
138
+ Verify.format_drift_report(report)
139
+ end
140
+
141
+ def render_tree_node(package, package_map, depth, ancestry, lines)
142
+ indent = " " * depth
143
+ lines << "#{indent}#{package.declaration.name} #{package.spec.version}"
144
+ return unless ancestry.add?(package.declaration.name)
145
+
146
+ package.spec.dependencies.each do |dependency|
147
+ resolved = package_map[dependency.name]
148
+ if resolved
149
+ if ancestry.include?(resolved.declaration.name)
150
+ lines << "#{indent} #{resolved.declaration.name} #{resolved.spec.version} (cycle)"
151
+ else
152
+ render_tree_node(resolved, package_map, depth + 1, ancestry, lines)
153
+ end
154
+ else
155
+ lines << "#{indent} #{dependency.name} (missing)"
156
+ end
157
+ end
158
+ end
159
+
160
+ def remove_path_if_exists(path)
161
+ return unless File.exist?(path)
162
+
163
+ File.directory?(path) ? FileUtils.rm_rf(path) : File.delete(path)
164
+ end
165
+ end
166
+ end