react_on_rails 16.1.2 → 16.2.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 (106) hide show
  1. checksums.yaml +4 -4
  2. data/.rspec +2 -0
  3. data/.rubocop.yml +85 -0
  4. data/Gemfile.development_dependencies +8 -7
  5. data/Gemfile.lock +158 -119
  6. data/Steepfile +56 -0
  7. data/lib/generators/react_on_rails/base_generator.rb +43 -120
  8. data/lib/generators/react_on_rails/dev_tests_generator.rb +2 -1
  9. data/lib/generators/react_on_rails/generator_helper.rb +102 -2
  10. data/lib/generators/react_on_rails/install_generator.rb +36 -156
  11. data/lib/generators/react_on_rails/js_dependency_manager.rb +383 -0
  12. data/lib/generators/react_on_rails/templates/base/base/.dev-services.yml.example +76 -0
  13. data/lib/generators/react_on_rails/templates/base/base/bin/shakapacker-precompile-hook +30 -0
  14. data/lib/generators/react_on_rails/templates/base/base/bin/switch-bundler +141 -0
  15. data/lib/generators/react_on_rails/templates/base/base/config/initializers/react_on_rails.rb.tt +44 -45
  16. data/lib/generators/react_on_rails/templates/base/base/config/{shakapacker.yml → shakapacker.yml.tt} +28 -3
  17. data/lib/generators/react_on_rails/templates/base/base/config/webpack/development.js.tt +15 -9
  18. data/lib/generators/react_on_rails/templates/base/base/config/webpack/serverWebpackConfig.js.tt +42 -6
  19. data/lib/react_on_rails/configuration.rb +149 -32
  20. data/lib/react_on_rails/controller.rb +3 -3
  21. data/lib/react_on_rails/dev/pack_generator.rb +168 -2
  22. data/lib/react_on_rails/dev/process_manager.rb +136 -14
  23. data/lib/react_on_rails/dev/server_manager.rb +194 -26
  24. data/lib/react_on_rails/dev/service_checker.rb +200 -0
  25. data/lib/react_on_rails/doctor.rb +341 -12
  26. data/lib/react_on_rails/engine.rb +75 -1
  27. data/lib/react_on_rails/git_utils.rb +3 -1
  28. data/lib/react_on_rails/helper.rb +70 -192
  29. data/lib/react_on_rails/locales/base.rb +17 -5
  30. data/lib/react_on_rails/packer_utils.rb +79 -2
  31. data/lib/react_on_rails/packs_generator.rb +57 -39
  32. data/lib/react_on_rails/prerender_error.rb +74 -17
  33. data/lib/react_on_rails/pro_helper.rb +64 -0
  34. data/lib/react_on_rails/react_component/render_options.rb +7 -7
  35. data/lib/react_on_rails/server_rendering_pool/ruby_embedded_java_script.rb +2 -5
  36. data/lib/react_on_rails/smart_error.rb +326 -0
  37. data/lib/react_on_rails/system_checker.rb +8 -9
  38. data/lib/react_on_rails/test_helper/webpack_assets_status_checker.rb +16 -7
  39. data/lib/react_on_rails/utils.rb +241 -55
  40. data/lib/react_on_rails/version.rb +1 -1
  41. data/lib/react_on_rails/version_checker.rb +383 -35
  42. data/lib/tasks/generate_packs.rake +12 -6
  43. data/lib/tasks/locale.rake +6 -1
  44. data/rakelib/docker.rake +26 -0
  45. data/rakelib/dummy_apps.rake +30 -0
  46. data/rakelib/example_type.rb +121 -0
  47. data/rakelib/examples_config.yml +52 -0
  48. data/rakelib/lint.rake +52 -0
  49. data/rakelib/node_package.rake +15 -0
  50. data/rakelib/rbs.rake +70 -0
  51. data/rakelib/run_rspec.rake +223 -0
  52. data/rakelib/shakapacker_examples.rake +171 -0
  53. data/rakelib/task_helpers.rb +134 -0
  54. data/rakelib/update_changelog.rake +73 -0
  55. data/react_on_rails.gemspec +4 -3
  56. data/sig/README.md +52 -0
  57. data/sig/react_on_rails/configuration.rbs +96 -0
  58. data/sig/react_on_rails/controller.rbs +15 -0
  59. data/sig/react_on_rails/dev/file_manager.rbs +15 -0
  60. data/sig/react_on_rails/dev/pack_generator.rbs +19 -0
  61. data/sig/react_on_rails/dev/process_manager.rbs +22 -0
  62. data/sig/react_on_rails/dev/server_manager.rbs +39 -0
  63. data/sig/react_on_rails/dev/service_checker.rbs +22 -0
  64. data/sig/react_on_rails/error.rbs +4 -0
  65. data/sig/react_on_rails/generators/js_dependency_manager.rbs +123 -0
  66. data/sig/react_on_rails/git_utils.rbs +8 -0
  67. data/sig/react_on_rails/helper.rbs +65 -0
  68. data/sig/react_on_rails/json_parse_error.rbs +10 -0
  69. data/sig/react_on_rails/locales.rbs +46 -0
  70. data/sig/react_on_rails/packer_utils.rbs +15 -0
  71. data/sig/react_on_rails/prerender_error.rbs +21 -0
  72. data/sig/react_on_rails/server_rendering_pool.rbs +12 -0
  73. data/sig/react_on_rails/smart_error.rbs +28 -0
  74. data/sig/react_on_rails/test_helper.rbs +11 -0
  75. data/sig/react_on_rails/utils.rbs +34 -0
  76. data/sig/react_on_rails/version_checker.rbs +12 -0
  77. data/sig/react_on_rails.rbs +17 -0
  78. metadata +49 -32
  79. data/AI_AGENT_INSTRUCTIONS.md +0 -63
  80. data/CHANGELOG.md +0 -1836
  81. data/CLAUDE.md +0 -135
  82. data/CODING_AGENTS.md +0 -313
  83. data/CONTRIBUTING.md +0 -668
  84. data/Dockerfile_tests +0 -12
  85. data/KUDOS.md +0 -114
  86. data/LICENSE.md +0 -47
  87. data/LICENSES/README.md +0 -14
  88. data/NEWS.md +0 -62
  89. data/PROJECTS.md +0 -63
  90. data/REACT-ON-RAILS-PRO-LICENSE.md +0 -129
  91. data/README.md +0 -217
  92. data/SUMMARY.md +0 -88
  93. data/TODO.md +0 -135
  94. data/bin/lefthook/check-trailing-newlines +0 -38
  95. data/bin/lefthook/get-changed-files +0 -26
  96. data/bin/lefthook/prettier-format +0 -26
  97. data/bin/lefthook/ruby-autofix +0 -26
  98. data/bin/lefthook/ruby-lint +0 -27
  99. data/docker-compose.yml +0 -11
  100. data/eslint.config.ts +0 -232
  101. data/knip.ts +0 -114
  102. data/lib/react_on_rails/pro/NOTICE +0 -21
  103. data/lib/react_on_rails/pro/helper.rb +0 -122
  104. data/lib/react_on_rails/pro/utils.rb +0 -53
  105. data/tsconfig.eslint.json +0 -6
  106. data/tsconfig.json +0 -19
