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.
Files changed (55) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +295 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +229 -0
  5. data/Rakefile +6 -0
  6. data/SECURITY.md +43 -0
  7. data/config/routes.rb +5 -0
  8. data/lib/generators/hyperdrive/content_sync_support.rb +54 -0
  9. data/lib/generators/hyperdrive/discover/discover_generator.rb +103 -0
  10. data/lib/generators/hyperdrive/gitignore_support.rb +28 -0
  11. data/lib/generators/hyperdrive/install/USAGE +22 -0
  12. data/lib/generators/hyperdrive/install/install_generator.rb +168 -0
  13. data/lib/generators/hyperdrive/install/templates/initializer.rb.tt +3 -0
  14. data/lib/generators/hyperdrive/install_summary.rb +70 -0
  15. data/lib/generators/hyperdrive/sync/USAGE +21 -0
  16. data/lib/generators/hyperdrive/sync/sync_generator.rb +40 -0
  17. data/lib/generators/hyperdrive/sync_runner.rb +70 -0
  18. data/lib/rails/hyperdrive/artifact_status.rb +86 -0
  19. data/lib/rails/hyperdrive/audit_header.rb +83 -0
  20. data/lib/rails/hyperdrive/auto_install.rb +98 -0
  21. data/lib/rails/hyperdrive/bundler_artifact_discovery.rb +320 -0
  22. data/lib/rails/hyperdrive/claude_md_import.rb +69 -0
  23. data/lib/rails/hyperdrive/companion_discovery.rb +232 -0
  24. data/lib/rails/hyperdrive/console_executor.rb +64 -0
  25. data/lib/rails/hyperdrive/data/gem_categories.yml +52 -0
  26. data/lib/rails/hyperdrive/drift_verdict.rb +42 -0
  27. data/lib/rails/hyperdrive/eager_footprint.rb +48 -0
  28. data/lib/rails/hyperdrive/engine.rb +22 -0
  29. data/lib/rails/hyperdrive/index_document.rb +49 -0
  30. data/lib/rails/hyperdrive/install_layout.rb +45 -0
  31. data/lib/rails/hyperdrive/install_pipeline.rb +415 -0
  32. data/lib/rails/hyperdrive/install_plan.rb +78 -0
  33. data/lib/rails/hyperdrive/install_shell.rb +43 -0
  34. data/lib/rails/hyperdrive/lock_file.rb +171 -0
  35. data/lib/rails/hyperdrive/mcp_server.rb +80 -0
  36. data/lib/rails/hyperdrive/resources/skill.rb +63 -0
  37. data/lib/rails/hyperdrive/resources/stack_profile.rb +32 -0
  38. data/lib/rails/hyperdrive/safety/rack_middleware.rb +54 -0
  39. data/lib/rails/hyperdrive/skill_template.rb +52 -0
  40. data/lib/rails/hyperdrive/sql_safety.rb +28 -0
  41. data/lib/rails/hyperdrive/stack_profile.rb +176 -0
  42. data/lib/rails/hyperdrive/tools/base.rb +39 -0
  43. data/lib/rails/hyperdrive/tools/describe_app.rb +21 -0
  44. data/lib/rails/hyperdrive/tools/list_models.rb +76 -0
  45. data/lib/rails/hyperdrive/tools/list_routes.rb +33 -0
  46. data/lib/rails/hyperdrive/tools/locate_source.rb +86 -0
  47. data/lib/rails/hyperdrive/tools/lookup_doc.rb +60 -0
  48. data/lib/rails/hyperdrive/tools/run_ruby.rb +31 -0
  49. data/lib/rails/hyperdrive/tools/run_sql.rb +49 -0
  50. data/lib/rails/hyperdrive/tools/tail_logs.rb +65 -0
  51. data/lib/rails/hyperdrive/version.rb +5 -0
  52. data/lib/rails/hyperdrive.rb +37 -0
  53. data/lib/rails-hyperdrive.rb +2 -0
  54. data/lib/tasks/hyperdrive.rake +22 -0
  55. metadata +161 -0
