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
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
require "open3"
|
|
2
|
+
require "shellwords"
|
|
3
|
+
require "tmpdir"
|
|
4
|
+
require "rails/hyperdrive/ancestor_locator"
|
|
5
|
+
require "rails/hyperdrive/drift_verdict"
|
|
6
|
+
require "rails/hyperdrive/install_layout"
|
|
7
|
+
require "rails/hyperdrive/resolve_prompt"
|
|
8
|
+
|
|
9
|
+
module Rails
|
|
10
|
+
module Hyperdrive
|
|
11
|
+
# Hands each unresolved <dest>.new sidecar to the user's resolver command,
|
|
12
|
+
# git-mergetool style, and deletes the sidecar when it exits 0. Nothing here
|
|
13
|
+
# writes the lock: the sidecar's absence is the whole resolution signal.
|
|
14
|
+
class SidecarResolver
|
|
15
|
+
TOKENS = %w[LOCAL REMOTE BASE MERGED SOURCE PREVIOUS_SOURCE KIND PROMPT].freeze
|
|
16
|
+
TOKEN_PATTERN = /\$(#{TOKENS.sort_by { |t| -t.length }.join("|")})\b/
|
|
17
|
+
|
|
18
|
+
Outcome = Struct.new(:resolved, :unresolved, :skipped, keyword_init: true)
|
|
19
|
+
|
|
20
|
+
Candidate = Struct.new(:dest, :sidecar, :kind, :source, :previous_source, :ancestor, :delivered,
|
|
21
|
+
keyword_init: true)
|
|
22
|
+
|
|
23
|
+
def initialize(root:, shell:, command:, lock:, sidecars: [], prompt_path: nil, dry_run: false)
|
|
24
|
+
@root = File.expand_path(root.to_s)
|
|
25
|
+
@shell = shell
|
|
26
|
+
@command = command.to_s
|
|
27
|
+
@lock = lock
|
|
28
|
+
@sidecars = Array(sidecars)
|
|
29
|
+
@prompt_path = prompt_path
|
|
30
|
+
@dry_run = dry_run
|
|
31
|
+
@outcome = Outcome.new(resolved: [], unresolved: [], skipped: [])
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def call
|
|
35
|
+
candidates.each { |candidate| process(candidate) }
|
|
36
|
+
@outcome
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def candidates
|
|
42
|
+
written = @sidecars.to_h { |s| [s.dest.to_s, s] }
|
|
43
|
+
list = []
|
|
44
|
+
@lock.each_entry do |entry|
|
|
45
|
+
sidecar = InstallLayout.sidecar_path(entry.path)
|
|
46
|
+
delivered = written[entry.path.to_s]
|
|
47
|
+
# A dry run wrote no sidecar to disk, so this run's deliveries are
|
|
48
|
+
# only knowable from the pipeline result.
|
|
49
|
+
next unless delivered || File.file?(abs(sidecar))
|
|
50
|
+
|
|
51
|
+
list << Candidate.new(
|
|
52
|
+
dest: entry.path,
|
|
53
|
+
sidecar: sidecar,
|
|
54
|
+
kind: entry.kind.to_s,
|
|
55
|
+
source: entry.source_label.to_s,
|
|
56
|
+
# A sidecar left over from an earlier run has no struct, so the
|
|
57
|
+
# base comes back from the ancestor the lock recorded with it.
|
|
58
|
+
previous_source: delivered&.previous_source || entry.ancestor_label,
|
|
59
|
+
ancestor: delivered&.ancestor || AncestorLocator.locate_recorded_ancestor(entry),
|
|
60
|
+
delivered: !delivered.nil?
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
list
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def process(candidate)
|
|
67
|
+
unless candidate.delivered || pristine?(candidate)
|
|
68
|
+
@outcome.skipped << candidate.dest
|
|
69
|
+
@shell.say_status :warn,
|
|
70
|
+
"#{candidate.sidecar} (sidecar locally modified; resolve or delete it by hand)", :yellow
|
|
71
|
+
return
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
tokens = command_tokens
|
|
75
|
+
return unresolved(candidate, "resolve: command: is not a valid command line") if tokens.nil?
|
|
76
|
+
return unresolved(candidate, "resolve: command: names no program") if tokens.empty?
|
|
77
|
+
|
|
78
|
+
if @dry_run
|
|
79
|
+
@shell.say_status :resolve, "#{candidate.dest} (would run #{tokens.first})", :yellow
|
|
80
|
+
return
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
run(candidate, tokens)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def command_tokens
|
|
87
|
+
Shellwords.split(@command)
|
|
88
|
+
rescue ArgumentError
|
|
89
|
+
nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# A sidecar is machine-written only while it still hashes to the upstream
|
|
93
|
+
# the lock records as delivered; anything else is the user's own work.
|
|
94
|
+
def pristine?(candidate)
|
|
95
|
+
entry = @lock.entry(candidate.dest)
|
|
96
|
+
return false unless entry
|
|
97
|
+
DriftVerdict.disk_sha(abs(candidate.sidecar)) == entry.source_sha
|
|
98
|
+
rescue StandardError
|
|
99
|
+
false
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def run(candidate, tokens)
|
|
103
|
+
Dir.mktmpdir("hyperdrive-resolve") do |dir|
|
|
104
|
+
base = write_base(candidate, dir)
|
|
105
|
+
values = values_for(candidate, base: base)
|
|
106
|
+
argv = substitute(tokens, values, base: base)
|
|
107
|
+
@shell.say_status :resolve, "#{candidate.dest} via #{argv.first}", :blue
|
|
108
|
+
_out, err, status = Open3.capture3(env_for(values), *argv, chdir: @root)
|
|
109
|
+
if status.success?
|
|
110
|
+
# Exit 0 is the tool's assertion that the file is resolved, so the
|
|
111
|
+
# sidecar goes even if the tool never wrote $MERGED.
|
|
112
|
+
resolved(candidate)
|
|
113
|
+
else
|
|
114
|
+
unresolved(candidate, "exit #{status.exitstatus}#{detail(err)}")
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
rescue StandardError => e
|
|
118
|
+
unresolved(candidate, first_line(e.message))
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def resolved(candidate)
|
|
122
|
+
@shell.remove_file candidate.sidecar
|
|
123
|
+
@outcome.resolved << candidate.dest
|
|
124
|
+
@shell.say_status :resolved, candidate.dest, :green
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def unresolved(candidate, reason)
|
|
128
|
+
@outcome.unresolved << { dest: candidate.dest, reason: reason }
|
|
129
|
+
@shell.say_status :unresolved, "#{candidate.dest} (#{reason})", :yellow
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def write_base(candidate, dir)
|
|
133
|
+
return nil unless candidate.ancestor
|
|
134
|
+
|
|
135
|
+
File.join(dir, "base-#{File.basename(candidate.dest)}").tap do |file|
|
|
136
|
+
File.binwrite(file, candidate.ancestor)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Substitution runs per token after the split, so a value holding spaces
|
|
141
|
+
# stays one argument.
|
|
142
|
+
def substitute(tokens, values, base:)
|
|
143
|
+
tokens.each_with_object([]) do |token, argv|
|
|
144
|
+
next if base.nil? && token == "$BASE"
|
|
145
|
+
argv << token.gsub(TOKEN_PATTERN) { values[Regexp.last_match(1)].to_s }
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def env_for(values)
|
|
150
|
+
values.to_h { |name, value| ["HYPERDRIVE_#{name}", value] }
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def values_for(candidate, base:)
|
|
154
|
+
live = abs(candidate.dest)
|
|
155
|
+
values = {
|
|
156
|
+
"LOCAL" => live,
|
|
157
|
+
"REMOTE" => abs(candidate.sidecar),
|
|
158
|
+
# A nil BASE substitutes empty and unsets HYPERDRIVE_BASE in the
|
|
159
|
+
# child, so an outer HYPERDRIVE_BASE can never leak into a run.
|
|
160
|
+
"BASE" => base,
|
|
161
|
+
"MERGED" => live,
|
|
162
|
+
"SOURCE" => candidate.source,
|
|
163
|
+
"PREVIOUS_SOURCE" => candidate.previous_source.to_s,
|
|
164
|
+
"KIND" => candidate.kind
|
|
165
|
+
}
|
|
166
|
+
values["PROMPT"] = prompt(candidate, base: base, values: values)
|
|
167
|
+
values
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def prompt(candidate, base:, values:)
|
|
171
|
+
knobs = {
|
|
172
|
+
local: values["LOCAL"], remote: values["REMOTE"], base: base, merged: values["MERGED"],
|
|
173
|
+
source: candidate.source, previous_source: candidate.previous_source, kind: candidate.kind
|
|
174
|
+
}
|
|
175
|
+
template = user_template
|
|
176
|
+
return ResolvePrompt.render(template, **knobs) if template
|
|
177
|
+
|
|
178
|
+
ResolvePrompt.render(ResolvePrompt.default_template, **knobs)
|
|
179
|
+
rescue StandardError => e
|
|
180
|
+
raise unless template
|
|
181
|
+
@shell.say_status :warn,
|
|
182
|
+
"resolve: prompt: #{@prompt_path} could not be rendered (#{first_line(e.message)}); using the default prompt",
|
|
183
|
+
:yellow
|
|
184
|
+
@user_template = false
|
|
185
|
+
ResolvePrompt.render(ResolvePrompt.default_template, **knobs)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def user_template
|
|
189
|
+
return nil unless @prompt_path
|
|
190
|
+
return nil if @user_template == false
|
|
191
|
+
|
|
192
|
+
@user_template ||= File.read(abs(@prompt_path))
|
|
193
|
+
rescue StandardError => e
|
|
194
|
+
@shell.say_status :warn,
|
|
195
|
+
"resolve: prompt: #{@prompt_path} could not be read (#{first_line(e.message)}); using the default prompt",
|
|
196
|
+
:yellow
|
|
197
|
+
@user_template = false
|
|
198
|
+
nil
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def detail(stderr)
|
|
202
|
+
line = first_line(stderr)
|
|
203
|
+
line.empty? ? "" : ": #{line}"
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def first_line(message)
|
|
207
|
+
message.to_s.lines.first.to_s.strip
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def abs(path)
|
|
211
|
+
File.join(@root, path)
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
@@ -3,7 +3,7 @@ require "erb"
|
|
|
3
3
|
module Rails
|
|
4
4
|
module Hyperdrive
|
|
5
5
|
# Renders a skill's *.md.erb sources against the app's resolved bundle.
|
|
6
|
-
# The
|
|
6
|
+
# The four helpers are the only supported template API, but the binding is
|
|
7
7
|
# an ordinary object rather than a sandbox: a template runs arbitrary Ruby
|
|
8
8
|
# with the privileges of whoever triggered discovery.
|
|
9
9
|
module SkillTemplate
|
|
@@ -27,6 +27,10 @@ module Rails
|
|
|
27
27
|
version&.to_s
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
+
def canonical_render?
|
|
31
|
+
false
|
|
32
|
+
end
|
|
33
|
+
|
|
30
34
|
def template_binding
|
|
31
35
|
binding
|
|
32
36
|
end
|
|
@@ -35,7 +39,9 @@ module Rails
|
|
|
35
39
|
# Canonical render binding: every gem reads as present at any requested
|
|
36
40
|
# version, so an if/elsif/else contributes only its first branch and
|
|
37
41
|
# templates meant for this render must use additive if blocks. There is
|
|
38
|
-
# no bundle to resolve against, so gem_version is nil.
|
|
42
|
+
# no bundle to resolve against, so gem_version is nil. canonical_render?
|
|
43
|
+
# is the exception: it is deterministic in both bindings, so branching on
|
|
44
|
+
# it is safe.
|
|
39
45
|
class CanonicalContext
|
|
40
46
|
def gem?(_name, _requirement = nil)
|
|
41
47
|
true
|
|
@@ -49,6 +55,10 @@ module Rails
|
|
|
49
55
|
nil
|
|
50
56
|
end
|
|
51
57
|
|
|
58
|
+
def canonical_render?
|
|
59
|
+
true
|
|
60
|
+
end
|
|
61
|
+
|
|
52
62
|
def template_binding
|
|
53
63
|
binding
|
|
54
64
|
end
|
|
@@ -147,7 +147,7 @@ module Rails
|
|
|
147
147
|
|
|
148
148
|
def enabled_gems
|
|
149
149
|
require "rails/hyperdrive/install_layout"
|
|
150
|
-
require "rails/hyperdrive/
|
|
150
|
+
require "rails/hyperdrive/config_file"
|
|
151
151
|
root =
|
|
152
152
|
if @app_root
|
|
153
153
|
@app_root.to_s
|
|
@@ -156,8 +156,8 @@ module Rails
|
|
|
156
156
|
end
|
|
157
157
|
return [] unless root
|
|
158
158
|
|
|
159
|
-
::Rails::Hyperdrive::
|
|
160
|
-
File.join(root, ::Rails::Hyperdrive::InstallLayout::
|
|
159
|
+
::Rails::Hyperdrive::ConfigFile.load(
|
|
160
|
+
File.join(root, ::Rails::Hyperdrive::InstallLayout::CONFIG_PATH)
|
|
161
161
|
).enabled_gems
|
|
162
162
|
rescue StandardError
|
|
163
163
|
[]
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rails-hyperdrive
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.9.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Bakaface
|
|
@@ -104,6 +104,7 @@ files:
|
|
|
104
104
|
- lib/rails/hyperdrive/canonical_skill_render.rb
|
|
105
105
|
- lib/rails/hyperdrive/claude_md_import.rb
|
|
106
106
|
- lib/rails/hyperdrive/companion_discovery.rb
|
|
107
|
+
- lib/rails/hyperdrive/config_file.rb
|
|
107
108
|
- lib/rails/hyperdrive/console_executor.rb
|
|
108
109
|
- lib/rails/hyperdrive/drift_verdict.rb
|
|
109
110
|
- lib/rails/hyperdrive/eager_footprint.rb
|
|
@@ -118,9 +119,12 @@ files:
|
|
|
118
119
|
- lib/rails/hyperdrive/lock_file.rb
|
|
119
120
|
- lib/rails/hyperdrive/manifest_lint.rb
|
|
120
121
|
- lib/rails/hyperdrive/mcp_server.rb
|
|
122
|
+
- lib/rails/hyperdrive/resolve/prompt.md.erb
|
|
123
|
+
- lib/rails/hyperdrive/resolve_prompt.rb
|
|
121
124
|
- lib/rails/hyperdrive/resources/skill.rb
|
|
122
125
|
- lib/rails/hyperdrive/resources/stack_profile.rb
|
|
123
126
|
- lib/rails/hyperdrive/safety/rack_middleware.rb
|
|
127
|
+
- lib/rails/hyperdrive/sidecar_resolver.rb
|
|
124
128
|
- lib/rails/hyperdrive/skill_template.rb
|
|
125
129
|
- lib/rails/hyperdrive/sql_safety.rb
|
|
126
130
|
- lib/rails/hyperdrive/stack_profile.rb
|