stable-cli-rails 0.6.9 → 0.7.11

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.
data/lib/stable/cli.rb CHANGED
@@ -10,12 +10,10 @@ require_relative 'registry'
10
10
 
11
11
  module Stable
12
12
  class CLI < Thor
13
- HOSTS_FILE = '/etc/hosts'
14
-
15
13
  def initialize(*)
16
14
  super
17
15
  Stable::Bootstrap.run!
18
- ensure_dependencies!
16
+ Services::SetupRunner.ensure_dependencies!
19
17
  dedupe_registry!
20
18
  end
21
19
 
@@ -32,174 +30,13 @@ module Stable
32
30
  method_option :postgres, type: :boolean, default: false, desc: 'Use Postgres for the database'
33
31
  method_option :mysql, type: :boolean, default: false, desc: 'Use MySQL for the database'
34
32
  def new(name, ruby: RUBY_VERSION, rails: nil, port: nil)
35
- port ||= next_free_port
36
- app_path = File.expand_path(name)
37
-
38
- # --- Add app to registry ---
39
- domain = "#{name}.test"
40
- apps = Registry.apps
41
-
42
- app = {
43
- name: name,
44
- path: app_path,
45
- domain: domain,
46
- port: port,
47
- ruby: ruby,
48
- started_at: nil
49
- }
50
-
51
- apps.reject! { |a| a[:name] == name }
52
- apps << app
53
-
54
- abort "Folder already exists: #{app_path}" if File.exist?(app_path)
55
-
56
- # --- Ensure RVM and Ruby ---
57
- ensure_rvm!
58
- puts "Using Ruby #{ruby} with RVM gemset #{name}..."
59
- system("bash -lc 'rvm #{ruby}@#{name} --create do true'") or abort("Failed to create RVM gemset #{name}")
60
- ruby_cmd = rvm_exec(app, ruby)
61
- # --- Install Rails in gemset if needed ---
62
- rails_version = rails || 'latest'
63
- rails_check = system("bash -lc '#{ruby_cmd} gem list -i rails#{rails ? " -v #{rails}" : ''}'")
64
- unless rails_check
65
- puts "Installing Rails #{rails_version} in gemset..."
66
- system("bash -lc '#{ruby_cmd} gem install rails #{rails ? "-v #{rails}" : ''}'") or abort('Failed to install Rails')
67
- end
68
-
69
- # --- Create Rails app ---
70
- puts "Creating Rails app #{name} (Ruby #{ruby})..."
71
- system("bash -lc '#{ruby_cmd} rails new #{app_path}'") or abort('Rails app creation failed')
72
-
73
- # --- Add .ruby-version and .ruby-gemset ---
74
- Dir.chdir(app_path) do
75
- File.write('.ruby-version', "#{ruby}\n")
76
- File.write('.ruby-gemset', "#{name}\n")
77
-
78
- # --- Install gems inside gemset ---
79
- puts 'Running bundle install...'
80
- system("bash -lc '#{ruby_cmd} bundle install --jobs=4 --retry=3'") or abort('bundle install failed')
81
- end
82
-
83
- # --- Database integration ---
84
- if options[:db]
85
- adapter = if options[:postgres]
86
- :postgresql
87
- elsif options[:mysql]
88
- :mysql
89
- else
90
- :postgresql
91
- end
92
-
93
- adapter == :postgresql ? 'postgresql' : 'mysql2'
94
- gem_name = adapter == :postgresql ? 'pg' : 'mysql2'
95
-
96
- gemfile_path = File.join(app_path, 'Gemfile')
97
- unless File.read(gemfile_path).include?(gem_name)
98
- File.open(gemfile_path, 'a') do |f|
99
- f.puts "\n# Added by Stable CLI"
100
- f.puts "gem '#{gem_name}'"
101
- end
102
- puts "✅ Added '#{gem_name}' gem to Gemfile"
103
- end
104
-
105
- # Ensure the gem is installed inside the gemset
106
- system("bash -lc 'cd #{app_path} && rvm #{ruby}@#{name} do bundle install --jobs=4 --retry=3'") or abort('bundle install failed')
107
-
108
- # --- Database setup ---
109
- db = Stable::DBManager.new(options[:db], adapter: adapter)
110
- creds = []
111
-
112
- case adapter
113
- when :postgresql
114
- db.create
115
- when :mysql
116
- creds = create_mysql_db(options[:db]) # creates DB and returns creds
117
- # Make sure mysql2 gem is loaded for Rails
118
- system("bash -lc 'cd #{app_path} && rvm #{ruby}@#{name} do bundle exec gem list | grep mysql2'") || abort('mysql2 gem not found in gemset')
119
- end
120
-
121
- # --- Generate database.yml ---
122
- db_config_path = File.join(app_path, 'config', 'database.yml')
123
- base_config = {
124
- 'adapter' => adapter == :postgresql ? 'postgresql' : 'mysql2',
125
- 'encoding' => adapter == :postgresql ? 'unicode' : 'utf8mb4',
126
- 'pool' => 5,
127
- 'database' => options[:db],
128
- 'username' => adapter == :mysql ? creds[:user] : nil,
129
- 'password' => adapter == :mysql ? creds[:password] : nil,
130
- 'host' => 'localhost',
131
- 'auth_plugin' => adapter == :mysql ? 'caching_sha2_password' : ''
132
- }
133
-
134
- db_configs = {
135
- 'default' => base_config,
136
- 'development' => base_config,
137
- 'test' => base_config.merge('database' => "#{options[:db]}_test"),
138
- 'production' => base_config.merge('database' => "#{options[:db]}_prod")
139
- }
140
-
141
- File.write(db_config_path, db_configs.to_yaml)
142
- puts "✅ Database '#{db.name}' configured in Rails app"
143
-
144
- # --- Prepare the database ---
145
- puts 'Preparing database...'
146
- system("bash -lc 'cd #{app_path} && rvm #{ruby}@#{name} do bundle exec rails db:prepare'") or abort('Database preparation failed')
147
- end
148
-
149
- # --- Generate Caddyfile ---
150
-
151
- puts 'Refreshing bundle environment...'
152
- system(
153
- "bash -lc 'cd #{app_path} && #{ruby_cmd} bundle check || #{ruby_cmd} bundle install'"
154
- ) or abort('Bundler setup failed')
155
-
156
- puts 'Preparing database...'
157
- system(
158
- "bash -lc 'cd #{app_path} && #{ruby_cmd} bundle exec rails db:prepare'"
159
- ) or abort('Database preparation failed')
160
-
161
- # --- Host entry & certificate ---
162
- add_host_entry(domain)
163
- generate_cert(domain) unless options[:skip_ssl]
164
- update_caddyfile(domain, port)
165
- ensure_caddy_running!
166
- caddy_reload
167
-
168
- # --- Start Rails server ---
169
- puts "Starting Rails server for #{name} on port #{port}..."
170
- log_file = File.join(app_path, 'log', 'stable.log')
171
- FileUtils.mkdir_p(File.dirname(log_file))
172
-
173
- abort "Port #{port} is already in use. Choose another port." if app_running?({ port: port })
174
-
175
- pid = spawn(
176
- 'bash',
177
- '-lc',
178
- "cd #{app_path} && #{ruby_cmd} bundle exec rails s -p #{port} -b 127.0.0.1",
179
- out: log_file,
180
- err: log_file
181
- )
182
- Process.detach(pid)
183
-
184
- app[:started_at] = Time.now.to_i
185
- Registry.save(apps)
186
-
187
- sleep 1.5
188
-
189
- wait_for_port(port)
190
- puts "✔ #{name} running at https://#{domain}"
33
+ safe_name = Validators::AppName.call!(name)
34
+ Commands::New.new(safe_name, options).call
191
35
  end
192
36
 
193
37
  desc 'list', 'List detected apps'
194
38
  def list
195
- apps = Registry.apps
196
- if apps.empty?
197
- puts 'No apps found.'
198
- else
199
- apps.each do |app|
200
- puts "#{app[:name]} -> https://#{app[:domain]}"
201
- end
202
- end
39
+ Commands::List.new.call
203
40
  end
204
41
 
205
42
  desc 'add FOLDER', 'Add a Rails app folder'
@@ -212,175 +49,90 @@ module Stable
212
49
 
213
50
  puts "Detected gemset: #{File.read('.ruby-gemset').strip}" if File.exist?('.ruby-gemset')
214
51
 
215
- apps = Registry.apps
216
52
  name = File.basename(folder)
217
53
  domain = "#{name}.test"
218
54
 
219
- if apps.any? { |a| a[:path] == folder }
55
+ if Services::AppRegistry.all.any? { |a| a[:path] == folder }
220
56
  puts "App already exists: #{name}"
221
57
  return
222
58
  end
223
59
 
224
60
  port = next_free_port
225
- ruby = detect_ruby_version(folder)
61
+ ruby = Stable::Services::Ruby.detect_ruby_version(folder)
226
62
 
227
- apps << { name: name, path: folder, domain: domain, port: port, ruby: ruby }
228
- Registry.save(apps)
63
+ app = { name: name, path: folder, domain: domain, port: port, ruby: ruby }
64
+ Services::AppRegistry.add_app(app)
229
65
  puts "Added #{name} -> https://#{domain} (port #{port})"
230
66
 
231
- add_host_entry(domain)
232
- generate_cert(domain)
233
- update_caddyfile(domain, port)
234
- caddy_reload
67
+ Services::HostsManager.add(domain)
68
+ Services::CaddyManager.add_app(name, skip_ssl: options[:skip_ssl])
69
+ Services::CaddyManager.reload
235
70
  end
236
71
 
237
72
  desc 'remove NAME', 'Remove an app by name'
238
73
  def remove(name)
239
- apps = Registry.apps
240
- app = apps.find { |a| a[:name] == name }
241
- if app.nil?
242
- puts "No app found with name #{name}"
243
- return
244
- end
245
-
246
- new_apps = apps.reject { |a| a[:name] == name }
247
- Registry.save(new_apps)
248
- puts "Removed #{name}"
249
-
250
- remove_host_entry(app[:domain])
251
- remove_caddy_entry(app[:domain])
252
- caddy_reload
74
+ Commands::Remove.new(name).call
253
75
  end
254
76
 
255
77
  desc 'start NAME', 'Start a Rails app with its correct Ruby version'
256
78
  def start(name)
257
- app = Registry.apps.find { |a| a[:name] == name }
258
- return puts("No app found with name #{name}") unless app
259
-
260
- port = app[:port] || next_free_port
261
- ruby = app[:ruby]
262
- path = app[:path]
263
-
264
- if app_running?(app)
265
- puts "#{name} is already running on https://#{app[:domain]} (port #{port})"
266
- return
267
- end
268
-
269
- gemset = gemset_for(app)
270
-
271
- rvm_cmd =
272
- if ruby && gemset
273
- system("bash -lc 'rvm #{ruby}@#{gemset} --create do true'")
274
- "rvm #{ruby}@#{gemset} do"
275
- elsif ruby
276
- "rvm #{ruby} do"
277
- end
278
-
279
- puts "Starting #{name} on port #{port}..."
280
-
281
- log_file = File.join(path, 'log', 'stable.log')
282
- FileUtils.mkdir_p(File.dirname(log_file))
283
-
284
- pid = spawn(
285
- 'bash',
286
- '-lc',
287
- <<~CMD,
288
- cd "#{path}"
289
- #{rvm_cmd} bundle exec rails s \
290
- -p #{port} \
291
- -b 127.0.0.1
292
- CMD
293
- out: log_file,
294
- err: log_file
295
- )
296
-
297
- Process.detach(pid)
298
-
299
- wait_for_port(port, timeout: 30)
300
-
301
- app[:started_at] = Time.now.to_i
302
- Registry.save(Registry.apps)
303
-
304
- generate_cert(app[:domain])
305
- update_caddyfile(app[:domain], port)
306
- caddy_reload
79
+ Commands::Start.new(name).call
80
+ end
307
81
 
308
- puts "#{name} started on https://#{app[:domain]}"
82
+ desc 'restart NAME', 'Restart a Rails app'
83
+ def restart(name)
84
+ Commands::Restart.new(name).call
309
85
  end
310
86
 
311
87
  desc 'stop NAME', 'Stop a Rails app (default port 3000)'
312
88
  def stop(name)
313
- app = Registry.apps.find { |a| a[:name] == name }
314
-
315
- output = `lsof -i tcp:#{app[:port]} -t`.strip
316
- if output.empty?
317
- puts "No app running on port #{app[:port]}"
318
- else
319
- output.split("\n").each { |pid| Process.kill('TERM', pid.to_i) }
320
- puts "Stopped #{name}"
321
- end
89
+ Commands::Stop.new(name).call
322
90
  end
323
91
 
324
92
  desc 'setup', 'Sets up Caddy and local trusted certificates'
325
93
  def setup
326
- FileUtils.mkdir_p(Stable::Paths.root)
327
- File.write(Stable::Paths.caddyfile, '') unless File.exist?(Stable::Paths.caddyfile)
328
- ensure_caddy_running!
329
- puts "Caddy home initialized at #{Stable::Paths.root}"
94
+ Commands::Setup.new.call
330
95
  end
331
96
 
332
97
  desc 'caddy reload', 'Reloads Caddy after adding/removing apps'
333
98
  def caddy_reload
334
- if system('which caddy > /dev/null')
335
- system("caddy reload --config #{Stable::Paths.caddyfile}")
336
- puts 'Caddy reloaded'
337
- else
338
- puts 'Caddy not found. Install Caddy first.'
339
- end
99
+ Services::CaddyManager.reload
100
+ puts 'Caddy reloaded'
340
101
  end
341
102
 
342
103
  desc 'secure DOMAIN', 'Generate trusted local HTTPS cert for a specific folder/domain'
343
104
  def secure(domain)
344
- app = Registry.apps.find { |a| a[:domain] == domain }
105
+ apps = Services::AppRegistry.all
106
+ app = apps.find { |a| a[:domain] == domain }
107
+ app ||= apps.find { |a| a[:name] == domain }
108
+ app ||= apps.find { |a| a[:domain] == "#{domain}.test" }
109
+
345
110
  unless app
346
111
  puts "No app found with domain #{domain}"
347
112
  return
348
113
  end
349
- secure_app(domain, app[:path], app[:port])
350
- caddy_reload
351
- puts "Secured https://#{domain}"
114
+
115
+ Services::CaddyManager.add_app(app[:name], skip_ssl: true)
116
+ Services::CaddyManager.reload
117
+ puts "Secured https://#{app[:domain]}"
352
118
  end
353
119
 
354
120
  desc 'doctor', 'Check Stable system health'
355
121
  def doctor
356
- puts "Stable doctor\n\n"
357
-
358
- puts "Ruby version: #{RUBY_VERSION}"
359
- puts "RVM: #{rvm_available? ? 'yes' : 'no'}"
360
- puts "rbenv: #{rbenv_available? ? 'yes' : 'no'}"
361
- puts "Caddy: #{system('which caddy > /dev/null') ? 'yes' : 'no'}"
362
- puts "mkcert: #{system('which mkcert > /dev/null') ? 'yes' : 'no'}"
363
-
364
- Registry.apps.each do |app|
365
- state = boot_state(app)
366
- ruby = app[:ruby] || 'default'
367
- port = app[:port]
368
-
369
- puts "#{app[:name]} → Ruby #{ruby} | port #{port} | #{state}"
370
- end
122
+ Commands::Doctor.new.call
371
123
  end
372
124
 
373
125
  desc 'upgrade-ruby NAME VERSION', 'Upgrade Ruby for an app'
374
126
  def upgrade_ruby(name, version)
375
- app = Registry.apps.find { |a| a[:name] == name }
127
+ app = Services::AppRegistry.find(name)
376
128
  unless app
377
129
  puts "No app named #{name}"
378
130
  return
379
131
  end
380
132
 
381
- if rvm_available?
133
+ if Stable::Services::Ruby.rvm_available?
382
134
  system("bash -lc 'rvm install #{version}'")
383
- elsif rbenv_available?
135
+ elsif Stable::Services::Ruby.rbenv_available?
384
136
  system("rbenv install #{version}")
385
137
  else
386
138
  puts 'No Ruby version manager found'
@@ -388,161 +140,15 @@ module Stable
388
140
  end
389
141
 
390
142
  File.write(File.join(app[:path], '.ruby-version'), version)
391
- app[:ruby] = version
392
- Registry.save(Registry.apps)
143
+ Services::AppRegistry.update(name, ruby: version)
393
144
 
394
145
  puts "#{name} now uses Ruby #{version}"
395
146
  end
396
147
 
397
148
  private
398
149
 
399
- def add_host_entry(domain)
400
- entry = "127.0.0.1\t#{domain}"
401
- hosts = File.read(HOSTS_FILE)
402
- unless hosts.include?(domain)
403
- puts "Adding #{domain} to #{HOSTS_FILE}..."
404
- File.open(HOSTS_FILE, 'a') { |f| f.puts entry }
405
- system('dscacheutil -flushcache; sudo killall -HUP mDNSResponder')
406
- end
407
- rescue Errno::EACCES
408
- ensure_hosts_entry(domain)
409
- end
410
-
411
- def remove_host_entry(domain)
412
- hosts = File.read(HOSTS_FILE)
413
- new_hosts = hosts.lines.reject { |line| line.include?(domain) }.join
414
- File.write(HOSTS_FILE, new_hosts)
415
- system('dscacheutil -flushcache; sudo killall -HUP mDNSResponder')
416
- rescue Errno::EACCES
417
- puts "Permission denied updating #{HOSTS_FILE}. Run 'sudo stable remove #{domain}' to remove hosts entry."
418
- end
419
-
420
- def ensure_hosts_entry(domain)
421
- entry = "127.0.0.1\t#{domain}"
422
-
423
- hosts = File.read(HOSTS_FILE)
424
- return if hosts.include?(domain)
425
-
426
- if Process.uid.zero?
427
- File.open(HOSTS_FILE, 'a') { |f| f.puts entry }
428
- else
429
- system(%(echo "#{entry}" | sudo tee -a #{HOSTS_FILE} > /dev/null))
430
- end
431
-
432
- system('dscacheutil -flushcache; sudo killall -HUP mDNSResponder')
433
- end
434
-
435
- def secure_app(domain, _folder, port)
436
- ensure_certs_dir!
437
-
438
- cert_path = File.join(Stable::Paths.certs_dir, "#{domain}.pem")
439
- key_path = File.join(Stable::Paths.certs_dir, "#{domain}-key.pem")
440
-
441
- # Generate certificates if missing
442
- if system('which mkcert > /dev/null')
443
- unless File.exist?(cert_path) && File.exist?(key_path)
444
- system("mkcert -cert-file #{cert_path} -key-file #{key_path} #{domain}")
445
- end
446
- else
447
- puts 'mkcert not found. Please install mkcert.'
448
- return
449
- end
450
-
451
- # Auto-add Caddy block if not already in Caddyfile
452
- add_caddy_block(domain, cert_path, key_path, port)
453
- caddy_reload
454
- end
455
-
456
- def add_caddy_block(domain, cert, key, port)
457
- caddyfile = Stable::Paths.caddyfile
458
- FileUtils.touch(caddyfile) unless File.exist?(caddyfile)
459
- content = File.read(caddyfile)
460
-
461
- return if content.include?(domain) # don't duplicate
462
-
463
- block = <<~CADDY
464
-
465
- https://#{domain} {
466
- reverse_proxy 127.0.0.1:#{port}
467
- tls #{cert} #{key}
468
- }
469
- CADDY
470
-
471
- File.write(caddyfile, content + block)
472
- system("caddy fmt --overwrite #{caddyfile}")
473
- end
474
-
475
- # Remove Caddyfile entry for the domain
476
- def remove_caddy_entry(domain)
477
- return unless File.exist?(Stable::Paths.caddyfile)
478
-
479
- content = File.read(Stable::Paths.caddyfile)
480
- # Remove block starting with https://<domain> { ... }
481
- regex = %r{
482
- https://#{Regexp.escape(domain)}\s*\{
483
- .*?
484
- \}
485
- }mx
486
-
487
- new_content = content.gsub(regex, '')
488
-
489
- File.write(Stable::Paths.caddyfile, new_content)
490
- end
491
-
492
- def ensure_dependencies!
493
- unless system('which brew > /dev/null')
494
- puts 'Homebrew is required. Install it first: https://brew.sh'
495
- exit 1
496
- end
497
-
498
- # --- Install Caddy ---
499
- unless system('which caddy > /dev/null')
500
- puts 'Installing Caddy...'
501
- system('brew install caddy')
502
- end
503
-
504
- # --- Install mkcert ---
505
- unless system('which mkcert > /dev/null')
506
- puts 'Installing mkcert...'
507
- system('brew install mkcert nss')
508
- system('mkcert -install')
509
- end
510
-
511
- # --- Install PostgreSQL ---
512
- unless system('which psql > /dev/null')
513
- puts 'Installing PostgreSQL...'
514
- system('brew install postgresql')
515
- system('brew services start postgresql')
516
- end
517
-
518
- # --- Install MySQL ---
519
- unless system('which mysql > /dev/null')
520
- puts 'Installing MySQL...'
521
- system('brew install mysql')
522
- system('brew services start mysql')
523
- end
524
-
525
- puts '✅ All dependencies are installed and running.'
526
- end
527
-
528
-
529
- def ensure_caddy_running!
530
- api_port = 2019
531
-
532
- # Check if Caddy API is reachable
533
- require 'socket'
534
- begin
535
- TCPSocket.new('127.0.0.1', api_port).close
536
- puts 'Caddy already running.'
537
- rescue Errno::ECONNREFUSED
538
- puts 'Starting Caddy in background...'
539
- system("caddy run --config #{Stable::Paths.caddyfile} --adapter caddyfile --watch --resume &")
540
- sleep 3
541
- end
542
- end
543
-
544
150
  def next_free_port
545
- used_ports = Registry.apps.map { |a| a[:port] }
151
+ used_ports = Services::AppRegistry.all.map { |a| a[:port] }
546
152
  port = 3000
547
153
  port += 1 while used_ports.include?(port) || port_in_use?(port)
548
154
  port
@@ -552,207 +158,8 @@ module Stable
552
158
  system("lsof -i tcp:#{port} > /dev/null 2>&1")
553
159
  end
554
160
 
555
- def generate_cert(domain)
556
- cert_path = File.join(Stable::Paths.certs_dir, "#{domain}.pem")
557
- key_path = File.join(Stable::Paths.certs_dir, "#{domain}-key.pem")
558
- FileUtils.mkdir_p(Stable::Paths.certs_dir)
559
-
560
- return if File.exist?(cert_path) && File.exist?(key_path)
561
-
562
- if system('which mkcert > /dev/null')
563
- system("mkcert -cert-file #{cert_path} -key-file #{key_path} #{domain}")
564
- else
565
- puts 'mkcert not found. Please install mkcert.'
566
- end
567
- end
568
-
569
- def update_caddyfile(domain, port)
570
- caddyfile = Stable::Paths.caddyfile
571
- FileUtils.touch(caddyfile) unless File.exist?(caddyfile)
572
- content = File.read(caddyfile)
573
-
574
- # remove existing block for domain
575
- regex = %r{
576
- https://#{Regexp.escape(domain)}\s*\{
577
- .*?
578
- \}
579
- }mx
580
-
581
- content = content.gsub(regex, '')
582
-
583
- # add new block
584
- cert_path = File.join(Stable::Paths.certs_dir, "#{domain}.pem")
585
- key_path = File.join(Stable::Paths.certs_dir, "#{domain}-key.pem")
586
- block = <<~CADDY
587
-
588
- https://#{domain} {
589
- reverse_proxy 127.0.0.1:#{port}
590
- tls #{cert_path} #{key_path}
591
- }
592
- CADDY
593
-
594
- File.write(caddyfile, content + block)
595
- system("caddy fmt --overwrite #{caddyfile}")
596
- end
597
-
598
- def ensure_certs_dir!
599
- certs_dir = Stable::Paths.certs_dir
600
- FileUtils.mkdir_p(certs_dir)
601
-
602
- begin
603
- FileUtils.chown_R(Etc.getlogin, nil, certs_dir)
604
- rescue StandardError => e
605
- puts "Could not change ownership: #{e.message}"
606
- end
607
-
608
- # Restrict permissions for security
609
- Dir.glob("#{certs_dir}/*.pem").each do |pem|
610
- FileUtils.chmod(0o600, pem)
611
- end
612
- end
613
-
614
- def wait_for_port(port, timeout: 20)
615
- require 'socket'
616
- start = Time.now
617
-
618
- loop do
619
- TCPSocket.new('127.0.0.1', port).close
620
- return
621
- rescue Errno::ECONNREFUSED
622
- raise "Rails never bound port #{port}. Check log/stable.log" if Time.now - start > timeout
623
-
624
- sleep 0.5
625
- end
626
- end
627
-
628
- def ensure_rvm!
629
- return if system('which rvm > /dev/null')
630
-
631
- puts 'RVM not found. Installing RVM...'
632
-
633
- install_cmd = <<~CMD
634
- curl -sSL https://get.rvm.io | bash -s stable
635
- CMD
636
-
637
- abort 'RVM installation failed' unless system(install_cmd)
638
-
639
- # Load RVM into current process
640
- rvm_script = File.expand_path('~/.rvm/scripts/rvm')
641
- abort 'RVM installed but could not be loaded' unless File.exist?(rvm_script)
642
-
643
- ENV['PATH'] = "#{File.expand_path('~/.rvm/bin')}:#{ENV['PATH']}"
644
-
645
- system(%(bash -lc "source #{rvm_script} && rvm --version")) ||
646
- abort('RVM installed but not functional')
647
- end
648
-
649
- def ensure_ruby_installed!(version)
650
- return if system("rvm list strings | grep ruby-#{version} > /dev/null")
651
-
652
- puts "Installing Ruby #{version}..."
653
- system("rvm install #{version}") || abort("Failed to install Ruby #{version}")
654
- end
655
-
656
- def detect_ruby_version(path)
657
- rv = File.join(path, '.ruby-version')
658
- return File.read(rv).strip if File.exist?(rv)
659
-
660
- gemfile = File.join(path, 'Gemfile')
661
- if File.exist?(gemfile)
662
- ruby_line = File.read(gemfile)[/^ruby ['"](.+?)['"]/, 1]
663
- return ruby_line if ruby_line
664
- end
665
-
666
- nil
667
- end
668
-
669
- def rvm_available?
670
- system("bash -lc 'command -v rvm > /dev/null'")
671
- end
672
-
673
- def rbenv_available?
674
- system('command -v rbenv > /dev/null')
675
- end
676
-
677
- def ensure_rvm_ruby!(version)
678
- system("bash -lc 'rvm list strings | grep -q #{version} || rvm install #{version}'")
679
- end
680
-
681
- def ensure_rbenv_ruby!(version)
682
- system("rbenv versions | grep -q #{version} || rbenv install #{version}")
683
- end
684
-
685
- def app_running?(app)
686
- return false unless app && app[:port]
687
-
688
- system("lsof -i tcp:#{app[:port]} -sTCP:LISTEN > /dev/null 2>&1")
689
- end
690
-
691
- def boot_state(app)
692
- return 'stopped' unless app_running?(app)
693
-
694
- if app[:started_at]
695
- elapsed = Time.now.to_i - app[:started_at]
696
- return "booting (#{elapsed}s)" if elapsed < 10
697
- end
698
-
699
- 'running'
700
- end
701
-
702
161
  def dedupe_registry!
703
- apps = Registry.apps
704
- apps.uniq! { |a| a[:name] }
705
- Registry.save(apps)
706
- end
707
-
708
- def gemset_for(app)
709
- gemset_file = File.join(app[:path], '.ruby-gemset')
710
- return File.read(gemset_file).strip if File.exist?(gemset_file)
711
-
712
- nil
713
- end
714
-
715
- def rvm_exec(app, ruby)
716
- gemset = gemset_for(app)
717
-
718
- if gemset
719
- "rvm #{ruby}@#{gemset} do"
720
- else
721
- "rvm #{ruby} do"
722
- end
723
- end
724
-
725
- def create_mysql_db(db_name)
726
- socket = %w[
727
- /opt/homebrew/var/mysql/mysql.sock
728
- /tmp/mysql.sock
729
- ].find { |path| File.exist?(path) } || abort('MySQL socket not found')
730
-
731
- print 'Enter MySQL root username (default: root): '
732
- root_user = $stdin.gets.chomp
733
- root_user = 'root' if root_user.empty?
734
-
735
- print 'Enter MySQL root password (leave blank if none): '
736
- root_password = $stdin.noecho(&:gets).chomp
737
- puts
738
-
739
- password_arg = root_password.empty? ? '' : "-p#{root_password}"
740
-
741
- sql = <<~SQL
742
- CREATE DATABASE IF NOT EXISTS #{db_name};
743
- FLUSH PRIVILEGES;
744
- SQL
745
-
746
- require 'tempfile'
747
- Tempfile.create(['db_setup', '.sql']) do |file|
748
- file.write(sql)
749
- file.flush
750
- system("mysql --protocol=SOCKET --socket=#{socket} -u #{root_user} #{password_arg} < #{file.path}") or
751
- abort("Failed to create MySQL DB '#{db_name}'")
752
- end
753
-
754
- puts "✅ MySQL database '#{db_name}' created/ready"
755
- { user: root_user, password: root_password }
162
+ Services::AppRegistry.dedupe
756
163
  end
757
164
  end
758
165
  end