rails-hyperdrive 0.9.2 → 0.10.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 +50 -1
- data/README.md +3 -3
- data/lib/generators/hyperdrive/install/USAGE +5 -1
- data/lib/generators/hyperdrive/install/install_generator.rb +27 -3
- data/lib/generators/hyperdrive/sync_runner.rb +13 -2
- data/lib/rails/commands/hyperdrive/hyperdrive_command.rb +5 -1
- data/lib/rails/hyperdrive/auto_install.rb +5 -1
- data/lib/rails/hyperdrive/bundler_artifact_discovery.rb +9 -4
- data/lib/rails/hyperdrive/canonical_skill_render.rb +1 -1
- data/lib/rails/hyperdrive/install_pipeline.rb +26 -1
- data/lib/rails/hyperdrive/lock_file.rb +25 -4
- data/lib/rails/hyperdrive/mcp_server.rb +2 -3
- data/lib/rails/hyperdrive/sql_safety.rb +6 -0
- data/lib/rails/hyperdrive/tools/run_sql.rb +2 -2
- data/lib/rails/hyperdrive/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5831108b4ac1e545e827ce63f40ce9cb7615f8e51269a2f16914e2ed96f167bb
|
|
4
|
+
data.tar.gz: cd610fcf5441285f1f8357f4cee2796ad41d89f6d882447f90b6f3a430d7f2d6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0d46038fe1baddd36949795d0e13cfa180dda30d77476420866a1efc433089a2c4398ee440e0162ec07fa3bdea9fd07149b239c696a663ef06984027169c3670
|
|
7
|
+
data.tar.gz: da719e11364876cedddd585b81f812565333d97f4eab1c625d6b60e1a99a0598b695591c3bfac464a0b9b27f4c3f4274d7a14a88f9966d6039fa8a016cf95d81
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,54 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.10.0] - 2026-09-09
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **`bin/rails hyperdrive:init`/`sync`/`discover` run from a subdirectory of the
|
|
15
|
+
app now write to the app root.** The generators read the lockfile, the config
|
|
16
|
+
file, and `Gemfile.lock` through `Rails.root` but had no destination root, so
|
|
17
|
+
Thor defaulted to the current directory and `../bin/rails hyperdrive:init`
|
|
18
|
+
scattered `.mcp.json`, `.claude/`, and `.hyperdrive/` under wherever it was
|
|
19
|
+
invoked from. Each generator now starts with the app root as its destination.
|
|
20
|
+
- **A `.hyperdrive/lock.yml` that cannot be read or parsed now stops
|
|
21
|
+
`hyperdrive:init`/`sync` with an error naming the file.** Previously a syntax
|
|
22
|
+
error was silently read as an absent lock — the run warned "locally modified"
|
|
23
|
+
for every installed file and then rewrote the lock with every entry and
|
|
24
|
+
`claude_md.state` dropped — while an unreadable file, a `!ruby/object` tag, or
|
|
25
|
+
a YAML alias crashed with a raw backtrace. Unreadable, unparseable, and
|
|
26
|
+
non-map-root locks all halt before any content write, `--dry-run` included; an
|
|
27
|
+
empty file still reads as absent. `bundle install` reports the same line and
|
|
28
|
+
installs nothing. `rake hyperdrive:skills:check`/`render` likewise report
|
|
29
|
+
unparseable rendered frontmatter as a render error for every YAML failure, not
|
|
30
|
+
only syntax errors.
|
|
31
|
+
- **The install pipeline no longer reports a disabled artifact left on disk as
|
|
32
|
+
an orphan**, in any mode — the artifact is still shipped by its gem, so it is
|
|
33
|
+
not stranded, and its lock entry is carried as before. Additive runs (the
|
|
34
|
+
`bundle install` hook) now report no disabled artifacts at all.
|
|
35
|
+
- **`hyperdrive:init --mount-at` now accepts only a plain `/segment[/segment]`
|
|
36
|
+
path** (letters, digits, `_`, `-`) and refuses anything else before writing
|
|
37
|
+
anything, since the value was interpolated unescaped into `config/routes.rb`
|
|
38
|
+
as Ruby source. When `config/routes.rb` has no
|
|
39
|
+
`Rails.application.routes.draw do` block, init warns and tells you the line to
|
|
40
|
+
add by hand, and the summary no longer claims the mount was written.
|
|
41
|
+
|
|
42
|
+
### Changed
|
|
43
|
+
|
|
44
|
+
- **`run_sql` refuses `PRAGMA name = value` assignments.** The paren form
|
|
45
|
+
(`PRAGMA table_info(users)`) and bare reads stay allowed. The guard remains a
|
|
46
|
+
guardrail rather than a parser, so a mutation keyword inside a string literal
|
|
47
|
+
(`SELECT 'update me'`) is still refused.
|
|
48
|
+
- **`McpServer.rack_app` no longer accepts `allowed_hosts:`.** The method
|
|
49
|
+
memoizes, so the argument was ignored after the first call, and nothing ever
|
|
50
|
+
passed it; the allowlist is the middleware's own constant.
|
|
51
|
+
- **When one companion ships the same artifact name at two paths** — a skill
|
|
52
|
+
under both skill roots, or a flat file in both the convention root and its
|
|
53
|
+
manifest `<kind>_dir:` override — discovery now reports the dropped path as a
|
|
54
|
+
skip instead of choosing silently. The survivor is unchanged (the
|
|
55
|
+
lexicographically greatest path), and the gem is not marked as having skipped
|
|
56
|
+
anything, since the name still installs.
|
|
57
|
+
|
|
10
58
|
## [0.9.2] - 2026-09-08
|
|
11
59
|
|
|
12
60
|
### Added
|
|
@@ -849,7 +897,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
849
897
|
- `hyperdrive:init` generator that installs architecture skills and auto-discovers
|
|
850
898
|
per-gem skills.
|
|
851
899
|
|
|
852
|
-
[Unreleased]: https://github.com/rails-hyperdrive/rails-hyperdrive/compare/v0.
|
|
900
|
+
[Unreleased]: https://github.com/rails-hyperdrive/rails-hyperdrive/compare/v0.10.0...HEAD
|
|
901
|
+
[0.10.0]: https://github.com/rails-hyperdrive/rails-hyperdrive/releases/tag/v0.10.0
|
|
853
902
|
[0.9.2]: https://github.com/rails-hyperdrive/rails-hyperdrive/releases/tag/v0.9.2
|
|
854
903
|
[0.9.1]: https://github.com/rails-hyperdrive/rails-hyperdrive/releases/tag/v0.9.1
|
|
855
904
|
[0.9.0]: https://github.com/rails-hyperdrive/rails-hyperdrive/releases/tag/v0.9.0
|
data/README.md
CHANGED
|
@@ -71,7 +71,7 @@ $ bin/dev
|
|
|
71
71
|
# → agent has 8 tools, the eager guidelines (via CLAUDE.md), and the lazy skills
|
|
72
72
|
```
|
|
73
73
|
|
|
74
|
-
The engine mounts at `/_hyperdrive` by default; `--mount-at /some/path` moves both the route and the URL written to `.mcp.json`. The generated `.mcp.json` points at `http://localhost:3000<mount>/mcp`, so if your dev server runs on another port, edit the URL there.
|
|
74
|
+
The engine mounts at `/_hyperdrive` by default; `--mount-at /some/path` moves both the route and the URL written to `.mcp.json`. That value is interpolated into `config/routes.rb` as Ruby source, so it must be a plain path of one or more `/`-separated segments of letters, digits, `_` and `-`; anything else stops the run before it writes. If `config/routes.rb` has no `Rails.application.routes.draw do` block to anchor to, nothing is mounted and the run warns with the line to add by hand. The generated `.mcp.json` points at `http://localhost:3000<mount>/mcp`, so if your dev server runs on another port, edit the URL there.
|
|
75
75
|
|
|
76
76
|
---
|
|
77
77
|
|
|
@@ -82,7 +82,7 @@ The engine mounts at `/_hyperdrive` by default; `--mount-at /some/path` moves bo
|
|
|
82
82
|
| # | Tool | Purpose |
|
|
83
83
|
|---|------|---------|
|
|
84
84
|
| 1 | `run_ruby` | Eval Ruby in the booted Rails process, with timeout + output capture |
|
|
85
|
-
| 2 | `run_sql` | Read-only SQL via the AR connection (`SELECT`/`WITH`/`EXPLAIN`/`SHOW`/`PRAGMA` only; 100 rows max) |
|
|
85
|
+
| 2 | `run_sql` | Read-only SQL via the AR connection (`SELECT`/`WITH`/`EXPLAIN`/`SHOW`/`PRAGMA` only, and no `PRAGMA` assignments; 100 rows max) |
|
|
86
86
|
| 3 | `tail_logs` | Tail the last N lines of a log under `log/` (defaults to `log/<env>.log`) |
|
|
87
87
|
| 4 | `list_models` | List Active Record model classes with columns/validations/associations |
|
|
88
88
|
| 5 | `locate_source` | Resolve `Const` / `Const#method` / `Const.method` / `dep:<gem>` to a file:line |
|
|
@@ -184,7 +184,7 @@ CLAUDE.md # user-owned; ONE injected line: @.claude
|
|
|
184
184
|
.hyperdrive/lock.yml # git-tracked manifest (source gem, version, content hash)
|
|
185
185
|
```
|
|
186
186
|
|
|
187
|
-
A `git diff` is where you review what a companion gem added. The install summary names each artifact's source gem and version, and every installed file is hashed and attributed to its source in the git-tracked `.hyperdrive/lock.yml`. The files themselves land byte-identical to what the gem ships, with nothing injected. `hyperdrive:init` and `hyperdrive:sync` warn if your app gitignores these paths, since that empties the diff without changing what reaches the agent. The `hyperdrive:discover` cache is the one file rails-hyperdrive adds to `.gitignore`. The lockfile carries a schema version, and a rails-hyperdrive older than the one that wrote it stops with an upgrade message instead of rewriting state it cannot read.
|
|
187
|
+
A `git diff` is where you review what a companion gem added. The install summary names each artifact's source gem and version, and every installed file is hashed and attributed to its source in the git-tracked `.hyperdrive/lock.yml`. The files themselves land byte-identical to what the gem ships, with nothing injected. `hyperdrive:init` and `hyperdrive:sync` warn if your app gitignores these paths, since that empties the diff without changing what reaches the agent. The `hyperdrive:discover` cache is the one file rails-hyperdrive adds to `.gitignore`. The lockfile carries a schema version, and a rails-hyperdrive older than the one that wrote it stops with an upgrade message instead of rewriting state it cannot read. A lockfile that cannot be opened, does not parse, or has something other than a map at its root stops the run the same way, naming the file so you can fix it or restore it from git.
|
|
188
188
|
|
|
189
189
|
`CLAUDE.md` and `index.md` are the **eager chain**: they exist only because a companion gem ships a guideline, and both go when the last one leaves the bundle (the guideline file itself is left on disk and reported as an orphan). A `CLAUDE.md` you had before `hyperdrive:init` gets the one line appended, and tear-down strips just that line; a `CLAUDE.md` the installer created is deleted only while it is still byte-identical to what was written.
|
|
190
190
|
|
|
@@ -2,7 +2,11 @@ Description:
|
|
|
2
2
|
Bootstraps Rails Hyperdrive in this app:
|
|
3
3
|
- adds the rails-hyperdrive server to .mcp.json (Claude Code config),
|
|
4
4
|
merging into any servers already configured there
|
|
5
|
-
- mounts Rails::Hyperdrive::Engine at /_hyperdrive in config/routes.rb
|
|
5
|
+
- mounts Rails::Hyperdrive::Engine at /_hyperdrive in config/routes.rb
|
|
6
|
+
(idempotent; --mount-at takes one or more /segments of letters,
|
|
7
|
+
digits, _ and -, and is validated before anything is written). With
|
|
8
|
+
no `Rails.application.routes.draw do` block to anchor to, nothing is
|
|
9
|
+
mounted and the warning names the line to add by hand
|
|
6
10
|
- registers the bundler-hyperdrive plugin in the Gemfile, so newly
|
|
7
11
|
bundled companion gems install their artifacts on bundle install
|
|
8
12
|
- adds a .gitignore rule for the hyperdrive:discover cache
|
|
@@ -15,7 +15,9 @@ module Rails
|
|
|
15
15
|
include GitignoreSupport
|
|
16
16
|
|
|
17
17
|
ENGINE_MOUNT_TOKEN = "Rails::Hyperdrive::Engine"
|
|
18
|
+
ROUTES_DRAW_ANCHOR = /Rails\.application\.routes\.draw do\s*\n/
|
|
18
19
|
DEFAULT_MOUNT_AT = "/_hyperdrive".freeze
|
|
20
|
+
MOUNT_PATH_FORMAT = %r{\A/[A-Za-z0-9_-]+(?:/[A-Za-z0-9_-]+)*\z}
|
|
19
21
|
|
|
20
22
|
MCP_JSON_PATH = ".mcp.json".freeze
|
|
21
23
|
MCP_SERVER_KEY = "rails-hyperdrive".freeze
|
|
@@ -65,7 +67,7 @@ module Rails
|
|
|
65
67
|
# sibling USAGE file for `--help`.
|
|
66
68
|
source_root File.expand_path("templates", __dir__)
|
|
67
69
|
|
|
68
|
-
class_option :mount_at, type: :string, default: DEFAULT_MOUNT_AT, desc: "Engine mount path."
|
|
70
|
+
class_option :mount_at, type: :string, default: DEFAULT_MOUNT_AT, desc: "Engine mount path: one or more /segments of letters, digits, _ and -. Ignored with --skip-mcp."
|
|
69
71
|
class_option :skip_content, type: :boolean, default: false, desc: "Skip all .claude content, CLAUDE.md, the config file, and the lockfile; leave the .mcp.json, .gitignore, Gemfile, and mount steps."
|
|
70
72
|
class_option :skip_mcp, type: :boolean, default: false, desc: "Skip MCP setup entirely; write no .mcp.json entry and no engine mount."
|
|
71
73
|
class_option :dry_run, type: :boolean, default: false, desc: "Show what would change; write nothing."
|
|
@@ -74,6 +76,16 @@ module Rails
|
|
|
74
76
|
runner.verify_environment!
|
|
75
77
|
end
|
|
76
78
|
|
|
79
|
+
# The value is interpolated into config/routes.rb as Ruby source, so it
|
|
80
|
+
# is validated before anything is written, a dry run included.
|
|
81
|
+
def verify_options
|
|
82
|
+
return if options[:skip_mcp]
|
|
83
|
+
return if mount_path.match?(MOUNT_PATH_FORMAT)
|
|
84
|
+
|
|
85
|
+
raise Thor::Error, "hyperdrive: --mount-at must be a plain path of one or more " \
|
|
86
|
+
"/segments (letters, digits, _ and -), got #{options[:mount_at].to_s.inspect}"
|
|
87
|
+
end
|
|
88
|
+
|
|
77
89
|
def discover_artifacts
|
|
78
90
|
runner.discover_artifacts(skip: options[:skip_content])
|
|
79
91
|
end
|
|
@@ -132,12 +144,24 @@ module Rails
|
|
|
132
144
|
|
|
133
145
|
contents = File.read(::Rails.root.join(routes_file))
|
|
134
146
|
if contents.include?(ENGINE_MOUNT_TOKEN)
|
|
147
|
+
@mounted = true
|
|
135
148
|
say_status :identical, "#{routes_file} (engine already mounted)", :blue
|
|
136
149
|
return
|
|
137
150
|
end
|
|
138
151
|
|
|
139
152
|
snippet = " mount Rails::Hyperdrive::Engine => \"#{mount_path}\" if Rails.env.development?\n"
|
|
140
|
-
inject_into_file
|
|
153
|
+
# inject_into_file reports a non-match as an ordinary unchanged line
|
|
154
|
+
# and returns, so the anchor is checked here or the summary would
|
|
155
|
+
# claim a mount that never landed.
|
|
156
|
+
unless contents.match?(ROUTES_DRAW_ANCHOR)
|
|
157
|
+
say_status :warn, "#{routes_file} has no `Rails.application.routes.draw do` block; add " \
|
|
158
|
+
"`mount Rails::Hyperdrive::Engine => \"#{mount_path}\" if Rails.env.development?` " \
|
|
159
|
+
"to your routes by hand", :yellow
|
|
160
|
+
return
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
inject_into_file routes_file, snippet, after: ROUTES_DRAW_ANCHOR
|
|
164
|
+
@mounted = true
|
|
141
165
|
end
|
|
142
166
|
|
|
143
167
|
# The settings are the user's, so an existing file is never rewritten or
|
|
@@ -167,7 +191,7 @@ module Rails
|
|
|
167
191
|
if options[:skip_mcp]
|
|
168
192
|
say " MCP: skipped (--skip-mcp)"
|
|
169
193
|
else
|
|
170
|
-
say " Mount: #{mount_path} (in config/routes.rb)"
|
|
194
|
+
say " Mount: #{mount_path} #{@mounted ? "(in config/routes.rb)" : "(not written to config/routes.rb; see warning above)"}"
|
|
171
195
|
say " Server: #{::Rails::Hyperdrive::McpServer::TOOLS.size} MCP tools at http://localhost:3000#{mount_path}/mcp"
|
|
172
196
|
end
|
|
173
197
|
runner.summary_lines.each { |line| say line } unless options[:skip_content]
|
|
@@ -111,9 +111,20 @@ module Rails
|
|
|
111
111
|
# Raised from install, so it stops the run before any content write —
|
|
112
112
|
# a dry run included.
|
|
113
113
|
def verify_lock_schema!
|
|
114
|
-
return unless
|
|
114
|
+
return unless readable_lock.schema_ahead?
|
|
115
115
|
|
|
116
|
-
|
|
116
|
+
halt_lock(lock.schema_ahead_message(::Rails::Hyperdrive::InstallLayout::LOCK_PATH))
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def readable_lock
|
|
120
|
+
lock
|
|
121
|
+
rescue ::Rails::Hyperdrive::LockFile::UnreadableError => e
|
|
122
|
+
halt_lock(::Rails::Hyperdrive::LockFile.unreadable_message(
|
|
123
|
+
::Rails::Hyperdrive::InstallLayout::LOCK_PATH, e.reason
|
|
124
|
+
))
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def halt_lock(message)
|
|
117
128
|
@shell.say_status :error, message, :red
|
|
118
129
|
raise Thor::Error, "hyperdrive: #{message}"
|
|
119
130
|
end
|
|
@@ -49,9 +49,13 @@ module Rails
|
|
|
49
49
|
end
|
|
50
50
|
|
|
51
51
|
no_commands do
|
|
52
|
+
# Every generator read goes through ::Rails.root, so the destination
|
|
53
|
+
# must too — Thor would otherwise default it to the cwd `bin/rails` was
|
|
54
|
+
# invoked from and split reads and writes across two directories.
|
|
52
55
|
def start_generator(name, generator)
|
|
53
56
|
require_application!
|
|
54
|
-
::Rails::Generators::Hyperdrive.const_get(generator)
|
|
57
|
+
::Rails::Generators::Hyperdrive.const_get(generator)
|
|
58
|
+
.start(@argv, destination_root: ::Rails.root.to_s)
|
|
55
59
|
end
|
|
56
60
|
end
|
|
57
61
|
end
|
|
@@ -55,7 +55,11 @@ module Rails
|
|
|
55
55
|
return skip(:not_development) unless development?(env)
|
|
56
56
|
return skip(:not_initialized) unless File.exist?(File.join(root, InstallLayout::LOCK_PATH))
|
|
57
57
|
|
|
58
|
-
|
|
58
|
+
begin
|
|
59
|
+
lock = LockFile.load(File.join(root, InstallLayout::LOCK_PATH))
|
|
60
|
+
rescue LockFile::UnreadableError => e
|
|
61
|
+
return halted(LockFile.unreadable_message(InstallLayout::LOCK_PATH, e.reason))
|
|
62
|
+
end
|
|
59
63
|
return halted(lock.schema_ahead_message(InstallLayout::LOCK_PATH)) if lock.schema_ahead?
|
|
60
64
|
|
|
61
65
|
config = ConfigFile.load(File.join(root, InstallLayout::CONFIG_PATH))
|
|
@@ -101,11 +101,16 @@ module Rails
|
|
|
101
101
|
warn_unknown_manifest_keys(manifest, spec, seen, report)
|
|
102
102
|
end
|
|
103
103
|
|
|
104
|
-
# Collapse same-name
|
|
105
|
-
#
|
|
106
|
-
# identity is (name, source_gem).
|
|
104
|
+
# Collapse same-name candidates within one source gem (the same name
|
|
105
|
+
# shipped under two roots) to the greatest path; never across sources —
|
|
106
|
+
# composite identity is (name, source_gem, artifact_type).
|
|
107
107
|
candidates.group_by { |a| [a.name, a.source_gem, a.artifact_type] }.map do |_key, group|
|
|
108
|
-
group.max_by
|
|
108
|
+
winner = group.max_by(&:path)
|
|
109
|
+
group.each do |loser|
|
|
110
|
+
next if loser.equal?(winner)
|
|
111
|
+
report.skip "skip #{loser.path}: #{winner.path} takes precedence"
|
|
112
|
+
end
|
|
113
|
+
winner
|
|
109
114
|
end
|
|
110
115
|
end
|
|
111
116
|
|
|
@@ -124,7 +124,7 @@ module Rails
|
|
|
124
124
|
meta = YAML.safe_load(frontmatter, permitted_classes: [Symbol]) || {}
|
|
125
125
|
return if meta["name"] && meta["description"]
|
|
126
126
|
raise Error, "#{template}: rendered frontmatter lacks name: or description:"
|
|
127
|
-
rescue Psych::
|
|
127
|
+
rescue Psych::Exception
|
|
128
128
|
raise Error, "#{template}: rendered frontmatter is not parseable YAML"
|
|
129
129
|
end
|
|
130
130
|
|
|
@@ -48,7 +48,11 @@ module Rails
|
|
|
48
48
|
end
|
|
49
49
|
|
|
50
50
|
def call
|
|
51
|
-
|
|
51
|
+
begin
|
|
52
|
+
return halt_schema_ahead if old_lock.schema_ahead?
|
|
53
|
+
rescue LockFile::UnreadableError => e
|
|
54
|
+
return halt_unreadable_lock(e)
|
|
55
|
+
end
|
|
52
56
|
|
|
53
57
|
report_settings_warnings
|
|
54
58
|
@new_lock = LockFile.new(abs(InstallLayout::LOCK_PATH)).carry_document(old_lock)
|
|
@@ -89,6 +93,12 @@ module Rails
|
|
|
89
93
|
@result
|
|
90
94
|
end
|
|
91
95
|
|
|
96
|
+
def halt_unreadable_lock(error)
|
|
97
|
+
@shell.say_status :warn,
|
|
98
|
+
LockFile.unreadable_message(InstallLayout::LOCK_PATH, error.reason), :yellow
|
|
99
|
+
@result
|
|
100
|
+
end
|
|
101
|
+
|
|
92
102
|
def build_result
|
|
93
103
|
@build_result ||= InstallPlan.build(@artifacts, config: config)
|
|
94
104
|
end
|
|
@@ -139,6 +149,8 @@ module Rails
|
|
|
139
149
|
end
|
|
140
150
|
|
|
141
151
|
def report_disabled
|
|
152
|
+
return if additive?
|
|
153
|
+
|
|
142
154
|
build_result.disabled.each do |entry|
|
|
143
155
|
@shell.say_status :disabled, "#{entry.type} '#{entry.final_name}' (listed in #{InstallLayout::CONFIG_PATH})", :blue
|
|
144
156
|
end
|
|
@@ -598,12 +610,25 @@ module Rails
|
|
|
598
610
|
next if already_removed?(entry.path)
|
|
599
611
|
next unless DriftVerdict.verdict(file: abs(entry.path), lock_entry: entry, gem_sha: nil) == :orphaned
|
|
600
612
|
|
|
613
|
+
# A disabled artifact left on disk is still shipped by its gem, so it
|
|
614
|
+
# is not stranded and draws no orphan report.
|
|
615
|
+
if disabled_entry?(entry)
|
|
616
|
+
@new_lock.carry(entry)
|
|
617
|
+
next
|
|
618
|
+
end
|
|
619
|
+
|
|
601
620
|
@result.orphaned << entry.path
|
|
602
621
|
@shell.say_status :orphan, "#{entry.path} (#{orphan_reason(entry)}; left in place)", :yellow
|
|
603
622
|
@new_lock.carry(entry)
|
|
604
623
|
end
|
|
605
624
|
end
|
|
606
625
|
|
|
626
|
+
def disabled_entry?(entry)
|
|
627
|
+
type = InstallLayout::ARTIFACT_TYPES[entry.kind]
|
|
628
|
+
return false unless type
|
|
629
|
+
InstallPlan.disabled_dest?(config, type, entry.path, source_gem: entry.source_gem)
|
|
630
|
+
end
|
|
631
|
+
|
|
607
632
|
def orphan_reason(entry)
|
|
608
633
|
LockFile.orphan_reason(
|
|
609
634
|
source_label: entry.source_label,
|
|
@@ -9,6 +9,17 @@ module Rails
|
|
|
9
9
|
STATE_PRESENT = "present".freeze
|
|
10
10
|
STATE_REMOVED = "removed-by-user".freeze
|
|
11
11
|
|
|
12
|
+
# A lock that exists but cannot be understood is never treated as absent:
|
|
13
|
+
# rewriting it would drop every entry and the user's claude_md state.
|
|
14
|
+
class UnreadableError < StandardError
|
|
15
|
+
attr_reader :reason
|
|
16
|
+
|
|
17
|
+
def initialize(reason)
|
|
18
|
+
@reason = reason
|
|
19
|
+
super(reason)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
12
23
|
# In-memory form of one files: entry. On disk, source_gem and
|
|
13
24
|
# source_version are a single "gem@version" string; the split/join lives
|
|
14
25
|
# in this file only.
|
|
@@ -60,8 +71,16 @@ module Rails
|
|
|
60
71
|
def read
|
|
61
72
|
return self unless File.exist?(@path)
|
|
62
73
|
|
|
63
|
-
|
|
64
|
-
|
|
74
|
+
begin
|
|
75
|
+
raw = File.read(@path)
|
|
76
|
+
data = YAML.safe_load(raw)
|
|
77
|
+
rescue Psych::Exception => e
|
|
78
|
+
raise UnreadableError, "not valid YAML: #{e.message.to_s.lines.first.to_s.strip}"
|
|
79
|
+
rescue SystemCallError => e
|
|
80
|
+
raise UnreadableError, e.message
|
|
81
|
+
end
|
|
82
|
+
return self if data.nil?
|
|
83
|
+
raise UnreadableError, "root is not a map" unless data.is_a?(Hash)
|
|
65
84
|
|
|
66
85
|
@document = data
|
|
67
86
|
@schema_version = data["version"]
|
|
@@ -74,8 +93,10 @@ module Rails
|
|
|
74
93
|
@files[entry.path] = entry if entry.path
|
|
75
94
|
end
|
|
76
95
|
self
|
|
77
|
-
|
|
78
|
-
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def self.unreadable_message(display_path, reason)
|
|
99
|
+
"#{display_path} could not be read (#{reason}); fix it or restore it from git, then re-run"
|
|
79
100
|
end
|
|
80
101
|
|
|
81
102
|
# A lock written by a newer installer holds state this one cannot read,
|
|
@@ -32,15 +32,14 @@ module Rails
|
|
|
32
32
|
@server ||= build_server
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
-
def rack_app
|
|
35
|
+
def rack_app
|
|
36
36
|
# The transport's own DNS-rebinding check requires same-origin (host:port);
|
|
37
37
|
# it is disabled because Safety::RackMiddleware enforces the localhost
|
|
38
38
|
# Origin allowlist, which accepts any port.
|
|
39
39
|
@rack_app ||= Safety::RackMiddleware.new(
|
|
40
40
|
::MCP::Server::Transports::StreamableHTTPTransport.new(
|
|
41
41
|
server, stateless: true, enable_json_response: true, dns_rebinding_protection: false
|
|
42
|
-
)
|
|
43
|
-
allowed_hosts: allowed_hosts
|
|
42
|
+
)
|
|
44
43
|
)
|
|
45
44
|
end
|
|
46
45
|
|
|
@@ -6,6 +6,7 @@ module Rails
|
|
|
6
6
|
module SqlSafety
|
|
7
7
|
ALLOWED_LEADERS = /\A\s*(WITH\b.*?\bSELECT\b|SELECT\b|EXPLAIN\b|SHOW\b|PRAGMA\b)/im
|
|
8
8
|
FORBIDDEN_TOKEN = /\b(INSERT|UPDATE|DELETE|DROP|ALTER|TRUNCATE|CREATE|GRANT|REVOKE|REPLACE|MERGE|RENAME|VACUUM|ATTACH|DETACH)\b/i
|
|
9
|
+
PRAGMA_LEADER = /\A\s*PRAGMA\b/i
|
|
9
10
|
|
|
10
11
|
class Error < StandardError; end
|
|
11
12
|
|
|
@@ -16,6 +17,11 @@ module Rails
|
|
|
16
17
|
unless sql =~ ALLOWED_LEADERS
|
|
17
18
|
raise Error, "only SELECT / WITH...SELECT / EXPLAIN / SHOW / PRAGMA are allowed"
|
|
18
19
|
end
|
|
20
|
+
# `PRAGMA x = y` writes; the paren form (`PRAGMA table_info(users)`)
|
|
21
|
+
# cannot be told apart from a read and stays allowed.
|
|
22
|
+
if sql =~ PRAGMA_LEADER && sql.include?("=")
|
|
23
|
+
raise Error, "PRAGMA assignments are not allowed"
|
|
24
|
+
end
|
|
19
25
|
# Second pass catches a mutation smuggled inside a CTE body
|
|
20
26
|
# (e.g. `WITH x AS (DELETE ...) SELECT ...`).
|
|
21
27
|
if sql =~ FORBIDDEN_TOKEN
|
|
@@ -6,13 +6,13 @@ module Rails
|
|
|
6
6
|
module Tools
|
|
7
7
|
class RunSql < Base
|
|
8
8
|
tool_name "run_sql"
|
|
9
|
-
description "Read-only SQL via ActiveRecord::Base.connection. Rejects INSERT/UPDATE/DELETE/DROP/etc. at the parser level. Caps results at 100 rows."
|
|
9
|
+
description "Read-only SQL via ActiveRecord::Base.connection. Rejects INSERT/UPDATE/DELETE/DROP/etc. at the parser level, and refuses PRAGMA assignments (`PRAGMA foreign_keys = ON`). Caps results at 100 rows."
|
|
10
10
|
|
|
11
11
|
ROW_CAP = 100
|
|
12
12
|
|
|
13
13
|
input_schema(
|
|
14
14
|
properties: {
|
|
15
|
-
sql: { type: "string", description: "A SELECT / WITH / EXPLAIN / SHOW / PRAGMA statement." }
|
|
15
|
+
sql: { type: "string", description: "A SELECT / WITH / EXPLAIN / SHOW / PRAGMA statement. PRAGMA reads only: `PRAGMA table_info(users)` is allowed, `PRAGMA name = value` is refused." }
|
|
16
16
|
},
|
|
17
17
|
required: ["sql"]
|
|
18
18
|
)
|