pray-cli 1.16.0 → 1.18.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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -0
  3. data/lib/pray/apply_report.rb +49 -0
  4. data/lib/pray/cli/commands/init.rb +1 -20
  5. data/lib/pray/cli/commands/init_prayer.rb +76 -0
  6. data/lib/pray/cli/commands/init_prayer_manifest.rb +102 -0
  7. data/lib/pray/cli/commands/packages.rb +13 -2
  8. data/lib/pray/cli/commands/trust.rb +3 -3
  9. data/lib/pray/cli/commands/update_latest.rb +14 -16
  10. data/lib/pray/cli/commands/workflow.rb +5 -0
  11. data/lib/pray/cli/help.rb +52 -15
  12. data/lib/pray/cli/helpers.rb +13 -5
  13. data/lib/pray/cli/parse.rb +21 -1
  14. data/lib/pray/cli.rb +3 -1
  15. data/lib/pray/destination.rb +32 -2
  16. data/lib/pray/distribution.rb +94 -0
  17. data/lib/pray/format_serialize.rb +1 -4
  18. data/lib/pray/git_cache.rb +192 -0
  19. data/lib/pray/git_clone.rb +32 -0
  20. data/lib/pray/git_materialize.rb +76 -0
  21. data/lib/pray/git_run.rb +41 -0
  22. data/lib/pray/git_sources.rb +60 -145
  23. data/lib/pray/local_prayer.rb +38 -0
  24. data/lib/pray/lockfile.rb +8 -2
  25. data/lib/pray/manifest.rb +6 -3
  26. data/lib/pray/manifest_json.rb +1 -0
  27. data/lib/pray/manifest_parser_blocks.rb +15 -15
  28. data/lib/pray/materialize.rb +7 -1
  29. data/lib/pray/package_spec.rb +25 -13
  30. data/lib/pray/package_spec_render.rb +4 -3
  31. data/lib/pray/package_spec_version.rb +33 -0
  32. data/lib/pray/plan.rb +2 -1
  33. data/lib/pray/publish.rb +27 -2
  34. data/lib/pray/registry.rb +4 -15
  35. data/lib/pray/registry_http_cache.rb +17 -0
  36. data/lib/pray/render.rb +6 -36
  37. data/lib/pray/render_managed.rb +64 -0
  38. data/lib/pray/render_patch.rb +63 -12
  39. data/lib/pray/resolve.rb +6 -37
  40. data/lib/pray/resolve_local.rb +39 -0
  41. data/lib/pray/resolve_source.rb +5 -0
  42. data/lib/pray/serve.rb +15 -4
  43. data/lib/pray/serve_range.rb +36 -0
  44. data/lib/pray/torrent_manifest.rb +78 -0
  45. data/lib/pray/upstream_drift.rb +42 -0
  46. data/lib/pray/upstream_merge.rb +25 -0
  47. data/lib/pray/upstream_refresh.rb +28 -2
  48. data/lib/pray/verify.rb +2 -2
  49. data/lib/pray/version.rb +2 -2
  50. data/lib/pray.rb +10 -0
  51. metadata +17 -1
data/lib/pray/cli.rb CHANGED
@@ -9,6 +9,8 @@ require_relative "cli/help"
9
9
  require_relative "cli/suggest"
10
10
  require_relative "cli/helpers"
11
11
  require_relative "cli/commands/init"
12
+ require_relative "cli/commands/init_prayer_manifest"
13
+ require_relative "cli/commands/init_prayer"
12
14
  require_relative "cli/commands/workflow"
13
15
  require_relative "cli/commands/update_latest"
14
16
  require_relative "cli/commands/packages"
@@ -82,7 +84,7 @@ module Pray
82
84
  case command
83
85
  in [:manifest] then manifest_command
84
86
  in [:init, targets] then init_command(targets)
85
- in [:prayer_init] then prayer_init_command
87
+ in [:prayer_init, name, directory] then prayer_init_command(name, directory)
86
88
  in [:repo_init] then repo_init_command
