pray-cli 1.14.0 → 1.16.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cc753f65405f7052d882da07b3293b66c69e60cb26b8fe865919000323f2da1a
4
- data.tar.gz: 91586f374cc5a677b0f5d2b722a4ba5da5912d8bd3664eac0ccd48b864889c65
3
+ metadata.gz: 5950b63f50e919bd5864874919afde95413ed095954046604d570d4c3daf090a
4
+ data.tar.gz: 82056f753f0d244afd54de70b7528d1e5d98b0c67f0da18538eb5d906986f12b
5
5
  SHA512:
6
- metadata.gz: a84c2945dba3386f31dfdfc08d603ecee88d2be1e9898c20a993cd9dba9149ae4966b9cf9e02ba8c28905b7a1c81e2f5158bdf2eff8b36fe7f470e0bbd82ab6a
7
- data.tar.gz: 48f107b5721d69c62b83db0f99247f9c4821eea42f85d3459cac09b7441badc3fa092965f877b1d3ae3fb0d38f391e8f65ae76cc21a6fd1595799dd2de0c6324
6
+ metadata.gz: e3289cc2a08751a1358c37ddfb692d042c2d522374b02ed370dd25e703f5c5fe3be66996bbd008a7e6dfbe26a9ea6b278b87f640035a55a235ef7e1ed1d00967
7
+ data.tar.gz: a54b4dbed546b3f301d42692033552c3878b2281f25fee70e647e02634c19541a7440542bddbade45619a2e6dd4cac8bcefc05ff89b035645195ce90a3bffad6
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## 1.16.0 (2026-09-15)
4
+
5
+ - Rewrite a two-component pessimistic Prayfile pin on `pray update --latest` (`~> 2.2` to `~> 2.4` when registry latest is 2.4.0), matching the Rust and TypeScript CLIs. Install a newer version that the current constraint already allows.
6
+ - Resolve a local `.praypkg` from `tarball:`, including offline when the archive is on disk.
7
+ - Refuse two-package dependency cycles during resolve, matching the Rust CLI.
8
+ - Check dest files against `Prayfile.lock` managed spans without resolving packages (`inspect_locked_destinations`, RFC 0106).
9
+
10
+ ## 1.15.0 (2026-09-15)
11
+
12
+ - Refresh path-fork trees with `pray update` (RFC 0114). Rewrite `spec.upstream` pins with `pray update --latest` when the pin does not admit the latest upstream version. Rewrite Prayfile constraints with `pray update --latest` when they do not admit the registry latest version. `pray update --latest --dry-run` prints the planned rewrite and does not write.
13
+ - Keep `spec.upstream` in the packaged prayspec. Published registry metadata does not copy that pin.
14
+ - Keep a listed package spec in `spec.files` when packing; refuse a repeated or aliased content path in the package archive.
15
+ - Use the current project for a later install after an earlier in-process command in another directory.
16
+
3
17
  ## 1.14.0 (2026-09-14)
4
18
 
5
19
  - Keep an unchanged package version's artifact, first-publish time, and yank when `pray publish` runs again (RFC 0061).
data/lib/pray/archive.rb CHANGED
@@ -4,6 +4,7 @@ require "json"
4
4
  require "open3"
5
5
  require "fileutils"
6
6
  require_relative "archive_unpack"
7
+ require_relative "path_safety"
7
8
 
8
9
  module Pray
9
10
  module Archive
@@ -11,14 +12,23 @@ module Pray
11
12
 
12
13
  def build_package_archive_bytes(package)
13
14
  prayspec_path = Resolve.find_prayspec_file(package.root)
14
- prayspec_name = File.basename(prayspec_path)
15
+ prayspec_name = PathSafety.validate_archive_member_path!(File.basename(prayspec_path))
15
16
  metadata = package_metadata_json(package)
17
+ written_paths = Set.new
18
+ record_archive_path(written_paths, "metadata.json", prayspec_name)
19
+ record_archive_path(written_paths, prayspec_name, prayspec_name)
20
+ package.spec.files.each do |file|
21
+ record_archive_path(written_paths, file, prayspec_name)
22
+ end
16
23
 
