stable-cli-rails 0.6.7 → 0.6.9

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d85bcf8eb4e71a8dc2c0bdb7e77fd053f844c81160f5c8cdc4d6fa42194b1275
4
- data.tar.gz: 0465bfad22e484c444f3c0564e54e135d7f2c83e93996af0085e9f3aa8cf1f6a
3
+ metadata.gz: 2d0788683f7e8f99ac96bf00779599a7003e7c2f866a5a124a0fbbbca56c7a1a
4
+ data.tar.gz: 2c4d7c5ae3f0ee878e9f8c85660df787c340782eef1a1ea3c9c60460180c3259
5
5
  SHA512:
6
- metadata.gz: 7dfdd1728588725ffdc0fda92cb367fb53323f5e6683c2c78c2b10a9ce06fef8ca5df494a11868c260f3be12d88021f0d922e88add43b97fd91a32e56432c342
7
- data.tar.gz: e8e9f9cf95140ec1090130c059ebfca7087a2377136bc306a304918d0e35b1e25a3dd4c448cd99cce5a8e0eec22766298586d16ce463f2b28c6de678f1ed8d92
6
+ metadata.gz: ad3f5ba69aca2cca188f8a138e370a63547cf040a6ad715509d2afe7c6347fc1ed292073b2286d63dc7e446b1728a2bca29eae7db053751325fcdb9b6ac8ac2e
7
+ data.tar.gz: 02670b007fef74d07357f9bbc65526c1ac8256341f4c32c0e18cac37c0e55e45ef6d96efe16b4450f222a800b6e79f185a0f5888bb810d33423d5d9fe9ca61fd
data/lib/stable/cli.rb CHANGED
@@ -2,7 +2,9 @@
2
2
 
3
3
  require 'thor'
4
4
  require 'etc'
5
+ require 'tempfile'
5
6
  require 'fileutils'
7
+ require 'io/console'
6
8
  require_relative 'scanner'
7
9
  require_relative 'registry'
8
10
 
@@ -14,6 +16,7 @@ module Stable
14
16
  super
15
17
  Stable::Bootstrap.run!
16
18
  ensure_dependencies!
19
+ dedupe_registry!
17
20
  end
18
21
 
19
22
  def self.exit_on_failure?
@@ -25,28 +28,47 @@ module Stable
25
28
  method_option :rails, type: :string, desc: 'Rails version to install (optional)'
26
29
  method_option :port, type: :numeric, desc: 'Port to run Rails app on'
27
30
  method_option :skip_ssl, type: :boolean, default: false, desc: 'Skip HTTPS setup'
31
+ method_option :db, type: :string, desc: 'Database name to create and integrate'
32
+ method_option :postgres, type: :boolean, default: false, desc: 'Use Postgres for the database'
33
+ method_option :mysql, type: :boolean, default: false, desc: 'Use MySQL for the database'
28
34
  def new(name, ruby: RUBY_VERSION, rails: nil, port: nil)
29
35
  port ||= next_free_port
30
36
  app_path = File.expand_path(name)
31
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
+
32
54
  abort "Folder already exists: #{app_path}" if File.exist?(app_path)
33
55
 
34
56
  # --- Ensure RVM and Ruby ---
35
57
  ensure_rvm!
36
58
  puts "Using Ruby #{ruby} with RVM gemset #{name}..."
37
- system("bash -lc 'rvm #{ruby}@#{name} --create do true'") or abort('Failed to create RVM gemset')
38
-
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)
39
61
  # --- Install Rails in gemset if needed ---
40
62
  rails_version = rails || 'latest'
