rails-hyperdrive 0.3.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 +7 -0
- data/CHANGELOG.md +295 -0
- data/LICENSE.txt +21 -0
- data/README.md +229 -0
- data/Rakefile +6 -0
- data/SECURITY.md +43 -0
- data/config/routes.rb +5 -0
- data/lib/generators/hyperdrive/content_sync_support.rb +54 -0
- data/lib/generators/hyperdrive/discover/discover_generator.rb +103 -0
- data/lib/generators/hyperdrive/gitignore_support.rb +28 -0
- data/lib/generators/hyperdrive/install/USAGE +22 -0
- data/lib/generators/hyperdrive/install/install_generator.rb +168 -0
- data/lib/generators/hyperdrive/install/templates/initializer.rb.tt +3 -0
- data/lib/generators/hyperdrive/install_summary.rb +70 -0
- data/lib/generators/hyperdrive/sync/USAGE +21 -0
- data/lib/generators/hyperdrive/sync/sync_generator.rb +40 -0
- data/lib/generators/hyperdrive/sync_runner.rb +70 -0
- data/lib/rails/hyperdrive/artifact_status.rb +86 -0
- data/lib/rails/hyperdrive/audit_header.rb +83 -0
- data/lib/rails/hyperdrive/auto_install.rb +98 -0
- data/lib/rails/hyperdrive/bundler_artifact_discovery.rb +320 -0
- data/lib/rails/hyperdrive/claude_md_import.rb +69 -0
- data/lib/rails/hyperdrive/companion_discovery.rb +232 -0
- data/lib/rails/hyperdrive/console_executor.rb +64 -0
- data/lib/rails/hyperdrive/data/gem_categories.yml +52 -0
- data/lib/rails/hyperdrive/drift_verdict.rb +42 -0
- data/lib/rails/hyperdrive/eager_footprint.rb +48 -0
- data/lib/rails/hyperdrive/engine.rb +22 -0
- data/lib/rails/hyperdrive/index_document.rb +49 -0
- data/lib/rails/hyperdrive/install_layout.rb +45 -0
- data/lib/rails/hyperdrive/install_pipeline.rb +415 -0
- data/lib/rails/hyperdrive/install_plan.rb +78 -0
- data/lib/rails/hyperdrive/install_shell.rb +43 -0
- data/lib/rails/hyperdrive/lock_file.rb +171 -0
- data/lib/rails/hyperdrive/mcp_server.rb +80 -0
- data/lib/rails/hyperdrive/resources/skill.rb +63 -0
- data/lib/rails/hyperdrive/resources/stack_profile.rb +32 -0
- data/lib/rails/hyperdrive/safety/rack_middleware.rb +54 -0
- data/lib/rails/hyperdrive/skill_template.rb +52 -0
- data/lib/rails/hyperdrive/sql_safety.rb +28 -0
- data/lib/rails/hyperdrive/stack_profile.rb +176 -0
- data/lib/rails/hyperdrive/tools/base.rb +39 -0
- data/lib/rails/hyperdrive/tools/describe_app.rb +21 -0
- data/lib/rails/hyperdrive/tools/list_models.rb +76 -0
- data/lib/rails/hyperdrive/tools/list_routes.rb +33 -0
- data/lib/rails/hyperdrive/tools/locate_source.rb +86 -0
- data/lib/rails/hyperdrive/tools/lookup_doc.rb +60 -0
- data/lib/rails/hyperdrive/tools/run_ruby.rb +31 -0
- data/lib/rails/hyperdrive/tools/run_sql.rb +49 -0
- data/lib/rails/hyperdrive/tools/tail_logs.rb +65 -0
- data/lib/rails/hyperdrive/version.rb +5 -0
- data/lib/rails/hyperdrive.rb +37 -0
- data/lib/rails-hyperdrive.rb +2 -0
- data/lib/tasks/hyperdrive.rake +22 -0
- metadata +161 -0
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
require "time"
|
|
2
|
+
require "rails/hyperdrive/audit_header"
|
|
3
|
+
require "rails/hyperdrive/bundler_artifact_discovery"
|
|
4
|
+
require "rails/hyperdrive/claude_md_import"
|
|
5
|
+
require "rails/hyperdrive/drift_verdict"
|
|
6
|
+
require "rails/hyperdrive/eager_footprint"
|
|
7
|
+
require "rails/hyperdrive/index_document"
|
|
8
|
+
require "rails/hyperdrive/install_layout"
|
|
9
|
+
require "rails/hyperdrive/install_plan"
|
|
10
|
+
require "rails/hyperdrive/lock_file"
|
|
11
|
+
|
|
12
|
+
module Rails
|
|
13
|
+
module Hyperdrive
|
|
14
|
+
# The application root is explicit and no Rails constant is touched, so any
|
|
15
|
+
# process that can see the bundle can run this.
|
|
16
|
+
class InstallPipeline
|
|
17
|
+
ARTIFACT_DESTINATIONS =
|
|
18
|
+
[InstallLayout::SKILLS_DIR, InstallLayout::HYPERDRIVE_DIR, InstallLayout::LOCK_PATH].freeze
|
|
19
|
+
|
|
20
|
+
WARN_LINES = 150
|
|
21
|
+
WARN_TOKENS = 1_500
|
|
22
|
+
|
|
23
|
+
MODES = %i[preserve overwrite additive].freeze
|
|
24
|
+
|
|
25
|
+
Result = Struct.new(:installed, :updated, :unchanged, :skipped, :orphaned, :removed, keyword_init: true)
|
|
26
|
+
|
|
27
|
+
def initialize(root:, shell:, artifacts:, mode: :preserve, warnings: [])
|
|
28
|
+
raise ArgumentError, "unknown mode #{mode.inspect}" unless MODES.include?(mode)
|
|
29
|
+
|
|
30
|
+
@root = File.expand_path(root.to_s)
|
|
31
|
+
@shell = shell
|
|
32
|
+
@artifacts = artifacts
|
|
33
|
+
@mode = mode
|
|
34
|
+
@warnings = warnings
|
|
35
|
+
@result = Result.new(installed: [], updated: [], unchanged: [], skipped: [], orphaned: [], removed: [])
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def call
|
|
39
|
+
@new_lock = LockFile.new(abs(InstallLayout::LOCK_PATH)).carry_settings(old_lock)
|
|
40
|
+
@plan = plan
|
|
41
|
+
|
|
42
|
+
report_collisions
|
|
43
|
+
report_disabled
|
|
44
|
+
install_skills
|
|
45
|
+
guidelines = install_guidelines
|
|
46
|
+
remove_disabled
|
|
47
|
+
remove_stale_support_files
|
|
48
|
+
carry_orphans
|
|
49
|
+
eager_guidelines = write_index_md(guidelines)
|
|
50
|
+
write_claude_md(guidelines)
|
|
51
|
+
write_lock
|
|
52
|
+
print_warnings
|
|
53
|
+
print_footprint(guidelines: eager_guidelines)
|
|
54
|
+
warn_if_destinations_gitignored
|
|
55
|
+
@result
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def plan
|
|
59
|
+
build_result.entries
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def lock
|
|
63
|
+
@new_lock
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def build_result
|
|
69
|
+
@build_result ||= InstallPlan.build(@artifacts, lock: old_lock)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def old_lock
|
|
73
|
+
@old_lock ||= LockFile.load(abs(InstallLayout::LOCK_PATH))
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def additive?
|
|
77
|
+
@mode == :additive
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def overwrite_mode?
|
|
81
|
+
@mode == :overwrite
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def abs(path)
|
|
85
|
+
File.join(@root, path)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def report_collisions
|
|
89
|
+
@plan.group_by { |e| [e.type, e.artifact.name] }.each do |(type, name), group|
|
|
90
|
+
next unless group.first.collision
|
|
91
|
+
@shell.say_status :conflict,
|
|
92
|
+
"#{type} '#{name}' shipped by #{group.map { |e| e.artifact.source_gem }.join(", ")}; installing all (postfixed)",
|
|
93
|
+
:yellow
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def report_disabled
|
|
98
|
+
build_result.disabled.each do |entry|
|
|
99
|
+
@shell.say_status :disabled, "#{entry.type} '#{entry.final_name}' (listed in #{InstallLayout::LOCK_PATH})", :blue
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def install_skills
|
|
104
|
+
@plan.select { |e| e.type == :skill }.each do |entry|
|
|
105
|
+
install_file(entry: entry, type: :skill, install_ready_body: entry.install_ready_body)
|
|
106
|
+
entry.support_files.each do |file|
|
|
107
|
+
install_file(
|
|
108
|
+
dest: file[:dest],
|
|
109
|
+
type: :skill_support,
|
|
110
|
+
install_ready_body: file[:body],
|
|
111
|
+
source_gem: entry.source_gem,
|
|
112
|
+
version: entry.version,
|
|
113
|
+
artifact_kind: "skill_support"
|
|
114
|
+
)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def install_guidelines
|
|
120
|
+
@plan.select { |e| e.type == :guideline }.map do |entry|
|
|
121
|
+
body = entry.install_ready_body
|
|
122
|
+
warn_if_oversize(entry.dest, body)
|
|
123
|
+
install_file(entry: entry, type: :guideline, install_ready_body: body)
|
|
124
|
+
{ base: "#{entry.final_name}.md", dest: entry.dest, body: body }
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def install_file(dest: nil, type:, install_ready_body:, entry: nil, source_gem: nil, version: nil, artifact_kind: nil)
|
|
129
|
+
write = {
|
|
130
|
+
dest: dest || entry.dest,
|
|
131
|
+
type: type,
|
|
132
|
+
body: install_ready_body,
|
|
133
|
+
source_gem: source_gem || entry.source_gem,
|
|
134
|
+
version: version || entry.version,
|
|
135
|
+
gem_sha: DriftVerdict.body_sha(install_ready_body),
|
|
136
|
+
artifact_kind: artifact_kind || entry.artifact_kind
|
|
137
|
+
}
|
|
138
|
+
old = old_lock.entry(write[:dest])
|
|
139
|
+
|
|
140
|
+
return additive_install(write, old) if additive?
|
|
141
|
+
|
|
142
|
+
file = abs(write[:dest])
|
|
143
|
+
case DriftVerdict.verdict(file: file, kind: write[:artifact_kind], lock_entry: old, gem_sha: write[:gem_sha])
|
|
144
|
+
when :missing
|
|
145
|
+
@shell.say_status(:reinstall, "#{write[:dest]} (was missing)", :yellow) if old
|
|
146
|
+
write_artifact(**write)
|
|
147
|
+
when :current
|
|
148
|
+
@new_lock.carry(old)
|
|
149
|
+
@result.unchanged << write[:dest]
|
|
150
|
+
@shell.say_status :unchanged, write[:dest], :blue
|
|
151
|
+
when :outdated
|
|
152
|
+
write_artifact(**write)
|
|
153
|
+
when :edited
|
|
154
|
+
if overwrite_mode?
|
|
155
|
+
write_artifact(**write)
|
|
156
|
+
else
|
|
157
|
+
@result.skipped << write[:dest]
|
|
158
|
+
@shell.say_status :skip, "#{write[:dest]} (locally modified; run hyperdrive:sync --overwrite to overwrite)", :yellow
|
|
159
|
+
@new_lock.carry(old) if old
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Additive installs can create a file and never overwrite one, so an
|
|
165
|
+
# artifact the user edited — or deleted on purpose — survives.
|
|
166
|
+
def additive_install(write, old)
|
|
167
|
+
if old
|
|
168
|
+
@new_lock.carry(old)
|
|
169
|
+
(old.source_sha == write[:gem_sha] ? @result.unchanged : @result.skipped) << write[:dest]
|
|
170
|
+
elsif File.exist?(abs(write[:dest]))
|
|
171
|
+
@result.skipped << write[:dest]
|
|
172
|
+
else
|
|
173
|
+
write_artifact(**write)
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Supporting files land byte-identical to what the gem ships — no audit
|
|
178
|
+
# header, since they may be non-markdown or binary; provenance and sha
|
|
179
|
+
# live in the lock alone.
|
|
180
|
+
def write_artifact(dest:, type:, body:, source_gem:, version:, gem_sha:, artifact_kind:)
|
|
181
|
+
installed_at = Time.now.utc
|
|
182
|
+
header_args = { source_gem: source_gem, version: version, sha256: gem_sha, installed_at: installed_at }
|
|
183
|
+
on_disk =
|
|
184
|
+
case type
|
|
185
|
+
when :skill
|
|
186
|
+
AuditHeader.inject_into_frontmatter(body, AuditHeader.build(**header_args))
|
|
187
|
+
when :skill_support
|
|
188
|
+
body
|
|
189
|
+
else
|
|
190
|
+
AuditHeader.prepend_html(body, AuditHeader.build_html(**header_args))
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
existed = old_lock.entry(dest)
|
|
194
|
+
@shell.create_file dest, on_disk
|
|
195
|
+
(existed ? @result.updated : @result.installed) << dest
|
|
196
|
+
@new_lock.upsert(
|
|
197
|
+
path: dest,
|
|
198
|
+
kind: artifact_kind,
|
|
199
|
+
source_gem: source_gem,
|
|
200
|
+
source_version: version,
|
|
201
|
+
source_sha: gem_sha,
|
|
202
|
+
installed_at: installed_at.iso8601
|
|
203
|
+
)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# The pipeline's only delete path, so it is gated on the file still
|
|
207
|
+
# matching the content the lock recorded: hand-edited work is reported and
|
|
208
|
+
# left for its owner to remove. Runs before orphans are carried, or a
|
|
209
|
+
# removed file would read as one.
|
|
210
|
+
def remove_disabled
|
|
211
|
+
return if additive?
|
|
212
|
+
|
|
213
|
+
old_lock.each_entry do |entry|
|
|
214
|
+
type = InstallLayout::ARTIFACT_TYPES[entry.kind]
|
|
215
|
+
next unless type
|
|
216
|
+
next if @new_lock.entry(entry.path)
|
|
217
|
+
|
|
218
|
+
next unless InstallPlan.disabled_dest?(old_lock, type, entry.path)
|
|
219
|
+
|
|
220
|
+
file = abs(entry.path)
|
|
221
|
+
next unless File.exist?(file)
|
|
222
|
+
|
|
223
|
+
if DriftVerdict.unedited?(file, lock_entry: entry)
|
|
224
|
+
@shell.remove_file entry.path
|
|
225
|
+
@result.removed << entry.path
|
|
226
|
+
prune_empty_dirs(entry.path)
|
|
227
|
+
else
|
|
228
|
+
@result.skipped << entry.path
|
|
229
|
+
@shell.say_status :skip,
|
|
230
|
+
"#{entry.path} (disabled but locally modified; delete it by hand)", :yellow
|
|
231
|
+
@new_lock.carry(entry)
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Deletion is gated on the recorded sha, so a user-edited copy is never
|
|
237
|
+
# removed.
|
|
238
|
+
def remove_stale_support_files
|
|
239
|
+
return if additive?
|
|
240
|
+
|
|
241
|
+
planned_skill_dirs = @plan.select { |e| e.type == :skill }.map { |e| File.dirname(e.dest) }
|
|
242
|
+
|
|
243
|
+
old_lock.each_entry do |entry|
|
|
244
|
+
next unless entry.kind == "skill_support"
|
|
245
|
+
next if @new_lock.entry(entry.path)
|
|
246
|
+
next unless planned_skill_dirs.include?(InstallLayout.skill_dir_of(entry.path))
|
|
247
|
+
|
|
248
|
+
file = abs(entry.path)
|
|
249
|
+
next unless File.exist?(file)
|
|
250
|
+
|
|
251
|
+
if DriftVerdict.unedited?(file, lock_entry: entry)
|
|
252
|
+
@shell.remove_file entry.path
|
|
253
|
+
@result.removed << entry.path
|
|
254
|
+
prune_empty_dirs(entry.path)
|
|
255
|
+
else
|
|
256
|
+
@result.skipped << entry.path
|
|
257
|
+
@shell.say_status :skip,
|
|
258
|
+
"#{entry.path} (no longer shipped by #{entry.source_label} but locally modified; delete it by hand)",
|
|
259
|
+
:yellow
|
|
260
|
+
@new_lock.carry(entry)
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Skill directories go only once empty; any user file keeps the whole
|
|
266
|
+
# chain alive. The just-removed entry is subtracted because a dry run
|
|
267
|
+
# deletes nothing from disk.
|
|
268
|
+
def prune_empty_dirs(removed_dest)
|
|
269
|
+
removed = abs(removed_dest)
|
|
270
|
+
dir = File.dirname(removed_dest)
|
|
271
|
+
while dir.start_with?("#{InstallLayout::SKILLS_DIR}/")
|
|
272
|
+
children = Dir.exist?(abs(dir)) ? Dir.children(abs(dir)).map { |c| File.join(abs(dir), c) } : []
|
|
273
|
+
children -= [removed]
|
|
274
|
+
break unless children.empty?
|
|
275
|
+
|
|
276
|
+
@shell.remove_file dir
|
|
277
|
+
removed = abs(dir)
|
|
278
|
+
dir = File.dirname(dir)
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def carry_orphans
|
|
283
|
+
planned = @plan.map(&:dest)
|
|
284
|
+
old_lock.each_entry do |entry|
|
|
285
|
+
next if planned.include?(entry.path)
|
|
286
|
+
next if @new_lock.entry(entry.path)
|
|
287
|
+
next unless DriftVerdict.verdict(file: abs(entry.path), kind: entry.kind, lock_entry: entry, gem_sha: nil) == :orphaned
|
|
288
|
+
|
|
289
|
+
@result.orphaned << entry.path
|
|
290
|
+
@shell.say_status :orphan,
|
|
291
|
+
"#{entry.path} (source #{entry.source_label} no longer in bundle; left in place)", :yellow
|
|
292
|
+
@new_lock.carry(entry)
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# Returns the eager set: the guidelines index.md ends up including.
|
|
297
|
+
def write_index_md(guidelines)
|
|
298
|
+
return amend_index_md(guidelines) if additive?
|
|
299
|
+
return teardown_index_md if guidelines.empty?
|
|
300
|
+
|
|
301
|
+
existing = read_index
|
|
302
|
+
rendered = IndexDocument.render(
|
|
303
|
+
guidelines: guidelines,
|
|
304
|
+
existing: existing,
|
|
305
|
+
previously_installed: old_lock.guideline_paths.map { |p| File.basename(p) }
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
if existing == rendered.content
|
|
309
|
+
@shell.say_status :unchanged, InstallLayout::INDEX_PATH, :blue
|
|
310
|
+
else
|
|
311
|
+
@shell.create_file InstallLayout::INDEX_PATH, rendered.content
|
|
312
|
+
end
|
|
313
|
+
rendered.guidelines
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def amend_index_md(guidelines)
|
|
317
|
+
added = guidelines.select { |g| @result.installed.include?(g[:dest]) }.map { |g| g[:base] }
|
|
318
|
+
content = IndexDocument.amend(existing: read_index, added: added)
|
|
319
|
+
@shell.create_file InstallLayout::INDEX_PATH, content if content
|
|
320
|
+
[] # an additive run prints no footprint, so it reports no eager set
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# No sha gate: the file has no lock entry, and the only user input it
|
|
324
|
+
# carries is a deleted `@`-line, which is moot once no guideline is
|
|
325
|
+
# planned.
|
|
326
|
+
def teardown_index_md
|
|
327
|
+
if File.exist?(abs(InstallLayout::INDEX_PATH))
|
|
328
|
+
@shell.remove_file InstallLayout::INDEX_PATH
|
|
329
|
+
@result.removed << InstallLayout::INDEX_PATH
|
|
330
|
+
end
|
|
331
|
+
[]
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def read_index
|
|
335
|
+
file = abs(InstallLayout::INDEX_PATH)
|
|
336
|
+
File.read(file) if File.exist?(file)
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def write_claude_md(guidelines)
|
|
340
|
+
file = abs(ClaudeMdImport::PATH)
|
|
341
|
+
content = File.read(file) if File.exist?(file)
|
|
342
|
+
decision =
|
|
343
|
+
if guidelines.empty? && !additive?
|
|
344
|
+
ClaudeMdImport.teardown(content: content, state: old_lock.claude_md_state)
|
|
345
|
+
else
|
|
346
|
+
ClaudeMdImport.decide(content: content, state: old_lock.claude_md_state, mode: @mode)
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
case decision.action
|
|
350
|
+
when :create then @shell.create_file ClaudeMdImport::PATH, decision.body
|
|
351
|
+
when :append then @shell.append_to_file ClaudeMdImport::PATH, decision.body
|
|
352
|
+
when :rewrite then @shell.create_file ClaudeMdImport::PATH, decision.body
|
|
353
|
+
when :delete then @shell.remove_file ClaudeMdImport::PATH
|
|
354
|
+
end
|
|
355
|
+
@shell.say_status :warn, decision.warning, :yellow if decision.warning
|
|
356
|
+
|
|
357
|
+
@new_lock.claude_md_state = decision.state
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def write_lock
|
|
361
|
+
@shell.create_file InstallLayout::LOCK_PATH, @new_lock.to_yaml
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def print_warnings
|
|
365
|
+
return if Array(@warnings).empty?
|
|
366
|
+
@shell.say ""
|
|
367
|
+
@shell.say_status :warn, "discovery skipped #{@warnings.size} artifact(s):", :yellow
|
|
368
|
+
@warnings.each { |w| @shell.say " - #{w}" }
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def print_footprint(guidelines:)
|
|
372
|
+
return if additive?
|
|
373
|
+
|
|
374
|
+
EagerFootprint.lines(guidelines: guidelines).each do |line|
|
|
375
|
+
@shell.say_status line.status, line.message, line.color
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def warn_if_destinations_gitignored
|
|
380
|
+
# An additive run has no terminal to warn at; it reports through its result.
|
|
381
|
+
return if additive?
|
|
382
|
+
ignored = gitignored_destinations
|
|
383
|
+
return if ignored.empty?
|
|
384
|
+
|
|
385
|
+
@shell.say_status :warn,
|
|
386
|
+
"#{ignored.join(", ")} #{ignored.one? ? "is" : "are"} gitignored; installed artifacts stay out of " \
|
|
387
|
+
"git status and pull-request diffs, so companion-shipped content reaches the agent unreviewed",
|
|
388
|
+
:yellow
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
# Patterns, negations, and per-repository excludes all decide ignore
|
|
392
|
+
# status, so git is the only correct oracle. Fail-open: no match, no
|
|
393
|
+
# repository, and no git alike exit non-zero and read as "not ignored".
|
|
394
|
+
def gitignored_destinations
|
|
395
|
+
output = IO.popen(
|
|
396
|
+
["git", "check-ignore", "--", *ARTIFACT_DESTINATIONS],
|
|
397
|
+
chdir: @root, err: File::NULL, &:read
|
|
398
|
+
)
|
|
399
|
+
return [] unless $?&.success?
|
|
400
|
+
output.to_s.split("\n").map(&:strip).reject(&:empty?)
|
|
401
|
+
rescue SystemCallError
|
|
402
|
+
[]
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def warn_if_oversize(dest, body)
|
|
406
|
+
lines = body.lines.size
|
|
407
|
+
tokens = EagerFootprint.approx_tokens(body)
|
|
408
|
+
return unless lines > WARN_LINES || tokens > WARN_TOKENS
|
|
409
|
+
@shell.say_status :warn,
|
|
410
|
+
"#{dest} is large (#{lines} lines, ~#{tokens} tokens); guidelines are eager — move tutorial content to a skill",
|
|
411
|
+
:yellow
|
|
412
|
+
end
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
require "rails/hyperdrive/bundler_artifact_discovery"
|
|
2
|
+
require "rails/hyperdrive/install_layout"
|
|
3
|
+
|
|
4
|
+
module Rails
|
|
5
|
+
module Hyperdrive
|
|
6
|
+
# A name shipped by one source installs at its canonical path; a name
|
|
7
|
+
# shipped by several installs every variant, postfixed --<source_gem>.
|
|
8
|
+
module InstallPlan
|
|
9
|
+
Entry = Struct.new(:type, :artifact, :final_name, :dest, :collision, keyword_init: true) do
|
|
10
|
+
# The renamed name: is part of the hashed body, so a postfixed skill
|
|
11
|
+
# hashes to a stable value.
|
|
12
|
+
def install_ready_body
|
|
13
|
+
body = BundlerArtifactDiscovery.install_ready_body(artifact)
|
|
14
|
+
return body unless type == :skill && final_name != artifact.name
|
|
15
|
+
body.sub(/^name:\s*.+$/, "name: #{final_name}")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def support_files
|
|
19
|
+
return [] unless type == :skill
|
|
20
|
+
dir = File.dirname(dest)
|
|
21
|
+
Array(artifact.support_files).map do |file|
|
|
22
|
+
{ path: file[:path], body: file[:body], dest: "#{dir}/#{file[:path]}" }
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def source_gem
|
|
27
|
+
artifact.source_gem
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def version
|
|
31
|
+
artifact.spec_version
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def source_label
|
|
35
|
+
"#{source_gem}@#{version}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def artifact_kind
|
|
39
|
+
type.to_s
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
Result = Struct.new(:entries, :disabled, keyword_init: true)
|
|
44
|
+
|
|
45
|
+
module_function
|
|
46
|
+
|
|
47
|
+
# A collision installs under a postfixed name, so the shipped name
|
|
48
|
+
# disables every variant and a postfixed name disables just one.
|
|
49
|
+
def build(artifacts, lock: nil)
|
|
50
|
+
result = Result.new(entries: [], disabled: [])
|
|
51
|
+
artifacts.group_by { |a| [a.artifact_type, a.name] }.each do |(type, name), group|
|
|
52
|
+
collision = group.size > 1
|
|
53
|
+
group.each do |artifact|
|
|
54
|
+
final_name = collision ? InstallLayout.postfixed_name(name, artifact.source_gem) : name
|
|
55
|
+
entry = Entry.new(
|
|
56
|
+
type: type,
|
|
57
|
+
artifact: artifact,
|
|
58
|
+
final_name: final_name,
|
|
59
|
+
dest: InstallLayout.dest_for(type, final_name),
|
|
60
|
+
collision: collision
|
|
61
|
+
)
|
|
62
|
+
dropped = lock && (lock.disabled?(type, name) || (collision && lock.disabled?(type, final_name)))
|
|
63
|
+
(dropped ? result.disabled : result.entries) << entry
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
result
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# A supporting file is disabled through its owning skill's name.
|
|
70
|
+
def disabled_dest?(lock, type, dest)
|
|
71
|
+
name = InstallLayout.installed_name(type, dest)
|
|
72
|
+
return false unless name
|
|
73
|
+
lookup = type == :skill_support ? :skill : type
|
|
74
|
+
lock.disabled?(lookup, name) || lock.disabled?(lookup, InstallLayout.base_name(name))
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
|
|
3
|
+
module Rails
|
|
4
|
+
module Hyperdrive
|
|
5
|
+
class InstallShell
|
|
6
|
+
attr_reader :root
|
|
7
|
+
|
|
8
|
+
def initialize(root:, io: nil, pretend: false)
|
|
9
|
+
@root = File.expand_path(root.to_s)
|
|
10
|
+
@io = io
|
|
11
|
+
@pretend = pretend
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def create_file(path, content)
|
|
15
|
+
abs = File.join(@root, path)
|
|
16
|
+
unless @pretend
|
|
17
|
+
FileUtils.mkdir_p(File.dirname(abs))
|
|
18
|
+
File.write(abs, content)
|
|
19
|
+
end
|
|
20
|
+
say_status(:create, path)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def remove_file(path)
|
|
24
|
+
FileUtils.rm_rf(File.join(@root, path)) unless @pretend
|
|
25
|
+
say_status(:remove, path)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def append_to_file(path, content)
|
|
29
|
+
abs = File.join(@root, path)
|
|
30
|
+
File.open(abs, "a") { |f| f.write(content) } unless @pretend
|
|
31
|
+
say_status(:append, path)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def say_status(kind, message, _color = nil)
|
|
35
|
+
@io&.puts(format("%12s %s", kind, message))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def say(message = "")
|
|
39
|
+
@io&.puts(message)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
require "yaml"
|
|
2
|
+
|
|
3
|
+
module Rails
|
|
4
|
+
module Hyperdrive
|
|
5
|
+
# installed_at is volatile metadata, never an input to any comparison.
|
|
6
|
+
class LockFile
|
|
7
|
+
SCHEMA_VERSION = 1
|
|
8
|
+
STATE_PRESENT = "present".freeze
|
|
9
|
+
STATE_REMOVED = "removed-by-user".freeze
|
|
10
|
+
|
|
11
|
+
DISABLED_KEYS = { skill: "skills", guideline: "guidelines" }.freeze
|
|
12
|
+
|
|
13
|
+
# In-memory form of one files: entry. On disk, source_gem and
|
|
14
|
+
# source_version are a single "gem@version" string; the split/join lives
|
|
15
|
+
# in this file only.
|
|
16
|
+
Entry = Struct.new(:path, :kind, :source_gem, :source_version, :source_sha, :installed_at, keyword_init: true) do
|
|
17
|
+
def source_label
|
|
18
|
+
source_version ? "#{source_gem}@#{source_version}" : source_gem
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
attr_reader :path
|
|
23
|
+
attr_accessor :claude_md_state
|
|
24
|
+
|
|
25
|
+
# Load existing lock state from disk (absent file → empty lock).
|
|
26
|
+
def self.load(path)
|
|
27
|
+
new(path).tap(&:read)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def initialize(path)
|
|
31
|
+
@path = path.to_s
|
|
32
|
+
@claude_md_state = nil # nil = no lock has been written yet
|
|
33
|
+
@files = {} # path(String) => Entry
|
|
34
|
+
@document = {} # raw parsed YAML, kept so unknown keys survive
|
|
35
|
+
@disabled = empty_disabled
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def read
|
|
39
|
+
return self unless File.exist?(@path)
|
|
40
|
+
|
|
41
|
+
data = YAML.safe_load(File.read(@path))
|
|
42
|
+
return self unless data.is_a?(Hash)
|
|
43
|
+
|
|
44
|
+
@document = data
|
|
45
|
+
claude_md = data["claude_md"]
|
|
46
|
+
@claude_md_state = claude_md["state"] if claude_md.is_a?(Hash)
|
|
47
|
+
@disabled = parse_disabled(data["disabled"])
|
|
48
|
+
Array(data["files"]).each do |raw|
|
|
49
|
+
next unless raw.is_a?(Hash)
|
|
50
|
+
entry = build_entry(raw)
|
|
51
|
+
@files[entry.path] = entry if entry.path
|
|
52
|
+
end
|
|
53
|
+
self
|
|
54
|
+
rescue Psych::SyntaxError
|
|
55
|
+
self
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def entry(file_path)
|
|
59
|
+
@files[file_path.to_s]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def guideline_paths
|
|
63
|
+
@files.values.select { |e| e.kind == "guideline" }.map(&:path)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def each_entry(&block)
|
|
67
|
+
@files.values.each(&block)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def disabled?(type, name)
|
|
71
|
+
Array(@disabled[type.to_sym]).include?(name.to_s)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Adopt the state that is not derived from installed content, so
|
|
75
|
+
# rewriting the file preserves it.
|
|
76
|
+
def carry_settings(other)
|
|
77
|
+
@document = other.document.dup
|
|
78
|
+
@disabled = other.disabled_lists.dup
|
|
79
|
+
self
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def upsert(path:, kind:, source_gem:, source_version:, source_sha:, installed_at:)
|
|
83
|
+
@files[path.to_s] = Entry.new(
|
|
84
|
+
path: path.to_s,
|
|
85
|
+
kind: kind.to_s,
|
|
86
|
+
source_gem: source_gem.to_s,
|
|
87
|
+
source_version: source_version.to_s,
|
|
88
|
+
source_sha: source_sha.to_s,
|
|
89
|
+
installed_at: installed_at.to_s
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def carry(entry)
|
|
94
|
+
return unless entry && entry.path
|
|
95
|
+
@files[entry.path] = entry
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def to_yaml
|
|
99
|
+
carried = @document["claude_md"]
|
|
100
|
+
carried = {} unless carried.is_a?(Hash)
|
|
101
|
+
|
|
102
|
+
document = @document.merge(
|
|
103
|
+
"version" => SCHEMA_VERSION,
|
|
104
|
+
"claude_md" => carried.merge("state" => @claude_md_state),
|
|
105
|
+
"disabled" => DISABLED_KEYS.each_with_object({}) { |(type, key), h| h[key] = @disabled[type] },
|
|
106
|
+
"files" => @files.values.sort_by(&:path).map { |e| serialize_entry(e) }
|
|
107
|
+
)
|
|
108
|
+
# A nil state means no import line is being managed. Recording one anyway
|
|
109
|
+
# would make the next run read the absent line as a deletion the user
|
|
110
|
+
# made, and never add it back.
|
|
111
|
+
document.delete("claude_md") if @claude_md_state.nil?
|
|
112
|
+
document.to_yaml
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
protected
|
|
116
|
+
|
|
117
|
+
def document
|
|
118
|
+
@document
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def disabled_lists
|
|
122
|
+
@disabled
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
private
|
|
126
|
+
|
|
127
|
+
def empty_disabled
|
|
128
|
+
DISABLED_KEYS.keys.each_with_object({}) { |type, h| h[type] = [] }
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def parse_disabled(raw)
|
|
132
|
+
return empty_disabled unless raw.is_a?(Hash)
|
|
133
|
+
|
|
134
|
+
DISABLED_KEYS.each_with_object({}) do |(type, key), h|
|
|
135
|
+
h[type] = Array(raw[key]).map { |name| name.to_s.strip }.reject(&:empty?).uniq
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def build_entry(raw)
|
|
140
|
+
source_gem, source_version = split_source(raw["source"])
|
|
141
|
+
Entry.new(
|
|
142
|
+
path: raw["path"],
|
|
143
|
+
kind: raw["artifact"],
|
|
144
|
+
source_gem: source_gem,
|
|
145
|
+
source_version: source_version,
|
|
146
|
+
source_sha: raw["source_sha"],
|
|
147
|
+
installed_at: raw["installed_at"]
|
|
148
|
+
)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# A source with no "@" (hand-edited) keeps its whole value as the gem
|
|
152
|
+
# part with a nil version, so serialization re-emits it unchanged.
|
|
153
|
+
def split_source(source)
|
|
154
|
+
return [nil, nil] if source.nil?
|
|
155
|
+
source = source.to_s
|
|
156
|
+
idx = source.index("@")
|
|
157
|
+
idx ? [source[0...idx], source[(idx + 1)..]] : [source, nil]
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def serialize_entry(entry)
|
|
161
|
+
{
|
|
162
|
+
"path" => entry.path,
|
|
163
|
+
"artifact" => entry.kind,
|
|
164
|
+
"source" => entry.source_label,
|
|
165
|
+
"source_sha" => entry.source_sha,
|
|
166
|
+
"installed_at" => entry.installed_at
|
|
167
|
+
}
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|