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
|
@@ -3,6 +3,7 @@ require "time"
|
|
|
3
3
|
require "rails/hyperdrive/ancestor_locator"
|
|
4
4
|
require "rails/hyperdrive/bundler_artifact_discovery"
|
|
5
5
|
require "rails/hyperdrive/claude_md_import"
|
|
6
|
+
require "rails/hyperdrive/config_file"
|
|
6
7
|
require "rails/hyperdrive/drift_verdict"
|
|
7
8
|
require "rails/hyperdrive/three_way_merge"
|
|
8
9
|
require "rails/hyperdrive/eager_footprint"
|
|
@@ -16,7 +17,8 @@ module Rails
|
|
|
16
17
|
# The application root is explicit and no Rails constant is touched, so any
|
|
17
18
|
# process that can see the bundle can run this.
|
|
18
19
|
class InstallPipeline
|
|
19
|
-
ARTIFACT_DESTINATIONS =
|
|
20
|
+
ARTIFACT_DESTINATIONS =
|
|
21
|
+
(InstallLayout.dest_roots + [InstallLayout::LOCK_PATH, InstallLayout::CONFIG_PATH]).freeze
|
|
20
22
|
|
|
21
23
|
WARN_LINES = 150
|
|
22
24
|
WARN_TOKENS = 1_500
|
|
@@ -26,7 +28,10 @@ module Rails
|
|
|
26
28
|
Result = Struct.new(:installed, :updated, :unchanged, :skipped, :orphaned, :removed, :merged, :sidecars,
|
|
27
29
|
keyword_init: true)
|
|
28
30
|
|
|
29
|
-
|
|
31
|
+
Sidecar = Struct.new(:dest, :ancestor, :previous_source, keyword_init: true)
|
|
32
|
+
|
|
33
|
+
def initialize(root:, shell:, artifacts:, mode: :preserve, report: BundlerArtifactDiscovery::Report.new,
|
|
34
|
+
config: nil)
|
|
30
35
|
raise ArgumentError, "unknown mode #{mode.inspect}" unless MODES.include?(mode)
|
|
31
36
|
|
|
32
37
|
@root = File.expand_path(root.to_s)
|
|
@@ -34,6 +39,7 @@ module Rails
|
|
|
34
39
|
@artifacts = artifacts
|
|
35
40
|
@mode = mode
|
|
36
41
|
@report = report
|
|
42
|
+
@config = config
|
|
37
43
|
@bundled_gems = Array(report.bundled_gems).map(&:to_s)
|
|
38
44
|
@skipped_gems = Array(report.skipped_gems).map(&:to_s)
|
|
39
45
|
@result = Result.new(
|
|
@@ -44,7 +50,8 @@ module Rails
|
|
|
44
50
|
def call
|
|
45
51
|
return halt_schema_ahead if old_lock.schema_ahead?
|
|
46
52
|
|
|
47
|
-
|
|
53
|
+
report_settings_warnings
|
|
54
|
+
@new_lock = LockFile.new(abs(InstallLayout::LOCK_PATH)).carry_document(old_lock)
|
|
48
55
|
@plan = plan
|
|
49
56
|
|
|
50
57
|
report_collisions
|
|
@@ -56,6 +63,7 @@ module Rails
|
|
|
56
63
|
carry_orphans
|
|
57
64
|
eager_guidelines = write_index_md(guidelines)
|
|
58
65
|
write_claude_md(guidelines)
|
|
66
|
+
clear_resolved_ancestors
|
|
59
67
|
write_lock
|
|
60
68
|
print_warnings
|
|
61
69
|
print_notices
|
|
@@ -82,7 +90,19 @@ module Rails
|
|
|
82
90
|
end
|
|
83
91
|
|
|
84
92
|
def build_result
|
|
85
|
-
@build_result ||= InstallPlan.build(@artifacts,
|
|
93
|
+
@build_result ||= InstallPlan.build(@artifacts, config: config)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def config
|
|
97
|
+
@config ||= ConfigFile.load(abs(InstallLayout::CONFIG_PATH))
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def report_settings_warnings
|
|
101
|
+
return if additive?
|
|
102
|
+
|
|
103
|
+
config.warnings.each { |warning| @shell.say_status :warn, warning, :yellow }
|
|
104
|
+
return unless old_lock.legacy_settings?
|
|
105
|
+
@shell.say_status :warn, old_lock.legacy_settings_message(InstallLayout::LOCK_PATH), :yellow
|
|
86
106
|
end
|
|
87
107
|
|
|
88
108
|
def old_lock
|
|
@@ -120,7 +140,7 @@ module Rails
|
|
|
120
140
|
|
|
121
141
|
def report_disabled
|
|
122
142
|
build_result.disabled.each do |entry|
|
|
123
|
-
@shell.say_status :disabled, "#{entry.type} '#{entry.final_name}' (listed in #{InstallLayout::
|
|
143
|
+
@shell.say_status :disabled, "#{entry.type} '#{entry.final_name}' (listed in #{InstallLayout::CONFIG_PATH})", :blue
|
|
124
144
|
end
|
|
125
145
|
end
|
|
126
146
|
|
|
@@ -239,56 +259,101 @@ module Rails
|
|
|
239
259
|
# Reconciliation for a locally-modified destination in :sidecar/:merge
|
|
240
260
|
# mode. The lock records the newest upstream *delivered* — installed
|
|
241
261
|
# live, merged in, or written as a sidecar — never the live file's own
|
|
242
|
-
# hash, so the same upstream version is offered exactly once.
|
|
262
|
+
# hash, so the same upstream version is offered exactly once. While a
|
|
263
|
+
# sidecar is pending the entry also records the *ancestor*: the upstream
|
|
264
|
+
# the live file's own edits descend from, which a later run needs as the
|
|
265
|
+
# merge base.
|
|
243
266
|
def reconcile_edited(write, old, source_relpath:, final_name:)
|
|
244
267
|
dest = write[:dest]
|
|
245
268
|
sidecar = InstallLayout.sidecar_path(dest)
|
|
269
|
+
pending = File.exist?(abs(sidecar))
|
|
270
|
+
pristine = pending && delivered_sidecar?(sidecar, delivered_shas(write, old))
|
|
246
271
|
|
|
247
272
|
if old && old.source_sha == write[:gem_sha]
|
|
248
|
-
|
|
249
|
-
if File.exist?(abs(sidecar))
|
|
250
|
-
@shell.say_status :warn, "#{sidecar} (unresolved sidecar; reconcile it with #{dest}, then delete it)", :yellow
|
|
251
|
-
end
|
|
252
|
-
return
|
|
273
|
+
return retry_pending(write, old, sidecar, pending: pending, pristine: pristine, final_name: final_name)
|
|
253
274
|
end
|
|
254
275
|
|
|
255
276
|
# An edited sidecar is user work: leave it, and leave the lock at the
|
|
256
277
|
# old upstream so this delivery is re-offered once the user clears it.
|
|
257
|
-
if
|
|
278
|
+
if pending && !pristine
|
|
258
279
|
@result.skipped << dest
|
|
259
280
|
@shell.say_status :warn, "#{sidecar} (sidecar locally modified; resolve or delete it)", :yellow
|
|
260
281
|
@new_lock.carry(old) if old
|
|
261
282
|
return
|
|
262
283
|
end
|
|
263
284
|
|
|
285
|
+
deliver(write, old, pending: pending, source_relpath: source_relpath, final_name: final_name)
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# Nothing new to deliver: the pending sidecar is still the standing
|
|
289
|
+
# offer, so --merge attempts to complete it rather than re-offering it.
|
|
290
|
+
def retry_pending(write, old, sidecar, pending:, pristine:, final_name:)
|
|
264
291
|
reason = nil
|
|
265
|
-
if merge_mode?
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
merged, reason = attempt_merge(write, old, source_relpath: source_relpath, final_name: final_name)
|
|
273
|
-
if merged
|
|
274
|
-
write_merged(write, merged)
|
|
275
|
-
sweep_sidecar(write, old)
|
|
276
|
-
return
|
|
277
|
-
end
|
|
292
|
+
if merge_mode? && pristine
|
|
293
|
+
ancestor = AncestorLocator.locate_recorded_ancestor(old, final_name: final_name)
|
|
294
|
+
merged, reason = ancestor ? merge_bodies(write, ancestor) : [nil, "ancestor unavailable"]
|
|
295
|
+
if merged
|
|
296
|
+
write_merged(write, merged)
|
|
297
|
+
sweep_sidecar(write, old)
|
|
298
|
+
return
|
|
278
299
|
end
|
|
279
300
|
end
|
|
280
301
|
|
|
281
|
-
|
|
302
|
+
skip_edited(write, old)
|
|
303
|
+
return unless pending
|
|
304
|
+
|
|
305
|
+
message = "#{sidecar} (unresolved sidecar; reconcile it with #{write[:dest]}, then delete it"
|
|
306
|
+
message += "; #{reason}" if reason
|
|
307
|
+
@shell.say_status :warn, "#{message})", :yellow
|
|
282
308
|
end
|
|
283
309
|
|
|
284
|
-
|
|
285
|
-
|
|
310
|
+
# The ancestor is the live file's own base, so a delivery landing on top
|
|
311
|
+
# of a pending one keeps the ancestor already recorded.
|
|
312
|
+
def deliver(write, old, pending:, source_relpath:, final_name:)
|
|
313
|
+
if pending
|
|
314
|
+
record = recorded_ancestor(old)
|
|
315
|
+
ancestor = AncestorLocator.locate_recorded_ancestor(old, final_name: final_name)
|
|
316
|
+
previous_source = old&.ancestor_label
|
|
317
|
+
reason = "ancestor unavailable" if merge_mode? && ancestor.nil?
|
|
318
|
+
else
|
|
319
|
+
record = fresh_ancestor(old, source_relpath)
|
|
320
|
+
ancestor = locate_ancestor(write, old, source_relpath: source_relpath, final_name: final_name)
|
|
321
|
+
previous_source = old&.source_label
|
|
322
|
+
reason = fresh_merge_reason(old, ancestor) if merge_mode?
|
|
323
|
+
end
|
|
286
324
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
325
|
+
if merge_mode? && ancestor
|
|
326
|
+
merged, reason = merge_bodies(write, ancestor)
|
|
327
|
+
if merged
|
|
328
|
+
write_merged(write, merged)
|
|
329
|
+
sweep_sidecar(write, old)
|
|
330
|
+
return
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
write_sidecar(write, reason, ancestor: ancestor, previous_source: previous_source, ancestor_record: record)
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def fresh_merge_reason(old, ancestor)
|
|
338
|
+
return "no previous install recorded" unless old
|
|
339
|
+
return "#{old.source_label} not found in installed gems" unless ancestor
|
|
340
|
+
nil
|
|
341
|
+
end
|
|
291
342
|
|
|
343
|
+
# A fresh delivery always reads the ancestor off the entry's own source,
|
|
344
|
+
# never off ancestor keys left over from a window that already closed.
|
|
345
|
+
def fresh_ancestor(old, source_relpath)
|
|
346
|
+
return nil unless old && source_relpath
|
|
347
|
+
{ gem: old.source_gem, version: old.source_version, sha: old.source_sha, relpath: source_relpath }
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def recorded_ancestor(old)
|
|
351
|
+
return nil unless old&.ancestor?
|
|
352
|
+
{ gem: old.ancestor_gem, version: old.ancestor_version, sha: old.ancestor_sha,
|
|
353
|
+
relpath: old.ancestor_relpath }
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def merge_bodies(write, ancestor)
|
|
292
357
|
ours = live_body(write)
|
|
293
358
|
if ours.nil? || [ours, ancestor, write[:body]].any? { |b| ThreeWayMerge.binary?(b) }
|
|
294
359
|
return [nil, "binary content"]
|
|
@@ -302,6 +367,16 @@ module Rails
|
|
|
302
367
|
end
|
|
303
368
|
end
|
|
304
369
|
|
|
370
|
+
# Reconstruction is sha-gated against the lock entry recorded before this
|
|
371
|
+
# run, so it must happen before the new upstream is locked.
|
|
372
|
+
def locate_ancestor(write, old, source_relpath:, final_name:)
|
|
373
|
+
return nil unless old
|
|
374
|
+
|
|
375
|
+
AncestorLocator.locate(
|
|
376
|
+
kind: write[:artifact_kind], relpath: source_relpath, lock_entry: old, final_name: final_name
|
|
377
|
+
)
|
|
378
|
+
end
|
|
379
|
+
|
|
305
380
|
def live_body(write)
|
|
306
381
|
file = abs(write[:dest])
|
|
307
382
|
return File.binread(file) if write[:type] == :skill_support
|
|
@@ -320,14 +395,14 @@ module Rails
|
|
|
320
395
|
# The sidecar is byte-identical to what a live install of the new
|
|
321
396
|
# upstream would write, so `mv <dest>.new <dest>` accepts it wholesale
|
|
322
397
|
# and the lock already verifies it.
|
|
323
|
-
def write_sidecar(write, reason)
|
|
398
|
+
def write_sidecar(write, reason, ancestor: nil, previous_source: nil, ancestor_record: nil)
|
|
324
399
|
sidecar = InstallLayout.sidecar_path(write[:dest])
|
|
325
400
|
@shell.create_file sidecar, write[:body]
|
|
326
|
-
@result.sidecars << write[:dest]
|
|
401
|
+
@result.sidecars << Sidecar.new(dest: write[:dest], ancestor: ancestor, previous_source: previous_source)
|
|
327
402
|
message = "#{write[:dest]} (locally modified; new upstream delivered to #{sidecar}"
|
|
328
403
|
message += "; #{reason}" if reason
|
|
329
404
|
@shell.say_status :sidecar, "#{message})", :yellow
|
|
330
|
-
upsert_lock(write, Time.now.utc)
|
|
405
|
+
upsert_lock(write, Time.now.utc, ancestor: ancestor_record)
|
|
331
406
|
end
|
|
332
407
|
|
|
333
408
|
# Only a machine-pristine sidecar — one still hashing to a delivered
|
|
@@ -387,17 +462,33 @@ module Rails
|
|
|
387
462
|
)
|
|
388
463
|
end
|
|
389
464
|
|
|
390
|
-
def upsert_lock(write, installed_at)
|
|
465
|
+
def upsert_lock(write, installed_at, ancestor: nil)
|
|
391
466
|
@new_lock.upsert(
|
|
392
467
|
path: write[:dest],
|
|
393
468
|
kind: write[:artifact_kind],
|
|
394
469
|
source_gem: write[:source_gem],
|
|
395
470
|
source_version: write[:version],
|
|
396
471
|
source_sha: write[:gem_sha],
|
|
397
|
-
installed_at: installed_at.iso8601
|
|
472
|
+
installed_at: installed_at.iso8601,
|
|
473
|
+
ancestor_gem: ancestor && ancestor[:gem],
|
|
474
|
+
ancestor_version: ancestor && ancestor[:version],
|
|
475
|
+
ancestor_sha: ancestor && ancestor[:sha],
|
|
476
|
+
ancestor_relpath: ancestor && ancestor[:relpath]
|
|
398
477
|
)
|
|
399
478
|
end
|
|
400
479
|
|
|
480
|
+
# Deleting <dest>.new is the only resolution signal, so a recorded
|
|
481
|
+
# ancestor stays until the run that finds the sidecar gone drops it.
|
|
482
|
+
def clear_resolved_ancestors
|
|
483
|
+
return if additive?
|
|
484
|
+
|
|
485
|
+
@new_lock.each_entry do |entry|
|
|
486
|
+
next unless entry.ancestor?
|
|
487
|
+
next if File.exist?(abs(InstallLayout.sidecar_path(entry.path)))
|
|
488
|
+
@new_lock.clear_ancestor(entry.path)
|
|
489
|
+
end
|
|
490
|
+
end
|
|
491
|
+
|
|
401
492
|
# The pipeline's only delete path, so it is gated on the file still
|
|
402
493
|
# matching the content the lock recorded: hand-edited work is reported and
|
|
403
494
|
# left for its owner to remove. The dest's sidecar goes with it, since
|
|
@@ -426,7 +517,7 @@ module Rails
|
|
|
426
517
|
type = InstallLayout::ARTIFACT_TYPES[entry.kind]
|
|
427
518
|
next unless type
|
|
428
519
|
next if @new_lock.entry(entry.path)
|
|
429
|
-
next unless InstallPlan.disabled_dest?(
|
|
520
|
+
next unless InstallPlan.disabled_dest?(config, type, entry.path, source_gem: entry.source_gem)
|
|
430
521
|
|
|
431
522
|
remove_or_carry(entry) { "disabled but locally modified; delete it by hand" }
|
|
432
523
|
end
|
|
@@ -53,7 +53,7 @@ module Rails
|
|
|
53
53
|
# disables every variant and a postfixed name disables just one. The
|
|
54
54
|
# postfixed form is honored whether or not a collision exists today, so
|
|
55
55
|
# the opt-out survives a collision appearing or resolving.
|
|
56
|
-
def build(artifacts,
|
|
56
|
+
def build(artifacts, config: nil)
|
|
57
57
|
result = Result.new(entries: [], disabled: [])
|
|
58
58
|
artifacts.group_by { |a| [a.artifact_type, a.name] }.each do |(type, name), group|
|
|
59
59
|
collision = group.size > 1
|
|
@@ -66,8 +66,8 @@ module Rails
|
|
|
66
66
|
dest: InstallLayout.dest_for(type, final_name),
|
|
67
67
|
collision: collision
|
|
68
68
|
)
|
|
69
|
-
dropped =
|
|
70
|
-
|
|
69
|
+
dropped = config && (config.disabled?(type, name) ||
|
|
70
|
+
config.disabled?(type, InstallLayout.postfixed_name(name, artifact.source_gem)))
|
|
71
71
|
(dropped ? result.disabled : result.entries) << entry
|
|
72
72
|
end
|
|
73
73
|
end
|
|
@@ -77,13 +77,13 @@ module Rails
|
|
|
77
77
|
# A supporting file is disabled through its owning skill's name. Given the
|
|
78
78
|
# installing gem, a canonically-installed file also answers to the
|
|
79
79
|
# postfixed name that disabled it while it was one of several variants.
|
|
80
|
-
def disabled_dest?(
|
|
80
|
+
def disabled_dest?(config, type, dest, source_gem: nil)
|
|
81
81
|
name = InstallLayout.installed_name(type, dest)
|
|
82
82
|
return false unless name
|
|
83
83
|
lookup = type == :skill_support ? :skill : type
|
|
84
84
|
base = InstallLayout.base_name(name)
|
|
85
|
-
return true if
|
|
86
|
-
!source_gem.nil? &&
|
|
85
|
+
return true if config.disabled?(lookup, name) || config.disabled?(lookup, base)
|
|
86
|
+
!source_gem.nil? && config.disabled?(lookup, InstallLayout.postfixed_name(base, source_gem))
|
|
87
87
|
end
|
|
88
88
|
end
|
|
89
89
|
end
|
|
@@ -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
|
|
@@ -102,9 +102,14 @@ module Rails
|
|
|
102
102
|
entries.each do |key, entry|
|
|
103
103
|
next if Array(kind.prefix_key).include?(key)
|
|
104
104
|
|
|
105
|
+
face = BundlerArtifactDiscovery.target_path(key)
|
|
105
106
|
unless shipped.include?(key)
|
|
106
107
|
problems << "#{kind.section} entry '#{key}' names no shipped #{kind.shipped_label}"
|
|
107
108
|
end
|
|
109
|
+
if face != key && entries.key?(face)
|
|
110
|
+
problems << "#{kind.section} entry '#{key}': '#{face}' keys the same file; keep one spelling"
|
|
111
|
+
end
|
|
112
|
+
|
|
108
113
|
unless entry.is_a?(Hash)
|
|
109
114
|
problems << "#{kind.section} entry '#{key}' must be a map of gating keys"
|
|
110
115
|
next
|
|
@@ -218,8 +223,9 @@ module Rails
|
|
|
218
223
|
end
|
|
219
224
|
end
|
|
220
225
|
|
|
226
|
+
# Both spellings of a template-shipped file, since either gates it.
|
|
221
227
|
def shipped_flat(repo_spec, kind, manifest)
|
|
222
|
-
BundlerArtifactDiscovery.
|
|
228
|
+
BundlerArtifactDiscovery.flat_keys(repo_spec, kind, manifest: manifest)
|
|
223
229
|
end
|
|
224
230
|
|
|
225
231
|
private_class_method :missing_manifest, :problems_in, :parse_root, :each_entry, :check_section_settings,
|
|
@@ -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
|