rails-hyperdrive 0.6.0 → 0.7.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 +4 -4
- data/CHANGELOG.md +266 -1
- data/README.md +44 -13
- data/lib/generators/hyperdrive/install/USAGE +11 -3
- data/lib/generators/hyperdrive/install/install_generator.rb +16 -4
- data/lib/generators/hyperdrive/install_summary.rb +3 -3
- data/lib/generators/hyperdrive/sync/USAGE +7 -5
- data/lib/generators/hyperdrive/sync_runner.rb +20 -10
- data/lib/hyperdrive/skill_tasks.rb +39 -0
- data/lib/rails/commands/hyperdrive/hyperdrive_command.rb +59 -0
- data/lib/rails/hyperdrive/ancestor_locator.rb +10 -13
- data/lib/rails/hyperdrive/artifact_status.rb +16 -7
- data/lib/rails/hyperdrive/auto_install.rb +25 -7
- data/lib/rails/hyperdrive/bundler_artifact_discovery.rb +360 -150
- data/lib/rails/hyperdrive/canonical_skill_render.rb +68 -39
- data/lib/rails/hyperdrive/companion_discovery.rb +4 -5
- data/lib/rails/hyperdrive/gem_manifest.rb +311 -80
- data/lib/rails/hyperdrive/gemspec_locator.rb +38 -0
- data/lib/rails/hyperdrive/install_layout.rb +148 -12
- data/lib/rails/hyperdrive/install_pipeline.rb +142 -78
- data/lib/rails/hyperdrive/install_plan.rb +21 -9
- data/lib/rails/hyperdrive/lock_file.rb +25 -3
- data/lib/rails/hyperdrive/manifest_lint.rb +230 -0
- data/lib/rails/hyperdrive/skill_template.rb +7 -5
- data/lib/rails/hyperdrive/version.rb +1 -1
- metadata +9 -7
- data/lib/rails/hyperdrive/skill_tasks.rb +0 -27
- data/lib/tasks/hyperdrive.rake +0 -22
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
require "fileutils"
|
|
2
2
|
require "yaml"
|
|
3
3
|
require "rails/hyperdrive/bundler_artifact_discovery"
|
|
4
|
+
require "rails/hyperdrive/gem_manifest"
|
|
5
|
+
require "rails/hyperdrive/gemspec_locator"
|
|
4
6
|
require "rails/hyperdrive/skill_template"
|
|
5
7
|
|
|
6
8
|
module Rails
|
|
@@ -9,75 +11,102 @@ module Rails
|
|
|
9
11
|
# SKILL.md it pairs with, using the canonical fail-open binding.
|
|
10
12
|
# Companion-repo dev tooling: problems raise.
|
|
11
13
|
module CanonicalSkillRender
|
|
12
|
-
Error =
|
|
14
|
+
Error = GemspecLocator::Error
|
|
15
|
+
|
|
16
|
+
PUBLIC_SKILLS_DIR = "skills".freeze
|
|
13
17
|
|
|
14
18
|
Rendered = Struct.new(:template, :dest, :body, keyword_init: true)
|
|
15
19
|
|
|
20
|
+
# `public_skills_root` is where generic skills.sh consumers read whether
|
|
21
|
+
# or not the gem declares a content root; `skills_root` is where this
|
|
22
|
+
# gem's rendered faces belong.
|
|
23
|
+
Roots = Struct.new(:skills_root, :templates_root, :public_skills_root, keyword_init: true)
|
|
24
|
+
|
|
16
25
|
module_function
|
|
17
26
|
|
|
18
|
-
|
|
19
|
-
|
|
27
|
+
# Resolved once per task so a check does not load the gemspec twice.
|
|
28
|
+
def resolve_roots(gemspec: nil, dir: Dir.pwd)
|
|
29
|
+
spec, gem_root = GemspecLocator.load_spec(gemspec, dir)
|
|
30
|
+
path = File.join(gem_root, GemManifest.manifest_relpath(spec))
|
|
31
|
+
# Rendering with default roots over a manifest the installer cannot
|
|
32
|
+
# read would mask the bug this surface exists to catch.
|
|
33
|
+
manifest, failure = GemManifest.read_root(path)
|
|
34
|
+
raise Error, "#{path}: #{failure}" if failure
|
|
35
|
+
|
|
36
|
+
Roots.new(
|
|
37
|
+
skills_root: File.join(gem_root, manifest_dir(manifest, "skills_dir", path) || PUBLIC_SKILLS_DIR),
|
|
38
|
+
templates_root: File.join(gem_root, manifest_dir(manifest, "skill_templates_dir", path) ||
|
|
39
|
+
File.join("lib", spec.name, "hyperdrive", "skills")),
|
|
40
|
+
public_skills_root: File.join(gem_root, PUBLIC_SKILLS_DIR)
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def manifest_dir(manifest, key, path)
|
|
45
|
+
value, failure = GemManifest.read_dir(manifest, key)
|
|
46
|
+
raise Error, "#{path}: #{failure}" if failure
|
|
47
|
+
value
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def write(gemspec: nil, dir: Dir.pwd, roots: nil)
|
|
51
|
+
render_all(gemspec: gemspec, dir: dir, roots: roots).each do |r|
|
|
20
52
|
FileUtils.mkdir_p(File.dirname(r.dest))
|
|
21
53
|
File.write(r.dest, r.body)
|
|
22
54
|
end
|
|
23
55
|
end
|
|
24
56
|
|
|
25
|
-
def stale(gemspec: nil, dir: Dir.pwd)
|
|
26
|
-
render_all(gemspec: gemspec, dir: dir).reject do |r|
|
|
57
|
+
def stale(gemspec: nil, dir: Dir.pwd, roots: nil)
|
|
58
|
+
render_all(gemspec: gemspec, dir: dir, roots: roots).reject do |r|
|
|
27
59
|
File.file?(r.dest) && File.read(r.dest) == r.body
|
|
28
60
|
end
|
|
29
61
|
end
|
|
30
62
|
|
|
31
|
-
def render_all(gemspec: nil, dir: Dir.pwd)
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
raise Error, "could not load gemspec #{spec_path}" unless spec
|
|
35
|
-
|
|
36
|
-
gem_root = File.dirname(File.expand_path(spec_path))
|
|
37
|
-
skills_root = File.join(gem_root, metadata_dir(spec, "rails_hyperdrive_skills_dir"))
|
|
38
|
-
templates_root = File.join(gem_root, metadata_dir(spec, "rails_hyperdrive_skill_templates_dir"))
|
|
63
|
+
def render_all(gemspec: nil, dir: Dir.pwd, roots: nil)
|
|
64
|
+
roots ||= resolve_roots(gemspec: gemspec, dir: dir)
|
|
65
|
+
templates_root = roots.templates_root
|
|
39
66
|
|
|
40
|
-
Dir.glob(File.join(templates_root, "**", "SKILL.md.erb")).sort.
|
|
67
|
+
Dir.glob(File.join(templates_root, "**", "SKILL.md.erb")).sort.flat_map do |template|
|
|
41
68
|
rel = File.dirname(template).delete_prefix(templates_root).delete_prefix("/")
|
|
42
|
-
dest_dir = File.join(skills_root, rel)
|
|
69
|
+
dest_dir = File.join(roots.skills_root, rel)
|
|
43
70
|
# A SKILL.md written beside the SKILL.md.erb would take precedence
|
|
44
71
|
# over it at discovery time, silently demoting the skill to its
|
|
45
72
|
# static face.
|
|
46
73
|
if File.expand_path(dest_dir) == File.expand_path(File.dirname(template))
|
|
47
74
|
raise Error, "#{template}: content dir equals template dir; set " \
|
|
48
|
-
"
|
|
75
|
+
"skills_dir: in hyperdrive.yml to a separate root"
|
|
49
76
|
end
|
|
50
77
|
|
|
51
78
|
body = render_template(template)
|
|
52
79
|
validate_output!(body, template)
|
|
53
|
-
Rendered.new(template: template, dest: File.join(dest_dir, "SKILL.md"), body: body)
|
|
80
|
+
[Rendered.new(template: template, dest: File.join(dest_dir, "SKILL.md"), body: body)] +
|
|
81
|
+
support_renders(File.dirname(template), dest_dir)
|
|
54
82
|
end
|
|
55
83
|
end
|
|
56
84
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
"e.g. rake \"hyperdrive:skills:render[path/to/name.gemspec]\""
|
|
69
|
-
else raise Error, "multiple gemspecs found in #{dir} " \
|
|
70
|
-
"(#{found.map { |f| File.basename(f) }.join(", ")}); pass an explicit path"
|
|
85
|
+
# Supporting files carry no frontmatter contract, so their faces are
|
|
86
|
+
# written unvalidated.
|
|
87
|
+
def support_renders(template_dir, dest_dir)
|
|
88
|
+
Dir.glob(File.join(template_dir, "**", "*.md.erb")).sort.filter_map do |template|
|
|
89
|
+
rel = template.delete_prefix("#{template_dir}/")
|
|
90
|
+
next if File.basename(rel) == "SKILL.md.erb"
|
|
91
|
+
Rendered.new(
|
|
92
|
+
template: template,
|
|
93
|
+
dest: File.join(dest_dir, rel.delete_suffix(".erb")),
|
|
94
|
+
body: render_template(template)
|
|
95
|
+
)
|
|
71
96
|
end
|
|
72
97
|
end
|
|
73
98
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
99
|
+
# A *.md.erb reachable through a public skills root is copied verbatim by
|
|
100
|
+
# generic skills.sh consumers.
|
|
101
|
+
def public_erb_templates(gemspec: nil, dir: Dir.pwd, roots: nil)
|
|
102
|
+
roots ||= resolve_roots(gemspec: gemspec, dir: dir)
|
|
103
|
+
templates_root = File.expand_path(roots.templates_root)
|
|
104
|
+
|
|
105
|
+
[roots.public_skills_root, roots.skills_root]
|
|
106
|
+
.uniq { |r| File.expand_path(r) }
|
|
107
|
+
.flat_map { |root| Dir.glob(File.join(root, "**", "*.md.erb")) }
|
|
108
|
+
.reject { |path| File.expand_path(path).start_with?("#{templates_root}/") }
|
|
109
|
+
.uniq.sort
|
|
81
110
|
end
|
|
82
111
|
|
|
83
112
|
def render_template(template)
|
|
@@ -99,7 +128,7 @@ module Rails
|
|
|
99
128
|
raise Error, "#{template}: rendered frontmatter is not parseable YAML"
|
|
100
129
|
end
|
|
101
130
|
|
|
102
|
-
private_class_method :
|
|
131
|
+
private_class_method :manifest_dir, :support_renders, :render_template, :validate_output!
|
|
103
132
|
end
|
|
104
133
|
end
|
|
105
134
|
end
|
|
@@ -10,11 +10,10 @@ require "bundler"
|
|
|
10
10
|
module Rails
|
|
11
11
|
module Hyperdrive
|
|
12
12
|
class CompanionDiscovery
|
|
13
|
-
TARGETS_KEY = "
|
|
14
|
-
ARTIFACTS_KEY = "
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
# query returns an empty 200 page and discovery reports no suggestions.
|
|
13
|
+
TARGETS_KEY = "hyperdrive_targets".freeze
|
|
14
|
+
ARTIFACTS_KEY = "hyperdrive_artifacts".freeze
|
|
15
|
+
# An undocumented passthrough to the rubygems search backend: if it stops
|
|
16
|
+
# matching, it degrades to an empty 200 page (no suggestions), never an error.
|
|
18
17
|
SEARCH_QUERY = "metadata.#{TARGETS_KEY}:*".freeze
|
|
19
18
|
SEARCH_ENDPOINT = "https://rubygems.org/api/v1/search.json".freeze
|
|
20
19
|
CACHE_RELATIVE_PATH = ".hyperdrive/discover_cache.json".freeze
|
|
@@ -1,26 +1,64 @@
|
|
|
1
1
|
require "yaml"
|
|
2
|
+
require "rails/hyperdrive/install_layout"
|
|
2
3
|
|
|
3
4
|
module Rails
|
|
4
5
|
module Hyperdrive
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
# never skipped because its gating could not be read, and nothing raises.
|
|
6
|
+
# Everything the installer reads from a companion gem: where each artifact
|
|
7
|
+
# kind lives, and which artifacts a given bundle gets.
|
|
8
|
+
# Fail-open at every level — malformed input warns and resolves to an
|
|
9
|
+
# ungated install at the default roots; an artifact is never skipped for
|
|
10
|
+
# unreadable config, and nothing raises.
|
|
11
11
|
class GemManifest
|
|
12
12
|
FILE_NAME = "hyperdrive.yml"
|
|
13
|
-
METADATA_KEY = "
|
|
13
|
+
METADATA_KEY = "hyperdrive_manifest"
|
|
14
|
+
|
|
15
|
+
SKILL_TEMPLATES_DIR_KEY = "skill_templates_dir".freeze
|
|
16
|
+
DIR_KEYS = (InstallLayout.dir_keys + [SKILL_TEMPLATES_DIR_KEY]).freeze
|
|
14
17
|
|
|
15
18
|
UNGATED = ["*"].freeze
|
|
19
|
+
MATCH_MODES = %w[any all].freeze
|
|
20
|
+
GEM_FORMS = "a gem, a comma-separated list, a YAML list of names and name: requirement " \
|
|
21
|
+
"pairs, or an any:/all: map (gems: is an alias)".freeze
|
|
22
|
+
VERSIONS_REMOVED = "versions: is no longer supported; version constraints ignored — " \
|
|
23
|
+
"put the requirement on the gem: member, e.g. `- name: \">= 1.0\"`".freeze
|
|
24
|
+
|
|
25
|
+
Gate = Struct.new(:targets, :versions, :hyperdrive_version, :conditional, :match_mode,
|
|
26
|
+
keyword_init: true)
|
|
27
|
+
|
|
28
|
+
# `warning`, when set, is phrased for the caller to prefix with its own
|
|
29
|
+
# context — the parser has no idea which entry it is reading.
|
|
30
|
+
TargetSpec = Struct.new(:targets, :match_mode, :versions, :warning, keyword_init: true)
|
|
16
31
|
|
|
17
|
-
|
|
32
|
+
GemKey = Struct.new(:value, :present, :conflict, :warning, keyword_init: true)
|
|
18
33
|
|
|
19
34
|
class << self
|
|
20
35
|
def load(spec, warnings: [])
|
|
21
36
|
new(spec, warnings: warnings)
|
|
22
37
|
end
|
|
23
38
|
|
|
39
|
+
# gems: and gem: are exact aliases. A map carrying both is a stylistic
|
|
40
|
+
# slip, not malformed input: picking a winner keeps the gate intact
|
|
41
|
+
# where failing open would silently widen it.
|
|
42
|
+
def gem_key(map)
|
|
43
|
+
both = map.key?("gem") && map.key?("gems")
|
|
44
|
+
GemKey.new(
|
|
45
|
+
value: map.key?("gems") ? map["gems"] : map["gem"],
|
|
46
|
+
present: map.key?("gem") || map.key?("gems"),
|
|
47
|
+
conflict: both,
|
|
48
|
+
warning: ("gem: and gems: are aliases; reading gems: and ignoring gem:" if both)
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# ".." segments are rejected (silent fallback to the conventional path)
|
|
53
|
+
# to prevent escaping the gem root.
|
|
54
|
+
def manifest_relpath(spec)
|
|
55
|
+
return FILE_NAME unless spec.respond_to?(:metadata)
|
|
56
|
+
raw = spec.metadata && spec.metadata[METADATA_KEY]
|
|
57
|
+
return FILE_NAME if raw.nil? || raw.to_s.strip.empty?
|
|
58
|
+
return FILE_NAME if raw.to_s.split(%r{[/\\]}).include?("..")
|
|
59
|
+
raw.to_s
|
|
60
|
+
end
|
|
61
|
+
|
|
24
62
|
# The metadata key counts even when its value is unusable — declaring
|
|
25
63
|
# it at all signals companion intent.
|
|
26
64
|
def opt_in?(spec)
|
|
@@ -31,52 +69,194 @@ module Rails
|
|
|
31
69
|
!raw.to_s.strip.empty?
|
|
32
70
|
end
|
|
33
71
|
|
|
72
|
+
# nil signals malformed; the caller decides how to fail open.
|
|
34
73
|
def parse_targets(raw)
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
entries.flat_map { |e| e.to_s.split(",") }.map(&:strip).reject(&:empty?)
|
|
74
|
+
return parse_target_map(raw) if raw.is_a?(Hash)
|
|
75
|
+
parse_target_names(raw)
|
|
38
76
|
end
|
|
39
77
|
|
|
40
|
-
#
|
|
78
|
+
# A version requirement is optional everywhere; nil means unconstrained.
|
|
41
79
|
def malformed_requirements?(versions)
|
|
42
80
|
requirements = versions.is_a?(Hash) ? versions.values : [versions]
|
|
43
81
|
requirements.compact.any? do |req|
|
|
44
|
-
parts = Array(req).flat_map { |s| s.is_a?(String) ? s.split(",").map(&:strip) : s }
|
|
45
82
|
begin
|
|
46
|
-
Gem::Requirement.new(*
|
|
83
|
+
Gem::Requirement.new(*requirement_parts(req))
|
|
47
84
|
false
|
|
48
85
|
rescue ArgumentError
|
|
49
86
|
true
|
|
50
87
|
end
|
|
51
88
|
end
|
|
52
89
|
end
|
|
90
|
+
|
|
91
|
+
# The fence names one requirement against one version, so a per-gem
|
|
92
|
+
# requirement map is meaningless here.
|
|
93
|
+
def malformed_fence?(value)
|
|
94
|
+
return true if value.is_a?(Hash)
|
|
95
|
+
malformed_requirements?(value)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def requirement_parts(req)
|
|
99
|
+
Array(req).flat_map { |s| s.is_a?(String) ? s.split(",").map(&:strip) : s }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Returns [relative directory or nil, failure reason or nil]. Absent or
|
|
103
|
+
# blank is no failure — the caller uses its own default root. ".."
|
|
104
|
+
# segments are rejected to prevent escaping the gem root.
|
|
105
|
+
def read_dir(root, key)
|
|
106
|
+
raw = root[key]
|
|
107
|
+
return [nil, nil] if raw.nil?
|
|
108
|
+
return [nil, "#{key}: must be a directory path relative to the gem root"] unless raw.is_a?(String)
|
|
109
|
+
return [nil, nil] if raw.strip.empty?
|
|
110
|
+
return [nil, "#{key}: must not contain '..' segments"] if raw.split(%r{[/\\]}).include?("..")
|
|
111
|
+
|
|
112
|
+
[raw, nil]
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Returns [root map or nil, failure reason or nil]. An absent or empty
|
|
116
|
+
# file is a valid empty manifest; the caller phrases the failure and
|
|
117
|
+
# decides whether to fall open on it.
|
|
118
|
+
def read_root(path)
|
|
119
|
+
return [{}, nil] unless File.file?(path)
|
|
120
|
+
|
|
121
|
+
data = YAML.safe_load(File.read(path), permitted_classes: [Symbol])
|
|
122
|
+
return [{}, nil] if data.nil?
|
|
123
|
+
return [data.transform_keys(&:to_s), nil] if data.is_a?(Hash)
|
|
124
|
+
|
|
125
|
+
[nil, "root must be a YAML map"]
|
|
126
|
+
rescue Psych::SyntaxError => e
|
|
127
|
+
[nil, "malformed YAML (#{e.message})"]
|
|
128
|
+
rescue StandardError => e
|
|
129
|
+
[nil, "unreadable (#{e.message})"]
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
private
|
|
133
|
+
|
|
134
|
+
# A list member is either a bare gem name (unconstrained, comma-split
|
|
135
|
+
# for the inline multi-name spelling) or a single-pair map carrying
|
|
136
|
+
# that member's own version requirement.
|
|
137
|
+
def parse_target_names(raw)
|
|
138
|
+
entries = raw.is_a?(Array) ? raw : [raw]
|
|
139
|
+
names = []
|
|
140
|
+
requirements = {}
|
|
141
|
+
dropped_star = false
|
|
142
|
+
|
|
143
|
+
entries.each do |entry|
|
|
144
|
+
case entry
|
|
145
|
+
when Hash
|
|
146
|
+
name, requirement = pair_member(entry)
|
|
147
|
+
return nil unless name
|
|
148
|
+
if name == "*"
|
|
149
|
+
dropped_star = true
|
|
150
|
+
next
|
|
151
|
+
end
|
|
152
|
+
names << name
|
|
153
|
+
requirements[name] = requirement if requirement
|
|
154
|
+
when nil, Array
|
|
155
|
+
return nil
|
|
156
|
+
else
|
|
157
|
+
names.concat(entry.to_s.split(",").map(&:strip).reject(&:empty?))
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
names = UNGATED.dup if names.empty? && dropped_star
|
|
162
|
+
TargetSpec.new(
|
|
163
|
+
targets: names,
|
|
164
|
+
match_mode: :any,
|
|
165
|
+
versions: requirements.empty? ? nil : requirements,
|
|
166
|
+
warning: ("a version requirement on '*' is meaningless; ignoring that member" if dropped_star)
|
|
167
|
+
)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# nil signals a malformed member. A "*" key is kept for the caller to
|
|
171
|
+
# drop — its requirement is meaningless, not unreadable.
|
|
172
|
+
def pair_member(entry)
|
|
173
|
+
return nil unless entry.size == 1
|
|
174
|
+
|
|
175
|
+
name, requirement = entry.first
|
|
176
|
+
name = name.to_s.strip
|
|
177
|
+
return nil if name.empty?
|
|
178
|
+
return [name, nil] if name == "*"
|
|
179
|
+
return nil if requirement.is_a?(Hash)
|
|
180
|
+
return [name, nil] if requirement.nil? || requirement == "*"
|
|
181
|
+
return nil if malformed_requirements?(requirement)
|
|
182
|
+
|
|
183
|
+
[name, requirement]
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def parse_target_map(raw)
|
|
187
|
+
keys = raw.keys.map(&:to_s)
|
|
188
|
+
return nil unless keys.size == 1 && MATCH_MODES.include?(keys.first)
|
|
189
|
+
|
|
190
|
+
# The list is the only container a mode key accepts. Reading a bare
|
|
191
|
+
# map as a one-member pair would make `{sidekiq: ">= 7"}` gate while
|
|
192
|
+
# its two-gem sibling falls open to ungated.
|
|
193
|
+
return nil if raw.values.first.is_a?(Hash)
|
|
194
|
+
|
|
195
|
+
spec = parse_target_names(raw.values.first)
|
|
196
|
+
return nil if spec.nil?
|
|
197
|
+
return spec if keys.first == "any"
|
|
198
|
+
|
|
199
|
+
all_target_spec(spec)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# "*" is satisfied by definition, so dropping it leaves the remaining
|
|
203
|
+
# targets gating; a list of nothing else has no targets left to gate on.
|
|
204
|
+
def all_target_spec(spec)
|
|
205
|
+
kept = spec.targets.reject { |n| n == "*" }
|
|
206
|
+
dropped = kept.size != spec.targets.size
|
|
207
|
+
|
|
208
|
+
TargetSpec.new(
|
|
209
|
+
targets: dropped && kept.empty? ? UNGATED : kept,
|
|
210
|
+
match_mode: :all,
|
|
211
|
+
versions: spec.versions,
|
|
212
|
+
warning: [spec.warning, ("'*' in all: is always satisfied; ignoring it" if dropped)]
|
|
213
|
+
.compact.join("; ").then { |w| w.empty? ? nil : w }
|
|
214
|
+
)
|
|
215
|
+
end
|
|
53
216
|
end
|
|
54
217
|
|
|
55
218
|
def initialize(spec, warnings:)
|
|
56
219
|
@spec = spec
|
|
57
220
|
@warnings = warnings
|
|
58
221
|
root = load_root
|
|
59
|
-
@
|
|
60
|
-
@
|
|
61
|
-
|
|
222
|
+
@sections = {}
|
|
223
|
+
@prefixes = {}
|
|
224
|
+
InstallLayout.content_kinds.each do |kind|
|
|
225
|
+
entries, settings = section(root, kind)
|
|
226
|
+
@sections[kind.type] = entries
|
|
227
|
+
@prefixes[kind.type] = read_name_prefix(kind, settings)
|
|
228
|
+
end
|
|
229
|
+
@dirs = DIR_KEYS.each_with_object({}) { |key, h| h[key] = resolve_dir(root, key) }
|
|
230
|
+
@default_spec, @default_fence = defaults(root)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def gate(type, key)
|
|
234
|
+
entries = @sections[type] || {}
|
|
235
|
+
return default_gate unless entries.key?(key)
|
|
236
|
+
entry_gate(entries[key], key, with_conditional: InstallLayout.kind(type).dir_shaped?)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def section_keys(type)
|
|
240
|
+
(@sections[type] || {}).keys
|
|
62
241
|
end
|
|
63
242
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
243
|
+
# The section-level scalar a kind prepends to every artifact name it
|
|
244
|
+
# discovers; nil when absent or unusable.
|
|
245
|
+
def name_prefix(type)
|
|
246
|
+
@prefixes[type]
|
|
67
247
|
end
|
|
68
248
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
249
|
+
# nil means the caller's default root.
|
|
250
|
+
def dir(key)
|
|
251
|
+
@dirs[key.to_s]
|
|
72
252
|
end
|
|
73
253
|
|
|
74
|
-
def
|
|
75
|
-
|
|
254
|
+
def skills_dir
|
|
255
|
+
dir(InstallLayout.kind(:skill).dir_key)
|
|
76
256
|
end
|
|
77
257
|
|
|
78
|
-
def
|
|
79
|
-
|
|
258
|
+
def skill_templates_dir
|
|
259
|
+
dir(SKILL_TEMPLATES_DIR_KEY)
|
|
80
260
|
end
|
|
81
261
|
|
|
82
262
|
private
|
|
@@ -85,88 +265,139 @@ module Rails
|
|
|
85
265
|
@warnings << "#{@spec.name}: #{message}"
|
|
86
266
|
end
|
|
87
267
|
|
|
88
|
-
# ".." segments are rejected (silent fallback to the conventional path)
|
|
89
|
-
# to prevent escaping the gem root.
|
|
90
268
|
def manifest_relpath
|
|
91
|
-
|
|
92
|
-
raw = @spec.metadata && @spec.metadata[METADATA_KEY]
|
|
93
|
-
return FILE_NAME if raw.nil? || raw.to_s.strip.empty?
|
|
94
|
-
return FILE_NAME if raw.to_s.split(%r{[/\\]}).include?("..")
|
|
95
|
-
raw.to_s
|
|
269
|
+
self.class.manifest_relpath(@spec)
|
|
96
270
|
end
|
|
97
271
|
|
|
98
|
-
#
|
|
99
|
-
#
|
|
272
|
+
# Gating that cannot be read must never keep content out, so a failure
|
|
273
|
+
# here resolves to an absent manifest.
|
|
100
274
|
def load_root
|
|
101
|
-
|
|
102
|
-
return
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
return {} if data.nil?
|
|
106
|
-
unless data.is_a?(Hash)
|
|
107
|
-
report("ignoring manifest #{manifest_relpath}: root must be a YAML map")
|
|
108
|
-
return {}
|
|
109
|
-
end
|
|
110
|
-
data.transform_keys(&:to_s)
|
|
111
|
-
rescue Psych::SyntaxError
|
|
112
|
-
report("ignoring manifest #{manifest_relpath}: malformed YAML")
|
|
113
|
-
{}
|
|
114
|
-
rescue StandardError => e
|
|
115
|
-
report("ignoring manifest #{manifest_relpath}: unreadable (#{e.message})")
|
|
275
|
+
root, failure = self.class.read_root(File.join(@spec.full_gem_path, manifest_relpath))
|
|
276
|
+
return root if root
|
|
277
|
+
|
|
278
|
+
report("ignoring manifest #{manifest_relpath}: #{failure}")
|
|
116
279
|
{}
|
|
117
280
|
end
|
|
118
281
|
|
|
119
|
-
def
|
|
120
|
-
value = root
|
|
121
|
-
|
|
282
|
+
def resolve_dir(root, key)
|
|
283
|
+
value, failure = self.class.read_dir(root, key)
|
|
284
|
+
report("manifest top-level #{failure}; ignoring it") if failure
|
|
285
|
+
value
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# Returns [gating entries, the kind's reserved section-level settings].
|
|
289
|
+
def section(root, kind)
|
|
290
|
+
value = root[kind.section]
|
|
291
|
+
return [{}, {}] if value.nil?
|
|
122
292
|
unless value.is_a?(Hash)
|
|
123
|
-
report("manifest #{
|
|
124
|
-
return {}
|
|
293
|
+
report("manifest #{kind.section}: must be a map of #{kind.key_label} to a gating map; ignoring the section")
|
|
294
|
+
return [{}, {}]
|
|
125
295
|
end
|
|
126
|
-
|
|
296
|
+
|
|
297
|
+
entries = value.transform_keys(&:to_s)
|
|
298
|
+
reserved = Array(kind.prefix_key)
|
|
299
|
+
[entries.reject { |k, _| reserved.include?(k) }, entries.slice(*reserved)]
|
|
127
300
|
end
|
|
128
301
|
|
|
302
|
+
# A prefix becomes part of the installed filename, so anything that could
|
|
303
|
+
# reach outside the kind's directory is refused rather than sanitized.
|
|
304
|
+
def read_name_prefix(kind, settings)
|
|
305
|
+
key = kind.prefix_key
|
|
306
|
+
return nil unless key
|
|
307
|
+
|
|
308
|
+
raw = settings[key]
|
|
309
|
+
return nil if raw.nil?
|
|
310
|
+
unless raw.is_a?(String)
|
|
311
|
+
report("manifest #{kind.section} #{key}: must be a name prefix string; ignoring it")
|
|
312
|
+
return nil
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
prefix = raw.strip
|
|
316
|
+
return nil if prefix.empty?
|
|
317
|
+
return prefix unless prefix.match?(%r{[/\\]}) || prefix.include?("..")
|
|
318
|
+
|
|
319
|
+
report("manifest #{kind.section} #{key}: must not contain path separators or '..' segments; ignoring it")
|
|
320
|
+
nil
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# Independent axes: an unusable gem: must not silence a parseable fence,
|
|
324
|
+
# the one gate that keeps content out of an installer too old to read it.
|
|
129
325
|
def defaults(root)
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
326
|
+
report("manifest top-level #{VERSIONS_REMOVED}") if root.key?("versions")
|
|
327
|
+
[default_target_spec(root), default_fence(root)]
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def default_target_spec(root)
|
|
331
|
+
gem_key = self.class.gem_key(root)
|
|
332
|
+
report("manifest top-level #{gem_key.warning}") if gem_key.warning
|
|
333
|
+
return nil unless gem_key.present
|
|
334
|
+
|
|
335
|
+
parsed = self.class.parse_targets(gem_key.value)
|
|
336
|
+
if parsed.nil? || parsed.targets.empty?
|
|
337
|
+
report("manifest top-level gem: default is unusable; ignoring it")
|
|
338
|
+
return nil
|
|
135
339
|
end
|
|
136
|
-
|
|
340
|
+
report("manifest top-level gem: #{parsed.warning}") if parsed.warning
|
|
341
|
+
parsed
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def default_fence(root)
|
|
345
|
+
return root["hyperdrive_version"] unless self.class.malformed_fence?(root["hyperdrive_version"])
|
|
346
|
+
|
|
347
|
+
report("manifest top-level hyperdrive_version: default is unusable; ignoring it")
|
|
348
|
+
nil
|
|
137
349
|
end
|
|
138
350
|
|
|
139
351
|
def default_gate
|
|
140
|
-
|
|
352
|
+
build_gate(@default_spec, hyperdrive_version: @default_fence)
|
|
141
353
|
end
|
|
142
354
|
|
|
143
|
-
def ungated
|
|
144
|
-
|
|
355
|
+
def ungated(hyperdrive_version: nil)
|
|
356
|
+
build_gate(nil, hyperdrive_version: hyperdrive_version)
|
|
145
357
|
end
|
|
146
358
|
|
|
147
|
-
|
|
148
|
-
|
|
359
|
+
def build_gate(target_spec, hyperdrive_version: nil, conditional: nil)
|
|
360
|
+
Gate.new(
|
|
361
|
+
targets: target_spec ? target_spec.targets : UNGATED,
|
|
362
|
+
versions: target_spec&.versions,
|
|
363
|
+
hyperdrive_version: hyperdrive_version,
|
|
364
|
+
conditional: conditional,
|
|
365
|
+
match_mode: target_spec ? target_spec.match_mode : :any
|
|
366
|
+
)
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
# Gating that cannot be read must not skip the artifact, so a malformed
|
|
370
|
+
# entry installs ungated, gem-wide gem: default and all. The fence is
|
|
371
|
+
# resolved first and rides those fall-throughs — except where it is
|
|
372
|
+
# itself the unparsable part, since the gem-wide default would then apply
|
|
373
|
+
# a constraint this entry never asked for.
|
|
149
374
|
def entry_gate(entry, key, with_conditional:)
|
|
150
375
|
unless entry.is_a?(Hash)
|
|
151
|
-
report("manifest entry for '#{key}' must be a map with gem
|
|
152
|
-
return ungated
|
|
376
|
+
report("manifest entry for '#{key}' must be a map with gem:; installing ungated unless fenced out")
|
|
377
|
+
return ungated(hyperdrive_version: @default_fence)
|
|
153
378
|
end
|
|
154
379
|
|
|
155
380
|
entry = entry.transform_keys(&:to_s)
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
"or a YAML list; installing ungated")
|
|
381
|
+
if self.class.malformed_fence?(entry["hyperdrive_version"])
|
|
382
|
+
report("manifest entry for '#{key}' has an unparsable hyperdrive_version: requirement; " \
|
|
383
|
+
"installing ungated and unfenced")
|
|
160
384
|
return ungated
|
|
161
385
|
end
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
386
|
+
fence = entry.key?("hyperdrive_version") ? entry["hyperdrive_version"] : @default_fence
|
|
387
|
+
|
|
388
|
+
gem_key = self.class.gem_key(entry)
|
|
389
|
+
report("manifest entry for '#{key}': #{gem_key.warning}") if gem_key.warning
|
|
390
|
+
parsed = gem_key.present ? self.class.parse_targets(gem_key.value) : nil
|
|
391
|
+
if gem_key.present && (parsed.nil? || parsed.targets.empty?)
|
|
392
|
+
report("manifest entry for '#{key}': gem: must name #{GEM_FORMS}; installing ungated unless fenced out")
|
|
393
|
+
return ungated(hyperdrive_version: fence)
|
|
165
394
|
end
|
|
395
|
+
report("manifest entry for '#{key}': #{VERSIONS_REMOVED}") if entry.key?("versions")
|
|
396
|
+
report("manifest entry for '#{key}' gem: #{parsed.warning}") if parsed&.warning
|
|
166
397
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
398
|
+
build_gate(
|
|
399
|
+
parsed || @default_spec,
|
|
400
|
+
hyperdrive_version: fence,
|
|
170
401
|
conditional: with_conditional ? entry["conditional"] : nil
|
|
171
402
|
)
|
|
172
403
|
end
|