17
24
  Dir.mktmpdir("pray-package-") do |staging|
18
25
  File.write(File.join(staging, "metadata.json"), metadata)
19
26
  File.write(File.join(staging, prayspec_name), File.binread(prayspec_path))
20
27
  package.spec.files.each do |file|
21
- destination = File.join(staging, file)
28
+ member = PathSafety.validate_archive_member_path!(file)
29
+ next if member == prayspec_name
30
+
31
+ destination = File.join(staging, member)
22
32
  FileUtils.mkdir_p(File.dirname(destination))
23
33
  File.binwrite(destination, File.binread(File.join(package.root, file)))
24
34
  end
@@ -66,5 +76,14 @@ module Pray
66
76
  def with_binary_process_encoding(&block)
67
77
  ArchiveUnpack.with_binary_process_encoding(&block)
68
78
  end
79
+
80
+ def record_archive_path(written_paths, path, auto_included_prayspec)
81
+ normalized = PathSafety.validate_archive_member_path!(path)
82
+ return if written_paths.add?(normalized)
83
+ return if normalized == auto_included_prayspec
84
+
85
+ raise Error.integrity("duplicate package archive path: #{normalized}")
86
+ end
87
+ private_class_method :record_archive_path
69
88
  end
70
89
  end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pray
4
+ module CLI
5
+ def update_latest_command(package, dry_run:, offline:)
6
+ path = manifest_path
7
+ original_text = Pray.read_manifest_text(path)
8
+ manifest_text = original_text
9
+ preview_options = ResolveOptions.new(
10
+ offline: offline,
11
+ refresh: true,
12
+ refresh_source_revisions: true,
13
+ ignore_locked_versions: true
14
+ )
15
+ project = resolve_current_project(preview_options)
16
+ if package && project.manifest.packages.none? { |entry| entry.name == package }
17
+ raise Error.manifest("package #{package} not found")
18
+ end
19
+
20
+ manifest_updates = []
21
+ project.packages.each do |resolved|
22
+ next if package && resolved.declaration.name != package
23
+ next unless resolved.registry_latest_version
24
+ next if Constraint.version_satisfies(resolved.registry_latest_version, resolved.declaration.constraint)
25
+
26
+ new_constraint = Constraint.latest_constraint_for_package(
27
+ resolved.declaration.constraint,
28
+ resolved.registry_latest_version
29
+ )
30
+ unless Constraint.version_satisfies(resolved.registry_latest_version, new_constraint)
31
+ raise Error.resolution(
32
+ "derived constraint #{new_constraint} does not admit registry latest #{resolved.registry_latest_version} for #{resolved.declaration.name}"
33
+ )
34
+ end
35
+
36
+ manifest_updates << {
37
+ name: resolved.declaration.name,
38
+ from_constraint: resolved.declaration.constraint,
39
+ to_constraint: new_constraint,
40
+ registry_latest_version: resolved.registry_latest_version
41
+ }
42
+ updated = resolved.declaration.dup
43
+ updated.constraint = new_constraint
44
+ manifest_text = Pray.replace_package_declaration(manifest_text, updated)
45
+ end
46
+
47
+ previous = File.exist?(lockfile_path) ? Pray.read_lockfile(lockfile_path) : nil
48
+ upstream_plans = Upstream.plan_path_upstream_latest_constraints(
49
+ project, previous, package, preview_options
50
+ )
51
+ print_latest_constraint_plans(manifest_updates, upstream_plans)
52
+
53
+ return if dry_run
54
+
55
+ Upstream.apply_path_upstream_latest_constraints(upstream_plans)
56
+ Transaction.write_file(path, manifest_text) if manifest_text != original_text
57
+ unlocked = package ? Set[package] : Set.new
58
+ options = ResolveOptions.new(
59
+ offline: offline,
60
+ refresh: true,
61
+ refresh_source_revisions: true,
62
+ ignore_locked_versions: package.nil?,
63
+ unlocked_packages: unlocked
64
+ )
65
+ current = resolve_current_project(options)
66
+ Upstream.apply_path_upstream_refreshes(current, previous, package, options)
67
+ install_command(
68
+ {
69
+ locked: false,
70
+ frozen: false,
71
+ offline: offline,
72
+ refresh: true,
73
+ ignore_locked_versions: package.nil?,
74
+ unlocked_packages: unlocked
75
+ }
76
+ )
77
+ end
78
+
79
+ def print_latest_constraint_plans(manifest_updates, upstream_plans)
80
+ if manifest_updates.empty? && upstream_plans.empty?
81
+ puts "All package constraints already allow latest versions"
82
+ return
83
+ end
84
+
85
+ manifest_updates.each do |update|
86
+ puts "Prayfile: #{update[:name]} constraint #{update[:from_constraint]} -> #{update[:to_constraint]} " \
87
+ "(registry latest #{update[:registry_latest_version]})"
88
+ end
89
+ upstream_plans.each do |plan|
90
+ puts "#{plan.package_name} upstream #{plan.current_constraint} -> #{plan.new_constraint} (latest #{plan.latest_version})"
91
+ end
92
+ end
93
+ end
94
+ end
@@ -10,7 +10,9 @@ module Pray
10
10
  frozen: flags[:frozen],
