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,216 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pray
4
+ module ManifestMethods
5
+ module ParserBlocks
6
+ def parse_symbols_block(manifest)
7
+ while (statement = next_statement)
8
+ return if statement == "end"
9
+
10
+ assignment = StatementSurface.split_symbol_assignment(statement)
11
+ unless assignment
12
+ raise Error.parse(
13
+ "manifest",
14
+ "unsupported statement inside pray/template block: #{statement}"
15
+ )
16
+ end
17
+
18
+ key, value_literal = assignment
19
+ unless Substitute.pray_symbol_key?(key)
20
+ raise Error.parse("manifest", "invalid pray symbol key `#{key}`")
21
+ end
22
+ if manifest.symbols.key?(key)
23
+ raise Error.parse("manifest", "duplicate pray symbol `#{key}`")
24
+ end
25
+
26
+ manifest.symbols[key] = string_from_literal(value_literal)
27
+ end
28
+ raise Error.parse("manifest", "missing 'end' for pray/template block")
29
+ end
30
+
31
+ def parse_group_block(manifest)
32
+ while (statement = next_statement)
33
+ return if statement == "end"
34
+ if (match = statement.match(/\Aagent (.+)\z/))
35
+ manifest.note_deprecated_keyword("agent")
36
+ Destination.upsert_package(manifest, parse_package_with_groups(match[1]))
37
+ elsif (match = statement.match(/\A(?:package|pray|use|include) (.+)\z/))
38
+ Destination.upsert_package(manifest, parse_package_with_groups(match[1]))
39
+ elsif /\Agroup (.+)\z/.match?(statement)
40
+ raise Error.parse("manifest", "nested group blocks are not supported")
41
+ else
42
+ raise Error.parse(
43
+ "manifest",
44
+ "group blocks only support agent, package, or pray declarations: #{statement}"
45
+ )
46
+ end
47
+ end
48
+ raise Error.parse("manifest", "missing 'end' for group block")
49
+ end
50
+
51
+ def parse_destination_block(manifest, rest, mode)
52
+ unless rest.rstrip.end_with?("do")
53
+ label = (mode == "compose") ? "compose" : "tree"
54
+ raise Error.parse("manifest", "#{label} must use a block")
55
+ end
56
+
57
+ header = rest.sub(/\s*do\z/, "").strip
58
+ values, = parse_call(header)
59
+ raise Error.parse("manifest", "destination missing path") if values.empty?
60
+
61
+ path = string_from_value(values.first)
62
+ manifest.targets << Destination.new_destination_target(mode, path)
63
+ index = manifest.targets.length - 1
64
+ parse_destination_body(manifest, index, mode)
65
+ end
66
+
67
+ def parse_destination_body(manifest, index, mode)
68
+ while (statement = next_statement)
69
+ return if statement == "end"
70
+
71
+ apply_destination_statement(manifest, statement, index, mode)
72
+ end
73
+ raise Error.parse("manifest", "missing 'end' for destination block")
74
+ end
75
+
76
+ def apply_destination_statement(manifest, statement, index, mode)
77
+ if (match = statement.match(/\Aagent (.+)\z/))
78
+ manifest.note_deprecated_keyword("agent")
79
+ apply_pray_statement(manifest, match[1], index)
80
+ return
81
+ end
82
+ if (match = statement.match(/\A(?:pray|use|include|package) (.+)\z/))
83
+ apply_pray_statement(manifest, match[1], index)
84
+ return
85
+ end
86
+ if mode == "compose" && (match = statement.match(/\Alocal (.+)\z/))
87
+ bind_compose_local(manifest, index, match[1])
88
+ return
89
+ end
90
+ raise Error.parse("manifest", "unsupported statement inside destination block: #{statement}")
91
+ end
92
+
93
+ def bind_compose_local(manifest, index, rest)
94
+ local = parse_local_decl(rest)
95
+ local.bound = true
96
+ Destination.bind_local_entry(manifest.targets[index], local.path)
97
+ Destination.upsert_local(manifest, local)
98
+ end
99
+
100
+ def parse_file_block(manifest, rest)
101
+ unless rest.rstrip.end_with?("do")
102
+ raise Error.parse("manifest", "file must use a block (or use pray ..., file: \"path\")")
103
+ end
104
+
105
+ header = rest.sub(/\s*do\z/, "").strip
106
+ values, = parse_call(header)
107
+ raise Error.parse("manifest", "file block missing path") if values.empty?
108
+
109
+ parse_file_body(manifest, string_from_value(values.first))
110
+ end
111
+
112
+ def parse_file_body(manifest, file_path)
113
+ saw_package = false
114
+ while (statement = next_statement)
115
+ if statement == "end"
116
+ unless saw_package
117
+ raise Error.parse("manifest", "file block requires a pray package declaration")
118
+ end
119
+ return
120
+ end
121
+ saw_package = true if apply_file_body_statement(manifest, statement, file_path)
122
+ end
123
+ raise Error.parse("manifest", "missing 'end' for file block")
124
+ end
125
+
126
+ def apply_file_body_statement(manifest, statement, file_path)
127
+ if (match = statement.match(/\Aagent (.+)\z/))
128
+ manifest.note_deprecated_keyword("agent")
129
+ bind_file_package(manifest, match[1], file_path)
130
+ return true
131
+ end
132
+ if (match = statement.match(/\A(?:pray|use|include|package) (.+)\z/))
133
+ bind_file_package(manifest, match[1], file_path)
134
+ return true
135
+ end
136
+ raise Error.parse("manifest", "unsupported statement inside file block: #{statement}")
137
+ end
138
+
139
+ def bind_file_package(manifest, rest, file_path)
140
+ package = parse_package_with_groups(rest)
141
+ if package.file
142
+ raise Error.parse("manifest", "file: keyword is invalid inside a file block")
143
+ end
144
+ package.file = file_path
145
+ package.bound = true
146
+ package.roles << "file" unless package.roles.include?("file")
147
+ Destination.upsert_package(manifest, package)
148
+ end
149
+
150
+ def apply_pray_statement(manifest, rest, destination_index)
151
+ values, keywords = parse_call(rest)
152
+ raise Error.parse("manifest", "pray missing package or path") if values.empty?
153
+
154
+ first = string_from_value(values.first)
155
+ return if apply_local_pray_path(manifest, first, keywords, values, destination_index)
156
+
157
+ package = parse_package_with_groups(rest)
158
+ bind_pray_package(manifest, package, destination_index)
159
+ end
160
+
161
+ def apply_local_pray_path(manifest, first, keywords, values, destination_index)
162
+ return false if package_signal?(values, keywords)
163
+ return false unless Destination.local_path_form?(first)
164
+
165
+ in_compose = destination_index &&
166
+ manifest.targets[destination_index]&.mode == "compose"
167
+ unless in_compose
168
+ raise Error.parse("manifest", "local pray paths are only valid inside compose blocks")
169
+ end
170
+
171
+ local = ManifestLocal.new(path: first, position: "after", optional: false, bound: true)
172
+ Destination.bind_local_entry(manifest.targets[destination_index], local.path)
173
+ Destination.upsert_local(manifest, local)
174
+ true
175
+ end
176
+
177
+ def package_signal?(values, keywords)
178
+ return true if values.length > 1
179
+
180
+ %w[source export exports file optional path git tag rev tarball oci targets features]
181
+ .any? { |key| keywords.key?(key) }
182
+ end
183
+
184
+ def bind_pray_package(manifest, package, destination_index)
185
+ if package.file
186
+ if destination_index
187
+ raise Error.parse("manifest", "file: is mutually exclusive with compose/tree nesting")
188
+ end
189
+ package.bound = true
190
+ package.roles << "file" unless package.roles.include?("file")
191
+ end
192
+ if destination_index
193
+ mode = manifest.targets[destination_index].mode
194
+ package.bound = true
195
+ role = Destination.role_for_destination(mode)
196
+ package.roles << role if role && !package.roles.include?(role)
197
+ Destination.bind_package_entry(manifest.targets[destination_index], package.name)
198
+ end
199
+ Destination.upsert_package(manifest, package)
200
+ end
201
+
202
+ def parse_target_block(manifest, target_index)
203
+ while (statement = next_statement)
204
+ return if statement == "end"
205
+
206
+ target = manifest.targets[target_index]
207
+ raise Error.manifest("target index out of range") unless target
208
+
209
+ manifest.note_deprecated_keyword("output") if statement.start_with?("output ")
210
+ apply_target_statement(target, statement)
211
+ end
212
+ raise Error.parse("manifest", "missing 'end' for target block")
213
+ end
214
+ end
215
+ end
216
+ end
@@ -0,0 +1,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pray
4
+ module ManifestMethods
5
+ module ParserHelpers
6
+ module_function
7
+
8
+ def parse_call(rest)
9
+ positional = []
10
+ keywords = {}
11
+ Literal.split_top_level(rest.strip.sub(/,\z/, ""), ",").each do |segment|
12
+ if (keyword = parse_keyword_segment(segment))
13
+ keywords[keyword[0]] = keyword[1]
14
+ elsif !segment.empty?
15
+ positional << Literal.parse_literal(segment)
16
+ end
17
+ end
18
+ [positional, keywords]
19
+ end
20
+
21
+ def parse_keyword_segment(segment)
22
+ if (index = Literal.find_top_level(segment, "=>"))
23
+ key = string_from_literal(segment[0...index].strip)
24
+ return [key, Literal.parse_literal(segment[(index + 2)..].strip)]
25
+ end
26
+ if (index = Literal.find_top_level(segment, ":"))
27
+ left = segment[0...index].strip
28
+ right = segment[(index + 1)..].strip
29
+ return nil if left.empty?
30
+
31
+ return [left.delete_prefix(":"), Literal.parse_literal(right)]
32
+ end
33
+ nil
34
+ end
35
+
36
+ def keyword_array(keywords, key)
37
+ keywords[key]&.as_array&.filter_map(&:as_string) || []
38
+ end
39
+
40
+ def string_from_value(value)
41
+ text = value.as_string
42
+ raise Error.parse("manifest", "expected string-like literal, found #{value.inspect}") unless text
43
+
44
+ text
45
+ end
46
+
47
+ def string_from_literal(input)
48
+ string_from_value(Literal.parse_literal(input))
49
+ end
50
+
51
+ def parse_source(rest)
52
+ values, keywords = parse_call(rest)
53
+ raise Error.parse("manifest", "source requires a name") if values.empty?
54
+ if values.length < 2 && !keywords.key?("path") && !keywords.key?("git")
55
+ raise Error.parse("manifest", "source requires a name and url, path:, or git:")
56
+ end
57
+
58
+ kind, url = source_kind_and_url(values, keywords)
59
+ ManifestSource.new(
60
+ name: string_from_value(values.first),
61
+ kind: kind,
62
+ url: url,
63
+ subdir: keyword_string(keywords, "subdir") || keyword_string(keywords, "distribution"),
64
+ rev: keyword_string(keywords, "rev"),
65
+ tag: keyword_string(keywords, "tag")
66
+ )
67
+ end
68
+
69
+ def source_kind_and_url(values, keywords)
70
+ if keywords["path"]
71
+ return ["path", string_from_value(keywords["path"])]
72
+ end
73
+ if keywords["git"]
74
+ url = string_from_value(keywords["git"])
75
+ url = "git+#{url}" unless url.start_with?("git+")
76
+ return ["git", url]
77
+ end
78
+
79
+ url = string_from_value(values[1])
80
+ [infer_source_kind(url), url]
81
+ end
82
+
83
+ def infer_source_kind(url)
84
+ return "git" if url.start_with?("git+")
85
+ return "pray_ssh" if url.start_with?("pray+ssh://", "ssh+pray://")
86
+
87
+ "registry"
88
+ end
89
+
90
+ def keyword_string(keywords, key)
91
+ keywords[key]&.then { |value| string_from_value(value) }
92
+ end
93
+
94
+ def parse_target_header(rest)
95
+ is_block = rest.rstrip.end_with?("do")
96
+ header = rest.sub(/\s*do\z/, "").strip
97
+ values, keywords = parse_call(header)
98
+ name = string_from_value(values.first)
99
+ outputs = keyword_array(keywords, "output")
100
+ folders = keyword_array(keywords, "folder") + keyword_array(keywords, "skills")
101
+ target = ManifestTarget.new(
102
+ name: name,
103
+ outputs: outputs,
104
+ skills: folders,
105
+ commands: keyword_array(keywords, "commands"),
106
+ rules: keyword_array(keywords, "rules"),
107
+ max_bytes: keywords["max_bytes"]&.as_integer
108
+ )
109
+ [target, is_block]
110
+ end
111
+
112
+ def parse_group_header(rest)
113
+ is_block = rest.rstrip.end_with?("do")
114
+ header = rest.sub(/\s*do\z/, "").strip
115
+ values, = parse_call(header)
116
+ raise Error.parse("manifest", "group missing name") if values.empty?
117
+
118
+ names = values.map { |value| string_from_value(value) }
119
+ [names, is_block]
120
+ end
121
+
122
+ def parse_package_decl(rest)
123
+ values, keywords = parse_call(rest)
124
+ raise Error.parse("manifest", "agent missing name") if values.empty?
125
+
126
+ file = keywords["file"]&.as_string
127
+ ManifestPackage.new(
128
+ name: string_from_value(values[0]),
129
+ constraint: package_constraint(values),
130
+ source: keywords["source"]&.as_string,
131
+ exports: package_exports(keywords),
132
+ targets: keyword_array(keywords, "targets"),
133
+ features: keyword_array(keywords, "features"),
134
+ optional: keywords["optional"]&.as_bool || false,
135
+ path: keywords["path"]&.as_string,
136
+ git: keywords["git"]&.as_string,
137
+ tag: keywords["tag"]&.as_string,
138
+ rev: keywords["rev"]&.as_string,
139
+ tarball: keywords["tarball"]&.as_string,
140
+ oci: keywords["oci"]&.as_string,
141
+ file: file,
142
+ roles: file ? ["file"] : [],
143
+ bound: false
144
+ )
145
+ end
146
+
147
+ def package_constraint(values)
148
+ return "*" unless values[1]
149
+
150
+ Constraint.normalize_version_constraint(string_from_value(values[1]))
151
+ end
152
+
153
+ def package_exports(keywords)
154
+ exports = keyword_array(keywords, "exports")
155
+ if (export = keywords["export"]&.as_string)
156
+ exports << export unless exports.include?(export)
157
+ end
158
+ exports
159
+ end
160
+
161
+ def parse_local_decl(rest)
162
+ values, keywords = parse_call(rest)
163
+ ManifestLocal.new(
164
+ path: string_from_value(values.first),
165
+ position: keywords["position"]&.as_string || keywords["at"]&.as_string || "after",
166
+ optional: keywords["optional"]&.as_bool || false,
167
+ bound: false
168
+ )
169
+ end
170
+
171
+ def parse_render_policy(rest)
172
+ _, keywords = parse_call(rest)
173
+ RenderPolicy.new(
174
+ mode: keywords["mode"]&.as_string || "managed",
175
+ conflict: keywords["conflict"]&.as_string || "fail",
176
+ churn: keywords["churn"]&.as_string || "minimal",
177
+ header: keyword_bool(keywords, "header", true),
178
+ section_markers: keyword_bool(keywords, "section_markers", true),
179
+ line_endings: keywords["line_endings"]&.as_string || "lf"
180
+ )
181
+ end
182
+
183
+ def keyword_bool(keywords, key, default)
184
+ value = keywords[key]
185
+ return default if value.nil?
186
+
187
+ value.as_bool
188
+ end
189
+
190
+ def apply_target_statement(target, statement)
191
+ case statement
192
+ when /\Aoutput (.+)\z/
193
+ target.outputs << string_from_literal(Regexp.last_match(1))
194
+ when /\Afolder (.+)\z/, /\Askills (.+)\z/
195
+ target.skills << string_from_literal(Regexp.last_match(1))
196
+ when /\Acommands (.+)\z/
197
+ target.commands << string_from_literal(Regexp.last_match(1))
198
+ when /\Arules (.+)\z/
199
+ target.rules << string_from_literal(Regexp.last_match(1))
200
+ when /\Amax_bytes (.+)\z/
201
+ target.max_bytes = Literal.parse_literal(Regexp.last_match(1).strip).as_integer
202
+ else
203
+ raise Error.parse("manifest", "unrecognized target statement: #{statement}")
204
+ end
205
+ end
206
+ end
207
+ end
208
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pray
4
+ module Materialize
5
+ module_function
6
+
7
+ def materialize_project(
8
+ manifest_path: nil,
9
+ frozen: false,
10
+ locked: false,
11
+ offline: false,
12
+ refresh: false
13
+ )
14
+ context = Invocation.invocation_context
15
+ manifest_path = File.expand_path(manifest_path || context.manifest_path)
16
+ unless File.exist?(manifest_path)
17
+ raise Error.manifest("missing #{manifest_path}")
18
+ end
19
+
20
+ project_root = context.project_root
21
+ options = ResolveOptions.new(
22
+ offline: offline,
23
+ refresh: refresh,
24
+ environment: context.environment
25
+ )
26
+ allow_git_refresh_fallback = !locked && !frozen
27
+ project = begin
28
+ Resolve.resolve_project_in_context(manifest_path, project_root, options)
29
+ rescue Error => error
30
+ if allow_git_refresh_fallback &&
31
+ !offline &&
32
+ !refresh &&
33
+ Resolve.resolution_may_benefit_from_git_source_refresh?(error)
34
+ refreshed = options.dup
35
+ refreshed.refresh = true
36
+ Resolve.resolve_project_in_context(manifest_path, project_root, refreshed)
37
+ else
38
+ raise
39
+ end
40
+ end
41
+ rendered = Render.render_project(project)
42
+ lockfile_path = default_lockfile_path(project.project_root)
43
+ next_lockfile = LockfileIO.build_lockfile(
44
+ project.manifest_hash,
45
+ project.environment,
46
+ project.project_root,
47
+ project.manifest.sources,
48
+ project.manifest.targets,
49
+ rendered,
50
+ project.packages,
51
+ project.source_revisions,
52
+ project.source_host_keys
53
+ )
54
+
55
+ if locked
56
+ unless File.exist?(lockfile_path)
57
+ raise Error.verify("missing Prayfile.lock; run install first")
58
+ end
59
+ existing = Pray.read_lockfile(lockfile_path)
60
+ unless Pray.lockfiles_equivalent?(existing, next_lockfile)
61
+ raise Error.verify("lockfile needs update; rerun install to refresh Prayfile.lock")
62
+ end
63
+ unless frozen
64
+ Render.write_rendered_targets(project, rendered)
65
+ end
66
+ if frozen
67
+ rendered.each do |target|
68
+ path = File.join(project.project_root, target.path)
69
+ on_disk = File.read(path)
70
+ if on_disk != target.content
71
+ raise Error.render(
72
+ "#{path} is stale; rerun install to regenerate it or plan to inspect the diff"
73
+ )
74
+ end
75
+ end
76
+ end
77
+ return
78
+ end
79
+
80
+ if frozen
81
+ existing = File.exist?(lockfile_path) ? Pray.read_lockfile(lockfile_path) : nil
82
+ if existing
83
+ rendered.each do |target|
84
+ output_path = File.join(project.project_root, target.path)
85
+ unless File.exist?(output_path)
86
+ raise Error.verify("Rendered file #{target.path} is missing under frozen mode")
87
+ end
88
+ end
89
+ end
90
+ end
91
+
92
+ Pray.write_lockfile_if_changed(lockfile_path, next_lockfile)
93
+ Render.write_rendered_targets(project, rendered)
94
+ end
95
+
96
+ def default_manifest_path(working_directory = Dir.pwd)
97
+ File.join(working_directory, "Prayfile")
98
+ end
99
+
100
+ def default_lockfile_path(project_root)
101
+ File.join(project_root, "Prayfile.lock")
102
+ end
103
+
104
+ def project_root_from_manifest(manifest_path)
105
+ Resolve.project_root_from_manifest(manifest_path)
106
+ end
107
+ end
108
+
109
+ module_function
110
+
111
+ def materialize_project(...) = Materialize.materialize_project(...)
112
+ def default_manifest_path(...) = Materialize.default_manifest_path(...)
113
+ def default_lockfile_path(...) = Materialize.default_lockfile_path(...)
114
+ def project_root_from_manifest(...) = Materialize.project_root_from_manifest(...)
115
+ def resolve_project(...) = Resolve.resolve_project(...)
116
+ def render_project(...) = Render.render_project(...)
117
+ def inspect_project(...) = Verify.inspect_project(...)
118
+ def verify_project(...) = Verify.verify_project(...)
119
+ def drift_project(...) = Verify.drift_project(...)
120
+ end