@@ -3,10 +3,14 @@
3
3
  require "English"
4
4
  require "open3"
5
5
  require "rainbow"
6
+ require_relative "../packer_utils"
7
+ require_relative "service_checker"
6
8
 
7
9
  module ReactOnRails
8
10
  module Dev
9
11
  class ServerManager
12
+ HELP_FLAGS = ["-h", "--help"].freeze
13
+
10
14
  class << self
11
15
  def start(mode = :development, procfile = nil, verbose: false, route: nil, rails_env: nil)
12
16
  case mode
@@ -27,7 +31,7 @@ module ReactOnRails
27
31
  puts "🔪 Killing all development processes..."
28
32
  puts ""
29
33
 
30
- killed_any = kill_running_processes || cleanup_socket_files
34
+ killed_any = kill_running_processes || kill_port_processes([3000, 3001]) || cleanup_socket_files
31
35
 
32
36
  print_kill_summary(killed_any)
33
37
  end
@@ -70,11 +74,39 @@ module ReactOnRails
70
74
  def terminate_processes(pids)
71
75
  pids.each do |pid|
72
76
  Process.kill("TERM", pid)
73
- rescue StandardError
77
+ rescue Errno::ESRCH, ArgumentError, RangeError
78
+ # Process already stopped, or invalid signal/PID - silently skip
79
+ nil
80
+ rescue Errno::EPERM
81
+ # Permission denied - warn the user
82
+ puts " ⚠️ Process #{pid} - permission denied (process owned by another user)"
74
83
  nil