11
11
  locked: flags[:locked],
12
12
  offline: flags[:offline],
13
- refresh: flags[:refresh]
13
+ refresh: flags[:refresh],
14
+ ignore_locked_versions: flags[:ignore_locked_versions],
15
+ unlocked_packages: flags[:unlocked_packages]
14
16
  )
15
17
  end
16
18
 
@@ -84,10 +86,10 @@ module Pray
84
86
  end
85
87
 
86
88
  def update_command(arguments)
87
- if arguments.any? { |argument| %w[--dry-run --json].include?(argument) }
89
+ if arguments.include?("--json") || (arguments.include?("--dry-run") && !arguments.include?("--latest"))
88
90
  raise Error.unsupported("this Ruby CLI does not support --dry-run or --json for update; use `pray plan` to preview the current Prayfile")
89
91
  end
90
- if arguments.any? { |argument| %w[--latest --major].include?(argument) }
92
+ if arguments.include?("--major")
91
93
  raise Error.unsupported(
92
94
  "to update beyond the current constraint, edit the package version in Prayfile, then run `pray update`"
93
95
  )
@@ -100,9 +102,29 @@ module Pray
100
102
  raise Error.manifest("package #{package} not found")
101
103
  end
102
104
  end
103
- current = resolve_current_project(ResolveOptions.new(offline: offline))
104
- Upstream.ensure_update_supported!(current.packages, package)
105
- install_command({locked: false, frozen: false, offline: offline, refresh: true})
105
+ if arguments.include?("--latest")
106
+ return update_latest_command(package, dry_run: arguments.include?("--dry-run"), offline: offline)
107
+ end
108
+ unlocked = package ? Set[package] : Set.new
109
+ options = ResolveOptions.new(
110
+ offline: offline,
111
+ refresh: true,
112
+ refresh_source_revisions: true,
113
+ ignore_locked_versions: package.nil?,
114
+ unlocked_packages: unlocked
115
+ )
116
+ current = resolve_current_project(options)
117
+ Upstream.apply_path_upstream_refreshes(current, current.previous_lockfile, package, options)
118
+ install_command(
119
+ {
120
+ locked: false,
121
+ frozen: false,
122
+ offline: offline,
123
+ refresh: true,
124
+ ignore_locked_versions: package.nil?,
125
+ unlocked_packages: unlocked
126
+ }
127
+ )
106
128
  end
107
129
 
108
130
  def unlock_command(name)
data/lib/pray/cli/help.rb CHANGED
@@ -101,7 +101,11 @@ module Pray
101
101
  "update" => <<~TEXT.strip,
102
102
  refresh package versions within constraints
103
103
 
104
- Usage: pray update [package]
104
+ Usage: pray update [package] [--latest] [--latest --dry-run]
105
+
106
+ --latest adjusts constraints to allow the latest package versions.
107
+ --latest also rewrites exact spec.upstream pins in path packages, then refreshes those trees.
108
+ --latest --dry-run prints the planned rewrite and does not write.
105
109
  TEXT