41
- rails_check = system("bash -lc 'rvm #{ruby}@#{name} do gem list -i rails#{rails ? " -v #{rails}" : ''}'")
63
+ rails_check = system("bash -lc '#{ruby_cmd} gem list -i rails#{rails ? " -v #{rails}" : ''}'")
42
64
  unless rails_check
43
65
  puts "Installing Rails #{rails_version} in gemset..."
44
- system("bash -lc 'rvm #{ruby}@#{name} do gem install rails #{rails ? "-v #{rails}" : ''}'") or abort('Failed to install Rails')
66
+ system("bash -lc '#{ruby_cmd} gem install rails #{rails ? "-v #{rails}" : ''}'") or abort('Failed to install Rails')
45
67
  end
46
68
 
47
69
  # --- Create Rails app ---
48
70
  puts "Creating Rails app #{name} (Ruby #{ruby})..."
49
- system("bash -lc 'rvm #{ruby}@#{name} do rails new #{app_path}'") or abort('Rails app creation failed')
71
+ system("bash -lc '#{ruby_cmd} rails new #{app_path}'") or abort('Rails app creation failed')
50
72
 
51
73
  # --- Add .ruby-version and .ruby-gemset ---
52
74
  Dir.chdir(app_path) do
@@ -55,14 +77,86 @@ module Stable
55
77
 
56
78
  # --- Install gems inside gemset ---
57
79
  puts 'Running bundle install...'
58
- system("bash -lc 'rvm #{ruby}@#{name} do bundle install --jobs=4 --retry=3'") or abort('bundle install failed')
80
+ system("bash -lc '#{ruby_cmd} bundle install --jobs=4 --retry=3'") or abort('bundle install failed')
59
81
  end
60
82
 
61
- # --- Add app to registry ---
62
- domain = "#{name}.test"
63
- apps = Registry.apps
64
- apps << { name: name, path: app_path, domain: domain, port: port, ruby: ruby }
65
- Registry.save(apps)
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')
66
160
 
67
161
  # --- Host entry & certificate ---
68
162
  add_host_entry(domain)
@@ -76,15 +170,20 @@ module Stable
76
170
  log_file = File.join(app_path, 'log', 'stable.log')
77
171
  FileUtils.mkdir_p(File.dirname(log_file))
78
172
 
173
+ abort "Port #{port} is already in use. Choose another port." if app_running?({ port: port })
174
+
79
175
  pid = spawn(
80
176
  'bash',
81
177
  '-lc',
82
- "cd #{app_path} && rvm #{ruby}@#{name} do bundle exec rails s -p #{port} -b 127.0.0.1",
178
+ "cd #{app_path} && #{ruby_cmd} bundle exec rails s -p #{port} -b 127.0.0.1",
83
179
  out: log_file,
84
180
  err: log_file
85
181
  )
86
182
  Process.detach(pid)
87
183
 
184
+ app[:started_at] = Time.now.to_i
185
+ Registry.save(apps)
186
+
88
187
  sleep 1.5
89
188
 
90
189
  wait_for_port(port)
@@ -111,6 +210,8 @@ module Stable
111
210
  return
112
211
  end
113
212
 
213
+ puts "Detected gemset: #{File.read('.ruby-gemset').strip}" if File.exist?('.ruby-gemset')
214
+
114
215
  apps = Registry.apps
115
216
  name = File.basename(folder)
116
217
  domain = "#{name}.test"
@@ -154,47 +255,54 @@ module Stable
154
255
  desc 'start NAME', 'Start a Rails app with its correct Ruby version'
155
256
  def start(name)
156
257
  app = Registry.apps.find { |a| a[:name] == name }
157
- unless app
158
- puts "No app found with name #{name}"
159
- return
160
- end
258
+ return puts("No app found with name #{name}") unless app
161
259
 
162
260
  port = app[:port] || next_free_port
163
261
  ruby = app[:ruby]
262
+ path = app[:path]
164
263
 
165
- puts "Starting #{name} on port #{port}#{ruby ? " (Ruby #{ruby})" : ''}..."
264
+ if app_running?(app)
265
+ puts "#{name} is already running on https://#{app[:domain]} (port #{port})"
266
+ return
267
+ end
166
268
 
167
- log_file = File.join(app[:path], 'log', 'stable.log')
168
- FileUtils.mkdir_p(File.dirname(log_file))
269
+ gemset = gemset_for(app)
169
270
 
170
- ruby_exec =
171
- if ruby
172
- if rvm_available?
173
- ensure_rvm_ruby!(ruby)
174
- "rvm #{ruby}@#{name} do"
175
- elsif rbenv_available?
176
- ensure_rbenv_ruby!(ruby)
177
- "RBENV_VERSION=#{ruby}"
178
- else
179
- puts 'No Ruby version manager found (rvm or rbenv)'
180
- return
181
- end
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"
182
277
  end
183
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
+
184
284
  pid = spawn(
185
285
  'bash',
186
286
  '-lc',
187
- "cd #{app[:path]} && rvm #{ruby}@#{name} do bundle exec rails s -p #{port} -b 127.0.0.1",
287
+ <<~CMD,
288
+ cd "#{path}"
289
+ #{rvm_cmd} bundle exec rails s \
290
+ -p #{port} \
291
+ -b 127.0.0.1
292
+ CMD
188
293
  out: log_file,
189
294
  err: log_file
190
295
  )
296
+
191
297
  Process.detach(pid)
192
298
 
193
- sleep 1.5
299
+ wait_for_port(port, timeout: 30)
300
+
301
+ app[:started_at] = Time.now.to_i
302
+ Registry.save(Registry.apps)
194
303
 
195
304
  generate_cert(app[:domain])
196
305
  update_caddyfile(app[:domain], port)
197
- wait_for_port(port)
198
306
  caddy_reload
199
307
 
200
308
  puts "#{name} started on https://#{app[:domain]}"
@@ -254,8 +362,11 @@ module Stable
254
362
  puts "mkcert: #{system('which mkcert > /dev/null') ? 'yes' : 'no'}"
255
363
 
256
364
  Registry.apps.each do |app|
257
- status = port_in_use?(app[:port]) ? 'running' : 'stopped'
258
- puts "#{app[:name]} → Ruby #{app[:ruby] || 'default'} (#{status})"
365
+ state = boot_state(app)
366
+ ruby = app[:ruby] || 'default'
367
+ port = app[:port]
368
+
369
+ puts "#{app[:name]} → Ruby #{ruby} | port #{port} | #{state}"
259
370
  end
260
371
  end
261
372
 
@@ -384,18 +495,37 @@ module Stable
384
495
  exit 1
385
496
  end
386
497
 
498
+ # --- Install Caddy ---
387
499
  unless system('which caddy > /dev/null')
388
500
  puts 'Installing Caddy...'
389
501
  system('brew install caddy')
390
502
  end
391
503
 
392
- return if system('which mkcert > /dev/null')
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
393
510
 
394
- puts 'Installing mkcert...'
395
- system('brew install mkcert nss')
396
- system('mkcert -install')
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.'
397
526
  end
398
527
 
528
+
399
529
  def ensure_caddy_running!
400
530
  api_port = 2019
401
531
 
@@ -481,16 +611,17 @@ module Stable
481
611
  end
482
612
  end
483
613
 
484
- def wait_for_port(port, timeout: 30)
614
+ def wait_for_port(port, timeout: 20)
485
615
  require 'socket'
486
- start_time = Time.now
616
+ start = Time.now
617
+
487
618
  loop do
488
619
  TCPSocket.new('127.0.0.1', port).close
489
- break
620
+ return
490
621
  rescue Errno::ECONNREFUSED
491
- raise "Timeout waiting for port #{port}" if Time.now - start_time > timeout
622
+ raise "Rails never bound port #{port}. Check log/stable.log" if Time.now - start > timeout
492
623
 
493
- sleep 0.1
624
+ sleep 0.5
494
625
  end
495
626
  end
496
627
 
@@ -550,5 +681,78 @@ module Stable
550
681
  def ensure_rbenv_ruby!(version)
551
682
  system("rbenv versions | grep -q #{version} || rbenv install #{version}")
552
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
+ 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 }
756
+ end
553
757
  end
554
758
  end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stable
4
+ class DBManager
5
+ attr_reader :name, :adapter
6
+
7
+ def initialize(name, adapter:)
8
+ @name = name
9
+ @adapter = adapter
10
+ end
11
+
12
+ # Main entry
13
+ def create
14
+ case adapter
15
+ when :postgresql
16
+ ensure_postgres_database
17
+ when :mysql
18
+ ensure_mysql_database
19
+ else
20
+ abort "Unsupported database adapter: #{adapter}"
21
+ end
22
+ end
23
+
24
+ # Rails database.yml config
25
+ def rails_config
26
+ case adapter
27
+ when :postgresql
28
+ {
29
+ 'adapter' => 'postgresql',
30
+ 'encoding' => 'unicode',
31
+ 'database' => name,
32
+ 'username' => app_user,
33
+ 'password' => nil,
34
+ 'host' => 'localhost',
35
+ 'pool' => 5
36
+ }
37
+ when :mysql
38
+ {
39
+ 'adapter' => 'mysql2',
40
+ 'encoding' => 'utf8mb4',
41
+ 'database' => name,
42
+ 'username' => app_user,
43
+ 'password' => '',
44
+ 'host' => 'localhost',
45
+ 'socket' => mysql_socket,
46
+ 'pool' => 5
47
+ }
48
+ else
49
+ abort "Unsupported adapter for Rails config: #{adapter}"
50
+ end
51
+ end
52
+
53
+ private
54
+
55
+ # -------------------- PostgreSQL --------------------
56
+
57
+ def ensure_postgres_database
58
+ exists = system(%(psql -lqt | cut -d \\| -f 1 | grep -w #{name} >/dev/null))
59
+
60
+ if exists
61
+ puts "⚠ Postgres database '#{name}' already exists. Skipping creation."
62
+ else
63
+ system("createdb #{name}") or abort("Failed to create Postgres DB '#{name}'")
64
+ end
65
+ end
66
+
67
+ # -------------------- MySQL --------------------
68
+
69
+ def ensure_mysql_database
70
+ ensure_mysql_root_auth!
71
+
72
+ # Create DB and user
73
+ socket = mysql_socket
74
+ user = app_user
75
+
76
+ system(%(
77
+ mysql --protocol=SOCKET --socket=#{socket} -u root <<SQL
78
+ CREATE DATABASE IF NOT EXISTS #{name};
79
+ CREATE USER IF NOT EXISTS '#{user}'@'localhost' IDENTIFIED BY '';
80
+ GRANT ALL PRIVILEGES ON #{name}.* TO '#{user}'@'localhost';
81
+ FLUSH PRIVILEGES;
82
+ SQL
83
+ )) or abort("Failed to create MySQL DB '#{name}'")
84
+
85
+ puts "✅ MySQL database '#{name}' ready"
86
+ end
87
+
88
+ # Ensure root can connect via socket
89
+ def ensure_mysql_root_auth!
90
+ ok = system("mysql -u root -e 'SELECT 1' >/dev/null 2>&1")
91
+ return if ok
92
+
93
+ puts '⚠ Fixing MySQL root authentication (requires sudo)...'
94
+ socket = mysql_socket
95
+
96
+ system(%(
97
+ sudo mysql --protocol=SOCKET --socket=#{socket} <<SQL
98
+ ALTER USER 'root'@'localhost' IDENTIFIED BY '';
99
+ FLUSH PRIVILEGES;
100
+ SQL
101
+ )) or abort('Failed to repair MySQL root authentication')
102
+ end
103
+
104
+ # Detect MySQL socket on macOS / Linux
105
+ def mysql_socket
106
+ paths = [
107
+ '/opt/homebrew/var/mysql/mysql.sock', # Homebrew macOS
108
+ '/tmp/mysql.sock', # Default
109
+ '/var/run/mysqld/mysqld.sock' # Linux default
110
+ ]
111
+
112
+ paths.each { |p| return p if File.exist?(p) }
113
+ abort 'MySQL socket not found. Is MySQL running?'
114
+ end
115
+
116
+ # Default Rails DB user
117
+ def app_user
118
+ ENV['USER'] || 'stable'
119
+ end
120
+ end
121
+ end
data/lib/stable.rb CHANGED
@@ -12,3 +12,4 @@ require_relative 'stable/cli'
12
12
  require_relative 'stable/registry'
13
13
  require_relative 'stable/scanner'
14
14
  require_relative 'stable/bootstrap'
15
+ require_relative 'stable/db_manager'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: stable-cli-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.7
4
+ version: 0.6.9
5
5
  platform: ruby
6
6
  authors:
7
7
  - Danny Simfukwe
@@ -50,6 +50,7 @@ files:
50
50
  - lib/stable.rb
51
51
  - lib/stable/bootstrap.rb
52
52
  - lib/stable/cli.rb
53
+ - lib/stable/db_manager.rb
53
54
  - lib/stable/paths.rb
54
55
  - lib/stable/registry.rb
55
56
  - lib/stable/scanner.rb