75
84
  end
76
85
  end
77
86
 
87
+ def kill_port_processes(ports)
88
+ killed_any = false
89
+
90
+ ports.each do |port|
91
+ pids = find_port_pids(port)
92
+ next unless pids.any?
93
+
94
+ puts " ☠️ Killing process on port #{port} (PIDs: #{pids.join(', ')})"
95
+ terminate_processes(pids)
96
+ killed_any = true
97
+ end
98
+
99
+ killed_any
100
+ end
101
+
102
+ def find_port_pids(port)
103
+ stdout, _status = Open3.capture2("lsof", "-ti", ":#{port}", err: File::NULL)
104
+ stdout.split("\n").map(&:to_i).reject { |pid| pid == Process.pid }
105
+ rescue StandardError
106
+ # lsof command not found or other error (permission denied, etc.)
107
+ []
108
+ end
109
+
78
110
  def cleanup_socket_files
79
111
  files = [".overmind.sock", "tmp/sockets/overmind.sock", "tmp/pids/server.pid"]
80
112
  killed_any = false
@@ -116,10 +148,18 @@ module ReactOnRails
116
148
  puts help_troubleshooting
117
149
  end
118
150
 
151
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity
119
152
  def run_from_command_line(args = ARGV)
120
153
  require "optparse"
121
154
 
122
- options = { route: nil, rails_env: nil }
155
+ # Get the command early to check for help/kill before running hooks
156
+ # We need to do this before OptionParser processes flags like -h/--help
157
+ command = args.find { |arg| !arg.start_with?("--") && !arg.start_with?("-") }
158
+
159
+ # Check if help flags are present in args (before OptionParser processes them)
160
+ help_requested = args.any? { |arg| HELP_FLAGS.include?(arg) }
161
+
162
+ options = { route: nil, rails_env: nil, verbose: false }
123
163
 
124
164
  OptionParser.new do |opts|
125
165
  opts.banner = "Usage: dev [command] [options]"
@@ -132,36 +172,125 @@ module ReactOnRails
132
172
  options[:rails_env] = env
133
173
  end
134
174
 
175
+ opts.on("-v", "--verbose", "Enable verbose output for pack generation") do
176
+ options[:verbose] = true
177
+ end
178
+
135
179
  opts.on("-h", "--help", "Prints this help") do
136
180
  show_help
137
181
  exit
138
182
  end
139
183
  end.parse!(args)
140
184
 
141
- # Get the command (anything that's not parsed as an option)
142
- command = args[0]
185
+ # Run precompile hook once before starting any mode (except kill/help)
186
+ # Then set environment variable to prevent duplicate execution in spawned processes.
187
+ # Note: We always set SHAKAPACKER_SKIP_PRECOMPILE_HOOK=true (even when no hook is configured)
188
+ # to provide a consistent signal that bin/dev is managing the precompile lifecycle.
189
+ # This allows custom scripts to detect bin/dev's presence and adjust behavior accordingly.
190
+ unless %w[kill help].include?(command) || help_requested
191
+ run_precompile_hook_if_present
192
+ ENV["SHAKAPACKER_SKIP_PRECOMPILE_HOOK"] = "true"
193
+ end
143
194
 
144
195
  # Main execution
145
196
  case command
146
197
  when "production-assets", "prod"