106
110
  "plan" => <<~TEXT.strip,
107
111
  preview install/apply changes
data/lib/pray/cli.rb CHANGED
@@ -10,6 +10,7 @@ require_relative "cli/suggest"
10
10
  require_relative "cli/helpers"
11
11
  require_relative "cli/commands/init"
12
12
  require_relative "cli/commands/workflow"
13
+ require_relative "cli/commands/update_latest"
13
14
  require_relative "cli/commands/packages"
14
15
  require_relative "cli/commands/distribution"
15
16
  require_relative "cli/commands/trust"
@@ -22,6 +23,7 @@ module Pray
22
23
  LOCKFILE_PATH = "Prayfile.lock"
23
24
 
24
25
  def run(arguments)
26
+ previous_context = Invocation.context
25
27
  arguments = arguments.dup
26
28
  if arguments.delete("--no-input")
27
29
  ENV["PRAY_NO_INPUT"] = "1"
@@ -35,6 +37,8 @@ module Pray
35
37
  else
36
38
  dispatch(command)
37
39
  end
40
+ ensure
41
+ Invocation.context = previous_context
38
42
  end
39
43
 
40
44
  def maybe_print_help(arguments)
@@ -17,15 +17,28 @@ module Pray
17
17
  end
18
18
 
19
19
  def version_satisfies(version, constraint)
20
- normalized = normalize_version_constraint(constraint)
21
- return true if normalized.empty? || normalized == "*"
20
+ parts = constraint.to_s.split(",").map(&:strip).reject(&:empty?)
21
+ return true if parts.empty?
22
22
 
23
- requirement = Gem::Requirement.new(normalized.strip)
24
- requirement.satisfied_by?(Gem::Version.new(version))
23
+ parsed = Gem::Version.new(version)
24
+ parts.all? { |part| version_satisfies_one(parsed, part) }
25
25
  rescue ArgumentError => error
26
26
  raise Error.resolution(error.message)
27
27
  end
28
28
 
29
+ def version_satisfies_one(version, constraint)
30
+ normalized = normalize_version_constraint(constraint)
31
+ return true if normalized.empty? || normalized == "*"
32
+
33
+ requirement_text = if normalized.lstrip.start_with?("~>")
34
+ ruby_pessimistic_to_semver(normalized)
35
+ else
36
+ normalized.strip
37
+ end
38
+ clauses = requirement_text.split(",").map(&:strip).reject(&:empty?)
39
+ Gem::Requirement.new(*clauses).satisfied_by?(version)
40
+ end
41
+
29
42
  def pessimistic_constraint_for_version(version)
30
43
  parsed = Gem::Version.new(version)
31
44
  if parsed.segments[1].to_i.zero? && parsed.segments[2].to_i.zero?
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pray
4
+ module DependencyGraph
5
+ module_function
6
+
7
+ def find_dependency_cycle(edges)
8
+ visiting = {}
9
+ visited = {}
10
+ stack = []
11
+ edges.keys.sort.each do |name|
12
+ next if visited[name]
13
+
14
+ cycle = depth_first_search(name, edges, visiting, visited, stack)
15
+ return cycle if cycle
16
+ end
17
+ nil
18
+ end
19
+
20
+ def depth_first_search(name, edges, visiting, visited, stack)
21
+ return nil if visited[name]
22
+ if visiting[name]
23
+ start = stack.index(name)
24
+ return nil unless start
25
+
26
+ return stack[start..] + [name]
27
+ end
28
+
29
+ visiting[name] = true
30
+ stack << name
31
+ Array(edges[name]).each do |dependency|
32
+ next unless edges.key?(dependency)
33
+
34
+ cycle = depth_first_search(dependency, edges, visiting, visited, stack)
35
+ return cycle if cycle
36
+ end
37
+ stack.pop
38
+ visiting.delete(name)
39
+ visited[name] = true
40
+ nil
41
+ end
42
+ end
43
+ end
@@ -16,8 +16,8 @@ module Pray
16
16
  next unless source.kind == "git"
17
17
 
18
18
  clone_url = source.url.delete_prefix("git+")
