rails-hyperdrive 0.6.0 → 0.7.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 +266 -1
- data/README.md +44 -13
- data/lib/generators/hyperdrive/install/USAGE +11 -3
- data/lib/generators/hyperdrive/install/install_generator.rb +16 -4
- data/lib/generators/hyperdrive/install_summary.rb +3 -3
- data/lib/generators/hyperdrive/sync/USAGE +7 -5
- data/lib/generators/hyperdrive/sync_runner.rb +20 -10
- data/lib/hyperdrive/skill_tasks.rb +39 -0
- data/lib/rails/commands/hyperdrive/hyperdrive_command.rb +59 -0
- data/lib/rails/hyperdrive/ancestor_locator.rb +10 -13
- data/lib/rails/hyperdrive/artifact_status.rb +16 -7
- data/lib/rails/hyperdrive/auto_install.rb +25 -7
- data/lib/rails/hyperdrive/bundler_artifact_discovery.rb +360 -150
- data/lib/rails/hyperdrive/canonical_skill_render.rb +68 -39
- data/lib/rails/hyperdrive/companion_discovery.rb +4 -5
- data/lib/rails/hyperdrive/gem_manifest.rb +311 -80
- data/lib/rails/hyperdrive/gemspec_locator.rb +38 -0
- data/lib/rails/hyperdrive/install_layout.rb +148 -12
- data/lib/rails/hyperdrive/install_pipeline.rb +142 -78
- data/lib/rails/hyperdrive/install_plan.rb +21 -9
- data/lib/rails/hyperdrive/lock_file.rb +25 -3
- data/lib/rails/hyperdrive/manifest_lint.rb +230 -0
- data/lib/rails/hyperdrive/skill_template.rb +7 -5
- data/lib/rails/hyperdrive/version.rb +1 -1
- metadata +9 -7
- data/lib/rails/hyperdrive/skill_tasks.rb +0 -27
- data/lib/tasks/hyperdrive.rake +0 -22
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Rake tasks for companion gem repos: add `require "hyperdrive/skill_tasks"`
|
|
2
|
+
# to the Rakefile. Every task takes an optional gemspec-path argument, e.g.
|
|
3
|
+
# rake "hyperdrive:skills:render[path/to/name.gemspec]".
|
|
4
|
+
require "rake"
|
|
5
|
+
require "rails/hyperdrive/canonical_skill_render"
|
|
6
|
+
require "rails/hyperdrive/manifest_lint"
|
|
7
|
+
|
|
8
|
+
namespace :hyperdrive do
|
|
9
|
+
namespace :skills do
|
|
10
|
+
desc "Render each SKILL.md.erb template to its canonical static SKILL.md"
|
|
11
|
+
task :render, [:gemspec] do |_t, args|
|
|
12
|
+
written = Rails::Hyperdrive::CanonicalSkillRender.write(gemspec: args[:gemspec])
|
|
13
|
+
written.each { |r| puts "render #{r.dest}" }
|
|
14
|
+
puts "no skill templates found" if written.empty?
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
desc "Fail when a canonical skill file is stale, or raw ERB sits under a public skills root"
|
|
18
|
+
task :check, [:gemspec] do |_t, args|
|
|
19
|
+
roots = Rails::Hyperdrive::CanonicalSkillRender.resolve_roots(gemspec: args[:gemspec])
|
|
20
|
+
stale = Rails::Hyperdrive::CanonicalSkillRender.stale(roots: roots)
|
|
21
|
+
raw = Rails::Hyperdrive::CanonicalSkillRender.public_erb_templates(roots: roots)
|
|
22
|
+
stale.each { |r| warn "stale: #{r.dest}" }
|
|
23
|
+
raw.each { |p| warn "raw ERB: #{p}" }
|
|
24
|
+
abort "#{stale.size} canonical skill file(s) stale; run `rake hyperdrive:skills:render`" unless stale.empty?
|
|
25
|
+
abort "#{raw.size} ERB template(s) under a public skills root; move them to the template directory" unless raw.empty?
|
|
26
|
+
puts "canonical skill files up to date"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
namespace :manifest do
|
|
31
|
+
desc "Fail on unknown keys, unparsable gating, or entries naming nothing shipped in hyperdrive.yml"
|
|
32
|
+
task :check, [:gemspec] do |_t, args|
|
|
33
|
+
result = Rails::Hyperdrive::ManifestLint.check(gemspec: args[:gemspec])
|
|
34
|
+
result.problems.each { |p| warn "#{result.manifest}: #{p}" }
|
|
35
|
+
abort "#{result.problems.size} problem(s) in #{result.manifest}" unless result.problems.empty?
|
|
36
|
+
puts result.manifest ? "#{result.manifest} is clean" : "no hyperdrive.yml; nothing to lint"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
require "rails/command"
|
|
2
|
+
require "rails/generators"
|
|
3
|
+
require "generators/hyperdrive/install/install_generator"
|
|
4
|
+
require "generators/hyperdrive/sync/sync_generator"
|
|
5
|
+
require "generators/hyperdrive/discover/discover_generator"
|
|
6
|
+
|
|
7
|
+
module Rails
|
|
8
|
+
module Command
|
|
9
|
+
# The generators are the sole authority on option parsing, defaults, and
|
|
10
|
+
# validation; this class only forwards argv and renders `--help`.
|
|
11
|
+
class HyperdriveCommand < Base
|
|
12
|
+
namespace "hyperdrive"
|
|
13
|
+
|
|
14
|
+
# Mirrors a generator's own flags so the help surface cannot drift from
|
|
15
|
+
# what the generator actually accepts. Thor's runtime options are
|
|
16
|
+
# excluded: they are inherited plumbing, not part of this command.
|
|
17
|
+
def self.mirror_generator_options(klass)
|
|
18
|
+
klass.class_options.each do |name, option|
|
|
19
|
+
next if ::Rails::Generators::Base.class_options.key?(name)
|
|
20
|
+
method_options[name] = option
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Thor treats a leading `--` as an options terminator, so with `-- --merge`
|
|
25
|
+
# the flag lands in positional args and `options[:merge]` is false. Both
|
|
26
|
+
# spellings must reach the generator identically.
|
|
27
|
+
def initialize(args = [], local_options = {}, config = {})
|
|
28
|
+
super
|
|
29
|
+
@argv = args + (local_options.is_a?(Array) ? local_options : [])
|
|
30
|
+
@argv = @argv.drop(1) if @argv.first == "--"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
desc "init", "Install Rails Hyperdrive into this app (writes .mcp.json, CLAUDE.md, companion content, mounts engine)"
|
|
34
|
+
mirror_generator_options ::Rails::Generators::Hyperdrive::InstallGenerator
|
|
35
|
+
def init(*)
|
|
36
|
+
start_generator("install", "InstallGenerator")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
desc "sync", "Sync Rails Hyperdrive content (skills, guidelines, agents, commands, index.md, lockfile); locally-edited files are preserved"
|
|
40
|
+
mirror_generator_options ::Rails::Generators::Hyperdrive::SyncGenerator
|
|
41
|
+
def sync(*)
|
|
42
|
+
start_generator("sync", "SyncGenerator")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
desc "discover", "Suggest uninstalled rails-hyperdrive companion gems for this app's stack (networked, cached)"
|
|
46
|
+
mirror_generator_options ::Rails::Generators::Hyperdrive::DiscoverGenerator
|
|
47
|
+
def discover(*)
|
|
48
|
+
start_generator("discover", "DiscoverGenerator")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
no_commands do
|
|
52
|
+
def start_generator(name, generator)
|
|
53
|
+
require_application!
|
|
54
|
+
::Rails::Generators::Hyperdrive.const_get(generator).start(@argv)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
require "bundler"
|
|
2
2
|
require "rails/hyperdrive/bundler_artifact_discovery"
|
|
3
3
|
require "rails/hyperdrive/drift_verdict"
|
|
4
|
+
require "rails/hyperdrive/install_layout"
|
|
4
5
|
require "rails/hyperdrive/skill_template"
|
|
5
6
|
|
|
6
7
|
module Rails
|
|
@@ -49,19 +50,15 @@ module Rails
|
|
|
49
50
|
end
|
|
50
51
|
|
|
51
52
|
def install_ready(body, kind:, final_name:)
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
)
|
|
62
|
-
else
|
|
63
|
-
body
|
|
64
|
-
end
|
|
53
|
+
type = InstallLayout::ARTIFACT_TYPES[kind.to_s]
|
|
54
|
+
descriptor = type && InstallLayout.kind(type)
|
|
55
|
+
return body unless descriptor
|
|
56
|
+
|
|
57
|
+
ready = BundlerArtifactDiscovery.install_ready_body(
|
|
58
|
+
BundlerArtifactDiscovery::Artifact.new(artifact_type: type, body: body)
|
|
59
|
+
)
|
|
60
|
+
return ready unless final_name && descriptor.collision_rewrites_name
|
|
61
|
+
ready.sub(/^name:\s*.+$/, "name: #{final_name}")
|
|
65
62
|
end
|
|
66
63
|
|
|
67
64
|
def resolved_bundle
|
|
@@ -10,26 +10,34 @@ module Rails
|
|
|
10
10
|
class ArtifactStatus
|
|
11
11
|
STATES = %i[installed missing outdated orphaned].freeze
|
|
12
12
|
|
|
13
|
-
Entry = Struct.new(:path, :state, :artifact, :locked_source, :bundle_source,
|
|
13
|
+
Entry = Struct.new(:path, :state, :artifact, :locked_source, :bundle_source, :source_gem, :source_bundled,
|
|
14
|
+
keyword_init: true) do
|
|
14
15
|
def to_s
|
|
15
16
|
case state
|
|
16
17
|
when :missing then "#{path} (from #{bundle_source})"
|
|
17
18
|
when :outdated then "#{path} (#{locked_source} → #{bundle_source})"
|
|
18
|
-
when :orphaned then "#{path} (
|
|
19
|
+
when :orphaned then "#{path} (#{orphan_reason})"
|
|
19
20
|
else path
|
|
20
21
|
end
|
|
21
22
|
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def orphan_reason
|
|
27
|
+
LockFile.orphan_reason(source_label: locked_source, source_gem: source_gem, bundled: source_bundled)
|
|
28
|
+
end
|
|
22
29
|
end
|
|
23
30
|
|
|
24
|
-
def self.compare(root:, artifacts:)
|
|
25
|
-
new(root: root, artifacts: artifacts).tap(&:compare)
|
|
31
|
+
def self.compare(root:, artifacts:, bundled_gems: [])
|
|
32
|
+
new(root: root, artifacts: artifacts, bundled_gems: bundled_gems).tap(&:compare)
|
|
26
33
|
end
|
|
27
34
|
|
|
28
35
|
attr_reader :entries
|
|
29
36
|
|
|
30
|
-
def initialize(root:, artifacts:)
|
|
37
|
+
def initialize(root:, artifacts:, bundled_gems: [])
|
|
31
38
|
@root = File.expand_path(root.to_s)
|
|
32
39
|
@artifacts = artifacts
|
|
40
|
+
@bundled_gems = Array(bundled_gems).map(&:to_s)
|
|
33
41
|
@entries = []
|
|
34
42
|
end
|
|
35
43
|
|
|
@@ -63,11 +71,12 @@ module Rails
|
|
|
63
71
|
# A disabled artifact left on disk was reported at install time; the
|
|
64
72
|
# bundle still ships it, so it is not an orphan.
|
|
65
73
|
type = InstallLayout::ARTIFACT_TYPES[locked.kind]
|
|
66
|
-
next if type && InstallPlan.disabled_dest?(lock, type, locked.path)
|
|
74
|
+
next if type && InstallPlan.disabled_dest?(lock, type, locked.path, source_gem: locked.source_gem)
|
|
67
75
|
|
|
68
76
|
@entries << Entry.new(
|
|
69
77
|
path: locked.path, state: :orphaned, artifact: locked.kind&.to_sym,
|
|
70
|
-
locked_source: locked.source_label, bundle_source: nil
|
|
78
|
+
locked_source: locked.source_label, bundle_source: nil,
|
|
79
|
+
source_gem: locked.source_gem, source_bundled: @bundled_gems.include?(locked.source_gem.to_s)
|
|
71
80
|
)
|
|
72
81
|
end
|
|
73
82
|
|
|
@@ -13,7 +13,8 @@ module Rails
|
|
|
13
13
|
module AutoInstall
|
|
14
14
|
DEVELOPMENT = "development".freeze
|
|
15
15
|
|
|
16
|
-
Result = Struct.new(:installed, :outdated, :orphaned, :unwired, :
|
|
16
|
+
Result = Struct.new(:installed, :outdated, :orphaned, :unwired, :advisories, :fence_warnings,
|
|
17
|
+
:halted, :skipped, :error, keyword_init: true) do
|
|
17
18
|
def ran?
|
|
18
19
|
skipped.nil?
|
|
19
20
|
end
|
|
@@ -24,6 +25,8 @@ module Rails
|
|
|
24
25
|
|
|
25
26
|
def messages
|
|
26
27
|
return [] unless ran?
|
|
28
|
+
return [halted] if halted
|
|
29
|
+
|
|
27
30
|
lines = []
|
|
28
31
|
unless Array(installed).empty?
|
|
29
32
|
lines << "installed #{installed.size} artifact(s):"
|
|
@@ -35,6 +38,10 @@ module Rails
|
|
|
35
38
|
lines << "#{stale.size} artifact(s) need attention — run bin/rails hyperdrive:sync"
|
|
36
39
|
stale.each { |entry| lines << " #{entry}" }
|
|
37
40
|
end
|
|
41
|
+
# An advisory names content that installed but not as the manifest
|
|
42
|
+
# asked; ordinary skips are init/sync's to report, not a bundle
|
|
43
|
+
# install's.
|
|
44
|
+
(Array(advisories) | Array(fence_warnings)).each { |warning| lines << warning }
|
|
38
45
|
lines
|
|
39
46
|
end
|
|
40
47
|
end
|
|
@@ -47,9 +54,12 @@ module Rails
|
|
|
47
54
|
return skip(:not_development) unless development?(env)
|
|
48
55
|
return skip(:not_initialized) unless File.exist?(File.join(root, InstallLayout::LOCK_PATH))
|
|
49
56
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
57
|
+
lock = LockFile.load(File.join(root, InstallLayout::LOCK_PATH))
|
|
58
|
+
return halted(lock.schema_ahead_message(InstallLayout::LOCK_PATH)) if lock.schema_ahead?
|
|
59
|
+
|
|
60
|
+
report = BundlerArtifactDiscovery::Report.new
|
|
61
|
+
artifacts = BundlerArtifactDiscovery.discover(enabled_gems: lock.enabled_gems, report: report)
|
|
62
|
+
status = ArtifactStatus.compare(root: root, artifacts: artifacts, bundled_gems: report.bundled_gems)
|
|
53
63
|
|
|
54
64
|
installed = []
|
|
55
65
|
unwired = false
|
|
@@ -59,7 +69,8 @@ module Rails
|
|
|
59
69
|
root: root,
|
|
60
70
|
shell: InstallShell.new(root: root, io: io),
|
|
61
71
|
artifacts: artifacts,
|
|
62
|
-
mode: :additive
|
|
72
|
+
mode: :additive,
|
|
73
|
+
report: report
|
|
63
74
|
)
|
|
64
75
|
installed = pipeline.call.installed
|
|
65
76
|
# This runs from a bundle install, which must not edit CLAUDE.md, so a
|
|
@@ -68,7 +79,8 @@ module Rails
|
|
|
68
79
|
unwired = pipeline.lock.claude_md_state.nil? && (installed & pipeline.lock.guideline_paths).any?
|
|
69
80
|
end
|
|
70
81
|
|
|
71
|
-
Result.new(installed: installed, outdated: status.outdated, orphaned: status.orphaned,
|
|
82
|
+
Result.new(installed: installed, outdated: status.outdated, orphaned: status.orphaned,
|
|
83
|
+
unwired: unwired, advisories: report.advisories, fence_warnings: report.fence_warnings)
|
|
72
84
|
rescue StandardError => e
|
|
73
85
|
skip(:error, e)
|
|
74
86
|
end
|
|
@@ -93,7 +105,13 @@ module Rails
|
|
|
93
105
|
end
|
|
94
106
|
|
|
95
107
|
def skip(reason, error = nil)
|
|
96
|
-
Result.new(installed: [], outdated: [], orphaned: [],
|
|
108
|
+
Result.new(installed: [], outdated: [], orphaned: [], advisories: [], fence_warnings: [],
|
|
109
|
+
skipped: reason, error: error)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# A completed run that installed nothing and has a reason to print.
|
|
113
|
+
def halted(reason)
|
|
114
|
+
Result.new(installed: [], outdated: [], orphaned: [], advisories: [], fence_warnings: [], halted: reason)
|
|
97
115
|
end
|
|
98
116
|
end
|
|
99
117
|
end
|