147
- start(:production_like, nil, verbose: false, route: options[:route], rails_env: options[:rails_env])
198
+ start(:production_like, nil, verbose: options[:verbose], route: options[:route],
199
+ rails_env: options[:rails_env])
148
200
  when "static"
149
- start(:static, "Procfile.dev-static-assets", verbose: false, route: options[:route])
201
+ start(:static, "Procfile.dev-static-assets", verbose: options[:verbose], route: options[:route])
150
202
  when "kill"
151
203
  kill_processes
152
- when "help", "--help", "-h"
204
+ when "help"
153
205
  show_help
154
206
  when "hmr", nil
155
- start(:development, "Procfile.dev", verbose: false, route: options[:route])
207
+ start(:development, "Procfile.dev", verbose: options[:verbose], route: options[:route])
156
208
  else
157
209
  puts "Unknown argument: #{command}"
158
210
  puts "Run 'dev help' for usage information"
159
211
  exit 1
160
212
  end
161
213
  end
214
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity
162
215
 
163
216
  private
164
217
 
218
+ def run_precompile_hook_if_present
219
+ require "open3"
220
+ require "shellwords"
221
+
222
+ hook_value = PackerUtils.shakapacker_precompile_hook_value
223
+ return unless hook_value
224
+
225
+ # Warn if Shakapacker version doesn't support SHAKAPACKER_SKIP_PRECOMPILE_HOOK
226
+ warn_if_shakapacker_version_too_old
227
+
228
+ puts Rainbow("🔧 Running Shakapacker precompile hook...").cyan
229
+ puts Rainbow(" Command: #{hook_value}").cyan
230
+ puts ""
231
+
232
+ # Capture stdout and stderr for better error reporting
233
+ # Use Shellwords.split for safer command execution (prevents shell metacharacter interpretation)
234
+ command_args = Shellwords.split(hook_value.to_s)
235
+ stdout, stderr, status = Open3.capture3(*command_args)
236
+
237
+ if status.success?
238
+ puts Rainbow("✅ Precompile hook completed successfully").green
239
+ puts ""
240
+ else
241
+ handle_precompile_hook_failure(hook_value, stdout, stderr)
242
+ end
243
+ end
244
+
245
+ # rubocop:disable Metrics/AbcSize
246
+ def handle_precompile_hook_failure(hook_value, stdout, stderr)
247
+ puts ""
248
+ puts Rainbow("❌ Precompile hook failed!").red.bold
249
+ puts Rainbow(" Command: #{hook_value}").red
250
+ puts ""
251
+
252
+ if stdout && !stdout.strip.empty?
253
+ puts Rainbow(" Output:").yellow
254
+ stdout.strip.split("\n").each { |line| puts Rainbow(" #{line}").yellow }
255
+ puts ""
256
+ end
257
+
258
+ if stderr && !stderr.strip.empty?
259
+ puts Rainbow(" Error:").red
260
+ stderr.strip.split("\n").each { |line| puts Rainbow(" #{line}").red }
261
+ puts ""
262
+ end
263
+
264
+ puts Rainbow("💡 Fix the hook command in config/shakapacker.yml or remove it to continue").yellow
265
+ exit 1
266
+ end
267
+ # rubocop:enable Metrics/AbcSize
268
+
269
+ # rubocop:disable Metrics/AbcSize
270
+ def warn_if_shakapacker_version_too_old
271
+ # Only warn for Shakapacker versions in the range 9.0.0 to 9.3.x
272
+ # Versions below 9.0.0 don't use the precompile_hook feature
273
+ # Versions 9.4.0+ support SHAKAPACKER_SKIP_PRECOMPILE_HOOK environment variable
274
+ has_precompile_hook_support = PackerUtils.shakapacker_version_requirement_met?("9.0.0")
275
+ has_skip_env_var_support = PackerUtils.shakapacker_version_requirement_met?("9.4.0")
276
+
277
+ return unless has_precompile_hook_support
278
+ return if has_skip_env_var_support
279
+
280
+ puts ""
281
+ puts Rainbow("⚠️ Warning: Shakapacker #{PackerUtils.shakapacker_version} detected").yellow.bold
282
+ puts ""
283
+ puts Rainbow(" The SHAKAPACKER_SKIP_PRECOMPILE_HOOK environment variable is not").yellow
284
+ puts Rainbow(" supported in Shakapacker versions below 9.4.0. This may cause the").yellow
285
+ puts Rainbow(" precompile_hook to run multiple times (once by bin/dev, and again").yellow
286
+ puts Rainbow(" by each webpack process).").yellow
287
+ puts ""
288
+ puts Rainbow(" Recommendation: Upgrade to Shakapacker 9.4.0 or later:").cyan
289
+ puts Rainbow(" bundle update shakapacker").cyan.bold
290
+ puts ""
291
+ end
292
+ # rubocop:enable Metrics/AbcSize
293
+
165
294
  def help_usage