19
- if local_filesystem_source?(clone_url) && !local_git_repo_path(clone_url)
20
- source_root = local_git_source_root(clone_url)
19
+ if local_filesystem_source?(clone_url) && !local_git_repo_path(project_root, clone_url)
20
+ source_root = local_git_source_root(project_root, clone_url)
21
21
  if source_root
22
22
  checkouts[source.name] = GitSourceCheckout.new(
23
23
  cache_directory: source_root,
@@ -76,12 +76,8 @@ module Pray
76
76
  File.directory?(File.join(path, "v1", "packages"))
77
77
  end
78
78
 
79
- def local_git_source_root(clone_url)
80
- path = if clone_url.start_with?("file://")
81
- clone_url.delete_prefix("file://")
82
- else
83
- clone_url
84
- end
79
+ def local_git_source_root(project_root, clone_url)
80
+ path = clone_url_filesystem_path(project_root, clone_url)
85
81
  return nil unless File.exist?(path)
86
82
 
87
83
  discover_distribution_root(path)
@@ -136,12 +132,17 @@ module Pray
136
132
  clone_url.start_with?("file://") || Pathname.new(clone_url).absolute?
137
133
  end
138
134
 
139
- def local_git_repo_path(clone_url)
140
- path = clone_url.delete_prefix("file://")
135
+ def local_git_repo_path(project_root, clone_url)
136
+ path = clone_url_filesystem_path(project_root, clone_url)
141
137
  git_directory = File.join(path, ".git")
142
138
  File.directory?(git_directory) ? path : nil
143
139
  end
144
140
 
141
+ def clone_url_filesystem_path(project_root, clone_url)
142
+ path = clone_url.delete_prefix("file://")
143
+ Pathname.new(path).absolute? ? path : File.expand_path(path, project_root)
144
+ end
145
+
145
146
  def global_cache_root
146
147
  return ENV["PRAY_CACHE"] if ENV["PRAY_CACHE"]
147
148
  return File.join(ENV["PRAY_HOME"], "cache") if ENV["PRAY_HOME"]
@@ -13,7 +13,9 @@ module Pray
13
13
  frozen: false,
14
14
  locked: false,
15
15
  offline: false,
16
- refresh: false
16
+ refresh: false,
17
+ ignore_locked_versions: false,
18
+ unlocked_packages: nil
17
19
  )
18
20
  context = Invocation.invocation_context
19
21
  manifest_path = File.expand_path(manifest_path || context.manifest_path)
@@ -25,6 +27,9 @@ module Pray
25
27
  options = ResolveOptions.new(
26
28
  offline: offline,
27
29
  refresh: refresh,
30
+ refresh_source_revisions: refresh,
31
+ ignore_locked_versions: ignore_locked_versions,
32
+ unlocked_packages: unlocked_packages ? Set.new(unlocked_packages) : Set.new,
28
33
  environment: context.environment
29
34
  )
30
35
  allow_git_refresh_fallback = !locked && !frozen
@@ -128,6 +133,7 @@ module Pray
128
133
  def resolve_project(...) = Resolve.resolve_project(...)
129
134
  def render_project(...) = Render.render_project(...)
130
135
  def inspect_project(...) = Verify.inspect_project(...)
136
+ def inspect_locked_destinations(...) = Verify.inspect_locked_destinations(...)
131
137
  def verify_project(...) = Verify.verify_project(...)
132
138
  def drift_project(...) = Verify.drift_project(...)
133
139
  end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pray
