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,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+
6
+ module Pray
7
+ SessionFile = Struct.new(
8
+ :server_url, :email, :token, :kind, :signer_fingerprint,
9
+ keyword_init: true
10
+ )
11
+
12
+ module Session
13
+ module_function
14
+
15
+ def session_file_path(root)
16
+ File.join(root, ".pray", "session.json")
17
+ end
18
+
19
+ def persist(root, session)
20
+ path = session_file_path(root)
21
+ FileUtils.mkdir_p(File.dirname(path))
22
+ sessions = load_sessions(path)
23
+ existing = sessions.find { |entry| entry.server_url == session.server_url }
24
+ if existing
25
+ existing.email = session.email
26
+ existing.token = session.token
27
+ existing.kind = session.kind
28
+ existing.signer_fingerprint = session.signer_fingerprint
29
+ else
30
+ sessions << session
31
+ end
32
+ document = if sessions.length == 1
33
+ session_to_hash(sessions.first)
34
+ else
35
+ {"sessions" => sessions.map { |entry| session_to_hash(entry) }}
36
+ end
37
+ File.write(path, JSON.pretty_generate(document))
38
+ session
39
+ end
40
+
41
+ def load_latest(root)
42
+ sessions = load_sessions(session_file_path(root))
43
+ sessions.reverse.find { |session| !session.email.to_s.strip.empty? }
44
+ end
45
+
46
+ def current_signer(root)
47
+ session = load_latest(root)
48
+ return session.email if session && !session.email.to_s.strip.empty?
49
+
50
+ "local"
51
+ end
52
+
53
+ def current_signer_fingerprint(root)
54
+ session = load_latest(root)
55
+ fingerprint = session&.signer_fingerprint.to_s.strip
56
+ fingerprint.empty? ? nil : fingerprint
57
+ end
58
+
59
+ def load_sessions(path)
60
+ return [] unless File.file?(path)
61
+
62
+ data = JSON.parse(File.read(path))
63
+ entries = (data.is_a?(Hash) && data.key?("sessions")) ? data["sessions"] : [data]
64
+ Array(entries).map { |entry| session_from_hash(entry) }
65
+ rescue JSON::ParserError => error
66
+ raise Error.parse("session file", error.message)
67
+ end
68
+
69
+ def session_to_hash(session)
70
+ hash = {
71
+ "server_url" => session.server_url,
72
+ "email" => session.email,
73
+ "token" => session.token,
74
+ "kind" => session.kind
75
+ }
76
+ hash["signer_fingerprint"] = session.signer_fingerprint if session.signer_fingerprint
77
+ hash
78
+ end
79
+
80
+ def session_from_hash(entry)
81
+ SessionFile.new(
82
+ server_url: entry["server_url"],
83
+ email: entry["email"],
84
+ token: entry["token"],
85
+ kind: entry["kind"],
86
+ signer_fingerprint: entry["signer_fingerprint"]
87
+ )
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "base64"
5
+
6
+ module Pray
7
+ module SshAgent
8
+ module_function
9
+
10
+ def sign(public_key, message)
11
+ agent_socket = ENV["SSH_AUTH_SOCK"]
12
+ raise Error.unsupported("SSH_AUTH_SOCK is not set") if agent_socket.to_s.empty?
13
+
14
+ raw_key_bytes = parse_ssh_ed25519_public_key(public_key)
15
+ public_key_blob = +""
16
+ write_ssh_string(public_key_blob, "ssh-ed25519")
17
+ write_ssh_string(public_key_blob, raw_key_bytes)
18
+
19
+ payload = +""
20
+ write_ssh_string(payload, public_key_blob)
21
+ write_ssh_string(payload, message)
22
+ write_u32(payload, 0)
23
+
24
+ UNIXSocket.open(agent_socket) do |stream|
25
+ write_ssh_message(stream, 13, payload)
26
+ message_type, response = read_ssh_message(stream)
27
+ unless message_type == 14
28
+ raise Error.resolution("ssh agent returned unexpected message type: #{message_type}")
29
+ end
30
+
31
+ signature_blob = read_ssh_string_bytes(response)
32
+ parse_ssh_signature_blob(signature_blob)
33
+ end
34
+ end
35
+
36
+ def parse_ssh_ed25519_public_key(public_key)
37
+ fields = public_key.split(/\s+/)
38
+ algorithm = fields[0]
39
+ raise Error.unsupported("public key must include an algorithm") unless algorithm
40
+ unless algorithm == "ssh-ed25519"
41
+ raise Error.unsupported("unsupported public key algorithm: #{algorithm}")
42
+ end
43
+
44
+ key_value = fields[1]
45
+ raise Error.unsupported("public key must include key bytes") unless key_value
46
+
47
+ blob = Base64.decode64(key_value)
48
+ cursor = blob.b
49
+ blob_algorithm = read_ssh_string!(cursor)
50
+ unless blob_algorithm == "ssh-ed25519"
51
+ raise Error.parse("public key", "ed25519 public key blob must start with ssh-ed25519")
52
+ end
53
+
54
+ key_bytes = read_ssh_string!(cursor)
55
+ unless key_bytes.bytesize == 32
56
+ raise Error.parse("public key", "ed25519 public key must be 32 bytes")
57
+ end
58
+
59
+ key_bytes
60
+ end
61
+
62
+ def parse_ssh_signature_blob(signature_blob)
63
+ cursor = signature_blob.b
64
+ algorithm = read_ssh_string!(cursor)
65
+ unless algorithm == "ssh-ed25519"
66
+ raise Error.unsupported("unsupported ssh signature algorithm: #{algorithm}")
67
+ end
68
+
69
+ Base64.strict_encode64(read_ssh_string!(cursor))
70
+ end
71
+
72
+ def write_ssh_message(stream, message_type, payload)
73
+ buffer = message_type.chr + payload
74
+ stream.write([buffer.bytesize].pack("N"))
75
+ stream.write(buffer)
76
+ end
77
+
78
+ def read_ssh_message(stream)
79
+ length = stream.read(4)&.unpack1("N")
80
+ raise Error.resolution("empty ssh agent response") unless length
81
+
82
+ buffer = stream.read(length)
83
+ raise Error.resolution("truncated ssh agent response") if buffer.nil? || buffer.bytesize < 1
84
+
85
+ [buffer.getbyte(0), buffer.byteslice(1, buffer.bytesize - 1).to_s]
86
+ end
87
+
88
+ def write_ssh_string(buffer, bytes)
89
+ bytes = bytes.b
90
+ buffer << [bytes.bytesize].pack("N")
91
+ buffer << bytes
92
+ end
93
+
94
+ def write_u32(buffer, value)
95
+ buffer << [value].pack("N")
96
+ end
97
+
98
+ def read_ssh_string!(cursor)
99
+ raise Error.resolution("truncated ssh agent response") if cursor.bytesize < 4
100
+
101
+ length = cursor.byteslice(0, 4).unpack1("N")
102
+ cursor.slice!(0, 4)
103
+ raise Error.resolution("truncated ssh agent response") if cursor.bytesize < length
104
+
105
+ value = cursor.byteslice(0, length)
106
+ cursor.slice!(0, length)
107
+ value
108
+ end
109
+
110
+ def read_ssh_string_bytes(buffer)
111
+ cursor = buffer.b
112
+ read_ssh_string!(cursor)
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,262 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pray
4
+ module StatementSurface
5
+ module_function
6
+
7
+ def expand_statement_surface(statement)
8
+ trimmed = statement.strip
9
+ return [] if trimmed.empty?
10
+
11
+ Literal.split_top_level(trimmed, ";").flat_map { |segment| expand_one_surface(segment) }
12
+ end
13
+
14
+ def expand_one_surface(statement)
15
+ trimmed = statement.strip
16
+ return [] if trimmed.empty?
17
+
18
+ braced = expand_brace_block(trimmed)
19
+ return braced if braced
20
+
21
+ [normalize_keyword_call(trimmed)]
22
+ end
23
+
24
+ def expand_brace_block(statement)
25
+ keyword = leading_identifier(statement)
26
+ return nil unless keyword
27
+
28
+ after_keyword = statement[keyword.length..].lstrip
29
+ # Only keyword{…}, keyword(…){…}, or keyword "…"{…} — not spec.exports = {…}.
30
+ header = split_brace_header(after_keyword)
31
+ return nil unless header
32
+
33
+ args, after_open = header
34
+ close_offset = matching_close_brace(after_open)
35
+ return nil unless close_offset
36
+ return nil unless after_open[(close_offset + 1)..].to_s.strip.empty?
37
+
38
+ body = after_open[0...close_offset].strip
39
+ return nil unless Literal.is_balanced?(body)
40
+
41
+ header_args = unwrap_outer_parens(args)
42
+ open = header_args.empty? ? "#{keyword} do" : "#{keyword} #{header_args} do"
43
+ output = [open]
44
+ output.concat(expand_statement_surface(body)) unless body.empty?
45
+ output << "end"
46
+ output
47
+ end
48
+
49
+ def split_brace_header(after_keyword)
50
+ if after_keyword.start_with?("{")
51
+ return ["", after_keyword[1..]]
52
+ end
53
+ if after_keyword.start_with?("(")
54
+ close = matching_close_paren(after_keyword)
55
+ return nil unless close
56
+
57
+ trailing = after_keyword[(close + 1)..].to_s.lstrip
58
+ return nil unless trailing.start_with?("{")
59
+
60
+ return [after_keyword[1...close].strip, trailing[1..]]
61
+ end
62
+ first = after_keyword[0]
63
+ return nil unless ['"', "'", ":"].include?(first)
64
+
65
+ brace_offset = find_top_level_char(after_keyword, "{")
66
+ return nil unless brace_offset
67
+
68
+ [after_keyword[0...brace_offset].strip, after_keyword[(brace_offset + 1)..]]
69
+ end
70
+
71
+ def normalize_keyword_call(statement)
72
+ spaced = normalize_spaced_block_opener(statement)
73
+ return spaced if spaced
74
+
75
+ keyword = leading_identifier(statement)
76
+ return statement unless keyword
77
+
78
+ after_keyword = statement[keyword.length..].lstrip
79
+ return statement unless after_keyword.start_with?("(")
80
+
81
+ close = matching_close_paren(after_keyword)
82
+ return statement unless close
83
+
84
+ inner = after_keyword[1...close].strip
85
+ trailing = after_keyword[(close + 1)..].to_s.strip
86
+ trailing.empty? ? "#{keyword} #{inner}" : "#{keyword} #{inner} #{trailing}"
87
+ end
88
+
89
+ def normalize_spaced_block_opener(statement)
90
+ trimmed = statement.strip
91
+ %w[pray template].each do |keyword|
92
+ next unless trimmed.start_with?(keyword)
93
+
94
+ rest = trimmed[keyword.length..].lstrip
95
+ return "#{keyword} do" if rest == "do"
96
+ end
97
+ nil
98
+ end
99
+
100
+ def split_symbol_assignment(statement)
101
+ trimmed = statement.strip
102
+ call = split_symbol_call(trimmed)
103
+ return call if call
104
+
105
+ match = trimmed.match(/\A(\S+)\s+(.+)\z/)
106
+ return nil unless match
107
+
108
+ key = match[1].strip
109
+ value = match[2].strip
110
+ return nil if key.empty? || value.empty?
111
+
112
+ [key, value]
113
+ end
114
+
115
+ def split_symbol_call(statement)
116
+ key = leading_identifier(statement)
117
+ return nil unless key
118
+
119
+ after_key = statement[key.length..].lstrip
120
+ return nil unless after_key.start_with?("(") && after_key.end_with?(")")
121
+ return nil unless matching_close_paren(after_key) == after_key.length - 1
122
+
123
+ inner = after_key[1...-1].strip
124
+ return nil if inner.empty?
125
+
126
+ [key, inner]
127
+ end
128
+
129
+ def leading_identifier(input)
130
+ trimmed = input.lstrip
131
+ end_index = 0
132
+ while end_index < trimmed.length
133
+ character = trimmed[end_index]
134
+ break unless character.match?(/[A-Za-z0-9_]/)
135
+
136
+ end_index += 1
137
+ end
138
+ return nil if end_index.zero?
139
+
140
+ ident = trimmed[0...end_index]
141
+ return nil unless ident[0].match?(/[A-Za-z]/)
142
+
143
+ ident
144
+ end
145
+
146
+ def unwrap_outer_parens(input)
147
+ trimmed = input.strip
148
+ if trimmed.start_with?("(") && matching_close_paren(trimmed) == trimmed.length - 1
149
+ return trimmed[1...-1].strip
150
+ end
151
+
152
+ trimmed
153
+ end
154
+
155
+ def matching_close_paren(input)
156
+ matching_close_delimited(input, "(", ")")
157
+ end
158
+
159
+ def matching_close_brace(input)
160
+ depth = 1
161
+ quote = nil
162
+ escaped = false
163
+ input.each_char.with_index do |character, index|
164
+ if quote
165
+ if escaped
166
+ escaped = false
167
+ elsif character == "\\"
168
+ escaped = true
169
+ elsif character == quote
170
+ quote = nil
171
+ end
172
+ next
173
+ end
174
+
175
+ case character
176
+ when '"', "'"
177
+ quote = character
178
+ when "{"
179
+ depth += 1
180
+ when "}"
181
+ depth -= 1
182
+ return index if depth.zero?
183
+ end
184
+ end
185
+ nil
186
+ end
187
+
188
+ def matching_close_delimited(input, open, close)
189
+ return nil unless input.start_with?(open)
190
+
191
+ depth = 0
192
+ quote = nil
193
+ escaped = false
194
+ input.each_char.with_index do |character, index|
195
+ if quote
196
+ if escaped
197
+ escaped = false
198
+ elsif character == "\\"
199
+ escaped = true
200
+ elsif character == quote
201
+ quote = nil
202
+ end
203
+ next
204
+ end
205
+
206
+ if character == open
207
+ depth += 1
208
+ elsif character == close
209
+ depth -= 1
210
+ return index if depth.zero?
211
+ end
212
+ end
213
+ nil
214
+ end
215
+
216
+ def find_top_level_char(input, needle)
217
+ depth = 0
218
+ quote = nil
219
+ escaped = false
220
+ input.each_char.with_index do |character, index|
221
+ if quote
222
+ if escaped
223
+ escaped = false
224
+ elsif character == "\\"
225
+ escaped = true
226
+ elsif character == quote
227
+ quote = nil
228
+ end
229
+ next
230
+ end
231
+
232
+ case character
233
+ when '"', "'"
234
+ quote = character
235
+ when "(", "[", "{"
236
+ return index if depth.zero? && character == needle
237
+
238
+ depth += 1
239
+ when ")", "]", "}"
240
+ depth -= 1
241
+ else
242
+ return index if depth.zero? && character == needle
243
+ end
244
+ end
245
+ nil
246
+ end
247
+
248
+ class Reader
249
+ def initialize
250
+ @pending = []
251
+ end
252
+
253
+ def push_raw(statement)
254
+ @pending.concat(StatementSurface.expand_statement_surface(statement))
255
+ end
256
+
257
+ def next
258
+ @pending.shift
259
+ end
260
+ end
261
+ end
262
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pray
4
+ module Substitute
5
+ PLACEHOLDER_PREFIX = "((pray:"
6
+ PLACEHOLDER_SUFFIX = "))"
7
+
8
+ module_function
9
+
10
+ def pray_symbol_key?(key)
11
+ return false if key.nil? || key.empty?
12
+
13
+ key.match?(/\A[A-Za-z0-9._\/-]+\z/)
14
+ end
15
+
16
+ def substitute_pray_symbols(text, symbols)
17
+ lookup = symbols.is_a?(Hash) ? symbols : symbols.to_h
18
+ output = +""
19
+ rest = text
20
+
21
+ loop do
22
+ start = rest.index(PLACEHOLDER_PREFIX)
23
+ unless start
24
+ output << rest
25
+ return output
26
+ end
27
+
28
+ output << rest[0...start]
29
+ after_prefix = rest[(start + PLACEHOLDER_PREFIX.length)..]
30
+ end_index = after_prefix.index(PLACEHOLDER_SUFFIX)
31
+ raise Error.render("unclosed ((pray:...) placeholder") unless end_index
32
+
33
+ path = after_prefix[0...end_index]
34
+ unless pray_symbol_key?(path)
35
+ raise Error.render("invalid ((pray:...)) path `#{path}`")
36
+ end
37
+
38
+ value = lookup[path]
39
+ unless value
40
+ raise Error.render(
41
+ "unknown pray symbol `#{path}`; declare it in `pray do ... end`"
42
+ )
43
+ end
44
+
45
+ output << value
46
+ rest = after_prefix[(end_index + PLACEHOLDER_SUFFIX.length)..]
47
+ end
48
+ end
49
+ end
50
+ end