166
295
  Rainbow("📋 Usage: bin/dev [command] [options]").bold
167
296
  end
@@ -202,6 +331,7 @@ module ReactOnRails
202
331
  end
203
332
  # rubocop:enable Metrics/AbcSize
204
333
 
334
+ # rubocop:disable Metrics/AbcSize
205
335
  def help_customization
206
336
  <<~CUSTOMIZATION
207
337
  #{Rainbow('🔧 CUSTOMIZATION:').cyan.bold}
@@ -212,15 +342,31 @@ module ReactOnRails
212
342
  #{Rainbow('•').yellow} #{Rainbow('Procfile.dev-prod-assets').green.bold} - Production-optimized assets (port 3001)
213
343
 
214
344
  #{Rainbow('Edit these files to customize the development environment for your needs.').white}
345
+
346
+ #{Rainbow('🔍 SERVICE DEPENDENCIES:').cyan.bold}
347
+ #{Rainbow('Configure required external services in').white} #{Rainbow('.dev-services.yml').green.bold}#{Rainbow(':').white}
348
+
349
+ #{Rainbow('•').yellow} #{Rainbow('bin/dev').white} #{Rainbow('checks services before starting (optional)').white}
350
+ #{Rainbow('•').yellow} #{Rainbow('Copy from').white} #{Rainbow('.dev-services.yml.example').green.bold} #{Rainbow('to get started').white}
351
+ #{Rainbow('•').yellow} #{Rainbow('Supports Redis, PostgreSQL, Elasticsearch, and custom services').white}
352
+ #{Rainbow('•').yellow} #{Rainbow('Shows helpful errors with start commands if services are missing').white}
353
+
354
+ #{Rainbow('Example .dev-services.yml:').white}
355
+ #{Rainbow(' services:').cyan}
356
+ #{Rainbow(' redis:').cyan}
357
+ #{Rainbow(' check_command: "redis-cli ping"').cyan}
358
+ #{Rainbow(' expected_output: "PONG"').cyan}
359
+ #{Rainbow(' start_command: "redis-server"').cyan}
215
360
  CUSTOMIZATION
216
361
  end
362
+ # rubocop:enable Metrics/AbcSize
217
363
 
218
364
  # rubocop:disable Metrics/AbcSize
219
365
  def help_mode_details
220
366
  <<~MODES
221
367
  #{Rainbow('🔥 HMR Development mode (default)').cyan.bold} - #{Rainbow('Procfile.dev').green}:
222
368
  #{Rainbow('•').yellow} #{Rainbow('Hot Module Replacement (HMR) enabled').white}
223
- #{Rainbow('•').yellow} #{Rainbow('React on Rails pack generation before Procfile start').white}
369
+ #{Rainbow('•').yellow} #{Rainbow('React on Rails pack generation (via precompile hook or bin/dev)').white}
224
370
  #{Rainbow('•').yellow} #{Rainbow('Webpack dev server for fast recompilation').white}
225
371
  #{Rainbow('•').yellow} #{Rainbow('Source maps for debugging').white}
226
372
  #{Rainbow('•').yellow} #{Rainbow('May have Flash of Unstyled Content (FOUC)').white}