4
+ module PackageSpecRender
5
+ module_function
6
+
7
+ def fork_spec_after_refresh(local, new_upstream, local_prayspec_file, clean_replica, merged_content_paths)
8
+ spec = local.dup
9
+ spec.files = [local_prayspec_file, *merged_content_paths]
10
+ if clean_replica
11
+ spec.exports = new_upstream.exports.dup
12
+ spec.templates = new_upstream.templates.dup
13
+ end
14
+ if spec.upstream
15
+ spec.upstream = PackageUpstream.new(
16
+ name: new_upstream.name,
17
+ constraint: Upstream.next_upstream_constraint(spec.upstream.constraint, new_upstream.version)
18
+ )
19
+ end
20
+ spec
21
+ end
22
+
23
+ def render_package_spec(spec)
24
+ lines = ["Package::Specification.new do |spec|"]
25
+ push_identity_fields(lines, spec)
26
+ push_content_fields(lines, spec)
27
+ push_relation_fields(lines, spec)
28
+ lines << "end"
29
+ lines << ""
30
+ lines.join("\n")
31
+ end
32
+
33
+ def push_identity_fields(lines, spec)
34
+ push_assignment(lines, "name", spec.name)
35
+ push_assignment(lines, "version", spec.version)
36
+ push_optional(lines, "summary", spec.summary)
37
+ push_optional(lines, "description", spec.description)
38
+ push_array(lines, "authors", spec.authors) unless spec.authors.nil? || spec.authors.empty?
39
+ push_optional(lines, "license", spec.license)
40
+ push_optional(lines, "homepage", spec.homepage)
41
+ push_optional(lines, "source_code_uri", spec.source_code_uri)
42
+ push_optional(lines, "changelog_uri", spec.changelog_uri)
43
+ push_optional(lines, "prayfile_version", spec.prayfile_version)
44
+ end
45
+
46
+ def push_content_fields(lines, spec)
47
+ push_array(lines, "files", spec.files)
48
+ push_exports(lines, spec.exports)
49
+ push_named_paths(lines, "skills", spec.skills)
50
+ push_named_paths(lines, "templates", spec.templates)
51
+ push_string_map(lines, "adapters", spec.adapters)
52
+ push_array(lines, "targets", spec.targets) unless spec.targets.nil? || spec.targets.empty?
53
+ end
54
+
55
+ def push_relation_fields(lines, spec)
56
+ (spec.dependencies || []).each do |dependency|
57
+ method = dependency.optional ? "add_optional_dependency" : "add_dependency"
58
+ lines << " spec.#{method} #{quote(dependency.name)}, #{quote(dependency.constraint)}"
59
+ end
60
+ unless spec.metadata.nil? || spec.metadata.empty?
61
+ lines << " spec.metadata = #{literal_map(spec.metadata)}"
62
+ end
63
+ return unless spec.upstream
64
+
65
+ lines << " spec.upstream #{quote(spec.upstream.name)}, #{quote(spec.upstream.constraint)}"
66
+ end
67
+
68
+ def push_assignment(lines, field, value)
69
+ lines << " spec.#{field} = #{quote(value)}"
70
+ end
71
+
72
+ def push_optional(lines, field, value)
73
+ push_assignment(lines, field, value) unless value.nil?
74
+ end
75
+
76
+ def push_array(lines, field, values)
77
+ lines << " spec.#{field} = [#{string_array(values)}]"
78
+ end
79
+
80
+ def push_exports(lines, exports)
81
+ return if exports.nil? || exports.empty?
82
+
83
+ lines << " spec.exports = {"
84
+ exports.each do |name, export|
85
+ fields = ["type: #{quote(export.kind)}", "path: #{quote(export.path)}"]
86
+ fields << "summary: #{quote(export.summary)}" if export.summary
87
+ fields << "only: [#{string_array(export.only)}]" unless export.only.nil? || export.only.empty?
88
+ fields << "except: [#{string_array(export.except)}]" unless export.except.nil? || export.except.empty?
89
+ fields << "default_path: #{quote(export.default_path)}" if export.default_path
90
+ lines << " #{quote(name)} => { #{fields.join(", ")} },"
91
+ end
92
+ lines << " }"
93
+ end
94
+
95
+ def push_named_paths(lines, field, entries)
96
+ return if entries.nil? || entries.empty?
97
+
98
+ lines << " spec.#{field} = {"
99
+ entries.each do |name, entry|
100
+ summary = entry.summary ? ", summary: #{quote(entry.summary)}" : ""
101
+ lines << " #{quote(name)} => { path: #{quote(entry.path)}#{summary} },"
102
+ end
103
+ lines << " }"
104
+ end
105
+
106
+ def push_string_map(lines, field, entries)
107
+ return if entries.nil? || entries.empty?
108
+
109
+ values = entries.map { |name, value| "#{quote(name)} => #{quote(value)}" }.join(", ")
110
+ lines << " spec.#{field} = { #{values} }"
111
+ end
112
+
113
+ def string_array(values)
114
+ Array(values).map { |value| quote(value) }.join(", ")
115
+ end
116
+
117
+ def quote(value)
118
+ escaped = value.to_s.gsub("\\", "\\\\").gsub('"', '\\"').gsub("\n", "\\n").gsub("\r", "\\r").gsub("\t", "\\t")
119
+ %("#{escaped}")
120
+ end
121
+
122
+ def literal_map(entries)
123
+ values = entries.map { |name, value| "#{quote(name)} => #{literal(value)}" }.join(", ")
124
+ "{ #{values} }"
125
+ end
126
+
127
+ def literal(value)
128
+ case value.kind
129
+ when :string then quote(value.value)
130
+ when :symbol then ":#{value.value}"
131
+ when :bool then value.value.to_s
132
+ when :null then "nil"
133
+ when :integer then value.value.to_s
134
+ when :array then "[#{value.value.map { |entry| literal(entry) }.join(", ")}]"
135
+ when :map then literal_map(value.value)
136
+ else quote(value.value)
137
+ end
138
+ end
139
+ private_class_method :push_identity_fields, :push_content_fields, :push_relation_fields,
140
+ :push_assignment, :push_optional, :push_array, :push_exports,
141
+ :push_named_paths, :push_string_map, :string_array, :quote, :literal_map, :literal
142
+ end
143
+ end
@@ -39,20 +39,24 @@ module Pray
39
39
  end
