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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +47 -0
- data/LICENSE.md +21 -0
- data/README.md +125 -0
- data/SECURITY.md +28 -0
- data/bin/polyrun +108 -0
- data/bin/pray +26 -0
- data/lib/pray/archive.rb +68 -0
- data/lib/pray/auth_client.rb +97 -0
- data/lib/pray/cli/commands/auth.rb +42 -0
- data/lib/pray/cli/commands/distribution.rb +17 -0
- data/lib/pray/cli/commands/init.rb +62 -0
- data/lib/pray/cli/commands/meta.rb +15 -0
- data/lib/pray/cli/commands/packages.rb +107 -0
- data/lib/pray/cli/commands/trust.rb +73 -0
- data/lib/pray/cli/commands/workflow.rb +126 -0
- data/lib/pray/cli/help.rb +168 -0
- data/lib/pray/cli/helpers.rb +166 -0
- data/lib/pray/cli/parse.rb +136 -0
- data/lib/pray/cli/parse_auth.rb +91 -0
- data/lib/pray/cli/parse_trust.rb +152 -0
- data/lib/pray/cli/suggest.rb +57 -0
- data/lib/pray/cli.rb +117 -0
- data/lib/pray/confess.rb +113 -0
- data/lib/pray/config.rb +52 -0
- data/lib/pray/constraint.rb +80 -0
- data/lib/pray/destination.rb +158 -0
- data/lib/pray/dotenv.rb +46 -0
- data/lib/pray/environment.rb +41 -0
- data/lib/pray/error.rb +55 -0
- data/lib/pray/format_manifest.rb +255 -0
- data/lib/pray/format_serialize.rb +135 -0
- data/lib/pray/git_sources.rb +228 -0
- data/lib/pray/hashing.rb +59 -0
- data/lib/pray/invocation.rb +110 -0
- data/lib/pray/literal.rb +382 -0
- data/lib/pray/lockfile.rb +259 -0
- data/lib/pray/lockfile_serialize.rb +125 -0
- data/lib/pray/manifest.rb +126 -0
- data/lib/pray/manifest_formatter.rb +56 -0
- data/lib/pray/manifest_json.rb +128 -0
- data/lib/pray/manifest_parser.rb +152 -0
- data/lib/pray/manifest_parser_blocks.rb +216 -0
- data/lib/pray/manifest_parser_helpers.rb +208 -0
- data/lib/pray/materialize.rb +120 -0
- data/lib/pray/package_spec.rb +245 -0
- data/lib/pray/path_safety.rb +41 -0
- data/lib/pray/plan.rb +83 -0
- data/lib/pray/project_context.rb +67 -0
- data/lib/pray/publish.rb +162 -0
- data/lib/pray/registry.rb +355 -0
- data/lib/pray/render.rb +360 -0
- data/lib/pray/resolve.rb +396 -0
- data/lib/pray/resolve_context.rb +19 -0
- data/lib/pray/serve.rb +130 -0
- data/lib/pray/serve_federation.rb +109 -0
- data/lib/pray/session.rb +90 -0
- data/lib/pray/ssh_agent.rb +115 -0
- data/lib/pray/statement_surface.rb +262 -0
- data/lib/pray/substitute.rb +50 -0
- data/lib/pray/sync.rb +219 -0
- data/lib/pray/terminal.rb +30 -0
- data/lib/pray/trust.rb +201 -0
- data/lib/pray/trust_feed.rb +110 -0
- data/lib/pray/trust_ops.rb +202 -0
- data/lib/pray/verify.rb +257 -0
- data/lib/pray/version.rb +6 -0
- data/lib/pray-cli.rb +4 -0
- data/lib/pray.rb +46 -0
- data/pray-cli.gemspec +48 -0
- metadata +187 -0
data/lib/pray/resolve.rb
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pray
|
|
4
|
+
ResolvedProject = Struct.new(
|
|
5
|
+
:manifest_path, :project_root, :manifest, :manifest_hash, :packages,
|
|
6
|
+
:local_files, :source_revisions, :source_host_keys, :environment
|
|
7
|
+
) do
|
|
8
|
+
def lockfile_hash
|
|
9
|
+
manifest_hash
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
ResolvedPackage = Struct.new(
|
|
14
|
+
:declaration, :root, :spec, :tree_hash, :artifact_hash, :artifact,
|
|
15
|
+
:selected_exports, :source_checksum, :export_bodies, :skill_files,
|
|
16
|
+
:signer_fingerprint, :registry_latest_version
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
ResolvedLocalFile = Struct.new(
|
|
20
|
+
:path, :manifest_path, :content, :position, :optional
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
module Resolve
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
def project_root_from_manifest(manifest_path)
|
|
27
|
+
parent = File.dirname(manifest_path)
|
|
28
|
+
(parent.empty? || parent == ".") ? "." : parent
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def canonical_project_root(manifest_path)
|
|
32
|
+
root = project_root_from_manifest(manifest_path)
|
|
33
|
+
return Pathname(root).cleanpath.to_s if Pathname(root).absolute?
|
|
34
|
+
|
|
35
|
+
File.expand_path(root)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def resolve_project(manifest_path, offline: false, refresh: false, environment: nil)
|
|
39
|
+
options = ResolveOptions.new(offline: offline, refresh: refresh, environment: environment)
|
|
40
|
+
resolve_project_with_options(manifest_path, options)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def resolve_project_with_options(manifest_path, options = ResolveOptions.new)
|
|
44
|
+
project_root = canonical_project_root(manifest_path)
|
|
45
|
+
resolve_project_in_context(manifest_path, project_root, options)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def resolve_project_in_context(manifest_path, project_root, options = ResolveOptions.new)
|
|
49
|
+
user_config = Config.load_user_config
|
|
50
|
+
lockfile_path = File.join(project_root, "Prayfile.lock")
|
|
51
|
+
lockfile_hints = File.exist?(lockfile_path) ? Pray.read_lockfile(lockfile_path) : nil
|
|
52
|
+
manifest_text = Pray.read_manifest_text(manifest_path)
|
|
53
|
+
manifest = Pray.parse_manifest(manifest_text)
|
|
54
|
+
manifest.deprecation_warnings.each { |warning| warn(warning) }
|
|
55
|
+
Environment.validate_environment(manifest, options.environment)
|
|
56
|
+
manifest_hash = manifest.manifest_hash
|
|
57
|
+
sources = source_map(manifest.sources)
|
|
58
|
+
git_sources = GitSources.prepare_git_sources(
|
|
59
|
+
project_root,
|
|
60
|
+
manifest.sources,
|
|
61
|
+
lockfile_hints,
|
|
62
|
+
refresh: options.refresh || options.refresh_source_revisions
|
|
63
|
+
)
|
|
64
|
+
source_revisions = git_sources.transform_values(&:revision)
|
|
65
|
+
source_host_keys = Trust.prepare_source_host_keys(manifest.sources)
|
|
66
|
+
|
|
67
|
+
packages = []
|
|
68
|
+
seen = {}
|
|
69
|
+
errors = []
|
|
70
|
+
manifest.packages.each do |declaration|
|
|
71
|
+
package = resolve_package(
|
|
72
|
+
project_root,
|
|
73
|
+
sources,
|
|
74
|
+
git_sources,
|
|
75
|
+
user_config,
|
|
76
|
+
declaration,
|
|
77
|
+
lockfile_hints,
|
|
78
|
+
offline: options.offline
|
|
79
|
+
)
|
|
80
|
+
if seen[package.declaration.name]
|
|
81
|
+
raise Error.resolution("duplicate package declaration: #{package.declaration.name}")
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
seen[package.declaration.name] = true
|
|
85
|
+
packages << package
|
|
86
|
+
rescue Error => error
|
|
87
|
+
errors << "#{declaration.name}: #{error.message}"
|
|
88
|
+
end
|
|
89
|
+
raise Error.resolution(errors.join("\n")) unless errors.empty?
|
|
90
|
+
|
|
91
|
+
local_files = []
|
|
92
|
+
local_errors = []
|
|
93
|
+
manifest.local.each do |local|
|
|
94
|
+
local_files << resolve_local_file(project_root, local)
|
|
95
|
+
rescue Error => error
|
|
96
|
+
local_errors << "local #{local.path}: #{error.message}"
|
|
97
|
+
end
|
|
98
|
+
raise Error.resolution(local_errors.join("\n")) unless local_errors.empty?
|
|
99
|
+
|
|
100
|
+
ResolvedProject.new(
|
|
101
|
+
manifest_path: manifest_path,
|
|
102
|
+
project_root: project_root,
|
|
103
|
+
manifest: manifest,
|
|
104
|
+
manifest_hash: manifest_hash,
|
|
105
|
+
packages: packages,
|
|
106
|
+
local_files: local_files,
|
|
107
|
+
source_revisions: source_revisions,
|
|
108
|
+
source_host_keys: source_host_keys,
|
|
109
|
+
environment: options.environment
|
|
110
|
+
)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def resolve_project_with_git_refresh_fallback(
|
|
114
|
+
manifest_path,
|
|
115
|
+
offline: false,
|
|
116
|
+
refresh: false,
|
|
117
|
+
allow_git_refresh_fallback: false,
|
|
118
|
+
environment: nil
|
|
119
|
+
)
|
|
120
|
+
options = ResolveOptions.new(offline: offline, refresh: refresh, environment: environment)
|
|
121
|
+
resolve_project_with_options(manifest_path, options)
|
|
122
|
+
rescue Error => error
|
|
123
|
+
if allow_git_refresh_fallback &&
|
|
124
|
+
!offline &&
|
|
125
|
+
!refresh &&
|
|
126
|
+
resolution_may_benefit_from_git_source_refresh?(error)
|
|
127
|
+
refreshed = ResolveOptions.new(offline: offline, refresh: true, environment: environment)
|
|
128
|
+
resolve_project_with_options(manifest_path, refreshed)
|
|
129
|
+
else
|
|
130
|
+
raise
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def resolution_may_benefit_from_git_source_refresh?(error)
|
|
135
|
+
error.category == :resolution && error.message.include?("no registry version")
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def missing_local_embed_guidance(path)
|
|
139
|
+
"Prayfile lists `local \"#{path}\"` but the file does not exist. " \
|
|
140
|
+
"Create the file or remove the entry from Prayfile, then run `pray install`."
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def resolve_package(project_root, sources, git_sources, user_config, declaration, lockfile, offline: false)
|
|
144
|
+
root, registry_latest_version = resolve_package_root_with_metadata(
|
|
145
|
+
project_root, sources, git_sources, user_config, declaration, lockfile, offline: offline
|
|
146
|
+
)
|
|
147
|
+
spec_path = find_prayspec_file(root)
|
|
148
|
+
spec_text = File.read(spec_path)
|
|
149
|
+
spec = Pray.parse_package_spec(spec_text).canonicalized
|
|
150
|
+
if spec.name != declaration.name
|
|
151
|
+
raise Error.resolution(
|
|
152
|
+
"package path #{root.inspect} declares #{spec.name.inspect}, expected #{declaration.name.inspect}"
|
|
153
|
+
)
|
|
154
|
+
end
|
|
155
|
+
unless Constraint.version_satisfies(spec.version, declaration.constraint)
|
|
156
|
+
raise Error.resolution(
|
|
157
|
+
"package #{declaration.name} version #{spec.version} does not satisfy constraint #{declaration.constraint}"
|
|
158
|
+
)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
selected_exports = select_exports(declaration, spec)
|
|
162
|
+
file_bytes = load_package_file_bytes(root, spec)
|
|
163
|
+
tree_hash = PackageSpec.tree_hash_from_file_bytes(file_bytes)
|
|
164
|
+
export_bodies = load_export_bodies(file_bytes, spec, selected_exports)
|
|
165
|
+
skill_files = build_skill_file_index(spec)
|
|
166
|
+
|
|
167
|
+
ResolvedPackage.new(
|
|
168
|
+
declaration: declaration,
|
|
169
|
+
root: root,
|
|
170
|
+
spec: spec,
|
|
171
|
+
tree_hash: tree_hash,
|
|
172
|
+
artifact_hash: tree_hash,
|
|
173
|
+
artifact: "path:#{File.dirname(spec_path)}",
|
|
174
|
+
selected_exports: selected_exports,
|
|
175
|
+
source_checksum: tree_hash,
|
|
176
|
+
export_bodies: export_bodies,
|
|
177
|
+
skill_files: skill_files,
|
|
178
|
+
signer_fingerprint: nil,
|
|
179
|
+
registry_latest_version: registry_latest_version
|
|
180
|
+
)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def resolve_package_root_with_metadata(
|
|
184
|
+
project_root, sources, git_sources, user_config, declaration, lockfile, offline: false
|
|
185
|
+
)
|
|
186
|
+
if (local_path = user_config.local.package[declaration.name])
|
|
187
|
+
return [File.expand_path(local_path, project_root), nil]
|
|
188
|
+
end
|
|
189
|
+
return [File.expand_path(declaration.path, project_root), nil] if declaration.path
|
|
190
|
+
|
|
191
|
+
if declaration.source
|
|
192
|
+
source = sources[declaration.source]
|
|
193
|
+
raise Error.resolution("unknown source: #{declaration.source}") unless source
|
|
194
|
+
|
|
195
|
+
if (local_path = user_config.local.source[declaration.source])
|
|
196
|
+
source_root = File.expand_path(local_path, project_root)
|
|
197
|
+
resolved = Registry.resolve_local_registry_package_root(
|
|
198
|
+
project_root,
|
|
199
|
+
"local:#{declaration.source}",
|
|
200
|
+
source_root,
|
|
201
|
+
declaration,
|
|
202
|
+
preferred_version: lockfile_preferred_version(lockfile, declaration.name),
|
|
203
|
+
offline: offline
|
|
204
|
+
)
|
|
205
|
+
return [resolved.root, resolved.registry_latest_version]
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
case source.kind
|
|
209
|
+
when "path"
|
|
210
|
+
return [File.join(project_root, source.url, declaration.name.tr("/", "-")), nil]
|
|
211
|
+
when "registry", "static index"
|
|
212
|
+
resolved = Registry.resolve_registry_package_root(
|
|
213
|
+
project_root,
|
|
214
|
+
source.url,
|
|
215
|
+
declaration,
|
|
216
|
+
preferred_version: lockfile_preferred_version(lockfile, declaration.name),
|
|
217
|
+
offline: offline
|
|
218
|
+
)
|
|
219
|
+
return [resolved.root, resolved.registry_latest_version]
|
|
220
|
+
when "pray_ssh"
|
|
221
|
+
raise Error.unsupported("pray_ssh sources are not implemented yet in pray-cli Ruby")
|
|
222
|
+
when "git"
|
|
223
|
+
checkout = git_sources[source.name]
|
|
224
|
+
raise Error.resolution("git source #{source.name} was not prepared") unless checkout
|
|
225
|
+
|
|
226
|
+
clone_url = source.url.delete_prefix("git+")
|
|
227
|
+
distribution_root = GitSources.resolve_distribution_root(checkout.cache_directory, checkout.subdir)
|
|
228
|
+
source_key = checkout.revision.to_s.empty? ? clone_url : "#{clone_url}@#{checkout.revision}"
|
|
229
|
+
resolved = Registry.resolve_local_registry_package_root(
|
|
230
|
+
project_root,
|
|
231
|
+
source_key,
|
|
232
|
+
distribution_root,
|
|
233
|
+
declaration,
|
|
234
|
+
preferred_version: lockfile_preferred_version(lockfile, declaration.name),
|
|
235
|
+
offline: offline
|
|
236
|
+
)
|
|
237
|
+
return [resolved.root, resolved.registry_latest_version]
|
|
238
|
+
else
|
|
239
|
+
raise Error.unsupported("source kind #{source.kind} not implemented yet")
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
if declaration.git || declaration.tarball || declaration.oci
|
|
244
|
+
raise Error.unsupported("remote sources are not implemented yet")
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
[File.join(project_root, declaration.name.tr("/", "-")), nil]
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def resolve_package_root(project_root, sources, git_sources, user_config, declaration, lockfile = nil)
|
|
251
|
+
resolve_package_root_with_metadata(project_root, sources, git_sources, user_config, declaration, lockfile).first
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def lockfile_preferred_version(lockfile, package_name)
|
|
255
|
+
return nil unless lockfile
|
|
256
|
+
|
|
257
|
+
lockfile.package.find { |entry| entry.name == package_name }&.version
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def resolve_local_file(project_root, declaration)
|
|
261
|
+
path = File.join(project_root, declaration.path)
|
|
262
|
+
unless File.exist?(path)
|
|
263
|
+
if declaration.optional
|
|
264
|
+
return ResolvedLocalFile.new(
|
|
265
|
+
path: path,
|
|
266
|
+
manifest_path: declaration.path,
|
|
267
|
+
content: "",
|
|
268
|
+
position: declaration.position,
|
|
269
|
+
optional: true
|
|
270
|
+
)
|
|
271
|
+
end
|
|
272
|
+
raise Error.resolution(missing_local_embed_guidance(declaration.path))
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
ResolvedLocalFile.new(
|
|
276
|
+
path: path,
|
|
277
|
+
manifest_path: declaration.path,
|
|
278
|
+
content: Hashing.normalize_line_endings(File.read(path)),
|
|
279
|
+
position: declaration.position,
|
|
280
|
+
optional: declaration.optional
|
|
281
|
+
)
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def find_prayspec_file(root)
|
|
285
|
+
files = Dir.children(root).filter_map do |entry|
|
|
286
|
+
path = File.join(root, entry)
|
|
287
|
+
(File.file?(path) && File.extname(entry) == ".prayspec") ? path : nil
|
|
288
|
+
end
|
|
289
|
+
case files.length
|
|
290
|
+
when 1 then files.first
|
|
291
|
+
when 0 then raise Error.resolution("no prayspec file found in #{root.inspect}")
|
|
292
|
+
else raise Error.resolution("multiple prayspec files found in #{root.inspect}")
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def source_map(sources)
|
|
297
|
+
sources.to_h { |source| [source.name, source] }
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def select_exports(declaration, spec)
|
|
301
|
+
unless declaration.exports.empty?
|
|
302
|
+
declaration.exports.each do |export|
|
|
303
|
+
unless spec.exports.key?(export)
|
|
304
|
+
raise Error.resolution("package #{declaration.name} does not export #{export}")
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
return declaration.exports
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
roles = declaration.roles || []
|
|
311
|
+
return spec.exports.keys.sort if roles.empty? && declaration.file.nil?
|
|
312
|
+
|
|
313
|
+
effective_roles = roles.dup
|
|
314
|
+
effective_roles << "file" if declaration.file && !effective_roles.include?("file")
|
|
315
|
+
|
|
316
|
+
selected = []
|
|
317
|
+
effective_roles.each do |role|
|
|
318
|
+
compatible = spec.exports.filter_map do |name, export|
|
|
319
|
+
name if Destination.export_kind_matches_role?(export.kind, role)
|
|
320
|
+
end
|
|
321
|
+
case compatible.length
|
|
322
|
+
when 1
|
|
323
|
+
selected << compatible.first unless selected.include?(compatible.first)
|
|
324
|
+
when 0
|
|
325
|
+
raise Error.resolution(
|
|
326
|
+
"package #{declaration.name} has no export compatible with #{role}"
|
|
327
|
+
)
|
|
328
|
+
else
|
|
329
|
+
raise Error.resolution(
|
|
330
|
+
"package #{declaration.name} has multiple exports compatible with #{role}; set export: \"name\""
|
|
331
|
+
)
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
selected
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def load_package_file_bytes(root, spec)
|
|
338
|
+
file_bytes = {}
|
|
339
|
+
spec.files.each do |file|
|
|
340
|
+
path = File.join(root, file)
|
|
341
|
+
raise Error.integrity("package file missing: #{file}") unless File.exist?(path)
|
|
342
|
+
raise Error.integrity("package file is a directory: #{file}") if File.directory?(path)
|
|
343
|
+
|
|
344
|
+
file_bytes[file] = File.binread(path)
|
|
345
|
+
end
|
|
346
|
+
file_bytes
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def load_export_bodies(file_bytes, spec, selected_exports)
|
|
350
|
+
export_bodies = {}
|
|
351
|
+
selected_exports.each do |export_name|
|
|
352
|
+
entry = spec.exports[export_name]
|
|
353
|
+
raise Error.resolution("package #{spec.name} is missing export #{export_name}") unless entry
|
|
354
|
+
next unless entry.kind == "fragment"
|
|
355
|
+
|
|
356
|
+
bytes = file_bytes[entry.path]
|
|
357
|
+
raise Error.integrity("package file missing for export #{export_name}: #{entry.path}") unless bytes
|
|
358
|
+
|
|
359
|
+
export_bodies[export_name] = Hashing.normalize_line_endings(bytes.force_encoding(Encoding::UTF_8))
|
|
360
|
+
end
|
|
361
|
+
export_bodies
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def build_skill_file_index(spec)
|
|
365
|
+
index = {}
|
|
366
|
+
spec.exports.each do |export_name, export|
|
|
367
|
+
next unless %w[folder skill].include?(export.kind)
|
|
368
|
+
|
|
369
|
+
folder_prefix = export.path.delete_suffix("/")
|
|
370
|
+
files = indexed_files_under_prefix(spec.files, folder_prefix)
|
|
371
|
+
index[export_name] = files unless files.empty?
|
|
372
|
+
end
|
|
373
|
+
spec.skills.each do |skill_name, skill|
|
|
374
|
+
next if index.key?(skill_name)
|
|
375
|
+
|
|
376
|
+
skill_prefix = skill.path.delete_suffix("/")
|
|
377
|
+
files = indexed_files_under_prefix(spec.files, skill_prefix)
|
|
378
|
+
index[skill_name] = files unless files.empty?
|
|
379
|
+
end
|
|
380
|
+
index
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def indexed_files_under_prefix(files, prefix)
|
|
384
|
+
files.filter_map { |file| skill_relative_file(file, prefix) }
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def skill_relative_file(file, skill_prefix)
|
|
388
|
+
return nil unless file.start_with?(skill_prefix)
|
|
389
|
+
|
|
390
|
+
relative = file.delete_prefix(skill_prefix).delete_prefix("/")
|
|
391
|
+
return nil if relative.empty? || file == skill_prefix
|
|
392
|
+
|
|
393
|
+
relative
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pray
|
|
4
|
+
ResolveOptions = Struct.new(
|
|
5
|
+
:offline, :refresh, :unlocked_packages, :refresh_source_revisions,
|
|
6
|
+
:ignore_locked_versions, :environment
|
|
7
|
+
) do
|
|
8
|
+
def initialize(
|
|
9
|
+
offline: false,
|
|
10
|
+
refresh: false,
|
|
11
|
+
unlocked_packages: Set.new,
|
|
12
|
+
refresh_source_revisions: false,
|
|
13
|
+
ignore_locked_versions: false,
|
|
14
|
+
environment: nil
|
|
15
|
+
)
|
|
16
|
+
super
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
data/lib/pray/serve.rb
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "pathname"
|
|
6
|
+
require "json"
|
|
7
|
+
require_relative "path_safety"
|
|
8
|
+
require_relative "serve_federation"
|
|
9
|
+
|
|
10
|
+
module Pray
|
|
11
|
+
module Serve
|
|
12
|
+
DEFAULT_MAX_CONNECTIONS = 8
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def run_server(root:, host: "127.0.0.1", port: 7429, max_connections: DEFAULT_MAX_CONNECTIONS)
|
|
17
|
+
root = File.expand_path(root)
|
|
18
|
+
server = TCPServer.new(host, port)
|
|
19
|
+
connection_slots = SizedQueue.new(max_connections)
|
|
20
|
+
max_connections.times { connection_slots << true }
|
|
21
|
+
puts "Serving #{root} on http://#{host}:#{port}"
|
|
22
|
+
|
|
23
|
+
loop do
|
|
24
|
+
socket = server.accept
|
|
25
|
+
begin
|
|
26
|
+
connection_slots.pop(true)
|
|
27
|
+
rescue ThreadError
|
|
28
|
+
socket.print(service_unavailable)
|
|
29
|
+
socket.close
|
|
30
|
+
next
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
Thread.new { serve_connection(root, socket, connection_slots) }
|
|
34
|
+
rescue Interrupt
|
|
35
|
+
break
|
|
36
|
+
end
|
|
37
|
+
ensure
|
|
38
|
+
server&.close
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def serve_connection(root, socket, connection_slots)
|
|
42
|
+
handle_connection(root, socket)
|
|
43
|
+
ensure
|
|
44
|
+
connection_slots << true unless connection_slots.closed?
|
|
45
|
+
socket.close unless socket.closed?
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def handle_connection(root, socket)
|
|
49
|
+
request_line = socket.gets
|
|
50
|
+
return unless request_line
|
|
51
|
+
|
|
52
|
+
method, path, = request_line.split
|
|
53
|
+
headers = read_headers(socket)
|
|
54
|
+
body_length = headers["content-length"].to_i
|
|
55
|
+
body = body_length.positive? ? socket.read(body_length) : ""
|
|
56
|
+
|
|
57
|
+
response = dispatch_request(root, method, path, body)
|
|
58
|
+
socket.print(response)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def read_headers(socket)
|
|
62
|
+
headers = {}
|
|
63
|
+
loop do
|
|
64
|
+
line = socket.gets
|
|
65
|
+
break if line.nil? || line.strip.empty?
|
|
66
|
+
|
|
67
|
+
name, value = line.split(":", 2)
|
|
68
|
+
headers[name.strip.downcase] = value.strip if name && value
|
|
69
|
+
end
|
|
70
|
+
headers
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def dispatch_request(root, method, path, body = "")
|
|
74
|
+
path = path.split("?", 2).first
|
|
75
|
+
|
|
76
|
+
case [method, path]
|
|
77
|
+
when ["GET", "/.well-known/pray-federation.json"]
|
|
78
|
+
return ServeFederation.discovery_response(root)
|
|
79
|
+
when ["GET", "/v1/sync/index"]
|
|
80
|
+
return ServeFederation.index_response(root)
|
|
81
|
+
when ["POST", "/v1/confessions"]
|
|
82
|
+
return ServeFederation.append_confession(root, body)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
if method == "GET" && path.start_with?("/v1/sync/package/")
|
|
86
|
+
return ServeFederation.package_response(root, path)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
return not_found unless method == "GET"
|
|
90
|
+
|
|
91
|
+
if path == "/"
|
|
92
|
+
return html_response("<h1>Pray distribution</h1><p>Root: #{root}</p>")
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
file_path = PathSafety.join_under_root(root, path.delete_prefix("/"))
|
|
96
|
+
return not_found unless file_path
|
|
97
|
+
return not_found unless File.file?(file_path)
|
|
98
|
+
|
|
99
|
+
content_type = content_type_for(file_path)
|
|
100
|
+
file_body = File.binread(file_path)
|
|
101
|
+
ok_response(content_type, file_body)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def content_type_for(path)
|
|
105
|
+
case File.extname(path)
|
|
106
|
+
when ".json" then "application/json"
|
|
107
|
+
when ".praypkg" then "application/octet-stream"
|
|
108
|
+
else "text/plain"
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def ok_response(content_type, body)
|
|
113
|
+
"HTTP/1.1 200 OK\r\nContent-Type: #{content_type}\r\nContent-Length: #{body.bytesize}\r\nConnection: close\r\n\r\n#{body}"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def html_response(body)
|
|
117
|
+
ok_response("text/html", body)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def not_found
|
|
121
|
+
body = "not found"
|
|
122
|
+
"HTTP/1.1 404 Not Found\r\nContent-Type: text/plain\r\nContent-Length: #{body.bytesize}\r\nConnection: close\r\n\r\n#{body}"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def service_unavailable
|
|
126
|
+
body = "too many connections"
|
|
127
|
+
"HTTP/1.1 503 Service Unavailable\r\nContent-Type: text/plain\r\nContent-Length: #{body.bytesize}\r\nConnection: close\r\n\r\n#{body}"
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Pray
|
|
6
|
+
module ServeFederation
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def discovery_response(root)
|
|
10
|
+
peers = read_peers(root)
|
|
11
|
+
body = {
|
|
12
|
+
"spec" => "pray-federation-v1",
|
|
13
|
+
"server" => {
|
|
14
|
+
"name" => "pray",
|
|
15
|
+
"version" => VERSION,
|
|
16
|
+
"capabilities" => ["static_registry", "federation"]
|
|
17
|
+
},
|
|
18
|
+
"sync" => {
|
|
19
|
+
"index_url" => "/v1/sync/index",
|
|
20
|
+
"package_url" => "/v1/sync/package/{name}",
|
|
21
|
+
"artifact_url" => "/v1/artifacts/{package}/{version}/{artifact}",
|
|
22
|
+
"since_param" => "since"
|
|
23
|
+
},
|
|
24
|
+
"peers" => peers
|
|
25
|
+
}
|
|
26
|
+
Serve.ok_response("application/json", JSON.pretty_generate(body))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def index_response(root)
|
|
30
|
+
index = Publish.load_registry_index(root)
|
|
31
|
+
packages = index.packages.filter_map do |name|
|
|
32
|
+
metadata = Publish.load_registry_package_metadata(
|
|
33
|
+
Publish.registry_metadata_path(root, name), name
|
|
34
|
+
)
|
|
35
|
+
next if metadata.versions.empty?
|
|
36
|
+
|
|
37
|
+
updated_at = metadata.versions.map { |version| version.published_at.to_s }.max || "0"
|
|
38
|
+
{
|
|
39
|
+
"name" => name,
|
|
40
|
+
"updated_at" => updated_at,
|
|
41
|
+
"url" => "/v1/sync/package/#{name}"
|
|
42
|
+
}
|
|
43
|
+
end
|
|
44
|
+
body = {
|
|
45
|
+
"spec" => "prayfile-distribution-1",
|
|
46
|
+
"sync_version" => 0,
|
|
47
|
+
"packages" => packages
|
|
48
|
+
}
|
|
49
|
+
Serve.ok_response("application/json", JSON.pretty_generate(body))
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def package_response(root, path)
|
|
53
|
+
name = path.delete_prefix("/v1/sync/package/")
|
|
54
|
+
metadata_path = Publish.registry_metadata_path(root, name)
|
|
55
|
+
return Serve.not_found unless File.file?(metadata_path)
|
|
56
|
+
|
|
57
|
+
metadata = Publish.load_registry_package_metadata(metadata_path, name)
|
|
58
|
+
body = {
|
|
59
|
+
"name" => metadata.name,
|
|
60
|
+
"updated_at" => metadata.versions.map { |version| version.published_at.to_s }.max || "0",
|
|
61
|
+
"versions" => metadata.versions.map { |version| transport_version(version) }
|
|
62
|
+
}
|
|
63
|
+
Serve.ok_response("application/json", JSON.pretty_generate(body))
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def append_confession(root, body)
|
|
67
|
+
FileUtils.mkdir_p(File.join(root, "v1"))
|
|
68
|
+
path = File.join(root, "v1", "confessions.jsonl")
|
|
69
|
+
File.open(path, "a") { |file| file.puts(body.to_s.b) }
|
|
70
|
+
Serve.ok_response("application/json", JSON.generate({"status" => "ok"}))
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def transport_version(version)
|
|
74
|
+
hash = {
|
|
75
|
+
"version" => version.version,
|
|
76
|
+
"artifact" => version.artifact,
|
|
77
|
+
"artifact_hash" => version.artifact_hash.to_s,
|
|
78
|
+
"tree_hash" => version.tree_hash.to_s,
|
|
79
|
+
"yanked" => version.yanked,
|
|
80
|
+
"targets" => version.targets,
|
|
81
|
+
"exports" => version.exports,
|
|
82
|
+
"published_at" => version.published_at.to_s
|
|
83
|
+
}
|
|
84
|
+
if version.signer || version.signer_fingerprint
|
|
85
|
+
hash["publisher"] = {
|
|
86
|
+
"id" => version.signer.to_s,
|
|
87
|
+
"key_fingerprint" => version.signer_fingerprint.to_s
|
|
88
|
+
}
|
|
89
|
+
end
|
|
90
|
+
if version.signature
|
|
91
|
+
hash["signature"] = {
|
|
92
|
+
"public_key" => version.signer.to_s,
|
|
93
|
+
"signature" => version.signature,
|
|
94
|
+
"value" => version.signature
|
|
95
|
+
}
|
|
96
|
+
end
|
|
97
|
+
hash
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def read_peers(root)
|
|
101
|
+
path = File.join(root, "v1", "peers.json")
|
|
102
|
+
return [] unless File.file?(path)
|
|
103
|
+
|
|
104
|
+
Array(JSON.parse(File.read(path)))
|
|
105
|
+
rescue JSON::ParserError
|
|
106
|
+
[]
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|