@@ -229,7 +375,7 @@ module ReactOnRails
229
375
 
230
376
  #{Rainbow('📦 Static development mode').cyan.bold} - #{Rainbow('Procfile.dev-static-assets').green}:
231
377
  #{Rainbow('•').yellow} #{Rainbow('No HMR (static assets with auto-recompilation)').white}
232
- #{Rainbow('•').yellow} #{Rainbow('React on Rails pack generation before Procfile start').white}
378
+ #{Rainbow('•').yellow} #{Rainbow('React on Rails pack generation (via precompile hook or bin/dev)').white}
233
379
  #{Rainbow('•').yellow} #{Rainbow('Webpack watch mode for auto-recompilation').white}
234
380
  #{Rainbow('•').yellow} #{Rainbow('CSS extracted to separate files (no FOUC)').white}
235
381
  #{Rainbow('•').yellow} #{Rainbow('Development environment (faster builds than production)').white}
@@ -237,7 +383,7 @@ module ReactOnRails
237
383
  #{Rainbow('•').yellow} #{Rainbow('Access at:').white} #{Rainbow('http://localhost:3000/<route>').cyan.underline}
238
384
 
239
385
  #{Rainbow('🏭 Production-assets mode').cyan.bold} - #{Rainbow('Procfile.dev-prod-assets').green}:
240
- #{Rainbow('•').yellow} #{Rainbow('React on Rails pack generation before Procfile start').white}
386
+ #{Rainbow('•').yellow} #{Rainbow('React on Rails pack generation (via precompile hook or assets:precompile)').white}
241
387
  #{Rainbow('•').yellow} #{Rainbow('Asset precompilation with NODE_ENV=production (webpack optimizations)').white}
242
388
  #{Rainbow('•').yellow} #{Rainbow('RAILS_ENV=development by default for assets:precompile (avoids credentials)').white}
243
389
  #{Rainbow('•').yellow} #{Rainbow('Use --rails-env=production for assets:precompile only (not server processes)').white}
@@ -253,16 +399,24 @@ module ReactOnRails
253
399
  def run_production_like(_verbose: false, route: nil, rails_env: nil)
254
400
  procfile = "Procfile.dev-prod-assets"
255
401
 
402
+ features = [
403
+ "Precompiling assets with production optimizations",
404
+ "Running Rails server on port 3001",
405
+ "No HMR (Hot Module Replacement)",
406
+ "CSS extracted to separate files (no FOUC)"
407
+ ]
408
+
409
+ # NOTE: Pack generation happens automatically during assets:precompile
410
+ # either via precompile hook or via the configuration.rb adjust_precompile_task
411
+
256
412
  print_procfile_info(procfile, route: route)
413
+
414
+ # Check required services before starting
415
+ exit 1 unless ServiceChecker.check_services
416
+
257
417
  print_server_info(
258
418
  "🏭 Starting production-like development server...",
259
- [
260
- "Generating React on Rails packs",
261
- "Precompiling assets with production optimizations",
262
- "Running Rails server on port 3001",
263
- "No HMR (Hot Module Replacement)",
264
- "CSS extracted to separate files (no FOUC)"
265
- ],
419
+ features,
266
420
  3001,
267
421
  route: route
268
422
  )
@@ -381,15 +535,25 @@ module ReactOnRails
381
535
 
382
536
  def run_static_development(procfile, verbose: false, route: nil)
383
537
  print_procfile_info(procfile, route: route)
538
+
539
+ # Check required services before starting
540
+ exit 1 unless ServiceChecker.check_services
541
+
542
+ features = [
543
+ "Using shakapacker --watch (no HMR)",
544
+ "CSS extracted to separate files (no FOUC)",
545
+ "Development environment (source maps, faster builds)",
546
+ "Auto-recompiles on file changes"
547
+ ]
548
+
549
+ # Add pack generation info if not using precompile hook
550
+ unless ReactOnRails::PackerUtils.shakapacker_precompile_hook_configured?
551
+ features.unshift("Generating React on Rails packs")
552
+ end
553
+
384
554
  print_server_info(
385
555
  "⚡ Starting development server with static assets...",
386
- [
387
- "Generating React on Rails packs",
388
- "Using shakapacker --watch (no HMR)",
389
- "CSS extracted to separate files (no FOUC)",
390
- "Development environment (source maps, faster builds)",
391
- "Auto-recompiles on file changes"
392
- ],
556
+ features,
393
557
  route: route
394
558
  )
