rails-hyperdrive 0.7.0 → 0.9.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 +100 -1
- data/README.md +82 -22
- data/lib/generators/hyperdrive/install/USAGE +4 -2
- data/lib/generators/hyperdrive/install/install_generator.rb +47 -1
- data/lib/generators/hyperdrive/install_summary.rb +1 -1
- data/lib/generators/hyperdrive/sync/USAGE +15 -3
- data/lib/generators/hyperdrive/sync/sync_generator.rb +11 -1
- data/lib/generators/hyperdrive/sync_runner.rb +63 -4
- data/lib/rails/hyperdrive/ancestor_locator.rb +36 -1
- data/lib/rails/hyperdrive/artifact_status.rb +13 -5
- data/lib/rails/hyperdrive/auto_install.rb +17 -8
- data/lib/rails/hyperdrive/bundler_artifact_discovery.rb +70 -18
- data/lib/rails/hyperdrive/config_file.rb +135 -0
- data/lib/rails/hyperdrive/install_layout.rb +6 -1
- data/lib/rails/hyperdrive/install_pipeline.rb +129 -38
- data/lib/rails/hyperdrive/install_plan.rb +6 -6
- data/lib/rails/hyperdrive/lock_file.rb +72 -50
- data/lib/rails/hyperdrive/manifest_lint.rb +7 -1
- data/lib/rails/hyperdrive/resolve/prompt.md.erb +47 -0
- data/lib/rails/hyperdrive/resolve_prompt.rb +42 -0
- data/lib/rails/hyperdrive/sidecar_resolver.rb +215 -0
- data/lib/rails/hyperdrive/skill_template.rb +12 -2
- data/lib/rails/hyperdrive/stack_profile.rb +3 -3
- data/lib/rails/hyperdrive/version.rb +1 -1
- metadata +5 -1
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
require "thor"
|
|
2
2
|
require "rails/hyperdrive"
|
|
3
3
|
require "rails/hyperdrive/bundler_artifact_discovery"
|
|
4
|
+
require "rails/hyperdrive/config_file"
|
|
4
5
|
require "rails/hyperdrive/install_layout"
|
|
5
6
|
require "rails/hyperdrive/install_pipeline"
|
|
6
7
|
require "rails/hyperdrive/lock_file"
|
|
8
|
+
require "rails/hyperdrive/sidecar_resolver"
|
|
7
9
|
require "generators/hyperdrive/install_summary"
|
|
8
10
|
|
|
9
11
|
module Rails
|
|
@@ -13,6 +15,16 @@ module Rails
|
|
|
13
15
|
# `install` forces the ones it needs, so no call order can install with a
|
|
14
16
|
# half-built input set.
|
|
15
17
|
class SyncRunner
|
|
18
|
+
RESOLVER_HELP = <<~MSG.freeze
|
|
19
|
+
--resolve needs a resolver command; add one to %<config>s:
|
|
20
|
+
|
|
21
|
+
resolve:
|
|
22
|
+
command: <your tool> $PROMPT
|
|
23
|
+
|
|
24
|
+
Placeholders: $LOCAL $REMOTE $BASE $MERGED $SOURCE $PREVIOUS_SOURCE $KIND $PROMPT
|
|
25
|
+
(also exported as HYPERDRIVE_* environment variables). Exit 0 marks the sidecar resolved.
|
|
26
|
+
MSG
|
|
27
|
+
|
|
16
28
|
def initialize(shell:, root: nil)
|
|
17
29
|
@shell = shell
|
|
18
30
|
@root = root&.to_s
|
|
@@ -43,17 +55,58 @@ module Rails
|
|
|
43
55
|
shell: @shell,
|
|
44
56
|
artifacts: discover_artifacts,
|
|
45
57
|
mode: mode,
|
|
46
|
-
report: report
|
|
58
|
+
report: report,
|
|
59
|
+
config: config
|
|
47
60
|
)
|
|
48
|
-
@pipeline.call
|
|
61
|
+
@result = @pipeline.call
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Raised before any content write, so --resolve with nothing to run
|
|
65
|
+
# fails without half-syncing the app.
|
|
66
|
+
def verify_resolver!
|
|
67
|
+
return if config.resolve_command
|
|
68
|
+
|
|
69
|
+
raise Thor::Error,
|
|
70
|
+
"hyperdrive: " + format(RESOLVER_HELP, config: ::Rails::Hyperdrive::InstallLayout::CONFIG_PATH)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def resolve_sidecars(dry_run: false)
|
|
74
|
+
@resolve_outcome = ::Rails::Hyperdrive::SidecarResolver.new(
|
|
75
|
+
root: root,
|
|
76
|
+
shell: @shell,
|
|
77
|
+
command: config.resolve_command,
|
|
78
|
+
lock: @pipeline.lock,
|
|
79
|
+
sidecars: Array(@result&.sidecars),
|
|
80
|
+
prompt_path: config.resolve_prompt,
|
|
81
|
+
dry_run: dry_run
|
|
82
|
+
).call
|
|
49
83
|
end
|
|
50
84
|
|
|
51
85
|
def summary_lines
|
|
52
|
-
InstallSummary.lines(lock_entries)
|
|
86
|
+
InstallSummary.lines(lock_entries) + merge_lines + resolve_lines
|
|
53
87
|
end
|
|
54
88
|
|
|
55
89
|
private
|
|
56
90
|
|
|
91
|
+
def merge_lines
|
|
92
|
+
count = Array(@result&.merged).size
|
|
93
|
+
return [] if count.zero?
|
|
94
|
+
|
|
95
|
+
["", " Merged #{InstallSummary.quantify(count, "file")} by three-way merge; a clean merge is " \
|
|
96
|
+
"textually non-overlapping, not verified; review with `git diff`"]
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def resolve_lines
|
|
100
|
+
outcome = @resolve_outcome
|
|
101
|
+
return [] unless outcome
|
|
102
|
+
|
|
103
|
+
parts = { "resolved" => outcome.resolved.size, "unresolved" => outcome.unresolved.size,
|
|
104
|
+
"skipped" => outcome.skipped.size }.reject { |_label, count| count.zero? }
|
|
105
|
+
return ["", " No sidecars to resolve"] if parts.empty?
|
|
106
|
+
|
|
107
|
+
["", " Sidecars: #{parts.map { |label, count| "#{count} #{label}" }.join(", ")}"]
|
|
108
|
+
end
|
|
109
|
+
|
|
57
110
|
# Raised from install, so it stops the run before any content write —
|
|
58
111
|
# a dry run included.
|
|
59
112
|
def verify_lock_schema!
|
|
@@ -80,8 +133,14 @@ module Rails
|
|
|
80
133
|
)
|
|
81
134
|
end
|
|
82
135
|
|
|
136
|
+
def config
|
|
137
|
+
@config ||= ::Rails::Hyperdrive::ConfigFile.load(
|
|
138
|
+
File.join(root, ::Rails::Hyperdrive::InstallLayout::CONFIG_PATH)
|
|
139
|
+
)
|
|
140
|
+
end
|
|
141
|
+
|
|
83
142
|
def enabled_gems
|
|
84
|
-
|
|
143
|
+
config.enabled_gems
|
|
85
144
|
end
|
|
86
145
|
|
|
87
146
|
def lock_entries
|
|
@@ -2,6 +2,7 @@ require "bundler"
|
|
|
2
2
|
require "rails/hyperdrive/bundler_artifact_discovery"
|
|
3
3
|
require "rails/hyperdrive/drift_verdict"
|
|
4
4
|
require "rails/hyperdrive/install_layout"
|
|
5
|
+
require "rails/hyperdrive/lock_file"
|
|
5
6
|
require "rails/hyperdrive/skill_template"
|
|
6
7
|
|
|
7
8
|
module Rails
|
|
@@ -34,6 +35,40 @@ module Rails
|
|
|
34
35
|
nil
|
|
35
36
|
end
|
|
36
37
|
|
|
38
|
+
# The sha gate runs against the ancestor an entry records while its
|
|
39
|
+
# sidecar is pending, not against the entry's own source, which by then
|
|
40
|
+
# names the pending delivery.
|
|
41
|
+
def locate_recorded_ancestor(lock_entry, final_name: nil, gem_paths: Gem.path, resolved: nil)
|
|
42
|
+
return nil unless lock_entry&.ancestor?
|
|
43
|
+
|
|
44
|
+
locate(
|
|
45
|
+
kind: lock_entry.kind,
|
|
46
|
+
relpath: lock_entry.ancestor_relpath,
|
|
47
|
+
lock_entry: LockFile::Entry.new(
|
|
48
|
+
source_gem: lock_entry.ancestor_gem,
|
|
49
|
+
source_version: lock_entry.ancestor_version,
|
|
50
|
+
source_sha: lock_entry.ancestor_sha
|
|
51
|
+
),
|
|
52
|
+
final_name: final_name || final_name_from_dest(lock_entry),
|
|
53
|
+
gem_paths: gem_paths,
|
|
54
|
+
resolved: resolved
|
|
55
|
+
)
|
|
56
|
+
rescue StandardError
|
|
57
|
+
nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Only the kinds whose frontmatter carries the name need one, and for
|
|
61
|
+
# those the dest was built from it.
|
|
62
|
+
def final_name_from_dest(lock_entry)
|
|
63
|
+
type = InstallLayout::ARTIFACT_TYPES[lock_entry.kind.to_s]
|
|
64
|
+
descriptor = type && InstallLayout.kind(type)
|
|
65
|
+
return nil unless descriptor&.collision_rewrites_name
|
|
66
|
+
|
|
67
|
+
descriptor.installed_name(lock_entry.path.to_s)
|
|
68
|
+
rescue StandardError
|
|
69
|
+
nil
|
|
70
|
+
end
|
|
71
|
+
|
|
37
72
|
def read_candidate(gem_root, relpath, kind:, resolved:)
|
|
38
73
|
exact = File.join(gem_root, relpath)
|
|
39
74
|
if File.file?(exact)
|
|
@@ -67,7 +102,7 @@ module Rails
|
|
|
67
102
|
nil
|
|
68
103
|
end
|
|
69
104
|
|
|
70
|
-
private_class_method :read_candidate, :install_ready, :resolved_bundle
|
|
105
|
+
private_class_method :final_name_from_dest, :read_candidate, :install_ready, :resolved_bundle
|
|
71
106
|
end
|
|
72
107
|
end
|
|
73
108
|
end
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
require "rails/hyperdrive/config_file"
|
|
1
2
|
require "rails/hyperdrive/drift_verdict"
|
|
2
3
|
require "rails/hyperdrive/install_layout"
|
|
3
4
|
require "rails/hyperdrive/install_plan"
|
|
@@ -28,16 +29,17 @@ module Rails
|
|
|
28
29
|
end
|
|
29
30
|
end
|
|
30
31
|
|
|
31
|
-
def self.compare(root:, artifacts:, bundled_gems: [])
|
|
32
|
-
new(root: root, artifacts: artifacts, bundled_gems: bundled_gems).tap(&:compare)
|
|
32
|
+
def self.compare(root:, artifacts:, bundled_gems: [], config: nil)
|
|
33
|
+
new(root: root, artifacts: artifacts, bundled_gems: bundled_gems, config: config).tap(&:compare)
|
|
33
34
|
end
|
|
34
35
|
|
|
35
36
|
attr_reader :entries
|
|
36
37
|
|
|
37
|
-
def initialize(root:, artifacts:, bundled_gems: [])
|
|
38
|
+
def initialize(root:, artifacts:, bundled_gems: [], config: nil)
|
|
38
39
|
@root = File.expand_path(root.to_s)
|
|
39
40
|
@artifacts = artifacts
|
|
40
41
|
@bundled_gems = Array(bundled_gems).map(&:to_s)
|
|
42
|
+
@config = config
|
|
41
43
|
@entries = []
|
|
42
44
|
end
|
|
43
45
|
|
|
@@ -45,7 +47,7 @@ module Rails
|
|
|
45
47
|
lock = LockFile.load(File.join(@root, InstallLayout::LOCK_PATH))
|
|
46
48
|
offered = {}
|
|
47
49
|
|
|
48
|
-
InstallPlan.build(@artifacts,
|
|
50
|
+
InstallPlan.build(@artifacts, config: config).entries.each do |plan_entry|
|
|
49
51
|
offered[plan_entry.dest] = [DriftVerdict.body_sha(plan_entry.install_ready_body), plan_entry.source_label, plan_entry.type]
|
|
50
52
|
plan_entry.support_files.each do |file|
|
|
51
53
|
offered[file[:dest]] = [DriftVerdict.body_sha(file[:body]), plan_entry.source_label, :skill_support]
|
|
@@ -71,7 +73,7 @@ module Rails
|
|
|
71
73
|
# A disabled artifact left on disk was reported at install time; the
|
|
72
74
|
# bundle still ships it, so it is not an orphan.
|
|
73
75
|
type = InstallLayout::ARTIFACT_TYPES[locked.kind]
|
|
74
|
-
next if type && InstallPlan.disabled_dest?(
|
|
76
|
+
next if type && InstallPlan.disabled_dest?(config, type, locked.path, source_gem: locked.source_gem)
|
|
75
77
|
|
|
76
78
|
@entries << Entry.new(
|
|
77
79
|
path: locked.path, state: :orphaned, artifact: locked.kind&.to_sym,
|
|
@@ -90,6 +92,12 @@ module Rails
|
|
|
90
92
|
def stale?
|
|
91
93
|
!missing.empty? || !outdated.empty? || !orphaned.empty?
|
|
92
94
|
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
def config
|
|
99
|
+
@config ||= ConfigFile.load(File.join(@root, InstallLayout::CONFIG_PATH))
|
|
100
|
+
end
|
|
93
101
|
end
|
|
94
102
|
end
|
|
95
103
|
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
require "bundler"
|
|
2
2
|
require "rails/hyperdrive/artifact_status"
|
|
3
3
|
require "rails/hyperdrive/bundler_artifact_discovery"
|
|
4
|
+
require "rails/hyperdrive/config_file"
|
|
4
5
|
require "rails/hyperdrive/install_layout"
|
|
5
6
|
require "rails/hyperdrive/install_pipeline"
|
|
6
7
|
require "rails/hyperdrive/install_shell"
|
|
@@ -14,7 +15,7 @@ module Rails
|
|
|
14
15
|
DEVELOPMENT = "development".freeze
|
|
15
16
|
|
|
16
17
|
Result = Struct.new(:installed, :outdated, :orphaned, :unwired, :advisories, :fence_warnings,
|
|
17
|
-
:halted, :skipped, :error, keyword_init: true) do
|
|
18
|
+
:config_warnings, :halted, :skipped, :error, keyword_init: true) do
|
|
18
19
|
def ran?
|
|
19
20
|
skipped.nil?
|
|
20
21
|
end
|
|
@@ -27,7 +28,7 @@ module Rails
|
|
|
27
28
|
return [] unless ran?
|
|
28
29
|
return [halted] if halted
|
|
29
30
|
|
|
30
|
-
lines =
|
|
31
|
+
lines = Array(config_warnings).dup
|
|
31
32
|
unless Array(installed).empty?
|
|
32
33
|
lines << "installed #{installed.size} artifact(s):"
|
|
33
34
|
installed.each { |path| lines << " #{path}" }
|
|
@@ -57,9 +58,14 @@ module Rails
|
|
|
57
58
|
lock = LockFile.load(File.join(root, InstallLayout::LOCK_PATH))
|
|
58
59
|
return halted(lock.schema_ahead_message(InstallLayout::LOCK_PATH)) if lock.schema_ahead?
|
|
59
60
|
|
|
61
|
+
config = ConfigFile.load(File.join(root, InstallLayout::CONFIG_PATH))
|
|
62
|
+
config_warnings = config.warnings.dup
|
|
63
|
+
config_warnings << lock.legacy_settings_message(InstallLayout::LOCK_PATH) if lock.legacy_settings?
|
|
64
|
+
|
|
60
65
|
report = BundlerArtifactDiscovery::Report.new
|
|
61
|
-
artifacts = BundlerArtifactDiscovery.discover(enabled_gems:
|
|
62
|
-
status = ArtifactStatus.compare(root: root, artifacts: artifacts, bundled_gems: report.bundled_gems
|
|
66
|
+
artifacts = BundlerArtifactDiscovery.discover(enabled_gems: config.enabled_gems, report: report)
|
|
67
|
+
status = ArtifactStatus.compare(root: root, artifacts: artifacts, bundled_gems: report.bundled_gems,
|
|
68
|
+
config: config)
|
|
63
69
|
|
|
64
70
|
installed = []
|
|
65
71
|
unwired = false
|
|
@@ -70,7 +76,8 @@ module Rails
|
|
|
70
76
|
shell: InstallShell.new(root: root, io: io),
|
|
71
77
|
artifacts: artifacts,
|
|
72
78
|
mode: :additive,
|
|
73
|
-
report: report
|
|
79
|
+
report: report,
|
|
80
|
+
config: config
|
|
74
81
|
)
|
|
75
82
|
installed = pipeline.call.installed
|
|
76
83
|
# This runs from a bundle install, which must not edit CLAUDE.md, so a
|
|
@@ -80,7 +87,8 @@ module Rails
|
|
|
80
87
|
end
|
|
81
88
|
|
|
82
89
|
Result.new(installed: installed, outdated: status.outdated, orphaned: status.orphaned,
|
|
83
|
-
unwired: unwired, advisories: report.advisories, fence_warnings: report.fence_warnings
|
|
90
|
+
unwired: unwired, advisories: report.advisories, fence_warnings: report.fence_warnings,
|
|
91
|
+
config_warnings: config_warnings)
|
|
84
92
|
rescue StandardError => e
|
|
85
93
|
skip(:error, e)
|
|
86
94
|
end
|
|
@@ -106,12 +114,13 @@ module Rails
|
|
|
106
114
|
|
|
107
115
|
def skip(reason, error = nil)
|
|
108
116
|
Result.new(installed: [], outdated: [], orphaned: [], advisories: [], fence_warnings: [],
|
|
109
|
-
skipped: reason, error: error)
|
|
117
|
+
config_warnings: [], skipped: reason, error: error)
|
|
110
118
|
end
|
|
111
119
|
|
|
112
120
|
# A completed run that installed nothing and has a reason to print.
|
|
113
121
|
def halted(reason)
|
|
114
|
-
Result.new(installed: [], outdated: [], orphaned: [], advisories: [], fence_warnings: [],
|
|
122
|
+
Result.new(installed: [], outdated: [], orphaned: [], advisories: [], fence_warnings: [],
|
|
123
|
+
config_warnings: [], halted: reason)
|
|
115
124
|
end
|
|
116
125
|
end
|
|
117
126
|
end
|
|
@@ -88,9 +88,7 @@ module Rails
|
|
|
88
88
|
next
|
|
89
89
|
end
|
|
90
90
|
manifest = GemManifest.load(spec, warnings: report.warnings)
|
|
91
|
-
seen =
|
|
92
|
-
each_artifact_path(spec, manifest: manifest, report: report) do |path, kind, support_root, key|
|
|
93
|
-
seen[kind.type] << key
|
|
91
|
+
seen = each_artifact_path(spec, manifest: manifest, report: report) do |path, kind, support_root, key|
|
|
94
92
|
gate = manifest.gate(kind.type, key)
|
|
95
93
|
artifact = parse(path, source_spec: spec, kind: kind, resolved: resolved,
|
|
96
94
|
report: report, support_root: support_root, gate: gate, key: key, manifest: manifest)
|
|
@@ -111,20 +109,27 @@ module Rails
|
|
|
111
109
|
end
|
|
112
110
|
end
|
|
113
111
|
|
|
114
|
-
# Yields each candidate with its kind descriptor and
|
|
115
|
-
#
|
|
116
|
-
# filename.
|
|
117
|
-
#
|
|
118
|
-
#
|
|
112
|
+
# Yields each candidate with its kind descriptor and the manifest join key
|
|
113
|
+
# its gating is read under: a directory-shaped kind's relpath from its
|
|
114
|
+
# root, a flat kind's filename. Returns every key the manifest could have
|
|
115
|
+
# named a shipped artifact by, collected before parsing so a candidate
|
|
116
|
+
# later dropped (e.g. an ERB render failure) still counts as known.
|
|
119
117
|
def each_artifact_path(spec, manifest:, report:)
|
|
118
|
+
seen = Hash.new { |h, k| h[k] = [] }
|
|
120
119
|
InstallLayout.content_kinds.each do |kind|
|
|
121
120
|
if kind.dir_shaped?
|
|
122
|
-
skill_paths(spec, manifest: manifest, report: report)
|
|
123
|
-
.
|
|
121
|
+
skill_paths(spec, manifest: manifest, report: report).each do |path, support_root, rel|
|
|
122
|
+
seen[kind.type] << rel
|
|
123
|
+
yield path, kind, support_root, rel
|
|
124
|
+
end
|
|
124
125
|
else
|
|
125
|
-
|
|
126
|
+
seen[kind.type].concat(flat_keys(spec, kind, manifest: manifest))
|
|
127
|
+
flat_paths(spec, kind, manifest: manifest, report: report).each do |path|
|
|
128
|
+
yield path, kind, nil, flat_gate_key(path, kind, manifest, spec, report)
|
|
129
|
+
end
|
|
126
130
|
end
|
|
127
131
|
end
|
|
132
|
+
seen
|
|
128
133
|
end
|
|
129
134
|
|
|
130
135
|
# The staleness signal for gating detached from content: an entry
|
|
@@ -212,14 +217,60 @@ module Rails
|
|
|
212
217
|
"supporting *.md.erb templates; static supporting files ship in the paired content directory"
|
|
213
218
|
end
|
|
214
219
|
|
|
215
|
-
# One
|
|
216
|
-
#
|
|
217
|
-
def flat_paths(spec, kind, manifest:)
|
|
220
|
+
# One file per artifact: everything shipped, minus the templates a static
|
|
221
|
+
# file already ships the rendered face of.
|
|
222
|
+
def flat_paths(spec, kind, manifest:, report:)
|
|
223
|
+
resolve_flat_ties(flat_candidates(spec, kind, manifest: manifest), report)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Every filename a manifest may key one of the kind's artifacts by: the
|
|
227
|
+
# name as shipped, plus each template's rendered face. Templates the tie
|
|
228
|
+
# rule drops are included — the manifest names shipped files, not
|
|
229
|
+
# surviving ones.
|
|
230
|
+
def flat_keys(spec, kind, manifest:)
|
|
231
|
+
flat_candidates(spec, kind, manifest: manifest)
|
|
232
|
+
.flat_map { |path| [File.basename(path), File.basename(target_path(path))] }
|
|
233
|
+
.uniq
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Deduped by expanded path so overlapping roots yield each file once.
|
|
237
|
+
def flat_candidates(spec, kind, manifest:)
|
|
238
|
+
glob = kind.erb ? "{*.md,*.md.erb}" : "*.md"
|
|
218
239
|
artifact_roots(spec, kind, manifest)
|
|
219
|
-
.flat_map { |root| Dir.glob(File.join(root,
|
|
240
|
+
.flat_map { |root| Dir.glob(File.join(root, glob)) }
|
|
220
241
|
.uniq { |path| File.expand_path(path) }
|
|
221
242
|
end
|
|
222
243
|
|
|
244
|
+
# A flat kind is identified by its filename, so a static file and a
|
|
245
|
+
# template rendering to that name are the same artifact anywhere in the
|
|
246
|
+
# root set; the static one wins, never silently.
|
|
247
|
+
def resolve_flat_ties(paths, report)
|
|
248
|
+
paths.group_by { |path| File.basename(target_path(path)) }.flat_map do |face, group|
|
|
249
|
+
templates, static = group.partition { |path| erb_template?(path) }
|
|
250
|
+
next group if templates.empty? || static.empty?
|
|
251
|
+
|
|
252
|
+
templates.each { |path| report.skip "skip #{path}: #{face} takes precedence" }
|
|
253
|
+
static
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# A templated artifact answers to its shipped *.md.erb name and to the
|
|
258
|
+
# rendered face it installs as; the exact spelling wins when a manifest
|
|
259
|
+
# carries both.
|
|
260
|
+
def flat_gate_key(path, kind, manifest, spec, report)
|
|
261
|
+
shipped = File.basename(path)
|
|
262
|
+
face = File.basename(target_path(path))
|
|
263
|
+
return shipped if face == shipped
|
|
264
|
+
|
|
265
|
+
keys = manifest.section_keys(kind.type)
|
|
266
|
+
return face if keys.include?(face) && !keys.include?(shipped)
|
|
267
|
+
if keys.include?(shipped) && keys.include?(face)
|
|
268
|
+
report.warn "#{spec.name}: manifest #{kind.section} keys '#{shipped}' and '#{face}' both gate " \
|
|
269
|
+
"'#{face}'; using '#{shipped}'"
|
|
270
|
+
end
|
|
271
|
+
shipped
|
|
272
|
+
end
|
|
273
|
+
|
|
223
274
|
# The manifest's directory override is searched in addition to the kind's
|
|
224
275
|
# convention roots, never instead of them.
|
|
225
276
|
def artifact_roots(spec, kind, manifest)
|
|
@@ -239,7 +290,7 @@ module Rails
|
|
|
239
290
|
|
|
240
291
|
convention_root = File.join(spec.full_gem_path, "lib", spec.name, "hyperdrive")
|
|
241
292
|
Dir.glob(File.join(convention_root, "skills", "**", "{SKILL.md,SKILL.md.erb}")).any? ||
|
|
242
|
-
Dir.glob(File.join(convention_root, "guidelines", "*.md")).any?
|
|
293
|
+
Dir.glob(File.join(convention_root, "guidelines", "{*.md,*.md.erb}")).any?
|
|
243
294
|
end
|
|
244
295
|
|
|
245
296
|
def metadata_present?(spec, key)
|
|
@@ -257,7 +308,7 @@ module Rails
|
|
|
257
308
|
|
|
258
309
|
count = found.map { |p| File.dirname(p) }.uniq.size
|
|
259
310
|
report.notices << "gem '#{spec.name}' ships #{count} skills.sh skill(s); add \"#{spec.name}\" to enabled: " \
|
|
260
|
-
"in
|
|
311
|
+
"in #{InstallLayout::CONFIG_PATH} and re-run bin/rails hyperdrive:sync to install them"
|
|
261
312
|
end
|
|
262
313
|
|
|
263
314
|
# Frontmatter's schema is exactly name and description; any other key is
|
|
@@ -348,7 +399,7 @@ module Rails
|
|
|
348
399
|
# the manifest; the manifest still keys its own entries by the shipped
|
|
349
400
|
# filename.
|
|
350
401
|
def prefixed_stem(path, kind, manifest)
|
|
351
|
-
stem = File.basename(path, ".md")
|
|
402
|
+
stem = File.basename(target_path(path), ".md")
|
|
352
403
|
prefix = manifest.name_prefix(kind.type)
|
|
353
404
|
prefix ? "#{prefix}-#{stem}" : stem
|
|
354
405
|
end
|
|
@@ -629,6 +680,7 @@ module Rails
|
|
|
629
680
|
end
|
|
630
681
|
|
|
631
682
|
private_class_method :each_artifact_path, :warn_unknown_manifest_keys,
|
|
683
|
+
:flat_paths, :flat_candidates, :resolve_flat_ties, :flat_gate_key,
|
|
632
684
|
:artifact_roots, :prefixed_stem, :templates_root,
|
|
633
685
|
:resolve_same_dir_tie, :pair_with_templates, :warn_template_extras,
|
|
634
686
|
:opted_in?, :metadata_present?, :notice_skills_sh_content,
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
require "yaml"
|
|
2
|
+
require "rails/hyperdrive/install_layout"
|
|
3
|
+
|
|
4
|
+
module Rails
|
|
5
|
+
module Hyperdrive
|
|
6
|
+
# The hand-owned settings file: which artifacts never to install, and which
|
|
7
|
+
# gems to treat as companions. Fail-open — a malformed part warns and reads
|
|
8
|
+
# as absent, and nothing raises.
|
|
9
|
+
class ConfigFile
|
|
10
|
+
DISABLED_KEYS = { skill: "skills", guideline: "guidelines", agent: "agents", command: "commands" }.freeze
|
|
11
|
+
|
|
12
|
+
attr_reader :path, :warnings, :resolve_command, :resolve_prompt
|
|
13
|
+
|
|
14
|
+
def self.load(path)
|
|
15
|
+
new(path).tap(&:read)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize(path)
|
|
19
|
+
@path = path.to_s
|
|
20
|
+
@disabled = empty_disabled
|
|
21
|
+
@enabled = []
|
|
22
|
+
@warnings = []
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def exist?
|
|
26
|
+
File.file?(@path)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def read
|
|
30
|
+
return self unless File.file?(@path)
|
|
31
|
+
|
|
32
|
+
data = YAML.safe_load(File.read(@path))
|
|
33
|
+
return self if data.nil?
|
|
34
|
+
return warn_and_empty("root is not a map; reading it as empty") unless data.is_a?(Hash)
|
|
35
|
+
|
|
36
|
+
read_disabled(data["disabled"])
|
|
37
|
+
read_enabled(data["enabled"])
|
|
38
|
+
read_resolve(data["resolve"])
|
|
39
|
+
self
|
|
40
|
+
rescue StandardError => e
|
|
41
|
+
warn_and_empty("could not be parsed (#{first_line(e.message)}); reading it as empty")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def disabled?(type, name)
|
|
45
|
+
Array(@disabled[type.to_sym]).include?(name.to_s)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def enabled_gems
|
|
49
|
+
@enabled
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def read_disabled(raw)
|
|
55
|
+
return if raw.nil?
|
|
56
|
+
return report("disabled: must be a map of kind to list; ignoring it") unless raw.is_a?(Hash)
|
|
57
|
+
|
|
58
|
+
DISABLED_KEYS.each do |type, key|
|
|
59
|
+
value = raw[key]
|
|
60
|
+
next if value.nil?
|
|
61
|
+
unless value.is_a?(Array)
|
|
62
|
+
report("disabled: #{key}: must be a list of names; ignoring it")
|
|
63
|
+
next
|
|
64
|
+
end
|
|
65
|
+
@disabled[type] = normalize(value)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def read_resolve(raw)
|
|
70
|
+
return if raw.nil?
|
|
71
|
+
return report("resolve: must be a map; ignoring it") unless raw.is_a?(Hash)
|
|
72
|
+
|
|
73
|
+
@resolve_command = read_resolve_command(raw["command"])
|
|
74
|
+
@resolve_prompt = read_resolve_prompt(raw["prompt"])
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def read_resolve_command(value)
|
|
78
|
+
return nil if value.nil?
|
|
79
|
+
return value.strip if value.is_a?(String) && !value.strip.empty?
|
|
80
|
+
|
|
81
|
+
report("resolve: command: must be a non-empty string; ignoring it")
|
|
82
|
+
nil
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# The prompt is read relative to the app root; a path that escapes it is
|
|
86
|
+
# refused.
|
|
87
|
+
def read_resolve_prompt(value)
|
|
88
|
+
return nil if value.nil?
|
|
89
|
+
unless value.is_a?(String) && !value.strip.empty?
|
|
90
|
+
report("resolve: prompt: must be a non-empty string; ignoring it")
|
|
91
|
+
return nil
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
prompt = value.strip
|
|
95
|
+
if prompt.split("/").include?("..") || prompt.start_with?("/")
|
|
96
|
+
report("resolve: prompt: must be a path inside the app; ignoring it")
|
|
97
|
+
return nil
|
|
98
|
+
end
|
|
99
|
+
prompt
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def read_enabled(raw)
|
|
103
|
+
return if raw.nil?
|
|
104
|
+
return report("enabled: must be a list of gem names; ignoring it") unless raw.is_a?(Array)
|
|
105
|
+
|
|
106
|
+
@enabled = normalize(raw)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def normalize(list)
|
|
110
|
+
list.map { |name| name.to_s.strip }.reject(&:empty?).uniq
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def warn_and_empty(message)
|
|
114
|
+
report(message)
|
|
115
|
+
@disabled = empty_disabled
|
|
116
|
+
@enabled = []
|
|
117
|
+
@resolve_command = nil
|
|
118
|
+
@resolve_prompt = nil
|
|
119
|
+
self
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def report(message)
|
|
123
|
+
@warnings << "#{InstallLayout::CONFIG_PATH} #{message}"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def first_line(message)
|
|
127
|
+
message.to_s.lines.first.to_s.strip
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def empty_disabled
|
|
131
|
+
DISABLED_KEYS.keys.each_with_object({}) { |type, h| h[type] = [] }
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -11,6 +11,7 @@ module Rails
|
|
|
11
11
|
COMMANDS_DIR = ".claude/commands".freeze
|
|
12
12
|
INDEX_PATH = "#{HYPERDRIVE_DIR}/index.md".freeze
|
|
13
13
|
LOCK_PATH = ".hyperdrive/lock.yml".freeze
|
|
14
|
+
CONFIG_PATH = ".hyperdrive/config.yml".freeze
|
|
14
15
|
|
|
15
16
|
DEFAULT_TARGET = :claude
|
|
16
17
|
|
|
@@ -22,7 +23,7 @@ module Rails
|
|
|
22
23
|
# identified and validated, and where each target tool installs it.
|
|
23
24
|
Kind = Struct.new(
|
|
24
25
|
:type, :shape, :section, :key_label, :shipped_label, :dir_key, :prefix_key,
|
|
25
|
-
:convention_roots, :identity, :frontmatter, :install_body,
|
|
26
|
+
:convention_roots, :identity, :frontmatter, :install_body, :erb,
|
|
26
27
|
:collision_rewrites_name, :eager, :name_from_dest, :dests,
|
|
27
28
|
keyword_init: true
|
|
28
29
|
) do
|
|
@@ -63,6 +64,7 @@ module Rails
|
|
|
63
64
|
identity: :frontmatter,
|
|
64
65
|
frontmatter: :required,
|
|
65
66
|
install_body: :verbatim,
|
|
67
|
+
erb: true,
|
|
66
68
|
collision_rewrites_name: true,
|
|
67
69
|
eager: false,
|
|
68
70
|
name_from_dest: ->(dest) { File.basename(File.dirname(dest)) },
|
|
@@ -89,6 +91,7 @@ module Rails
|
|
|
89
91
|
identity: :frontmatter,
|
|
90
92
|
frontmatter: :required,
|
|
91
93
|
install_body: :strip_frontmatter,
|
|
94
|
+
erb: true,
|
|
92
95
|
collision_rewrites_name: false,
|
|
93
96
|
eager: true,
|
|
94
97
|
name_from_dest: ->(dest) { File.basename(dest, ".md") },
|
|
@@ -111,6 +114,7 @@ module Rails
|
|
|
111
114
|
identity: :frontmatter,
|
|
112
115
|
frontmatter: :required,
|
|
113
116
|
install_body: :verbatim,
|
|
117
|
+
erb: true,
|
|
114
118
|
collision_rewrites_name: true,
|
|
115
119
|
eager: false,
|
|
116
120
|
name_from_dest: ->(dest) { File.basename(dest, ".md") },
|
|
@@ -128,6 +132,7 @@ module Rails
|
|
|
128
132
|
identity: :filename_stem,
|
|
129
133
|
frontmatter: :optional,
|
|
130
134
|
install_body: :verbatim,
|
|
135
|
+
erb: true,
|
|
131
136
|
collision_rewrites_name: false,
|
|
132
137
|
eager: false,
|
|
133
138
|
name_from_dest: ->(dest) { File.basename(dest, ".md") },
|