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.
@@ -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,47 @@
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 'APP DOMAIN PORT RUBY STATUS '
25
+ puts '-' * 78
26
+ end
27
+
28
+ def format_row(app)
29
+ status =
30
+ if app[:started_at]
31
+ 'running'
32
+ else
33
+ 'stopped'
34
+ end
35
+
36
+ format(
37
+ '%-18s %-26s %-8s %-10s %-10s',
38
+ app[:name],
39
+ app[:domain],
40
+ app[:port],
41
+ app[:ruby],
42
+ status
43
+ )
44
+ end
45
+ end
46
+ end
47
+ 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,163 @@
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,
19
+ pid: nil)
20
+
21
+ abort "Folder already exists: #{app_path}" if File.exist?(app_path)
22
+
23
+ # --- Ensure Ruby + gemset ---
24
+ Ruby.ensure_version(ruby)
25
+ Ruby.ensure_rvm!
26
+ System::Shell.run("bash -lc 'source #{Ruby.rvm_script} && rvm #{ruby}@#{@name} --create do true'")
27
+
28
+ rvm_cmd = Ruby.rvm_prefix(ruby, @name)
29
+
30
+ # --- Install Rails into gemset if missing ---
31
+ rails_version = @options[:rails]
32
+ rails_check_cmd = if rails_version
33
+ "#{rvm_cmd} gem list -i rails -v #{rails_version}"
34
+ else
35
+ "#{rvm_cmd} gem list -i rails"
36
+ end
37
+
38
+ unless system("bash -lc '#{rails_check_cmd}'")
39
+ puts "Installing Rails #{rails_version || 'latest'} in gemset..."
40
+ install_cmd = rails_version ? "#{rvm_cmd} gem install rails -v #{rails_version}" : "#{rvm_cmd} gem install rails"
41
+ system("bash -lc '#{install_cmd}'") or abort('Failed to install Rails')
42
+ end
43
+
44
+ # --- Create Rails app ---
45
+ puts "Creating Rails app #{@name} (Ruby #{ruby})..."
46
+ System::Shell.run("bash -lc '#{rvm_cmd} rails new #{app_path}'")
47
+
48
+ # --- Write ruby version/gemset and bundle install inside gemset ---
49
+ Dir.chdir(app_path) do
50
+ File.write('.ruby-version', "#{ruby}\n")
51
+ File.write('.ruby-gemset', "#{@name}\n")
52
+
53
+ puts 'Running bundle install...'
54
+ System::Shell.run("bash -lc '#{rvm_cmd} bundle install --jobs=4 --retry=3'")
55
+ end
56
+
57
+ # --- Database integration ---
58
+ if @options[:db]
59
+ adapter = if @options[:mysql]
60
+ :mysql
61
+ else
62
+ :postgresql
63
+ end
64
+
65
+ gem_name = adapter == :postgresql ? 'pg' : 'mysql2'
66
+ gemfile_path = File.join(app_path, 'Gemfile')
67
+ unless File.read(gemfile_path).include?(gem_name)
68
+ File.open(gemfile_path, 'a') do |f|
69
+ f.puts "\n# Added by Stable CLI"
70
+ f.puts "gem '#{gem_name}'"
71
+ end
72
+ puts "✅ Added '#{gem_name}' gem to Gemfile"
73
+ end
74
+
75
+ # ensure gem is installed inside gemset
76
+ System::Shell.run("bash -lc 'cd #{app_path} && #{rvm_cmd} bundle install --jobs=4 --retry=3'")
77
+
78
+ # run adapter setup which will write database.yml and prepare
79
+ db_adapter = adapter == :mysql ? Database::MySQL : Database::Postgres
80
+ db_adapter.new(app_name: @name, app_path: app_path).setup
81
+ end
82
+
83
+ # --- Refresh bundle and prepare DB (idempotent) ---
84
+ System::Shell.run("bash -lc 'cd #{app_path} && #{rvm_cmd} bundle check || #{rvm_cmd} bundle install'")
85
+ System::Shell.run("bash -lc 'cd #{app_path} && #{rvm_cmd} bundle exec rails db:prepare'")
86
+
87
+ # --- Hosts, certs, caddy ---
88
+ Services::HostsManager.add(domain)
89
+ Services::CaddyManager.add_app(@name, skip_ssl: @options[:skip_ssl])
90
+ Services::CaddyManager.ensure_running!
91
+ Services::CaddyManager.reload
92
+
93
+ # --- Start the app ---
94
+ puts "Starting Rails server for #{@name} on port #{port}..."
95
+ log_file = File.join(app_path, 'log', 'stable.log')
96
+ FileUtils.mkdir_p(File.dirname(log_file))
97
+
98
+ abort "Port #{port} is already in use. Choose another port." if port_in_use?(port)
99
+
100
+ pid = spawn('bash', '-lc', "cd \"#{app_path}\" && #{rvm_cmd} bundle exec rails s -p #{port} -b 127.0.0.1",
101
+ out: log_file, err: log_file)
102
+ Process.detach(pid)
103
+
104
+ AppRegistry.update(@name, started_at: Time.now.to_i, pid: pid)
105
+
106
+ sleep 1.5
107
+ wait_for_port(port)
108
+ prefix = @options[:skip_ssl] ? 'http' : 'https'
109
+ display_domain = if @options[:skip_ssl]
110
+ "#{domain}:#{port}"
111
+ else
112
+ domain
113
+ end
114
+
115
+ puts "✔ #{@name} running at #{prefix}://#{display_domain}"
116
+ end
117
+
118
+ private
119
+
120
+ def create_rails_app
121
+ System::Shell.run("rails new #{@name}")
122
+ end
123
+
124
+ def setup_database
125
+ return unless @options[:mysql] || @options[:postgres]
126
+
127
+ adapter =
128
+ if @options[:mysql]
129
+ Database::MySQL
130
+ else
131
+ Database::Postgres
132
+ end
133
+
134
+ adapter.new(@name).setup
135
+ end
136
+
137
+ def next_free_port
138
+ used_ports = Services::AppRegistry.all.map { |a| a[:port] }
139
+ port = 3000
140
+ port += 1 while used_ports.include?(port) || port_in_use?(port)
141
+ port
142
+ end
143
+
144
+ def port_in_use?(port)
145
+ system("lsof -i tcp:#{port} > /dev/null 2>&1")
146
+ end
147
+
148
+ def wait_for_port(port, timeout: 20)
149
+ require 'socket'
150
+ start = Time.now
151
+
152
+ loop do
153
+ TCPSocket.new('127.0.0.1', port).close
154
+ return
155
+ rescue Errno::ECONNREFUSED
156
+ raise "Rails never bound port #{port}. Check log/stable.log" if Time.now - start > timeout
157
+
158
+ sleep 0.5
159
+ end
160
+ end
161
+ end
162
+ end
163
+ 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