stable-cli-rails 0.6.9 → 0.7.10

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.
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stable
4
+ module Commands
5
+ class Doctor
6
+ def call
7
+ puts 'Running Stable health checks...'
8
+ puts
9
+
10
+ checks = Services::DependencyChecker.new.run
11
+
12
+ checks.each do |check|
13
+ print_check(check)
14
+ end
15
+
16
+ puts
17
+ summary(checks)
18
+ end
19
+
20
+ private
21
+
22
+ def print_check(check)
23
+ icon = check[:ok] ? '✔' : '✖'
24
+ puts "#{icon} #{check[:name]}"
25
+ puts " #{check[:message]}" unless check[:ok]
26
+ end
27
+
28
+ def summary(checks)
29
+ failures = checks.count { |c| !c[:ok] }
30
+
31
+ if failures.zero?
32
+ puts 'All checks passed.'
33
+ else
34
+ puts "#{failures} issue(s) detected. Fix the above and re-run `stable doctor`."
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stable
4
+ module Commands
5
+ class List
6
+ def call
7
+ apps = Services::AppRegistry.all
8
+
9
+ if apps.empty?
10
+ puts 'No apps registered.'
11
+ return
12
+ end
13
+
14
+ print_header
15
+
16
+ apps.each do |app|
17
+ puts format_row(app)
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def print_header
24
+ puts format(
25
+ '%-18s %-26s %-8s %-10s %-10s',
26
+ 'APP',
27
+ 'DOMAIN',
28
+ 'PORT',
29
+ 'RUBY',
30
+ 'STATUS'
31
+ )
32
+ puts '-' * 78
33
+ end
34
+
35
+ def format_row(app)
36
+ status =
37
+ if app[:started_at]
38
+ 'running'
39
+ else
40
+ 'stopped'
41
+ end
42
+
43
+ format(
44
+ '%-18s %-26s %-8s %-10s %-10s',
45
+ app[:name],
46
+ app[:domain],
47
+ app[:port],
48
+ app[:ruby],
49
+ status
50
+ )
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stable
4
+ module Commands
5
+ class New
6
+ def initialize(name, options)
7
+ @name = name
8
+ @options = options
9
+ end
10
+
11
+ def call
12
+ Services::AppCreator.new(@name, @options).call
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stable
4
+ module Commands
5
+ class Remove
6
+ def initialize(name)
7
+ @name = name
8
+ end
9
+
10
+ def call
11
+ app = Services::AppRegistry.find(@name)
12
+ abort 'App not found' unless app
13
+
14
+ Services::ProcessManager.stop(app)
15
+ Services::HostsManager.remove(app[:domain])
16
+ Services::CaddyManager.remove(app[:domain])
17
+ Services::AppRegistry.remove(@name)
18
+ Services::CaddyManager.reload
19
+
20
+ puts "Removed #{@name}"
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stable
4
+ module Commands
5
+ class Restart
6
+ def initialize(name)
7
+ @name = name
8
+ end
9
+
10
+ def call
11
+ Services::AppRestarter.new(@name).call
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stable
4
+ module Commands
5
+ class Setup
6
+ def call
7
+ Services::SetupRunner.new.call
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stable
4
+ module Commands
5
+ class Start
6
+ def initialize(name)
7
+ @name = name
8
+ end
9
+
10
+ def call
11
+ Services::AppStarter.new(@name).call
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stable
4
+ module Commands
5
+ class Stop
6
+ def initialize(name)
7
+ @name = name
8
+ end
9
+
10
+ def call
11
+ Services::AppStopper.new(@name).call
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stable
4
+ module Config
5
+ APP_ROOT = File.expand_path('~/StableCaddy')
6
+ CADDYFILE = File.join(APP_ROOT, 'Caddyfile')
7
+ REGISTRY = File.join(APP_ROOT, 'apps.yml')
8
+ end
9
+ end
@@ -6,7 +6,7 @@ require 'fileutils'
6
6
  module Stable
7
7
  class Registry
8
8
  def self.file_path
9
- File.join(Stable.root, 'apps.yml')
9
+ Stable::Paths.apps_file
10
10
  end
11
11
 
12
12
  def self.save(apps)
@@ -15,7 +15,17 @@ module Stable
15
15
  end
16
16
 
17
17
  def self.apps
18
- File.exist?(file_path) ? YAML.load_file(file_path) : []
18
+ return [] unless File.exist?(file_path)
19
+
20
+ data = YAML.load_file(file_path) || []
21
+ data.map do |entry|
22
+ next entry unless entry.is_a?(Hash)
23
+
24
+ entry.each_with_object({}) do |(k, v), memo|
25
+ key = k.is_a?(String) ? k.to_sym : k
26
+ memo[key] = v
27
+ end
28
+ end
19
29
  end