87
89
  in [:install, flags] then install_command(flags)
88
90
  in [:add, add_args] then add_command(**add_args)
@@ -16,8 +16,7 @@ module Pray
16
16
 
17
17
  def local_path_form?(value)
18
18
  value.start_with?(".", "/") ||
19
- value.end_with?(".md", ".txt", ".markdown") ||
20
- !value.include?("/")
19
+ value.end_with?(".md", ".txt", ".markdown")
21
20
  end
22
21
 
23
22
  def destination_target_name(mode, path)
@@ -106,6 +105,7 @@ module Pray
106
105
  if existing.position == "after" && local.position != "after"
107
106
  existing.position = local.position
108
107
  end
108
+ existing.file ||= local.file
109
109
  end
110
110
 
111
111
  def bind_package_entry(target, package_name)
@@ -154,5 +154,35 @@ module Pray
154
154
  else false
155
155
  end
156
156
  end
157
+
158
+ def local_compose_embed?(manifest, local)
159
+ return false if local.file
160
+
161
+ manifest.targets.none? do |target|
162
+ target.mode == "tree" &&
163
+ target.entries.any? { |entry| entry.kind == "local" && entry.path == local.path }
164
+ end
165
+ end
166
+
167
+ def try_apply_local_pray(manifest, path, file_dest, other_package_signal, destination_index)
168
+ return false unless local_path_form?(path)
169
+
170
+ if file_dest
171
+ return false if other_package_signal
172
+ raise Error.parse("manifest", "a local file cannot use file:")
173
+ end
174
+ return false if other_package_signal
175
+ unless destination_index
176
+ raise Error.parse("manifest", "local pray paths are only valid inside compose")
177
+ end
178
+ mode = manifest.targets[destination_index].mode
179
+ unless mode == "compose"
180
+ raise Error.parse("manifest", "local pray paths are only valid inside compose")
181
+ end
182
+ local = ManifestLocal.new(path: path, bound: true)
183
+ bind_local_entry(manifest.targets[destination_index], local.path)
184
+ upsert_local(manifest, local)
185
+ true
186
+ end
157
187
  end
158
188
  end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+
6
+ module Pray
7
+ DISTRIBUTION_CONFIG_SPEC = "pray-distribution-config-1"
8
+ TORRENT_PROTOCOL = "torrent"
9
+ DISTRIBUTION_CONFIG_FIELDS = %w[spec protocols bootstrap_trackers enable_dht].freeze
10
+
11
+ RegistryDistributionSettings = Struct.new(
12
+ :spec, :protocols, :bootstrap_trackers, :enable_dht,
13
+ keyword_init: true
14
+ ) do
15
+ def initialize(spec: DISTRIBUTION_CONFIG_SPEC, protocols: [], bootstrap_trackers: [], enable_dht: false)
16
+ super
17
+ end
18
+
19
+ def allows_torrent?
20
+ Array(protocols).include?(TORRENT_PROTOCOL)
21
+ end
22
+ end
23
+
24
+ module Distribution
25
+ module_function
26
+
27
+ def default_settings
28
+ RegistryDistributionSettings.new
29
+ end
30
+
31
+ def parse_settings(text)
32
+ data = JSON.parse(text)
33
+ unless data.is_a?(Hash)
34
+ raise Error.parse("distribution config", "expected an object")
35
+ end
36
+
37
+ data.each_key do |key|
38
+ next if DISTRIBUTION_CONFIG_FIELDS.include?(key)
39
+
40
+ raise Error.parse("distribution config", "unsupported field: #{key}")
41
+ end
42
+ settings = RegistryDistributionSettings.new(
43
+ spec: data.fetch("spec", DISTRIBUTION_CONFIG_SPEC),
44
+ protocols: Array(data["protocols"]),
45
+ bootstrap_trackers: Array(data["bootstrap_trackers"]),
46
+ enable_dht: data["enable_dht"] == true
47
+ )
48
+ validate_settings!(settings)
49
+ settings
50
+ rescue JSON::ParserError => error
51
+ raise Error.parse("distribution config", error.message)
52
+ end
53
+
54
+ def read_settings(root)
55
+ path = File.join(root, "v1", "distribution.json")
56
+ return default_settings unless File.file?(path)
57
+
58
+ parse_settings(File.read(path))
59
+ end
60
+
61
+ def write_settings(root, settings = default_settings)
62
+ validate_settings!(settings)
63
+ path = File.join(root, "v1", "distribution.json")
64
+ FileUtils.mkdir_p(File.dirname(path))
65
+ payload = {"spec" => settings.spec, "protocols" => Array(settings.protocols)}
66
+ File.write(path, JSON.pretty_generate(payload))
67
+ end
68
+
69
+ def validate_settings!(settings)
70
+ unless settings.spec == DISTRIBUTION_CONFIG_SPEC
71
+ raise Error.parse("distribution config", "unsupported distribution config spec: #{settings.spec}")
72
+ end
73
+ Array(settings.protocols).each do |name|
74
+ next if name == TORRENT_PROTOCOL
75
+
76
+ raise Error.parse("distribution config", "unsupported protocol: #{name}")
77
+ end
78
+ if settings.enable_dht
79
+ raise Error.parse("distribution config", "DHT announce is not implemented")
80
+ end
81
+ return if Array(settings.bootstrap_trackers).empty? || settings.allows_torrent?
82
+
83
+ raise Error.parse("distribution config", "bootstrap_trackers requires protocols to include torrent")
84
+ end
85
+
86
+ def fetch_settings(source_url)
87
+ parse_settings(Registry.http_get(Registry.join_url(source_url, "v1/distribution.json")))
88
+ rescue Error => error
89
+ return default_settings if error.message.match?(/:\s*404\b/)
90
+
91
+ raise
92
+ end
93
+ end
94
+ end
@@ -42,10 +42,7 @@ module Pray
42
42
  path = target.skills.first || ""