395
559
 
@@ -400,6 +564,10 @@ module ReactOnRails
400
564
 
401
565
  def run_development(procfile, verbose: false, route: nil)
402
566
  print_procfile_info(procfile, route: route)
567
+
568
+ # Check required services before starting
569
+ exit 1 unless ServiceChecker.check_services
570
+
403
571
  PackGenerator.generate(verbose: verbose)
404
572
  ProcessManager.ensure_procfile(procfile)
405
573
  ProcessManager.run_with_process_manager(procfile)
@@ -0,0 +1,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "English"
5
+ require "rainbow"
6
+
7
+ module ReactOnRails
8
+ module Dev
9
+ # ServiceChecker validates that required external services are running
10
+ # before starting the development server.
11
+ #
12
+ # Configuration is read from .dev-services.yml in the app root:
13
+ #
14
+ # services:
15
+ # redis:
16
+ # check_command: "redis-cli ping"
17
+ # expected_output: "PONG"
18
+ # start_command: "redis-server"
19
+ # description: "Redis (for caching and background jobs)"
20
+ # postgresql:
21
+ # check_command: "pg_isready"
22
+ # expected_output: "accepting connections"
23
+ # start_command: "pg_ctl -D /usr/local/var/postgres start"
24
+ # description: "PostgreSQL database"
25
+ #
26
+ class ServiceChecker
27
+ # Configuration file keys
28
+ CONFIG_KEYS = {
29
+ services: "services",
30
+ check_command: "check_command",
31
+ expected_output: "expected_output",
32
+ start_command: "start_command",
33
+ install_hint: "install_hint",
34
+ description: "description"
35
+ }.freeze
36
+
37
+ class << self
38
+ # Check all required services and provide helpful output
39
+ #
40
+ # @param config_path [String] Path to .dev-services.yml (default: ./.dev-services.yml)
41
+ # @return [Boolean] true if all services are running or no config exists
42
+ def check_services(config_path: ".dev-services.yml")
43
+ return true unless File.exist?(config_path)
44
+
45
+ config = load_config(config_path)
46
+ return true unless config_has_services?(config)
47
+
48
+ check_and_report_services(config, config_path)
49
+ end
50
+
51
+ private
52
+
53
+ def config_has_services?(config)
54
+ config &&
55
+ config[CONFIG_KEYS[:services]].is_a?(Hash) &&
56
+ !config[CONFIG_KEYS[:services]].empty?
57
+ end
58
+
59
+ def check_and_report_services(config, config_path)
60
+ print_services_header(config_path)
61
+
62
+ failures = collect_service_failures(config[CONFIG_KEYS[:services]])
63
+
64
+ report_results(failures)
65
+ end
66
+
67
+ def collect_service_failures(services)
68
+ failures = []
69
+
70
+ services.each do |name, service_config|
71
+ if check_service(name, service_config)
72
+ print_service_ok(name, service_config[CONFIG_KEYS[:description]])
73
+ else
74
+ failures << { name: name, config: service_config }
75
+ print_service_failed(name, service_config[CONFIG_KEYS[:description]])
76
+ end
77
+ end
78
+
79
+ failures
80
+ end
81
+
82
+ def report_results(failures)
83
+ if failures.empty?
84
+ print_all_services_ok
85
+ true
86
+ else
87
+ print_failures_summary(failures)
88
+ false
89
+ end
90
+ end
91
+
92
+ def load_config(config_path)
93
+ YAML.load_file(config_path)
94
+ rescue StandardError => e
95
+ puts Rainbow("⚠️ Failed to load #{config_path}: #{e.message}").yellow
96
+ puts Rainbow(" Continuing without service checks...").yellow
97
+ puts ""
98
+ nil
99
+ end
100
+
101
+ def check_service(_name, config)
102
+ check_command = config[CONFIG_KEYS[:check_command]]
103
+ expected_output = config[CONFIG_KEYS[:expected_output]]
104
+
105
+ return false if check_command.nil?
106
+
107
+ output, status = run_check_command(check_command)
108
+
109
+ return false if status.nil?
110
+
111
+ return status.success? if expected_output.nil?
112
+
113
+ # Safe nil check for output before calling include?
114
+ status.success? && output&.include?(expected_output)
115
+ end
116
+
117
+ def run_check_command(command)
118
+ require "open3"
119
+ # Execute command as-is. Commands are from local .dev-services.yml config file
120
+ # which should be trusted. Shell metacharacters won't work as expected since
121
+ # Open3.capture3 doesn't invoke a shell by default for simple command strings.
122
+ stdout, stderr, status = Open3.capture3(command, err: %i[child out])
123
+ output = stdout + stderr
124
+ [output, status]
125
+ rescue Errno::ENOENT
126
+ # Command not found - service is not available
127
+ ["", nil]
128
+ rescue ArgumentError => e
129
+ # Invalid command format
130
+ warn "Invalid command format: #{e.message}" if ENV["DEBUG"]
131
+ ["", nil]
132
+ rescue StandardError => e
133
+ # Log unexpected errors for debugging
134
+ warn "Unexpected error checking service: #{e.message}" if ENV["DEBUG"]
135
+ ["", nil]
136
+ end
137
+
138
+ def print_services_header(config_path)
139
+ puts ""
140
+ puts Rainbow("🔍 Checking required services (#{config_path})...").cyan.bold
141
+ puts ""
142
+ end
143
+
144
+ def print_service_ok(name, description)
145
+ desc = description ? " - #{description}" : ""
146
+ puts " #{Rainbow('✓').green} #{name}#{desc}"
147
+ end
148
+
149
+ def print_service_failed(name, description)
150
+ desc = description ? " - #{description}" : ""
151
+ puts " #{Rainbow('✗').red} #{name}#{desc}"
152
+ end
153
+
154
+ def print_all_services_ok
155
+ puts ""
156
+ puts Rainbow("✅ All services are running").green.bold
157
+ puts ""
158
+ end
159
+
160
+ # rubocop:disable Metrics/AbcSize
161
+ def print_failures_summary(failures)
162
+ puts ""
163
+ puts Rainbow("❌ Some services are not running").red.bold
164
+ puts ""
165
+ puts Rainbow("Please start these services before running bin/dev:").yellow
166
+ puts ""
167
+
168
+ failures.each do |failure|
169
+ name = failure[:name]
170
+ config = failure[:config]
171
+ description = config[CONFIG_KEYS[:description]] || name
172
+
173
+ puts Rainbow(name.to_s).cyan.bold
174
+ puts " #{description}" if config[CONFIG_KEYS[:description]]
175
+
176
+ if config[CONFIG_KEYS[:start_command]]
177
+ puts ""
178
+ puts " #{Rainbow('To start:').yellow}"
179
+ puts " #{Rainbow(config[CONFIG_KEYS[:start_command]]).green}"
180
+ end
181
+
182
+ if config[CONFIG_KEYS[:install_hint]]
183
+ puts ""
184
+ puts " #{Rainbow('Not installed?').yellow} #{config[CONFIG_KEYS[:install_hint]]}"
185
+ end
186
+
187
+ puts ""
188
+ end
189
+
190
+ puts Rainbow("💡 Tips:").blue.bold
191
+ puts " • Start services manually, then run bin/dev again"
192
+ puts " • Or remove service from .dev-services.yml if not needed"
193
+ puts " • Or add service to Procfile.dev to start automatically"
194
+ puts ""
195
+ end
196
+ # rubocop:enable Metrics/AbcSize
197
+ end
198
+ end
199
+ end
200
+ end