rails-hyperdrive 0.8.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 +61 -1
- data/README.md +81 -21
- 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 +1 -1
- data/lib/rails/hyperdrive/config_file.rb +135 -0
- data/lib/rails/hyperdrive/install_layout.rb +1 -0
- 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/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/stack_profile.rb +3 -3
- data/lib/rails/hyperdrive/version.rb +1 -1
- metadata +5 -1
|
@@ -1,22 +1,36 @@
|
|
|
1
1
|
require "yaml"
|
|
2
|
+
require "rails/hyperdrive/install_layout"
|
|
2
3
|
|
|
3
4
|
module Rails
|
|
4
5
|
module Hyperdrive
|
|
5
6
|
# installed_at is volatile metadata, never an input to any comparison.
|
|
6
7
|
class LockFile
|
|
7
|
-
SCHEMA_VERSION =
|
|
8
|
+
SCHEMA_VERSION = 3
|
|
8
9
|
STATE_PRESENT = "present".freeze
|
|
9
10
|
STATE_REMOVED = "removed-by-user".freeze
|
|
10
11
|
|
|
11
|
-
DISABLED_KEYS = { skill: "skills", guideline: "guidelines", agent: "agents", command: "commands" }.freeze
|
|
12
|
-
|
|
13
12
|
# In-memory form of one files: entry. On disk, source_gem and
|
|
14
13
|
# source_version are a single "gem@version" string; the split/join lives
|
|
15
14
|
# in this file only.
|
|
16
|
-
Entry = Struct.new(
|
|
15
|
+
Entry = Struct.new(
|
|
16
|
+
:path, :kind, :source_gem, :source_version, :source_sha, :installed_at,
|
|
17
|
+
:ancestor_gem, :ancestor_version, :ancestor_sha, :ancestor_relpath,
|
|
18
|
+
keyword_init: true
|
|
19
|
+
) do
|
|
17
20
|
def source_label
|
|
18
21
|
source_version ? "#{source_gem}@#{source_version}" : source_gem
|
|
19
22
|
end
|
|
23
|
+
|
|
24
|
+
# The upstream the live file's edits descend from, recorded only while
|
|
25
|
+
# a sidecar delivery is pending.
|
|
26
|
+
def ancestor_label
|
|
27
|
+
return nil unless ancestor_gem
|
|
28
|
+
ancestor_version ? "#{ancestor_gem}@#{ancestor_version}" : ancestor_gem
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def ancestor?
|
|
32
|
+
!ancestor_gem.nil? && !ancestor_sha.nil?
|
|
33
|
+
end
|
|
20
34
|
end
|
|
21
35
|
|
|
22
36
|
attr_reader :path, :schema_version
|
|
@@ -39,8 +53,7 @@ module Rails
|
|
|
39
53
|
@claude_md_state = nil # nil = no lock has been written yet
|
|
40
54
|
@files = {} # path(String) => Entry
|
|
41
55
|
@document = {} # raw parsed YAML, kept so unknown keys survive
|
|
42
|
-
@
|
|
43
|
-
@enabled = []
|
|
56
|
+
@legacy_settings = false
|
|
44
57
|
@schema_version = nil
|
|
45
58
|
end
|
|
46
59
|
|
|
@@ -54,8 +67,7 @@ module Rails
|
|
|
54
67
|
@schema_version = data["version"]
|
|
55
68
|
claude_md = data["claude_md"]
|
|
56
69
|
@claude_md_state = claude_md["state"] if claude_md.is_a?(Hash)
|
|
57
|
-
@
|
|
58
|
-
@enabled = parse_enabled(data["enabled"])
|
|
70
|
+
@legacy_settings = data.key?("disabled") || data.key?("enabled")
|
|
59
71
|
Array(data["files"]).each do |raw|
|
|
60
72
|
next unless raw.is_a?(Hash)
|
|
61
73
|
entry = build_entry(raw)
|
|
@@ -79,6 +91,17 @@ module Rails
|
|
|
79
91
|
"this installer supports #{SCHEMA_VERSION}); upgrade rails-hyperdrive"
|
|
80
92
|
end
|
|
81
93
|
|
|
94
|
+
def legacy_settings?
|
|
95
|
+
@legacy_settings
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def legacy_settings_message(display_path)
|
|
99
|
+
"#{display_path} carries disabled:/enabled:; those settings now live in " \
|
|
100
|
+
"#{InstallLayout::CONFIG_PATH} and are ignored here. Move them now: disabled " \
|
|
101
|
+
"artifacts install again, and artifacts from gems enabled only there are " \
|
|
102
|
+
"removed by the next init/sync"
|
|
103
|
+
end
|
|
104
|
+
|
|
82
105
|
def entry(file_path)
|
|
83
106
|
@files[file_path.to_s]
|
|
84
107
|
end
|
|
@@ -91,35 +114,43 @@ module Rails
|
|
|
91
114
|
@files.values.each(&block)
|
|
92
115
|
end
|
|
93
116
|
|
|
94
|
-
def disabled?(type, name)
|
|
95
|
-
Array(@disabled[type.to_sym]).include?(name.to_s)
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
# Hand-editable list of gems the app opts into as companions.
|
|
99
|
-
def enabled_gems
|
|
100
|
-
@enabled
|
|
101
|
-
end
|
|
102
|
-
|
|
103
117
|
# Adopt the state that is not derived from installed content, so
|
|
104
118
|
# rewriting the file preserves it.
|
|
105
|
-
def
|
|
119
|
+
def carry_document(other)
|
|
106
120
|
@document = other.document.dup
|
|
107
|
-
@disabled = other.disabled_lists.dup
|
|
108
|
-
@enabled = other.enabled_gems.dup
|
|
109
121
|
self
|
|
110
122
|
end
|
|
111
123
|
|
|
112
|
-
def upsert(path:, kind:, source_gem:, source_version:, source_sha:, installed_at
|
|
124
|
+
def upsert(path:, kind:, source_gem:, source_version:, source_sha:, installed_at:,
|
|
125
|
+
ancestor_gem: nil, ancestor_version: nil, ancestor_sha: nil, ancestor_relpath: nil)
|
|
113
126
|
@files[path.to_s] = Entry.new(
|
|
114
127
|
path: path.to_s,
|
|
115
128
|
kind: kind.to_s,
|
|
116
129
|
source_gem: source_gem.to_s,
|
|
117
130
|
source_version: source_version.to_s,
|
|
118
131
|
source_sha: source_sha.to_s,
|
|
119
|
-
installed_at: installed_at.to_s
|
|
132
|
+
installed_at: installed_at.to_s,
|
|
133
|
+
ancestor_gem: ancestor_gem,
|
|
134
|
+
ancestor_version: ancestor_version,
|
|
135
|
+
ancestor_sha: ancestor_sha,
|
|
136
|
+
ancestor_relpath: ancestor_relpath
|
|
120
137
|
)
|
|
121
138
|
end
|
|
122
139
|
|
|
140
|
+
# Replaces the entry rather than mutating it, so an entry carried from a
|
|
141
|
+
# lock read earlier in the run keeps the values that run compared against.
|
|
142
|
+
def clear_ancestor(file_path)
|
|
143
|
+
entry = @files[file_path.to_s]
|
|
144
|
+
return unless entry&.ancestor_gem || entry&.ancestor_sha || entry&.ancestor_relpath
|
|
145
|
+
|
|
146
|
+
@files[entry.path] = entry.dup.tap do |cleared|
|
|
147
|
+
cleared.ancestor_gem = nil
|
|
148
|
+
cleared.ancestor_version = nil
|
|
149
|
+
cleared.ancestor_sha = nil
|
|
150
|
+
cleared.ancestor_relpath = nil
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
123
154
|
def carry(entry)
|
|
124
155
|
return unless entry && entry.path
|
|
125
156
|
@files[entry.path] = entry
|
|
@@ -132,14 +163,16 @@ module Rails
|
|
|
132
163
|
document = @document.merge(
|
|
133
164
|
"version" => SCHEMA_VERSION,
|
|
134
165
|
"claude_md" => carried.merge("state" => @claude_md_state),
|
|
135
|
-
"disabled" => DISABLED_KEYS.each_with_object({}) { |(type, key), h| h[key] = @disabled[type] },
|
|
136
|
-
"enabled" => @enabled,
|
|
137
166
|
"files" => @files.values.sort_by(&:path).map { |e| serialize_entry(e) }
|
|
138
167
|
)
|
|
139
168
|
# A nil state means no import line is being managed. Recording one anyway
|
|
140
169
|
# would make the next run read the absent line as a deletion the user
|
|
141
170
|
# made, and never add it back.
|
|
142
171
|
document.delete("claude_md") if @claude_md_state.nil?
|
|
172
|
+
# Every other unknown key round-trips; these two must not, or a lock
|
|
173
|
+
# would keep asserting settings nothing reads.
|
|
174
|
+
document.delete("disabled")
|
|
175
|
+
document.delete("enabled")
|
|
143
176
|
document.to_yaml
|
|
144
177
|
end
|
|
145
178
|
|
|
@@ -149,39 +182,22 @@ module Rails
|
|
|
149
182
|
@document
|
|
150
183
|
end
|
|
151
184
|
|
|
152
|
-
def disabled_lists
|
|
153
|
-
@disabled
|
|
154
|
-
end
|
|
155
|
-
|
|
156
185
|
private
|
|
157
186
|
|
|
158
|
-
def empty_disabled
|
|
159
|
-
DISABLED_KEYS.keys.each_with_object({}) { |type, h| h[type] = [] }
|
|
160
|
-
end
|
|
161
|
-
|
|
162
|
-
def parse_disabled(raw)
|
|
163
|
-
return empty_disabled unless raw.is_a?(Hash)
|
|
164
|
-
|
|
165
|
-
DISABLED_KEYS.each_with_object({}) do |(type, key), h|
|
|
166
|
-
h[type] = Array(raw[key]).map { |name| name.to_s.strip }.reject(&:empty?).uniq
|
|
167
|
-
end
|
|
168
|
-
end
|
|
169
|
-
|
|
170
|
-
def parse_enabled(raw)
|
|
171
|
-
return [] unless raw.is_a?(Array)
|
|
172
|
-
|
|
173
|
-
raw.map { |name| name.to_s.strip }.reject(&:empty?).uniq
|
|
174
|
-
end
|
|
175
|
-
|
|
176
187
|
def build_entry(raw)
|
|
177
188
|
source_gem, source_version = split_source(raw["source"])
|
|
189
|
+
ancestor_gem, ancestor_version = split_source(raw["ancestor_source"])
|
|
178
190
|
Entry.new(
|
|
179
191
|
path: raw["path"],
|
|
180
192
|
kind: raw["artifact"],
|
|
181
193
|
source_gem: source_gem,
|
|
182
194
|
source_version: source_version,
|
|
183
195
|
source_sha: raw["source_sha"],
|
|
184
|
-
installed_at: raw["installed_at"]
|
|
196
|
+
installed_at: raw["installed_at"],
|
|
197
|
+
ancestor_gem: ancestor_gem,
|
|
198
|
+
ancestor_version: ancestor_version,
|
|
199
|
+
ancestor_sha: raw["ancestor_sha"],
|
|
200
|
+
ancestor_relpath: raw["ancestor_relpath"]
|
|
185
201
|
)
|
|
186
202
|
end
|
|
187
203
|
|
|
@@ -195,13 +211,19 @@ module Rails
|
|
|
195
211
|
end
|
|
196
212
|
|
|
197
213
|
def serialize_entry(entry)
|
|
198
|
-
{
|
|
214
|
+
raw = {
|
|
199
215
|
"path" => entry.path,
|
|
200
216
|
"artifact" => entry.kind,
|
|
201
217
|
"source" => entry.source_label,
|
|
202
|
-
"source_sha" => entry.source_sha
|
|
203
|
-
"installed_at" => entry.installed_at
|
|
218
|
+
"source_sha" => entry.source_sha
|
|
204
219
|
}
|
|
220
|
+
# installed_at is re-added after the optional keys so their absence
|
|
221
|
+
# leaves an entry's key order untouched.
|
|
222
|
+
raw["ancestor_source"] = entry.ancestor_label if entry.ancestor_label
|
|
223
|
+
raw["ancestor_sha"] = entry.ancestor_sha if entry.ancestor_sha
|
|
224
|
+
raw["ancestor_relpath"] = entry.ancestor_relpath if entry.ancestor_relpath
|
|
225
|
+
raw["installed_at"] = entry.installed_at
|
|
226
|
+
raw
|
|
205
227
|
end
|
|
206
228
|
end
|
|
207
229
|
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
You are resolving one file for the rails-hyperdrive installer.
|
|
2
|
+
|
|
3
|
+
The file is a rails-hyperdrive-managed file of kind `<%= kind %>`; the new
|
|
4
|
+
upstream version is shipped by <%= source %>.<% if previous_source && !previous_source.empty? %> The version the project
|
|
5
|
+
last received came from <%= previous_source %>.<% end %> The project has edited its copy
|
|
6
|
+
locally, so the local edits and the new upstream version have to be reconciled
|
|
7
|
+
by hand.
|
|
8
|
+
|
|
9
|
+
Inputs:
|
|
10
|
+
|
|
11
|
+
- LOCAL: <%= local %>
|
|
12
|
+
The project's current copy, including its local edits.
|
|
13
|
+
- REMOTE: <%= remote %>
|
|
14
|
+
The new upstream copy, exactly as <%= source %> ships it. It contains none of
|
|
15
|
+
the local edits.
|
|
16
|
+
<% if base -%>
|
|
17
|
+
- BASE: <%= base %>
|
|
18
|
+
The common ancestor: the upstream copy the local edits were made on top of.
|
|
19
|
+
Anything that differs between BASE and LOCAL is a local edit; anything that
|
|
20
|
+
differs between BASE and REMOTE is an upstream change.
|
|
21
|
+
<% else -%>
|
|
22
|
+
- BASE: not available. There is no common ancestor to compare against, so
|
|
23
|
+
decide which differences are local edits by reading LOCAL and REMOTE.
|
|
24
|
+
<% end -%>
|
|
25
|
+
|
|
26
|
+
Write the reconciled file to:
|
|
27
|
+
|
|
28
|
+
- MERGED: <%= merged %>
|
|
29
|
+
This is the same path as LOCAL, so you are editing the project's copy in
|
|
30
|
+
place.
|
|
31
|
+
|
|
32
|
+
Rules:
|
|
33
|
+
|
|
34
|
+
- Take every upstream change from REMOTE.
|
|
35
|
+
- Keep every local customisation from LOCAL.
|
|
36
|
+
- Where an upstream change and a local edit touch the same thing, keep the
|
|
37
|
+
local intent and express it in terms of the new upstream text.
|
|
38
|
+
- Write a complete, valid file. No conflict markers, no commentary about the
|
|
39
|
+
merge, no placeholders, and nothing invented that appears in neither input.
|
|
40
|
+
<% if kind == "skill" || kind == "agent" -%>
|
|
41
|
+
- Keep the YAML frontmatter valid, and leave the `name:` line exactly as REMOTE
|
|
42
|
+
has it.
|
|
43
|
+
<% end -%>
|
|
44
|
+
- If you are not confident the result is correct, change nothing and exit with a
|
|
45
|
+
non-zero status.
|
|
46
|
+
|
|
47
|
+
Exit 0 only when MERGED holds the reconciled file.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
require "erb"
|
|
2
|
+
|
|
3
|
+
module Rails
|
|
4
|
+
module Hyperdrive
|
|
5
|
+
# Renders the text bound to $PROMPT for the resolver command. The binding
|
|
6
|
+
# is an ordinary object rather than a sandbox: a user-supplied template
|
|
7
|
+
# runs arbitrary Ruby with the privileges of whoever ran the sync.
|
|
8
|
+
module ResolvePrompt
|
|
9
|
+
DEFAULT_PATH = File.expand_path("resolve/prompt.md.erb", __dir__).freeze
|
|
10
|
+
|
|
11
|
+
KNOBS = %i[local remote base merged source previous_source kind].freeze
|
|
12
|
+
|
|
13
|
+
class Context
|
|
14
|
+
attr_reader(*KNOBS)
|
|
15
|
+
|
|
16
|
+
def initialize(local:, remote:, base:, merged:, source:, previous_source:, kind:)
|
|
17
|
+
@local = local
|
|
18
|
+
@remote = remote
|
|
19
|
+
@base = base
|
|
20
|
+
@merged = merged
|
|
21
|
+
@source = source
|
|
22
|
+
@previous_source = previous_source
|
|
23
|
+
@kind = kind
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def template_binding
|
|
27
|
+
binding
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
module_function
|
|
32
|
+
|
|
33
|
+
def render(template, **knobs)
|
|
34
|
+
ERB.new(template, trim_mode: "-").result(Context.new(**knobs).template_binding)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def default_template
|
|
38
|
+
File.read(DEFAULT_PATH)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -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
|
|
@@ -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
|