43
43
  lines << %(tree "#{path}" do)
44
44
  target.entries.each do |entry|
45
- next unless entry.kind == "package"
46
-
47
- package = find_package(manifest, entry.name)
48
- lines << " #{ManifestMethods.format_package_declaration(package)}" if package
45
+ lines << " #{format_destination_entry(entry, manifest)}"
49
46
  end
50
47
  lines << "end"
51
48
  end
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "pathname"
5
+ require_relative "error"
6
+ require_relative "hashing"
7
+ require_relative "git_run"
8
+ require_relative "git_clone"
9
+
10
+ module Pray
11
+ module GitCache
12
+ module_function
13
+
14
+ def ensure_git_repository(project_root, clone_url, refresh:, pinned_revision:, sparse_subdir:)
15
+ shared = git_source_cache_directory(project_root, clone_url)
16
+ ensure_shared_git_repository(project_root, clone_url, shared, refresh: refresh, pinned_revision: pinned_revision)
17
+ checkout = git_source_cache_directory(project_root, clone_url, sparse_subdir)
18
+ if checkout != shared
19
+ ensure_linked_worktree(shared, checkout)
20
+ GitClone.apply_sparse_checkout(checkout, sparse_subdir)
21
+ if pinned_revision
22
+ checkout_git_revision(checkout, clone_url, pinned_revision, refresh)
23
+ elsif refresh
24
+ GitRun.run_git(checkout, "reset", "--hard", git_head_revision(shared))
25
+ end
26
+ return [checkout, git_head_revision(checkout)]
27
+ end
28
+
29
+ GitClone.apply_sparse_checkout(shared)
30
+ [shared, git_head_revision(shared)]
31
+ end
32
+
33
+ def git_source_cache_directory(project_root, clone_url, subdir = nil)
34
+ identity = (subdir && !subdir.empty?) ? "#{clone_url}\n#{subdir}" : clone_url
35
+ File.join(project_root, ".pray", "cache", "git", cache_key(identity))
36
+ end
37
+
38
+ def git_source_cached_repository(project_root, clone_url)
39
+ shared = git_source_cache_directory(project_root, clone_url)
40
+ return shared if git_checkout?(shared)
41
+
42
+ cache_root = File.join(project_root, ".pray", "cache", "git")
43
+ return nil unless File.directory?(cache_root)
44
+
45
+ Dir.children(cache_root).each do |name|
46
+ path = File.join(cache_root, name)
47
+ return path if git_checkout?(path) && origin_matches?(path, clone_url)
48
+ end
49
+ nil
50
+ end
51
+
52
+ def git_checkout?(path)
53
+ File.exist?(File.join(path, ".git"))
54
+ end
55
+
56
+ def cache_key(text)
57
+ Hashing.sha256_prefixed(text)[7, 16]
58
+ end
59
+
60
+ def ensure_shared_git_repository(project_root, clone_url, shared, refresh:, pinned_revision:)
61
+ if File.directory?(File.join(shared, ".git"))
62
+ if pinned_revision
63
+ checkout_git_revision(shared, clone_url, pinned_revision, refresh)
64
+ elsif refresh
65
+ refresh_git_worktree(shared, clone_url)
66
+ end
67
+ refresh_global_from_project(clone_url, shared) if refresh
68
+ return
69
+ end
70
+
71
+ FileUtils.rm_rf(shared) if File.exist?(shared)
72
+ FileUtils.mkdir_p(File.dirname(shared))
73
+ seeded = seed_git_cache_from_global(clone_url, shared, project_root)
74
+ if seeded
75
+ GitRun.run_git(shared, "remote", "set-url", "origin", clone_url)
76
+ else
77
+ GitClone.clone_git_cache(project_root, clone_url, shared, quiet: false)
78
+ mirror_git_cache_to_global(clone_url, shared)
79
+ end
80
+ GitClone.apply_sparse_checkout(shared)
81
+ if pinned_revision
82
+ checkout_git_revision(shared, clone_url, pinned_revision, true)
83
+ elsif refresh && seeded
84
+ refresh_git_worktree(shared, clone_url)
85
+ end
86
+ refresh_global_from_project(clone_url, shared) if refresh && seeded
87
+ end
88
+
89
+ def ensure_linked_worktree(shared, checkout)
90
+ return if same_object_store?(shared, checkout)
91
+
92
+ FileUtils.rm_rf(checkout) if File.exist?(checkout)
93
+ FileUtils.mkdir_p(File.dirname(checkout))
94
+ return if GitRun.try_run_git(shared, "worktree", "add", "--detach", "--no-checkout", checkout)
95
+
96
+ GitRun.run_git(shared, "worktree", "add", "--detach", checkout)
97
+ end
98
+
99
+ def same_object_store?(shared, checkout)
100
+ return false unless git_checkout?(checkout)
101
+
102
+ git_common_dir(shared) == git_common_dir(checkout)
103
+ end
104
+
105
+ def git_common_dir(repository)
106
+ output, status = GitRun.capture_git(repository, "rev-parse", "--git-common-dir")
107
+ raise Error.resolution(GitRun.command_error("git rev-parse --git-common-dir", output)) unless status.success?
108
+
109
+ reported = output.strip
110
+ path = Pathname.new(reported).absolute? ? reported : File.expand_path(reported, repository)
111
+ File.realpath(path)
112
+ end
113
+
114
+ def origin_matches?(repository, clone_url)
115
+ output, status = GitRun.capture_git(repository, "remote", "get-url", "origin")
116
+ return false unless status.success?
117
+
118
+ origin = output.strip.delete_prefix("git+")
119
+ origin == clone_url
120
+ end
121
+
122
+ def global_cache_root
123
+ return ENV["PRAY_CACHE"] if ENV["PRAY_CACHE"]
124
+ return File.join(ENV["PRAY_HOME"], "cache") if ENV["PRAY_HOME"]
125
+
126
+ home = ENV["HOME"]
127
+ home ? File.join(home, ".cache", "pray") : nil
128
+ end
129
+
130
+ def global_git_cache_directory(clone_url)
131
+ root = global_cache_root
132
+ root ? File.join(root, "git", cache_key(clone_url)) : nil
133
+ end
134
+
135
+ def global_git_cache_ready?(global_cache)
136
+ File.directory?(File.join(global_cache, ".git")) || File.file?(File.join(global_cache, "HEAD"))
137
+ end
138
+
139
+ def seed_git_cache_from_global(clone_url, destination, working_directory)
140
+ global_cache = global_git_cache_directory(clone_url)
141
+ return false unless global_cache && global_git_cache_ready?(global_cache)
142
+
143
+ GitClone.clone_git_cache(working_directory, global_cache, destination, quiet: true)
144
+ true
145
+ end
146
+
147
+ def mirror_git_cache_to_global(clone_url, project_cache)
148
+ global_cache = global_git_cache_directory(clone_url)
149
+ return unless global_cache
150
+ return if global_git_cache_ready?(global_cache)
151
+
152
+ FileUtils.mkdir_p(File.dirname(global_cache))
153
+ FileUtils.rm_rf(global_cache) if File.exist?(global_cache)
154
+ GitRun.run_git(File.dirname(project_cache), "clone", "--bare", "--quiet", File.basename(project_cache), global_cache)
155
+ end
156
+
157
+ def fetch_origin(repository, revision = nil)
158
+ extra = revision ? [revision] : []
159
+ return if GitRun.try_run_git(repository, "fetch", "--depth", "1", "--filter=blob:none", "origin", *extra)
160
+
161
+ GitRun.run_git(repository, "fetch", "--depth", "1", "origin", *extra)
162
+ end
163
+
164
+ def checkout_git_revision(repository, _clone_url, revision, refresh)
165
+ fetch_origin(repository, revision) if refresh
166
+ GitRun.run_git(repository, "checkout", "--force", revision)
167
+ end
168
+
169
+ def refresh_git_worktree(repository, _clone_url)
170
+ fetch_origin(repository)
171
+ GitRun.run_git(repository, "reset", "--hard", "origin/HEAD")
172
+ end
173
+
174
+ def refresh_global_from_project(clone_url, project_cache)
175
+ global_cache = global_git_cache_directory(clone_url)
176
+ return unless global_cache
177
+
178
+ FileUtils.rm_rf(global_cache) if global_git_cache_ready?(global_cache) || File.exist?(global_cache)
179
+ mirror_git_cache_to_global(clone_url, project_cache)
180
+ end
181
+
182
+ def git_head_revision(repository)
183
+ output, status = GitRun.capture_git(repository, "rev-parse", "HEAD")
184
+ raise Error.resolution(GitRun.command_error("git rev-parse HEAD", output)) unless status.success?
185
+
186
+ revision = output.strip
187
+ raise Error.resolution("git repository has no HEAD revision") if revision.empty?
188
+
189
+ revision
190
+ end
191
+ end
192
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "git_run"
4
+
5
+ module Pray
6
+ module GitClone
7
+ module_function
8
+
9
+ def clone_git_cache(working_directory, source, destination, quiet:)
10
+ filtered = ["clone", "--depth", "1", "--filter=blob:none", "--sparse", source, destination]
11
+ filtered.insert(1, "--quiet") if quiet
12
+ return if GitRun.try_run_git(working_directory, *filtered)
13
+
14
+ full = ["clone", "--depth", "1", source, destination]
15
+ full.insert(1, "--quiet") if quiet
16
+ GitRun.run_git(working_directory, *full)
17
+ end
18
+
19
+ def apply_sparse_checkout(repository, subdir = nil)
20
+ GitRun.run_git(repository, "sparse-checkout", "init", "--cone")
21
+ GitRun.run_git(repository, "sparse-checkout", "set", *catalog_sparse_cones(subdir))
22
+ end
23
+
24
+ def catalog_sparse_cones(subdir)
25
+ if subdir && !subdir.empty?
26
+ ["#{subdir}/v1/packages"]
27
+ else
28
+ ["v1/packages", "prayers/v1/packages"]
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "git_run"
4
+ require_relative "error"
5
+ require_relative "path_safety"
6
+
7
+ module Pray
8
+ module GitMaterialize
9
+ module_function
10
+
11
+ def read_local_artifact_bytes(source_root, artifact)
12
+ if artifact.start_with?("file://")
13
+ path = PathSafety.join_under_root(source_root, artifact.delete_prefix("file://"))
14
+ raise Error.resolution("package artifact path escapes distribution root") unless path
15
+
16
+ return File.binread(path)
17
+ end
18
+ if artifact.match?(%r{\A[a-z][a-z0-9+.-]*:}i)
19
+ raise Error.integrity("remote artifact path must be relative: #{artifact}")
20
+ end
21
+
22
+ path = PathSafety.join_under_root(source_root, artifact)
23
+ raise Error.resolution("package artifact path escapes distribution root") unless path
24
+
25
+ materialize_catalog_file(source_root, artifact) unless File.exist?(path)
26
+ raise Error.resolution("package artifact missing at #{path}") unless File.exist?(path)
27
+
28
+ File.binread(path)
29
+ end
30
+
31
+ def materialize_catalog_file(source_root, relative)
32
+ return if File.file?(File.join(source_root, relative))
33
+ return unless sparse_checkout_enabled?(source_root)
34
+
35
+ toplevel = git_toplevel(source_root)
36
+ return unless toplevel
37
+
38
+ repo_relative = repo_relative_text(source_root, relative)
39
+ return unless repo_relative
40
+
41
+ cone = artifact_sparse_cone(repo_relative)
42
+ GitRun.run_git(toplevel, "sparse-checkout", "add", cone) if cone
43
+ GitRun.run_git(toplevel, "checkout", "HEAD", "--", repo_relative)
44
+ end
45
+
46
+ def sparse_checkout_enabled?(source_root)
47
+ output, status = GitRun.capture_git(source_root, "config", "--get", "core.sparseCheckout")
48
+ status.success? && output.strip == "true"
49
+ end
50
+
51
+ def git_toplevel(source_root)
52
+ output, status = GitRun.capture_git(source_root, "rev-parse", "--show-toplevel")
53
+ return unless status.success?
54
+
55
+ text = output.strip
56
+ text.empty? ? nil : text
57
+ end
58
+
59
+ def repo_relative_text(source_root, relative)
60
+ output, status = GitRun.capture_git(source_root, "rev-parse", "--show-prefix")
61
+ return unless status.success?
62
+
63
+ prefix = output.strip
64
+ prefix.empty? ? relative : File.join(prefix, relative)
65
+ end
66
+
67
+ def artifact_sparse_cone(repo_relative)
68
+ parts = repo_relative.split("/").reject(&:empty?)
69
+ return repo_relative if parts.length < 2
70
+
71
+ parts.pop
72
+ parts.pop if parts.last != "artifacts" && parts.length > 3
73
+ parts.join("/")
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require_relative "error"
5
+
6
+ module Pray
7
+ module GitRun
8
+ module_function
9
+
10
+ def run_git(cwd, *arguments)
11
+ output, status = capture_git(cwd, *arguments)
12
+ return if status.success?
13
+
14
+ raise Error.resolution(command_error("git #{arguments.join(" ")}", output))
15
+ end
16
+
17
+ def try_run_git(cwd, *arguments)
18
+ _output, status = capture_git(cwd, *arguments)
19
+ status.success?
20
+ end
21
+
22
+ def capture_git(cwd, *arguments)
23
+ env = ENV.to_h.merge("GIT_TERMINAL_PROMPT" => "0")
24
+ Open3.capture2e(
25
+ env,
26
+ "git",
27
+ "-c",
28
+ "protocol.file.allow=always",
29
+ "-C",
30
+ cwd,
31
+ *arguments,
32
+ stdin_data: ""
33
+ )
34
+ end
35
+
36
+ def command_error(program, output)
37
+ message = output.strip
38
+ message.empty? ? "#{program} failed" : "#{program} failed: #{message}"
39
+ end
40
+ end
41
+ end