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.
@@ -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 = 2
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(:path, :kind, :source_gem, :source_version, :source_sha, :installed_at, keyword_init: true) do
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
- @disabled = empty_disabled
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
- @disabled = parse_disabled(data["disabled"])
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 carry_settings(other)
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/lock_file"
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::LockFile.load(
160
- File.join(root, ::Rails::Hyperdrive::InstallLayout::LOCK_PATH)
159
+ ::Rails::Hyperdrive::ConfigFile.load(
160
+ File.join(root, ::Rails::Hyperdrive::InstallLayout::CONFIG_PATH)
161
161
  ).enabled_gems
162
162
  rescue StandardError
163
163
  []
@@ -1,5 +1,5 @@
1
1
  module Rails
2
2
  module Hyperdrive
3
- VERSION = "0.8.0"
3
+ VERSION = "0.9.0"
4
4
  end
5
5
  end
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.8.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