40
40
 
41
41
  def validate_archive_member_path!(path)
42
- cleaned = path.to_s.delete_prefix("./")
43
- if cleaned.empty? || Pathname.new(cleaned).absolute? || cleaned.start_with?("/")
42
+ text = path.to_s.tr("\\", "/")
43
+ if text.empty? || Pathname.new(text).absolute? || text.start_with?("/")
44
44
  raise Error.integrity("package path must be relative: #{path}")
45
45
  end
46
46
 
47
- cleaned.split("/").each do |part|
47
+ parts = []
48
+ text.split("/").each do |part|
48
49
  next if part.empty? || part == "."
49
50
 
50
51
  if part == ".." || part.include?("\0")
51
52
  raise Error.integrity("package path escapes package root: #{path}")
52
53
  end
54
+
55
+ parts << part
53
56
  end
57
+ raise Error.integrity("package path must be relative: #{path}") if parts.empty?
54
58
 
55
- cleaned
59
+ parts.join("/")
56
60
  end
57
61
 
58
62
  def validate_project_relative_path!(value)
data/lib/pray/registry.rb CHANGED
@@ -8,6 +8,7 @@ require "pathname"
8
8
  require "time"
9
9
  require_relative "path_safety"
10
10
  require_relative "http_body"
11
+ require_relative "registry_paths"
11
12
 
12
13
  module Pray
13
14
  RegistryPackageVersion = Struct.new(
@@ -40,6 +41,17 @@ module Pray
40
41
  module_function
41
42
 
42
43
  def resolve_registry_package_root(project_root, source_url, declaration, preferred_version: nil, offline: false)
44
+ if local_source?(source_url)
45
+ return resolve_local_registry_package_root(
46
+ project_root,
47
+ source_url,
48
+ RegistryPaths.local_registry_root(project_root, source_url),
49
+ declaration,
50
+ preferred_version: preferred_version,
51
+ offline: offline
52
+ )
53
+ end
54
+
43
55
  metadata = fetch_package_metadata(source_url, declaration.name)
44
56
  registry_latest_version = registry_latest_version_label(metadata)
45
57
  selected = select_package_version(metadata, declaration.constraint, preferred_version)
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module Pray
6
+ module RegistryPaths
7
+ module_function
8
+
9
+ def local_registry_root(project_root, source_url)
10
+ path = source_url.delete_prefix("file://")
11
+ Pathname.new(path).absolute? ? path : File.expand_path(path, project_root)
12
+ end
13
+ end
14
+ end