20
30
  end
21
31
  end
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stable
4
+ module Services
5
+ class AppCreator
6
+ def initialize(name, options)
7
+ @name = name
8
+ @options = options
9
+ end
10
+
11
+ def call
12
+ ruby = @options[:ruby] || RUBY_VERSION
13
+ port = @options[:port] || next_free_port
14
+ app_path = File.expand_path(@name)
15
+ domain = "#{@name}.test"
16
+
17
+ # --- Register app in registry ---
18
+ Services::AppRegistry.add(name: @name, path: app_path, domain: domain, port: port, ruby: ruby, started_at: nil, pid: nil)
19
+
20
+ abort "Folder already exists: #{app_path}" if File.exist?(app_path)
21
+
22
+ # --- Ensure Ruby + gemset ---
23
+ Ruby.ensure_version(ruby)
24
+ Ruby.ensure_rvm!
25
+ System::Shell.run("bash -lc 'source #{Ruby.rvm_script} && rvm #{ruby}@#{@name} --create do true'")
26
+
27
+ rvm_cmd = Ruby.rvm_prefix(ruby, @name)
28
+
29
+ # --- Install Rails into gemset if missing ---
30
+ rails_version = @options[:rails]
31
+ rails_check_cmd = if rails_version
32
+ "#{rvm_cmd} gem list -i rails -v #{rails_version}"
33
+ else
34
+ "#{rvm_cmd} gem list -i rails"
35
+ end
36
+
37
+ unless system("bash -lc '#{rails_check_cmd}'")
38
+ puts "Installing Rails #{rails_version || 'latest'} in gemset..."
39
+ install_cmd = rails_version ? "#{rvm_cmd} gem install rails -v #{rails_version}" : "#{rvm_cmd} gem install rails"
40
+ system("bash -lc '#{install_cmd}'") or abort('Failed to install Rails')
41
+ end
42
+
43
+ # --- Create Rails app ---
44
+ puts "Creating Rails app #{@name} (Ruby #{ruby})..."
45
+ System::Shell.run("bash -lc '#{rvm_cmd} rails new #{app_path}'")
46
+
47
+ # --- Write ruby version/gemset and bundle install inside gemset ---
48
+ Dir.chdir(app_path) do
49
+ File.write('.ruby-version', "#{ruby}\n")
50
+ File.write('.ruby-gemset', "#{@name}\n")
51
+
52
+ puts 'Running bundle install...'
53
+ System::Shell.run("bash -lc '#{rvm_cmd} bundle install --jobs=4 --retry=3'")
54
+ end
55
+
56
+ # --- Database integration ---
57
+ if @options[:db]
58
+ adapter = if @options[:mysql]
59
+ :mysql
60
+ else
61
+ :postgresql
62
+ end
63
+
64
+ gem_name = adapter == :postgresql ? 'pg' : 'mysql2'
65
+ gemfile_path = File.join(app_path, 'Gemfile')
66
+ unless File.read(gemfile_path).include?(gem_name)
67
+ File.open(gemfile_path, 'a') do |f|
68
+ f.puts "\n# Added by Stable CLI"
69
+ f.puts "gem '#{gem_name}'"
70
+ end
71
+ puts "✅ Added '#{gem_name}' gem to Gemfile"
72
+ end
73
+
74
+ # ensure gem is installed inside gemset
75
+ System::Shell.run("bash -lc 'cd #{app_path} && #{rvm_cmd} bundle install --jobs=4 --retry=3'")
76
+
77
+ # run adapter setup which will write database.yml and prepare
78
+ db_adapter = adapter == :mysql ? Database::MySQL : Database::Postgres
79
+ db_adapter.new(app_name: @name, app_path: app_path).setup
80
+ end
81
+
82
+ # --- Refresh bundle and prepare DB (idempotent) ---
83
+ System::Shell.run("bash -lc 'cd #{app_path} && #{rvm_cmd} bundle check || #{rvm_cmd} bundle install'")
84
+ System::Shell.run("bash -lc 'cd #{app_path} && #{rvm_cmd} bundle exec rails db:prepare'")
85
+
86
+ # --- Hosts, certs, caddy ---
87
+ Services::HostsManager.add(domain)
88
+ Services::CaddyManager.add_app(@name, skip_ssl: @options[:skip_ssl])
89
+ Services::CaddyManager.ensure_running!
90
+ Services::CaddyManager.reload
91
+
92
+ # --- Start the app ---
93
+ puts "Starting Rails server for #{@name} on port #{port}..."
94
+ log_file = File.join(app_path, 'log', 'stable.log')
95
+ FileUtils.mkdir_p(File.dirname(log_file))
96
+
97
+ abort "Port #{port} is already in use. Choose another port." if port_in_use?(port)
98
+
99
+ pid = spawn('bash', '-lc', "cd \"#{app_path}\" && #{rvm_cmd} bundle exec rails s -p #{port} -b 127.0.0.1",
100
+ out: log_file, err: log_file)
101
+ Process.detach(pid)
102
+
103
+ AppRegistry.update(@name, started_at: Time.now.to_i, pid: pid)
104
+
105
+ sleep 1.5
106
+ wait_for_port(port)
107
+ prefix = @options[:skip_ssl] ? 'http' : 'https'
108
+ display_domain = if @options[:skip_ssl]
109
+ "#{domain}:#{port}"
110
+ else
111
+ domain
112
+ end
113
+
114
+ puts "✔ #{@name} running at #{prefix}://#{display_domain}"
115
+ end
116
+
117
+ private
118
+
119
+ def create_rails_app
120
+ System::Shell.run("rails new #{@name}")
121
+ end
122
+
123
+ def setup_database
124
+ return unless @options[:mysql] || @options[:postgres]
125
+
126
+ adapter =
127
+ if @options[:mysql]
128
+ Database::MySQL
129
+ else
130
+ Database::Postgres
131
+ end
132
+
133
+ adapter.new(@name).setup
134
+ end
135
+
136
+ def next_free_port
137
+ used_ports = Services::AppRegistry.all.map { |a| a[:port] }
138
+ port = 3000
139
+ port += 1 while used_ports.include?(port) || port_in_use?(port)
140
+ port
141
+ end
142
+
143
+ def port_in_use?(port)
144
+ system("lsof -i tcp:#{port} > /dev/null 2>&1")
145
+ end
146
+
147
+ def wait_for_port(port, timeout: 20)
148
+ require 'socket'
149
+ start = Time.now
150
+
151
+ loop do
152
+ TCPSocket.new('127.0.0.1', port).close
153
+ return
154
+ rescue Errno::ECONNREFUSED
155
+ raise "Rails never bound port #{port}. Check log/stable.log" if Time.now - start > timeout
156
+
157
+ sleep 0.5
158
+ end
159
+ end
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stable
4
+ module Services
5
+ class AppRegistry
6
+ class << self
7
+ def all
8
+ Stable::Registry.apps
9
+ end
10
+
11
+ def find(name)
12
+ Stable::Registry.apps.find { |a| a[:name] == name }
13
+ end
14
+
15
+ def fetch(name)
16
+ app = find(name)
17
+ abort("No app found with name #{name}") unless app
18
+ app
19
+ end
20
+
21
+ # Register a new app by name. If the folder already exists in the current
22
+ # working directory it will use that path. Port is allocated sequentially.
23
+ def register(name)
24
+ path = File.expand_path(name)
25
+ domain = "#{name}.test"
26
+
27
+ used_ports = Stable::Registry.apps.map { |a| a[:port] }.compact
28
+ port = (used_ports.max || 2999) + 1
29
+
30
+ app = {
31
+ name: name,
32
+ path: path,
33
+ domain: domain,
34
+ port: port,
35
+ ruby: nil,
36
+ started_at: nil,
37
+ pid: nil
38
+ }
39
+
40
+ add(app)
41
+ app
42
+ end
43
+
44
+ def add(app)
45
+ apps = Stable::Registry.apps
46
+ apps.reject! { |a| a[:name] == app[:name] }
47
+ apps << app
48
+ Stable::Registry.save(apps)
49
+ end
50
+
51
+ # Remove duplicate app entries (by name) and persist the canonical list
52
+ def dedupe
53
+ apps = Stable::Registry.apps
54
+ apps.uniq! { |a| a[:name] }
55
+ Stable::Registry.save(apps)
56
+ apps
57
+ end
58
+
59
+ def remove(name)
60
+ apps = Stable::Registry.apps.reject { |a| a[:name] == name }
61
+ Stable::Registry.save(apps)
62
+ end
63
+
64
+ def update(name, attrs)
65
+ apps = Stable::Registry.apps
66
+ idx = apps.index { |a| a[:name] == name }
67
+ return unless idx
68
+
69
+ apps[idx] = apps[idx].merge(attrs)
70
+ Stable::Registry.save(apps)
71
+ end
72
+
73
+ def mark_stopped(name)
74
+ update(name, started_at: nil, pid: nil)
75
+ end
76
+
77
+ alias register_app register
78
+ alias add_app add
79
+ end
80
+ end
81
+ end
82
+ end
83
+
84
+ module Stable
85
+ AppRegistry = Services::AppRegistry
86
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stable
4
+ module Services
5
+ class AppRemover
6
+ def initialize(name)
7
+ @name = name
8
+ end
9
+
10
+ def call
11
+ app = AppRegistry.fetch(@name)
12
+ ProcessManager.stop(app)
13
+ HostsManager.remove(app[:domain])
14
+ CaddyManager.remove(app[:domain])
15
+ AppRegistry.remove(@name)
16
+ CaddyManager.reload
17
+ puts "✔ #{@name} removed"
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stable
4
+ module Services
5
+ class AppRestarter
6
+ def initialize(name)
7
+ @name = name
8
+ end
9
+
10
+ def call
11
+ app = Services::AppRegistry.find(@name)
12
+ unless app
13
+ puts("No app found with name #{@name}")
14
+ return
15
+ end
16
+
17
+ # Stop if running
18
+ if app[:pid]
19
+ begin
20
+ Process.kill('TERM', app[:pid].to_i)
21
+ rescue Errno::ESRCH
22
+ # already dead
23
+ end
24
+ AppRegistry.update(@name, started_at: nil, pid: nil)
25
+ puts "✔ #{@name} stopped"
26
+ end
27
+
28
+ Services::AppStarter.new(@name).call
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stable
4
+ module Services
5
+ class AppStarter
6
+ def initialize(name)
7
+ @name = name
8
+ end
9
+
10
+ def call
11
+ app = Services::AppRegistry.find(@name)
12
+ unless app
13
+ puts("No app found with name #{@name}")
14
+ return
15
+ end
16
+
17
+ port = app[:port] || next_free_port
18
+ ruby = app[:ruby]
19
+ path = app[:path]
20
+
21
+ if app_running?(app)
22
+ puts "#{@name} is already running on https://#{app[:domain]} (port #{port})"
23
+ return
24
+ end
25
+
26
+ gemset = Stable::Services::Ruby.gemset_for(app)
27
+
28
+ rvm_cmd =
29
+ if ruby && gemset
30
+ System::Shell.run("bash -lc 'source #{Stable::Services::Ruby.rvm_script} && rvm #{ruby}@#{gemset} --create do true'")
31
+ Stable::Services::Ruby.rvm_prefix(ruby, gemset)
32
+ elsif ruby
33
+ Stable::Services::Ruby.rvm_prefix(ruby, nil)
34
+ end
35
+
36
+ puts "Starting #{@name} on port #{port}..."
37
+
38
+ log_file = File.join(path, 'log', 'stable.log')
39
+ FileUtils.mkdir_p(File.dirname(log_file))
40
+
41
+ pid = spawn(
42
+ 'bash',
43
+ '-lc',
44
+ "cd \"#{path}\" && #{rvm_cmd} bundle exec rails s -p #{port} -b 127.0.0.1",
45
+ out: log_file,
46
+ err: log_file
47
+ ).to_i
48
+
49
+ Process.detach(pid)
50
+
51
+ wait_for_port(port, timeout: 30)
52
+
53
+ AppRegistry.update(app[:name], started_at: Time.now.to_i, pid: pid)
54
+
55
+ Stable::Services::CaddyManager.add_app(app[:name], skip_ssl: false)
56
+ Stable::Services::CaddyManager.reload
57
+
58
+ puts "#{@name} started on https://#{app[:domain]}"
59
+ end
60
+
61
+ private
62
+
63
+ def next_free_port
64
+ used_ports = Services::AppRegistry.all.map { |a| a[:port] }
65
+ port = 3000
66
+ port += 1 while used_ports.include?(port) || port_in_use?(port)
67
+ port
68
+ end
69
+
70
+ def port_in_use?(port)
71
+ system("lsof -i tcp:#{port} > /dev/null 2>&1")
72
+ end
73
+
74
+ def wait_for_port(port, timeout: 20)
75
+ require 'socket'
76
+ start = Time.now
77
+
78
+ loop do
79
+ TCPSocket.new('127.0.0.1', port).close
80
+ return
81
+ rescue Errno::ECONNREFUSED
82
+ raise "Rails never bound port #{port}. Check log/stable.log" if Time.now - start > timeout
83
+
84
+ sleep 0.5
85
+ end
86
+ end
87
+
88
+ def app_running?(app)
89
+ return false unless app && app[:port]
90
+
91
+ system("lsof -i tcp:#{app[:port]} -sTCP:LISTEN > /dev/null 2>&1")
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stable
4
+ module Services
5
+ class AppStopper
6
+ def initialize(name)
7
+ @app = AppRegistry.fetch(name)
8
+ end
9
+
10
+ def call
11
+ ProcessManager.stop(@app)
12
+ AppRegistry.mark_stopped(@app[:name])
13
+ puts "✔ #{@app[:name]} stopped"
14
+ end
15
+ end
16
+ end
17
+ end