rails-mcp-server 1.0.0 → 1.1.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/README.md +91 -169
- data/exe/rails-mcp-server +89 -17
- data/exe/rails-mcp-setup-claude +2 -1
- data/lib/rails-mcp-server/config.rb +72 -0
- data/lib/rails-mcp-server/tools/analyze_controller_views.rb +239 -0
- data/lib/rails-mcp-server/tools/analyze_environment_config.rb +427 -0
- data/lib/rails-mcp-server/tools/analyze_models.rb +116 -0
- data/lib/rails-mcp-server/tools/base_tool.rb +9 -0
- data/lib/rails-mcp-server/tools/get_file.rb +55 -0
- data/lib/rails-mcp-server/tools/get_model.rb +116 -0
- data/lib/rails-mcp-server/tools/get_routes.rb +24 -0
- data/lib/rails-mcp-server/tools/get_schema.rb +141 -0
- data/lib/rails-mcp-server/tools/list_files.rb +54 -0
- data/lib/rails-mcp-server/tools/project_info.rb +86 -0
- data/lib/rails-mcp-server/tools/switch_project.rb +25 -0
- data/lib/rails-mcp-server/utilities/run_process.rb +29 -0
- data/lib/rails-mcp-server/version.rb +1 -1
- data/lib/rails_mcp_server.rb +201 -375
- metadata +45 -4
data/lib/rails_mcp_server.rb
CHANGED
@@ -1,104 +1,67 @@
|
|
1
|
-
#!/usr/bin/env ruby
|
2
|
-
|
3
|
-
require "mcp"
|
4
|
-
require "yaml"
|
5
1
|
require "logger"
|
6
|
-
require "json"
|
7
2
|
require "fileutils"
|
3
|
+
require "forwardable"
|
4
|
+
require "open3"
|
8
5
|
require_relative "rails-mcp-server/version"
|
6
|
+
require_relative "rails-mcp-server/config"
|
7
|
+
require_relative "rails-mcp-server/utilities/run_process"
|
8
|
+
require_relative "rails-mcp-server/tools/base_tool"
|
9
|
+
require_relative "rails-mcp-server/tools/project_info"
|
10
|
+
require_relative "rails-mcp-server/tools/list_files"
|
11
|
+
require_relative "rails-mcp-server/tools/get_file"
|
12
|
+
require_relative "rails-mcp-server/tools/get_routes"
|
13
|
+
require_relative "rails-mcp-server/tools/analyze_models"
|
14
|
+
require_relative "rails-mcp-server/tools/get_schema"
|
15
|
+
require_relative "rails-mcp-server/tools/analyze_controller_views"
|
16
|
+
require_relative "rails-mcp-server/tools/analyze_environment_config"
|
17
|
+
require_relative "rails-mcp-server/tools/switch_project"
|
9
18
|
|
10
19
|
module RailsMcpServer
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
# rubocop:disable Style/GlobalVars
|
15
|
-
# Initialize configuration
|
16
|
-
def get_config_dir
|
17
|
-
# Use XDG_CONFIG_HOME if set, otherwise use ~/.config
|
18
|
-
xdg_config_home = ENV["XDG_CONFIG_HOME"]
|
19
|
-
if xdg_config_home && !xdg_config_home.empty?
|
20
|
-
File.join(xdg_config_home, "rails-mcp")
|
21
|
-
else
|
22
|
-
File.join(Dir.home, ".config", "rails-mcp")
|
23
|
-
end
|
24
|
-
end
|
25
|
-
|
26
|
-
# Create config directory if it doesn't exist
|
27
|
-
config_dir = get_config_dir
|
28
|
-
FileUtils.mkdir_p(File.join(config_dir, "log"))
|
29
|
-
|
30
|
-
# Default paths
|
31
|
-
projects_file = File.join(config_dir, "projects.yml")
|
32
|
-
log_file = File.join(config_dir, "log", "rails_mcp_server.log")
|
33
|
-
log_level = :info
|
34
|
-
|
35
|
-
# Parse command-line arguments
|
36
|
-
i = 0
|
37
|
-
while i < ARGV.length
|
38
|
-
case ARGV[i]
|
39
|
-
when "--log-level"
|
40
|
-
log_level = ARGV[i + 1].to_sym
|
41
|
-
i += 2
|
42
|
-
else
|
43
|
-
i += 1
|
44
|
-
end
|
45
|
-
end
|
20
|
+
@levels = {debug: Logger::DEBUG, info: Logger::INFO, error: Logger::ERROR}
|
21
|
+
@config = Config.setup
|
46
22
|
|
47
|
-
|
48
|
-
|
49
|
-
$logger.level = Logger.const_get(log_level.to_s.upcase)
|
23
|
+
class << self
|
24
|
+
extend Forwardable
|
50
25
|
|
51
|
-
|
52
|
-
$logger.formatter = proc do |severity, datetime, progname, msg|
|
53
|
-
"[#{datetime.strftime("%Y-%m-%d %H:%M:%S")}] #{severity}: #{msg}\n"
|
54
|
-
end
|
55
|
-
|
56
|
-
def log(level, message)
|
57
|
-
levels = {debug: Logger::DEBUG, info: Logger::INFO, warn: Logger::WARN, error: Logger::ERROR, fatal: Logger::FATAL}
|
58
|
-
log_level = levels[level] || Logger::INFO
|
59
|
-
$logger.add(log_level, message)
|
60
|
-
end
|
26
|
+
attr_reader :config
|
61
27
|
|
62
|
-
|
63
|
-
|
28
|
+
def_delegators :@config, :log_level, :log_level=
|
29
|
+
def_delegators :@config, :logger, :logger=
|
30
|
+
def_delegators :@config, :projects
|
31
|
+
def_delegators :@config, :current_project, :current_project=
|
32
|
+
def_delegators :@config, :active_project_path, :active_project_path=
|
64
33
|
|
65
|
-
|
66
|
-
|
67
|
-
log(:info, "Creating empty projects file: #{projects_file}")
|
68
|
-
FileUtils.mkdir_p(File.dirname(projects_file))
|
69
|
-
File.write(projects_file, "# Rails MCP Projects\n# Format: project_name: /path/to/project\n")
|
70
|
-
end
|
71
|
-
|
72
|
-
# Load projects
|
73
|
-
projects_file = File.expand_path(projects_file)
|
74
|
-
projects = {}
|
34
|
+
def log(level, message)
|
35
|
+
log_level = @levels[level] || Logger::INFO
|
75
36
|
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
else
|
81
|
-
log(:warn, "Projects file not found: #{projects_file}")
|
37
|
+
@config.logger.add(log_level, message)
|
38
|
+
end
|
39
|
+
end
|
40
|
+
class Error < StandardError; end
|
82
41
|
end
|
83
42
|
|
84
|
-
#
|
85
|
-
$active_project = nil
|
86
|
-
$active_project_path = nil
|
87
|
-
|
88
|
-
# Define MCP server using the mcp-rb DSL
|
89
|
-
name "rails-mcp-server"
|
90
|
-
version RailsMcpServer::VERSION
|
43
|
+
# rubocop:disable Style/GlobalVars
|
91
44
|
|
92
45
|
# Utility functions for Rails operations
|
93
46
|
def get_directory_structure(path, max_depth: 3, current_depth: 0, prefix: "")
|
94
47
|
return "" if current_depth > max_depth || !File.directory?(path)
|
95
48
|
|
49
|
+
# Define ignored directories
|
50
|
+
ignored_dirs = [
|
51
|
+
".git", "node_modules", "tmp", "log",
|
52
|
+
"storage", "coverage", "public/assets",
|
53
|
+
"public/packs", ".bundle", "vendor/bundle",
|
54
|
+
"vendor/cache"
|
55
|
+
]
|
56
|
+
|
96
57
|
output = ""
|
97
58
|
directories = []
|
98
59
|
files = []
|
99
60
|
|
100
61
|
Dir.foreach(path) do |entry|
|
101
62
|
next if entry == "." || entry == ".."
|
63
|
+
next if ignored_dirs.include?(entry) # Skip ignored directories
|
64
|
+
|
102
65
|
full_path = File.join(path, entry)
|
103
66
|
|
104
67
|
if File.directory?(full_path)
|
@@ -155,355 +118,218 @@ def underscore(string)
|
|
155
118
|
.downcase
|
156
119
|
end
|
157
120
|
|
158
|
-
#
|
159
|
-
|
160
|
-
|
121
|
+
# Helper method to extract settings from environment files
|
122
|
+
def extract_env_settings(content)
|
123
|
+
settings = {}
|
161
124
|
|
162
|
-
|
163
|
-
|
125
|
+
# Match configuration settings
|
126
|
+
content.scan(/config\.([a-zA-Z0-9_.]+)\s*=\s*([^#\n]+)/) do |match|
|
127
|
+
key = match[0].strip
|
128
|
+
value = match[1].strip
|
164
129
|
|
165
|
-
|
166
|
-
|
130
|
+
# Clean up the value
|
131
|
+
value = value.chomp(";").strip
|
167
132
|
|
168
|
-
|
169
|
-
$active_project = project_name
|
170
|
-
$active_project_path = File.expand_path(projects[project_name])
|
171
|
-
log(:info, "Switched to project: #{project_name} at path: #{$active_project_path}")
|
172
|
-
"Switched to project: #{project_name} at path: #{$active_project_path}"
|
173
|
-
else
|
174
|
-
log(:warn, "Project not found: #{project_name}")
|
175
|
-
raise "Project '#{project_name}' not found. Available projects: #{projects.keys.join(", ")}"
|
176
|
-
end
|
133
|
+
settings[key] = value
|
177
134
|
end
|
178
|
-
end
|
179
|
-
|
180
|
-
tool "get_project_info" do
|
181
|
-
description "Retrieve comprehensive information about the current Rails project, including Rails version, directory structure, API-only status, and overall project organization. Useful for initial project exploration and understanding the codebase structure."
|
182
|
-
|
183
|
-
call do |args|
|
184
|
-
unless $active_project
|
185
|
-
raise "No active project. Please switch to a project first."
|
186
|
-
end
|
187
135
|
|
188
|
-
|
189
|
-
gemfile_path = File.join($active_project_path, "Gemfile")
|
190
|
-
gemfile_content = File.exist?(gemfile_path) ? File.read(gemfile_path) : "Gemfile not found"
|
191
|
-
|
192
|
-
# Get Rails version
|
193
|
-
rails_version = gemfile_content.match(/gem ['"]rails['"],\s*['"](.+?)['"]/)&.captures&.first || "Unknown"
|
194
|
-
|
195
|
-
# Check if it's an API-only app
|
196
|
-
config_application_path = File.join($active_project_path, "config", "application.rb")
|
197
|
-
is_api_only = File.exist?(config_application_path) &&
|
198
|
-
File.read(config_application_path).include?("config.api_only = true")
|
199
|
-
|
200
|
-
log(:info, "Project info: Rails v#{rails_version}, API-only: #{is_api_only}")
|
201
|
-
|
202
|
-
<<~INFO
|
203
|
-
Current project: #{$active_project}
|
204
|
-
Path: #{$active_project_path}
|
205
|
-
Rails version: #{rails_version}
|
206
|
-
API only: #{is_api_only ? "Yes" : "No"}
|
207
|
-
|
208
|
-
Project structure:
|
209
|
-
#{get_directory_structure($active_project_path, max_depth: 2)}
|
210
|
-
INFO
|
211
|
-
end
|
136
|
+
settings
|
212
137
|
end
|
213
138
|
|
214
|
-
|
215
|
-
|
216
|
-
|
217
|
-
|
218
|
-
|
139
|
+
# Helper method to find ENV variable usage in the codebase
|
140
|
+
def find_env_vars_in_codebase(project_path)
|
141
|
+
env_vars = {}
|
142
|
+
|
143
|
+
# Define directories to search
|
144
|
+
search_dirs = [
|
145
|
+
File.join(project_path, "app"),
|
146
|
+
File.join(project_path, "config"),
|
147
|
+
File.join(project_path, "lib")
|
148
|
+
]
|
149
|
+
|
150
|
+
# Define file patterns to search
|
151
|
+
file_patterns = ["*.rb", "*.yml", "*.erb", "*.js"]
|
152
|
+
|
153
|
+
search_dirs.each do |dir|
|
154
|
+
if File.directory?(dir)
|
155
|
+
file_patterns.each do |pattern|
|
156
|
+
Dir.glob(File.join(dir, "**", pattern)).each do |file|
|
157
|
+
content = File.read(file)
|
158
|
+
|
159
|
+
# Extract ENV variables
|
160
|
+
content.scan(/ENV\s*\[\s*['"]([^'"]+)['"]\s*\]/).each do |match|
|
161
|
+
env_var = match[0]
|
162
|
+
env_vars[env_var] ||= []
|
163
|
+
env_vars[env_var] << file.sub("#{project_path}/", "")
|
164
|
+
end
|
219
165
|
|
220
|
-
|
221
|
-
|
166
|
+
# Also match ENV['VAR'] pattern
|
167
|
+
content.scan(/ENV\s*\.\s*\[\s*['"]([^'"]+)['"]\s*\]/).each do |match|
|
168
|
+
env_var = match[0]
|
169
|
+
env_vars[env_var] ||= []
|
170
|
+
env_vars[env_var] << file.sub("#{project_path}/", "")
|
171
|
+
end
|
222
172
|
|
223
|
-
|
224
|
-
|
225
|
-
|
173
|
+
# Also match ENV.fetch('VAR') pattern
|
174
|
+
content.scan(/ENV\s*\.\s*fetch\s*\(\s*['"]([^'"]+)['"]\s*/).each do |match|
|
175
|
+
env_var = match[0]
|
176
|
+
env_vars[env_var] ||= []
|
177
|
+
env_vars[env_var] << file.sub("#{project_path}/", "")
|
178
|
+
end
|
179
|
+
rescue => e
|
180
|
+
log(:error, "Error reading file #{file}: #{e.message}")
|
181
|
+
end
|
182
|
+
end
|
226
183
|
end
|
184
|
+
end
|
227
185
|
|
228
|
-
|
229
|
-
|
230
|
-
|
231
|
-
full_path = File.join($active_project_path, directory)
|
232
|
-
|
233
|
-
unless File.directory?(full_path)
|
234
|
-
raise "Directory '#{directory}' not found in the project."
|
235
|
-
end
|
186
|
+
env_vars
|
187
|
+
end
|
236
188
|
|
237
|
-
|
238
|
-
|
239
|
-
|
240
|
-
.sort # rubocop:disable Performance/ChainArrayAllocation
|
189
|
+
# Helper method to parse .env files
|
190
|
+
def parse_dotenv_file(file_path)
|
191
|
+
vars = {}
|
241
192
|
|
242
|
-
|
193
|
+
begin
|
194
|
+
File.readlines(file_path).each do |line| # rubocop:disable Performance/IoReadlines
|
195
|
+
# Skip comments and empty lines
|
196
|
+
next if line.strip.empty? || line.strip.start_with?("#")
|
243
197
|
|
244
|
-
|
198
|
+
# Parse KEY=value pattern
|
199
|
+
if line =~ /\A([A-Za-z0-9_]+)=(.*)\z/
|
200
|
+
key = $1
|
201
|
+
# Store just the existence of the variable, not its value
|
202
|
+
vars[key] = true
|
203
|
+
end
|
204
|
+
end
|
205
|
+
rescue => e
|
206
|
+
log(:error, "Error parsing .env file #{file_path}: #{e.message}")
|
245
207
|
end
|
208
|
+
|
209
|
+
vars
|
246
210
|
end
|
247
211
|
|
248
|
-
|
249
|
-
|
212
|
+
# Helper method to parse database.yml
|
213
|
+
def parse_database_config(file_path)
|
214
|
+
config = {}
|
250
215
|
|
251
|
-
|
252
|
-
|
216
|
+
begin
|
217
|
+
# Simple YAML parsing - not handling ERB
|
218
|
+
yaml_content = File.read(file_path)
|
219
|
+
yaml_data = YAML.safe_load(yaml_content) || {}
|
253
220
|
|
254
|
-
|
255
|
-
|
256
|
-
|
221
|
+
# Extract environment configurations
|
222
|
+
%w[development test production staging].each do |env|
|
223
|
+
config[env] = yaml_data[env] if yaml_data[env]
|
257
224
|
end
|
225
|
+
rescue => e
|
226
|
+
log(:error, "Error parsing database.yml: #{e.message}")
|
227
|
+
end
|
258
228
|
|
259
|
-
|
260
|
-
|
229
|
+
config
|
230
|
+
end
|
261
231
|
|
262
|
-
|
263
|
-
|
264
|
-
|
232
|
+
# Helper method to compare environment settings
|
233
|
+
def compare_environment_settings(env_settings)
|
234
|
+
result = {
|
235
|
+
unique_settings: {},
|
236
|
+
different_values: {}
|
237
|
+
}
|
265
238
|
|
266
|
-
|
267
|
-
|
239
|
+
# Get all settings across all environments
|
240
|
+
all_settings = env_settings.values.map(&:keys).flatten.uniq # rubocop:disable Performance/ChainArrayAllocation
|
268
241
|
|
269
|
-
|
242
|
+
# Find settings unique to certain environments
|
243
|
+
env_settings.each do |env, settings|
|
244
|
+
unique = settings.keys - (all_settings - settings.keys)
|
245
|
+
result[:unique_settings][env] = unique if unique.any?
|
270
246
|
end
|
271
|
-
end
|
272
247
|
|
273
|
-
|
274
|
-
|
248
|
+
# Find settings with different values across environments
|
249
|
+
all_settings.each do |setting|
|
250
|
+
values = {}
|
275
251
|
|
276
|
-
|
277
|
-
|
278
|
-
raise "No active project. Please switch to a project first."
|
252
|
+
env_settings.each do |env, settings|
|
253
|
+
values[env] = settings[setting] if settings[setting]
|
279
254
|
end
|
280
255
|
|
281
|
-
#
|
282
|
-
|
283
|
-
|
284
|
-
|
285
|
-
"Rails Routes:\n\n```\n#{routes_output}\n```"
|
256
|
+
# Only include if there are different values
|
257
|
+
if values.values.uniq.size > 1
|
258
|
+
result[:different_values][setting] = values
|
259
|
+
end
|
286
260
|
end
|
287
|
-
end
|
288
|
-
|
289
|
-
tool "get_models" do
|
290
|
-
description "Retrieve detailed information about Active Record models in the project. When called without parameters, lists all model files. When a specific model is specified, returns its schema, associations (has_many, belongs_to, has_one), and complete source code."
|
291
261
|
|
292
|
-
|
293
|
-
|
262
|
+
result
|
263
|
+
end
|
294
264
|
|
295
|
-
|
296
|
-
|
297
|
-
|
298
|
-
end
|
265
|
+
# Helper method to find missing ENV variables
|
266
|
+
def find_missing_env_vars(env_vars_in_code, dotenv_vars)
|
267
|
+
missing_vars = {}
|
299
268
|
|
300
|
-
|
269
|
+
# Check each ENV variable used in code
|
270
|
+
env_vars_in_code.each do |var, files|
|
271
|
+
# Environments where the variable is missing
|
272
|
+
missing_in = []
|
301
273
|
|
302
|
-
|
303
|
-
|
274
|
+
# Check in each .env file
|
275
|
+
if dotenv_vars.empty?
|
276
|
+
missing_in << "all environments (no .env files found)"
|
277
|
+
else
|
278
|
+
dotenv_vars.each do |env_file, vars|
|
279
|
+
env_name = env_file.gsub(/^\.env\.?|\.local$/, "")
|
280
|
+
env_name = "development" if env_name.empty?
|
304
281
|
|
305
|
-
|
306
|
-
|
307
|
-
|
308
|
-
log(:warn, "Model file not found: #{model_name}")
|
309
|
-
raise "Model '#{model_name}' not found."
|
282
|
+
if !vars.key?(var)
|
283
|
+
missing_in << env_name
|
284
|
+
end
|
310
285
|
end
|
286
|
+
end
|
311
287
|
|
312
|
-
|
313
|
-
|
314
|
-
# Get the model file content
|
315
|
-
model_content = File.read(model_file)
|
288
|
+
missing_vars[var] = missing_in if missing_in.any?
|
289
|
+
end
|
316
290
|
|
317
|
-
|
318
|
-
|
319
|
-
schema_info = execute_rails_command(
|
320
|
-
$active_project_path,
|
321
|
-
"runner \"puts #{model_name}.column_names\""
|
322
|
-
)
|
291
|
+
missing_vars
|
292
|
+
end
|
323
293
|
|
324
|
-
|
325
|
-
|
326
|
-
|
327
|
-
has_many = model_content.scan(/has_many\s+:(\w+)/).flatten
|
328
|
-
associations << "Has many: #{has_many.join(", ")}" unless has_many.empty?
|
329
|
-
end
|
294
|
+
# Helper method to check for security issues
|
295
|
+
def check_security_configuration(env_settings, database_config)
|
296
|
+
findings = []
|
330
297
|
|
331
|
-
|
332
|
-
|
333
|
-
|
298
|
+
# Check for common security settings
|
299
|
+
env_settings.each do |env, settings|
|
300
|
+
# Check for secure cookies in production
|
301
|
+
if env == "production"
|
302
|
+
if settings["cookies.secure"] == "false"
|
303
|
+
findings << "Production has cookies.secure = false"
|
334
304
|
end
|
335
305
|
|
336
|
-
if
|
337
|
-
|
338
|
-
associations << "Has one: #{has_one.join(", ")}" unless has_one.empty?
|
306
|
+
if settings["session_store.secure"] == "false"
|
307
|
+
findings << "Production has session_store.secure = false"
|
339
308
|
end
|
340
309
|
|
341
|
-
|
342
|
-
|
343
|
-
|
344
|
-
<<~INFO
|
345
|
-
Model: #{model_name}
|
346
|
-
|
347
|
-
Schema:
|
348
|
-
#{schema_info}
|
349
|
-
|
350
|
-
Associations:
|
351
|
-
#{associations.empty? ? "None found" : associations.join("\n")}
|
352
|
-
|
353
|
-
Model Definition:
|
354
|
-
```ruby
|
355
|
-
#{model_content}
|
356
|
-
```
|
357
|
-
INFO
|
358
|
-
else
|
359
|
-
log(:info, "Listing all models")
|
360
|
-
|
361
|
-
# List all models
|
362
|
-
models_dir = File.join($active_project_path, "app", "models")
|
363
|
-
unless File.directory?(models_dir)
|
364
|
-
raise "Models directory not found."
|
310
|
+
# Force SSL
|
311
|
+
if settings["force_ssl"] == "false"
|
312
|
+
findings << "Production has force_ssl = false"
|
365
313
|
end
|
314
|
+
end
|
366
315
|
|
367
|
-
|
368
|
-
|
369
|
-
|
370
|
-
.sort # rubocop:disable Performance/ChainArrayAllocation
|
371
|
-
|
372
|
-
log(:debug, "Found #{model_files.size} model files")
|
373
|
-
|
374
|
-
"Models in the project:\n\n#{model_files.join("\n")}"
|
316
|
+
# Check for CSRF protection
|
317
|
+
if settings["action_controller.default_protect_from_forgery"] == "false"
|
318
|
+
findings << "#{env} has CSRF protection disabled"
|
375
319
|
end
|
376
320
|
end
|
377
|
-
end
|
378
321
|
|
379
|
-
|
380
|
-
|
381
|
-
|
382
|
-
|
383
|
-
description: "Database table name to get detailed schema information for (e.g., 'users', 'products'). Use snake_case, plural form. If omitted, returns complete database schema."
|
384
|
-
|
385
|
-
call do |args|
|
386
|
-
unless $active_project
|
387
|
-
raise "No active project. Please switch to a project first."
|
322
|
+
# Check for hardcoded credentials in database.yml
|
323
|
+
database_config.each do |env, config|
|
324
|
+
if config["username"] && !config["username"].include?("ENV")
|
325
|
+
findings << "Database username hardcoded in database.yml for #{env}"
|
388
326
|
end
|
389
327
|
|
390
|
-
|
391
|
-
|
392
|
-
if table_name
|
393
|
-
log(:info, "Getting schema for table: #{table_name}")
|
394
|
-
|
395
|
-
# Execute the Rails schema command for a specific table
|
396
|
-
schema_output = execute_rails_command(
|
397
|
-
$active_project_path,
|
398
|
-
"runner \"require 'active_record'; puts ActiveRecord::Base.connection.columns('#{table_name}').map{|c| [c.name, c.type, c.null, c.default].inspect}.join('\n')\""
|
399
|
-
)
|
400
|
-
|
401
|
-
if schema_output.strip.empty?
|
402
|
-
raise "Table '#{table_name}' not found or has no columns."
|
403
|
-
end
|
404
|
-
|
405
|
-
# Parse the column information
|
406
|
-
columns = schema_output.strip.split("\n").map do |column_info|
|
407
|
-
eval(column_info) # This is safe because we're generating the string ourselves # rubocop:disable Security/Eval
|
408
|
-
end
|
409
|
-
|
410
|
-
# Format the output
|
411
|
-
formatted_columns = columns.map do |name, type, nullable, default|
|
412
|
-
"#{name} (#{type})#{nullable ? ", nullable" : ""}#{default ? ", default: #{default}" : ""}"
|
413
|
-
end
|
414
|
-
|
415
|
-
output = <<~SCHEMA
|
416
|
-
Table: #{table_name}
|
417
|
-
|
418
|
-
Columns:
|
419
|
-
#{formatted_columns.join("\n")}
|
420
|
-
SCHEMA
|
421
|
-
|
422
|
-
# Try to get foreign keys
|
423
|
-
begin
|
424
|
-
fk_output = execute_rails_command(
|
425
|
-
$active_project_path,
|
426
|
-
"runner \"require 'active_record'; puts ActiveRecord::Base.connection.foreign_keys('#{table_name}').map{|fk| [fk.from_table, fk.to_table, fk.column, fk.primary_key].inspect}.join('\n')\""
|
427
|
-
)
|
428
|
-
|
429
|
-
unless fk_output.strip.empty?
|
430
|
-
foreign_keys = fk_output.strip.split("\n").map do |fk_info|
|
431
|
-
eval(fk_info) # This is safe because we're generating the string ourselves # rubocop:disable Security/Eval
|
432
|
-
end
|
433
|
-
|
434
|
-
formatted_fks = foreign_keys.map do |from_table, to_table, column, primary_key|
|
435
|
-
"#{column} -> #{to_table}.#{primary_key}"
|
436
|
-
end
|
437
|
-
|
438
|
-
output += <<~FK
|
439
|
-
|
440
|
-
Foreign Keys:
|
441
|
-
#{formatted_fks.join("\n")}
|
442
|
-
FK
|
443
|
-
end
|
444
|
-
rescue => e
|
445
|
-
log(:warn, "Error fetching foreign keys: #{e.message}")
|
446
|
-
end
|
447
|
-
|
448
|
-
output
|
449
|
-
else
|
450
|
-
log(:info, "Getting full schema")
|
451
|
-
|
452
|
-
# Execute the Rails schema:dump command
|
453
|
-
# First, check if we need to create the schema file
|
454
|
-
schema_file = File.join($active_project_path, "db", "schema.rb")
|
455
|
-
unless File.exist?(schema_file)
|
456
|
-
log(:info, "Schema file not found, attempting to generate it")
|
457
|
-
execute_rails_command($active_project_path, "db:schema:dump")
|
458
|
-
end
|
459
|
-
|
460
|
-
if File.exist?(schema_file)
|
461
|
-
# Read the schema file
|
462
|
-
schema_content = File.read(schema_file)
|
463
|
-
|
464
|
-
# Try to get table list
|
465
|
-
tables_output = execute_rails_command(
|
466
|
-
$active_project_path,
|
467
|
-
"runner \"require 'active_record'; puts ActiveRecord::Base.connection.tables.sort.join('\n')\""
|
468
|
-
)
|
469
|
-
|
470
|
-
tables = tables_output.strip.split("\n")
|
471
|
-
|
472
|
-
<<~SCHEMA
|
473
|
-
Database Schema
|
474
|
-
|
475
|
-
Tables:
|
476
|
-
#{tables.join("\n")}
|
477
|
-
|
478
|
-
Schema Definition:
|
479
|
-
```ruby
|
480
|
-
#{schema_content}
|
481
|
-
```
|
482
|
-
SCHEMA
|
483
|
-
|
484
|
-
else
|
485
|
-
# If we can't get the schema file, try to get the table list
|
486
|
-
tables_output = execute_rails_command(
|
487
|
-
$active_project_path,
|
488
|
-
"runner \"require 'active_record'; puts ActiveRecord::Base.connection.tables.sort.join('\n')\""
|
489
|
-
)
|
490
|
-
|
491
|
-
if tables_output.strip.empty?
|
492
|
-
raise "Could not retrieve schema information. Try running 'rails db:schema:dump' in your project first."
|
493
|
-
end
|
494
|
-
|
495
|
-
tables = tables_output.strip.split("\n")
|
496
|
-
|
497
|
-
<<~SCHEMA
|
498
|
-
Database Schema
|
499
|
-
|
500
|
-
Tables:
|
501
|
-
#{tables.join("\n")}
|
502
|
-
|
503
|
-
Note: Full schema definition is not available. Run 'rails db:schema:dump' to generate the schema.rb file.
|
504
|
-
SCHEMA
|
505
|
-
end
|
328
|
+
if config["password"] && !config["password"].include?("ENV")
|
329
|
+
findings << "Database password hardcoded in database.yml for #{env}"
|
506
330
|
end
|
507
331
|
end
|
332
|
+
|
333
|
+
findings
|
508
334
|
end
|
509
335
|
# rubocop:enable Style/GlobalVars
|