@@ -0,0 +1,54 @@
1
+ require "generators/hyperdrive/sync_runner"
2
+
3
+ module Rails
4
+ module Generators
5
+ module Hyperdrive
6
+ # Thor registers every public method defined directly on a generator
7
+ # class as a runnable command, so these shared helpers must stay in an
8
+ # included module or private.
9
+ module ContentSyncSupport
10
+ # Routes InstallPipeline's writes through Thor, so its output and
11
+ # `--dry-run` handling cover installed content too.
12
+ class ThorShell
13
+ def initialize(generator)
14
+ @generator = generator
15
+ end
16
+
17
+ def create_file(path, content)
18
+ @generator.create_file(path, content, force: true)
19
+ end
20
+
21
+ def append_to_file(path, content)
22
+ @generator.append_to_file(path, content)
23
+ end
24
+
25
+ def remove_file(path)
26
+ @generator.remove_file(path)
27
+ end
28
+
29
+ def say_status(kind, message, color = nil)
30
+ @generator.say_status(kind, message, color)
31
+ end
32
+
33
+ def say(message = "")
34
+ @generator.say(message)
35
+ end
36
+ end
37
+
38
+ # Thor's file-writing helpers all read options[:pretend], so mapping
39
+ # --dry-run on read keeps it in force for every step regardless of
40
+ # which one runs first.
41
+ def options
42
+ opts = super
43
+ opts[:dry_run] ? opts.merge(pretend: true) : opts
44
+ end
45
+
46
+ private
47
+
48
+ def runner
49
+ @runner ||= SyncRunner.new(shell: ThorShell.new(self))
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,103 @@
1
+ require "rails/generators"
2
+ require "rails/generators/base"
3
+ require "rails/hyperdrive"
4
+ require "rails/hyperdrive/companion_discovery"
5
+ require "generators/hyperdrive/gitignore_support"
6
+
7
+ module Rails
8
+ module Generators
9
+ module Hyperdrive
10
+ # Read-only with respect to the app: never edits the Gemfile and never installs gems.
11
+ class DiscoverGenerator < ::Rails::Generators::Base
12
+ include GitignoreSupport
13
+
14
+ CACHE_RULE = ::Rails::Hyperdrive::CompanionDiscovery::CACHE_RELATIVE_PATH
15
+
16
+ source_root __dir__
17
+
18
+ class_option :refresh, type: :boolean, default: false,
19
+ desc: "Ignore the cached results and re-query rubygems."
20
+
21
+ def verify_environment
22
+ return if defined?(::Rails) && ::Rails.respond_to?(:root) && ::Rails.root
23
+ say_status :error, "must be run inside a Rails app", :red
24
+ raise Thor::Error, "hyperdrive: not in a Rails app"
25
+ end
26
+
27
+ def ensure_cache_gitignored
28
+ ensure_gitignored(CACHE_RULE)
29
+ end
30
+
31
+ def discover_and_report
32
+ result = ::Rails::Hyperdrive::CompanionDiscovery.new(
33
+ lockfile_path: ::Rails.root.join("Gemfile.lock").to_s,
34
+ cache_path: ::Rails.root.join(CACHE_RULE).to_s,
35
+ refresh: options[:refresh]
36
+ ).run
37
+
38
+ if result.status == :unavailable
39
+ say_status :unavailable, "rubygems discovery unavailable — #{result.detail}; no cached results", :yellow
40
+ return
41
+ end
42
+
43
+ say_status :stale, "rubygems unreachable; showing #{format_age(result.age_seconds)} cached results — #{result.detail}", :yellow if result.status == :stale
44
+
45
+ report_suggestions(result.suggestions)
46
+ report_warnings(result.warnings)
47
+ end
48
+
49
+ no_tasks do
50
+ def report_suggestions(suggestions)
51
+ if suggestions.empty?
52
+ say_status :none, "no rails-hyperdrive companion gems found for your stack", :blue
53
+ return
54
+ end
55
+
56
+ say ""
57
+ say "Found gems with rails-hyperdrive content for your stack:"
58
+ suggestions.each { |s| say " #{format_line(s)}" }
59
+
60
+ to_add = suggestions.reject(&:installed)
61
+ return if to_add.empty?
62
+
63
+ say ""
64
+ to_add.each { |s| say "Run: bundle add #{s.gem_name} --group=development" }
65
+ say "Then: bin/rails hyperdrive:init"
66
+ end
67
+
68
+ def format_line(suggestion)
69
+ marker = suggestion.installed ? "✓" : "!"
70
+ companion = "#{suggestion.gem_name} #{suggestion.version}"
71
+ lhs =
72
+ if suggestion.matched_target
73
+ "#{suggestion.matched_target} #{suggestion.matched_version} → #{companion}"
74
+ else
75
+ "#{companion} (applies to any stack)"
76
+ end
77
+ status = suggestion.installed ? "(installed)" : "(suggested)"
78
+ artifacts =
79
+ if suggestion.installed || suggestion.artifacts.empty?
80
+ ""
81
+ else
82
+ " — ships #{suggestion.artifacts.join(" + ")}"
83
+ end
84
+ "#{marker} #{lhs}#{artifacts} #{status}"
85
+ end
86
+
87
+ def report_warnings(warnings)
88
+ return if warnings.empty?
89
+ say ""
90
+ say_status :warn, "discovery skipped #{warnings.size} gem(s):", :yellow
91
+ warnings.each { |w| say " - #{w}" }
92
+ end
93
+
94
+ def format_age(seconds)
95
+ return "cached" unless seconds
96
+ hours = (seconds / 3600.0).round
97
+ hours <= 1 ? "~1h-old" : "~#{hours}h-old"
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,28 @@
1
+ module Rails
2
+ module Generators
3
+ module Hyperdrive
4
+ # Included as a module so its methods are not registered as Thor commands —
5
+ # Thor's method_added hook fires only for methods defined directly on the
6
+ # generator class.
7
+ module GitignoreSupport
8
+ GITIGNORE = ".gitignore".freeze
9
+
10
+ # The rule must name a specific file, never a directory — the lockfile
11
+ # in the same directory stays tracked.
12
+ def ensure_gitignored(rule)
13
+ abs = ::Rails.root.join(GITIGNORE)
14
+ unless File.exist?(abs)
15
+ create_file GITIGNORE, "#{rule}\n"
16
+ return
17
+ end
18
+
19
+ body = File.read(abs)
20
+ return if body.split("\n").any? { |line| line.strip == rule }
21
+
22
+ prefix = body.end_with?("\n") || body.empty? ? "" : "\n"
23
+ append_to_file GITIGNORE, "#{prefix}#{rule}\n"
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,22 @@
1
+ Description:
2
+ Bootstraps Rails Hyperdrive in this app:
3
+ - adds the rails-hyperdrive server to .mcp.json (Claude Code config),
4
+ merging into any servers already configured there
5
+ - mounts Rails::Hyperdrive::Engine at /_hyperdrive in config/routes.rb (idempotent)
6
+ - discovers companion-gem skills + guidelines in the bundle and installs
7
+ them with audit headers (skills to .claude/skills/, guidelines to
8
+ .claude/hyperdrive/guidelines/)
9
+ - maintains the .claude/hyperdrive/index.md aggregator and injects one
10
+ @-include line into CLAUDE.md — both only while a companion gem ships
11
+ a guideline, and both removed when the last one goes
12
+ - tracks everything in .hyperdrive/lock.yml
13
+
14
+ Re-running re-syncs content and leaves locally-modified files untouched
15
+ (skip + warn). Routine content refresh is bin/rails hyperdrive:sync
16
+ (pass --overwrite to restore gem-shipped content).
17
+
18
+ Examples:
19
+ bin/rails hyperdrive:init
20
+ bin/rails hyperdrive:init --mount-at /admin/hyperdrive
21
+ bin/rails hyperdrive:init --skip-content
22
+ bin/rails hyperdrive:init --dry-run
@@ -0,0 +1,168 @@
1
+ require "rails/generators"
2
+ require "rails/generators/base"
3
+ require "json"
4
+ require "rails/hyperdrive/companion_discovery"
5
+ require "rails/hyperdrive/mcp_server"
6
+ require "generators/hyperdrive/content_sync_support"
7
+ require "generators/hyperdrive/gitignore_support"
8
+
9
+ module Rails
10
+ module Generators
11
+ module Hyperdrive
12
+ class InstallGenerator < ::Rails::Generators::Base
13
+ include ContentSyncSupport
14
+ include GitignoreSupport
15
+
16
+ ENGINE_MOUNT_TOKEN = "Rails::Hyperdrive::Engine"
17
+ DEFAULT_MOUNT_AT = "/_hyperdrive".freeze
18
+
19
+ MCP_JSON_PATH = ".mcp.json".freeze
20
+ MCP_SERVER_KEY = "rails-hyperdrive".freeze
21
+
22
+ GEMFILE = "Gemfile".freeze
23
+ BUNDLER_PLUGIN = "bundler-rails-hyperdrive".freeze
24
+
25
+ source_root File.expand_path("templates", __dir__)
26
+
27
+ class_option :mount_at, type: :string, default: DEFAULT_MOUNT_AT, desc: "Engine mount path."
28
+ class_option :skip_content, type: :boolean, default: false, desc: "Skip all .claude content, CLAUDE.md, and the lockfile; write only .mcp.json and the mount."
29
+ class_option :dry_run, type: :boolean, default: false, desc: "Show what would change; write nothing."
30
+
31
+ def verify_environment
32
+ runner.verify_environment!
33
+ end
34
+
35
+ def discover_artifacts
36
+ runner.discover_artifacts(skip: options[:skip_content])
37
+ end
38
+
39
+ # The write is forced: Thor's conflict prompt would otherwise block the
40
+ # run waiting on stdin.
41
+ def write_mcp_json
42
+ existing = mcp_json_on_disk
43
+ document = existing ? parse_mcp_json(existing) : {}
44
+ return if document.nil?
45
+
46
+ (document["mcpServers"] ||= {})[MCP_SERVER_KEY] = mcp_server_entry
47
+ content = JSON.pretty_generate(document) + "\n"
48
+
49
+ if existing == content
50
+ say_status :unchanged, MCP_JSON_PATH, :blue
51
+ else
52
+ create_file MCP_JSON_PATH, content, force: true
53
+ end
54
+ end
55
+
56
+ def ignore_discover_cache
57
+ ensure_gitignored(::Rails::Hyperdrive::CompanionDiscovery::CACHE_RELATIVE_PATH)
58
+ end
59
+
60
+ # Any existing directive counts as registered — a path- or
61
+ # version-qualified line is a deliberate choice this must not
62
+ # duplicate or rewrite.
63
+ def register_bundler_plugin
64
+ gemfile = ::Rails.root.join(GEMFILE)
65
+ unless File.exist?(gemfile)
66
+ say_status :skip, "no #{GEMFILE} found; add plugin #{BUNDLER_PLUGIN.inspect} manually", :yellow
67
+ return
68
+ end
69
+
70
+ body = File.read(gemfile)
71
+ if body.match?(/^\s*plugin\s+["']#{BUNDLER_PLUGIN}["']/)
72
+ say_status :identical, "#{GEMFILE} (#{BUNDLER_PLUGIN} plugin already registered)", :blue
73
+ return
74
+ end
75
+
76
+ prefix = body.end_with?("\n") || body.empty? ? "" : "\n"
77
+ append_to_file GEMFILE, "#{prefix}plugin #{BUNDLER_PLUGIN.inspect}\n"
78
+ end
79
+
80
+ def write_initializer
81
+ return if mount_path == DEFAULT_MOUNT_AT
82
+ template "initializer.rb.tt", "config/initializers/hyperdrive.rb"
83
+ end
84
+
85
+ def mount_engine
86
+ routes_file = "config/routes.rb"
87
+ unless File.exist?(::Rails.root.join(routes_file))
88
+ say_status :skip, "no #{routes_file} found; skipping engine mount", :yellow
89
+ return
90
+ end
91
+
92
+ contents = File.read(::Rails.root.join(routes_file))
93
+ if contents.include?(ENGINE_MOUNT_TOKEN)
94
+ say_status :identical, "#{routes_file} (engine already mounted)", :blue
95
+ return
96
+ end
97
+
98
+ snippet = " mount Rails::Hyperdrive::Engine => \"#{mount_path}\" if Rails.env.development?\n"
99
+ inject_into_file routes_file, snippet, after: /Rails\.application\.routes\.draw do\s*\n/
100
+ end
101
+
102
+ # `--skip-content` writes no lockfile either: the lock is a manifest of
103
+ # installed content, and an empty one would assert "zero files is the
104
+ # managed set". A later init or sync reconstructs the full state.
105
+ def sync_content
106
+ return if options[:skip_content]
107
+ runner.install(mode: :preserve)
108
+ end
109
+
110
+ def print_summary
111
+ say ""
112
+ say_status :done, "hyperdrive initialized", :green
113
+ say " Mount: #{mount_path} (in config/routes.rb)"
114
+ say " Server: #{::Rails::Hyperdrive::McpServer::TOOLS.size} MCP tools at http://localhost:3000#{mount_path}/mcp"
115
+ runner.summary_lines.each { |line| say line } unless options[:skip_content]
116
+ say ""
117
+ say " Next steps:"
118
+ say " 1. bin/rails server"
119
+ say " 2. Open Claude Code in this directory; it will read .mcp.json"
120
+ say " 3. Verify the connection: curl http://localhost:3000#{mount_path}/mcp"
121
+ end
122
+
123
+ no_tasks do
124
+ def mcp_json_on_disk
125
+ abs = ::Rails.root.join(MCP_JSON_PATH)
126
+ File.exist?(abs) ? File.read(abs) : nil
127
+ end
128
+
129
+ def mcp_server_entry
130
+ {
131
+ "url" => "http://localhost:3000#{mount_path}/mcp",
132
+ "type" => "http"
133
+ }
134
+ end
135
+
136
+ # A file we can't parse is never overwritten — its contents are
137
+ # unrecoverable.
138
+ def parse_mcp_json(raw)
139
+ document = JSON.parse(raw)
140
+ return unmergeable_mcp_json("top-level value is not a JSON object") unless document.is_a?(Hash)
141
+
142
+ servers = document["mcpServers"]
143
+ unless servers.nil? || servers.is_a?(Hash)
144
+ return unmergeable_mcp_json('"mcpServers" is not a JSON object')
145
+ end
146
+
147
+ document
148
+ rescue JSON::ParserError => e
149
+ unmergeable_mcp_json(e.message.lines.first.to_s.strip)
150
+ end
151
+
152
+ def unmergeable_mcp_json(reason)
153
+ say_status :warn,
154
+ "#{MCP_JSON_PATH} left unchanged (#{reason}); fix it and re-run to add the #{MCP_SERVER_KEY} server",
155
+ :yellow
156
+ nil
157
+ end
158
+
159
+ def mount_path
160
+ raw = options[:mount_at].to_s
161
+ raw = "/" + raw unless raw.start_with?("/")
162
+ raw.length > 1 ? raw.chomp("/") : raw
163
+ end
164
+ end
165
+ end
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,3 @@
1
+ Rails::Hyperdrive.configure do |c|
2
+ c.mount_at = "<%= mount_path %>"
3
+ end
@@ -0,0 +1,70 @@
1
+ require "rails/hyperdrive/install_layout"
2
+
3
+ module Rails
4
+ module Generators
5
+ module Hyperdrive
6
+ module InstallSummary
7
+ KIND_WIDTH = "guideline".length
8
+ KIND_ORDER = %w[skill guideline].freeze
9
+
10
+ module_function
11
+
12
+ # The lock is the authoritative set: it includes untouched,
13
+ # locally-modified, and orphaned files.
14
+ def lines(entries)
15
+ entries = entries.to_a
16
+ return [] if entries.empty?
17
+
18
+ support, listed = entries.partition { |e| e.kind.to_s == "skill_support" }
19
+ # A carried SKILL.md entry can record an older source than its
20
+ # supporting files, so counts key on the installed directory name,
21
+ # which is unique across sources.
22
+ support_counts = support
23
+ .group_by { |e| ::Rails::Hyperdrive::InstallLayout.installed_name(:skill_support, e.path.to_s) }
24
+ .transform_values(&:size)
25
+
26
+ out = [" #{installed_counts(listed)}", ""]
27
+ group_by_source(listed).each do |source, group|
28
+ out << " #{source}"
29
+ group.each do |entry|
30
+ name = display_name(entry)
31
+ count = entry.kind.to_s == "skill" ? support_counts[name].to_i : 0
32
+ suffix = count.positive? ? " (+#{quantify(count, "file")})" : ""
33
+ out << " #{entry.kind.to_s.ljust(KIND_WIDTH)} #{name}#{suffix}"
34
+ end
35
+ end
36
+ out
37
+ end
38
+
39
+ def installed_counts(entries)
40
+ counts = entries.group_by { |e| e.kind.to_s }.transform_values(&:size)
41
+ "Installed #{quantify(counts["skill"].to_i, "skill")}, #{quantify(counts["guideline"].to_i, "guideline")}"
42
+ end
43
+
44
+ def group_by_source(entries)
45
+ entries
46
+ .group_by { |e| e.source_label.to_s }
47
+ .sort_by { |source, group| [group.first.source_gem == "internal" ? 1 : 0, source] }
48
+ .map do |source, group|
49
+ [source, group.sort_by { |e| [KIND_ORDER.index(e.kind.to_s) || KIND_ORDER.size, display_name(e)] }]
50
+ end
51
+ end
52
+
53
+ def display_name(entry)
54
+ path = entry.path.to_s
55
+
56
+ # A kind outside the install layout can only come from a hand-edited
57
+ # lock, so it degrades to the filename instead of printing nothing.
58
+ type = ::Rails::Hyperdrive::InstallLayout::ARTIFACT_TYPES[entry.kind.to_s]
59
+ type ? ::Rails::Hyperdrive::InstallLayout.installed_name(type, path) : File.basename(path, ".md")
60
+ end
61
+
62
+ def quantify(count, noun)
63
+ "#{count} #{noun}#{"s" unless count == 1}"
64
+ end
65
+
66
+ private_class_method :installed_counts, :group_by_source, :display_name, :quantify
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,21 @@
1
+ Description:
2
+ Syncs Rails Hyperdrive content to match the current bundle (run after
3
+ `bundle update` or after adding a companion gem):
4
+ - discovers companion-gem skills + guidelines in the bundle and installs
5
+ them with audit headers (skills to .claude/skills/, guidelines to
6
+ .claude/hyperdrive/guidelines/)
7
+ - maintains the .claude/hyperdrive/index.md aggregator and the single
8
+ @-include line in CLAUDE.md — both only while a companion gem ships a
9
+ guideline, and both removed when the last one goes
10
+ - tracks everything in .hyperdrive/lock.yml
11
+
12
+ Locally-modified files are left untouched (skip + warn). Pass --overwrite
13
+ to restore them to the gem-shipped content.
14
+
15
+ Touches no bootstrap artifact: .mcp.json, the engine mount, the optional
16
+ initializer, and .gitignore belong to hyperdrive:init and are left alone.
17
+
18
+ Examples:
19
+ bin/rails hyperdrive:sync
20
+ bin/rails hyperdrive:sync --overwrite
21
+ bin/rails hyperdrive:sync --dry-run
@@ -0,0 +1,40 @@
1
+ require "rails/generators"
2
+ require "rails/generators/base"
3
+ require "generators/hyperdrive/content_sync_support"
4
+
5
+ module Rails
6
+ module Generators
7
+ module Hyperdrive
8
+ # Content-only by contract: no step may write a bootstrap artifact
9
+ # (.mcp.json, the engine mount, the initializer, the .gitignore rule).
10
+ class SyncGenerator < ::Rails::Generators::Base
11
+ include ContentSyncSupport
12
+
13
+ # No templates are rendered; source_root exists so Rails resolves the
14
+ # sibling USAGE file for `--help`.
15
+ source_root File.expand_path("templates", __dir__)
16
+
17
+ class_option :overwrite, type: :boolean, default: false, desc: "Restore locally-modified managed files to the gem-shipped content."
18
+ class_option :dry_run, type: :boolean, default: false, desc: "Show what would change; write nothing."
19
+
20
+ def verify_environment
21
+ runner.verify_environment!
22
+ end
23
+
24
+ def discover_artifacts
25
+ runner.discover_artifacts
26
+ end
27
+
28
+ def sync_content
29
+ runner.install(mode: options[:overwrite] ? :overwrite : :preserve)
30
+ end
31
+
32
+ def print_summary
33
+ say ""
34
+ say_status :done, "hyperdrive synced", :green
35
+ runner.summary_lines.each { |line| say line }
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,70 @@
1
+ require "thor"
2
+ require "rails/hyperdrive"
3
+ require "rails/hyperdrive/bundler_artifact_discovery"
4
+ require "rails/hyperdrive/install_pipeline"
5
+ require "generators/hyperdrive/install_summary"
6
+
7
+ module Rails
8
+ module Generators
9
+ module Hyperdrive
10
+ # Owns the init/sync sequence. Each phase is an idempotent memoizer and
11
+ # `install` forces the ones it needs, so no call order can install with a
12
+ # half-built input set.
13
+ class SyncRunner
14
+ def initialize(shell:, root: nil)
15
+ @shell = shell
16
+ @root = root&.to_s
17
+ end
18
+
19
+ def verify_environment!
20
+ unless defined?(::Rails) && ::Rails.respond_to?(:root) && ::Rails.root
21
+ @shell.say_status :error, "must be run inside a Rails app", :red
22
+ raise Thor::Error, "hyperdrive: not in a Rails app"
23
+ end
24
+ unless ::Rails.respond_to?(:env) && ::Rails.env.development?
25
+ env = ::Rails.respond_to?(:env) ? ::Rails.env.to_s : "unknown"
26
+ warn "hyperdrive: must run with Rails.env=development (current: #{env})"
27
+ raise Thor::Error, "hyperdrive: refuse to run outside development (Rails.env=#{env})"
28
+ end
29
+ end
30
+
31
+ def discover_artifacts(skip: false)
32
+ @artifacts ||= skip ? [] : ::Rails::Hyperdrive::BundlerArtifactDiscovery.discover(warnings: warnings)
33
+ end
34
+
35
+ def install(mode:)
36
+ @pipeline = ::Rails::Hyperdrive::InstallPipeline.new(
37
+ root: root,
38
+ shell: @shell,
39
+ artifacts: discover_artifacts,
40
+ mode: mode,
41
+ warnings: warnings
42
+ )
43
+ @pipeline.call
44
+ end
45
+
46
+ def summary_lines
47
+ InstallSummary.lines(lock_entries)
48
+ end
49
+
50
+ private
51
+
52
+ # Resolved lazily so verify_environment! can report a missing Rails app
53
+ # before anything dereferences ::Rails.root.
54
+ def root
55
+ @root ||= ::Rails.root.to_s
56
+ end
57
+
58
+ def warnings
59
+ @warnings ||= []
60
+ end
61
+
62
+ def lock_entries
63
+ entries = []
64
+ @pipeline&.lock&.each_entry { |e| entries << e }
65
+ entries
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,86 @@
1
+ require "rails/hyperdrive/drift_verdict"
2
+ require "rails/hyperdrive/install_layout"
3
+ require "rails/hyperdrive/install_plan"
4
+ require "rails/hyperdrive/lock_file"
5
+
6
+ module Rails
7
+ module Hyperdrive
8
+ # The comparison is against the lock manifest alone — installed files are
9
+ # never read, so an edited or deleted file does not change a verdict.
10
+ class ArtifactStatus
11
+ STATES = %i[installed missing outdated orphaned].freeze
12
+
13
+ Entry = Struct.new(:path, :state, :artifact, :locked_source, :bundle_source, keyword_init: true) do
14
+ def to_s
15
+ case state
16
+ when :missing then "#{path} (from #{bundle_source})"
17
+ when :outdated then "#{path} (#{locked_source} → #{bundle_source})"
18
+ when :orphaned then "#{path} (source #{locked_source} no longer in bundle)"
19
+ else path
20
+ end
21
+ end
22
+ end
23
+
24
+ def self.compare(root:, artifacts:)
25
+ new(root: root, artifacts: artifacts).tap(&:compare)
26
+ end
27
+
28
+ attr_reader :entries
29
+
30
+ def initialize(root:, artifacts:)
31
+ @root = File.expand_path(root.to_s)
32
+ @artifacts = artifacts
33
+ @entries = []
34
+ end
35
+
36
+ def compare
37
+ lock = LockFile.load(File.join(@root, InstallLayout::LOCK_PATH))
38
+ offered = {}
39
+
40
+ InstallPlan.build(@artifacts, lock: lock).entries.each do |plan_entry|
41
+ offered[plan_entry.dest] = [DriftVerdict.body_sha(plan_entry.install_ready_body), plan_entry.source_label, plan_entry.type]
42
+ plan_entry.support_files.each do |file|
43
+ offered[file[:dest]] = [DriftVerdict.body_sha(file[:body]), plan_entry.source_label, :skill_support]
44
+ end
45
+ end
46
+
47
+ offered.each do |path, (gem_sha, source_label, type)|
48
+ locked = lock.entry(path)
49
+ state =
50
+ if locked.nil? then :missing
51
+ elsif locked.source_sha == gem_sha then :installed
52
+ else :outdated
53
+ end
54
+ @entries << Entry.new(
55
+ path: path, state: state, artifact: type,
56
+ locked_source: locked&.source_label, bundle_source: source_label
57
+ )
58
+ end
59
+
60
+ lock.each_entry do |locked|
61
+ next if offered.key?(locked.path)
62
+
63
+ # A disabled artifact left on disk was reported at install time; the
64
+ # bundle still ships it, so it is not an orphan.
65
+ type = InstallLayout::ARTIFACT_TYPES[locked.kind]
66
+ next if type && InstallPlan.disabled_dest?(lock, type, locked.path)
67
+
68
+ @entries << Entry.new(
69
+ path: locked.path, state: :orphaned, artifact: locked.kind&.to_sym,
70
+ locked_source: locked.source_label, bundle_source: nil
71
+ )
72
+ end
73
+
74
+ self
75
+ end
76
+
77
+ STATES.each do |state|
78
+ define_method(state) { @entries.select { |e| e.state == state } }
79
+ end
80
+
81
+ def stale?
82
+ !missing.empty? || !outdated.empty? || !orphaned.empty?
83
+ end
84
+ end
85
+ end
86
+ end