stable-cli-rails 0.6.8 → 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,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stable
4
+ module Services
5
+ class CaddyManager
6
+ class << self
7
+ def caddyfile
8
+ Stable::Paths.caddyfile
9
+ end
10
+
11
+ def remove(domain)
12
+ return unless File.exist?(caddyfile)
13
+
14
+ content = File.read(caddyfile)
15
+ content = remove_domain_block(content, domain)
16
+
17
+ atomic_write(caddyfile, content)
18
+ system("caddy fmt --overwrite #{caddyfile}")
19
+
20
+ reload_if_running
21
+ end
22
+
23
+ def add_app(name, skip_ssl: false)
24
+ app = Services::AppRegistry.all.find { |a| a[:name] == name }
25
+ return unless app
26
+
27
+ domain = app[:domain]
28
+ port = app[:port]
29
+
30
+ ensure_certs_dir! unless skip_ssl
31
+ ensure_cert_for!(domain) unless skip_ssl
32
+
33
+ FileUtils.touch(caddyfile)
34
+ content = File.read(caddyfile)
35
+
36
+ content = remove_domain_block(content, domain)
37
+ content << build_block(domain, port, skip_ssl: skip_ssl)
38
+
39
+ atomic_write(caddyfile, content)
40
+ system("caddy fmt --overwrite #{caddyfile}")
41
+
42
+ ensure_running!
43
+ end
44
+
45
+ def reload
46
+ if system('which caddy > /dev/null')
47
+ pid = Process.spawn("caddy reload --config #{caddyfile}")
48
+ Process.detach(pid.to_i)
49
+ else
50
+ puts 'Caddy not found. Install Caddy first.'
51
+ end
52
+ end
53
+
54
+ def ensure_running!
55
+ if running?
56
+ reload
57
+ else
58
+ system("caddy run --config #{caddyfile} --adapter caddyfile &")
59
+ sleep 2
60
+ end
61
+ end
62
+
63
+ def reload_if_running
64
+ reload if running?
65
+ end
66
+
67
+ def running?
68
+ require 'socket'
69
+ TCPSocket.new('127.0.0.1', 2019).close
70
+ true
71
+ rescue Errno::ECONNREFUSED
72
+ false
73
+ end
74
+
75
+ private
76
+
77
+ def ensure_cert_for!(domain)
78
+ cert_path = File.join(Stable::Paths.certs_dir, "#{domain}.pem")
79
+ key_path = File.join(Stable::Paths.certs_dir, "#{domain}-key.pem")
80
+
81
+ return if valid_pem?(cert_path) && valid_pem?(key_path)
82
+
83
+ raise 'mkcert not installed' unless system('which mkcert > /dev/null')
84
+
85
+ System::Shell.run(
86
+ "mkcert -cert-file #{cert_path} -key-file #{key_path} #{domain}"
87
+ )
88
+
89
+ wait_for_pem!(cert_path)
90
+ wait_for_pem!(key_path)
91
+ end
92
+
93
+ def build_block(domain, port, skip_ssl: false)
94
+ cert_path = File.join(Stable::Paths.certs_dir, "#{domain}.pem") unless skip_ssl
95
+ key_path = File.join(Stable::Paths.certs_dir, "#{domain}-key.pem") unless skip_ssl
96
+ prefix = skip_ssl ? 'http' : 'https'
97
+ certs = skip_ssl ? '' : "tls #{cert_path} #{key_path}"
98
+ <<~CADDY
99
+
100
+ #{prefix}://#{domain} {
101
+ reverse_proxy 127.0.0.1:#{port}
102
+ #{certs}
103
+ }
104
+ CADDY
105
+ end
106
+
107
+ def remove_domain_block(content, domain)
108
+ regex = %r{
109
+ (https?|http)://#{Regexp.escape(domain)}\s*\{
110
+ .*?
111
+ \}
112
+ }mx
113
+
114
+ content.gsub(regex, '')
115
+ end
116
+
117
+ def atomic_write(path, content)
118
+ tmp = "#{path}.tmp"
119
+ File.write(tmp, content)
120
+ File.rename(tmp, path)
121
+ end
122
+
123
+ def valid_pem?(path)
124
+ File.exist?(path) &&
125
+ File.size?(path) &&
126
+ File.read(path, 64).include?('BEGIN')
127
+ end
128
+
129
+ def wait_for_pem!(path, timeout: 3)
130
+ start = Time.now
131
+
132
+ until valid_pem?(path)
133
+ raise "Invalid PEM file: #{path}" if Time.now - start > timeout
134
+ sleep 0.1
135
+ end
136
+ end
137
+
138
+ def ensure_certs_dir!
139
+ certs_dir = Stable::Paths.certs_dir
140
+ FileUtils.mkdir_p(certs_dir)
141
+
142
+ begin
143
+ FileUtils.chown_R(Etc.getlogin, nil, certs_dir)
144
+ rescue StandardError
145
+ end
146
+
147
+ Dir.glob("#{certs_dir}/*.pem").each do |pem|
148
+ mode = pem.end_with?('-key.pem') ? 0o600 : 0o644
149
+ FileUtils.chmod(mode, pem) rescue nil
150
+ end
151
+ end
152
+ end
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stable
4
+ module Services
5
+ module Database
6
+ class Base
7
+ def initialize(app_name:, app_path:)
8
+ @app_name = app_name
9
+ @app_path = app_path
10
+ end
11
+
12
+ def prepare
13
+ System::Shell.run(
14
+ "cd #{@app_path} && bundle exec rails db:prepare"
15
+ )
16
+ end
17
+
18
+ protected
19
+
20
+ def write_database_yml(creds)
21
+ config = {
22
+ 'default' => base_config(creds),
23
+ 'development' => base_config(creds),
24
+ 'test' => base_config(creds).merge(
25
+ 'database' => "#{@app_name}_test"
26
+ ),
27
+ 'production' => base_config(creds).merge(
28
+ 'database' => "#{@app_name}_production"
29
+ )
30
+ }
31
+
32
+ path = File.join(@app_path, 'config/database.yml')
33
+ File.write(path, config.to_yaml)
34
+ end
35
+
36
+ def base_config(_creds)
37
+ raise NotImplementedError
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stable
4
+ module Services
5
+ module Database
6
+ class MySQL < Base
7
+ def setup
8
+ creds = Stable::Utils::Prompts.mysql_root_credentials
9
+ create_database(creds)
10
+ write_database_yml(creds)
11
+ prepare
12
+ end
13
+
14
+ def create_database(creds)
15
+ System::Shell.run(
16
+ "mysql -u #{creds[:user]} -p#{creds[:password]} -e 'CREATE DATABASE IF NOT EXISTS #{@app_name};'"
17
+ )
18
+ end
19
+
20
+ protected
21
+
22
+ def base_config(creds)
23
+ {
24
+ 'adapter' => 'mysql2',
25
+ 'encoding' => 'utf8mb4',
26
+ 'pool' => 5,
27
+ 'database' => @app_name,
28
+ 'username' => creds[:user],
29
+ 'password' => creds[:password],
30
+ 'host' => 'localhost'
31
+ }
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stable
4
+ module Services
5
+ module Database
6
+ class Postgres < Base
7
+ def setup
8
+ System::Shell.run("createdb #{@app_name}")
9
+ prepare
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stable
4
+ module Services
5
+ class DependencyChecker
6
+ def run
7
+ [
8
+ check('Homebrew', 'brew'),
9
+ check('Caddy', 'caddy'),
10
+ check('mkcert', 'mkcert'),
11
+ check('RVM', 'rvm'),
12
+ check_caddy_running,
13
+ check_certs_dir,
14
+ check_apps_registry
15
+ ]
16
+ end
17
+
18
+ private
19
+
20
+ def check(name, command)
21
+ ok = system("which #{command} > /dev/null 2>&1")
22
+ {
23
+ name: name,
24
+ ok: ok,
25
+ message: ok ? nil : "#{name} not found in PATH"
26
+ }
27
+ end
28
+
29
+ def check_caddy_running
30
+ ok = system('pgrep caddy > /dev/null 2>&1')
31
+ {
32
+ name: 'Caddy running',
33
+ ok: ok,
34
+ message: ok ? nil : 'Caddy is installed but not running'
35
+ }
36
+ end
37
+
38
+ def check_certs_dir
39
+ path = File.expand_path('~/StableCaddy/certs')
40
+ ok = Dir.exist?(path)
41
+ {
42
+ name: 'Certificates directory',
43
+ ok: ok,
44
+ message: ok ? nil : "Missing #{path}. Run `stable setup`"
45
+ }
46
+ end
47
+
48
+ def check_apps_registry
49
+ path = File.expand_path('~/StableCaddy/apps.yml')
50
+ ok = File.exist?(path)
51
+ {
52
+ name: 'Apps registry',
53
+ ok: ok,
54
+ message: ok ? nil : 'Missing apps registry. Run `stable setup`'
55
+ }
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stable
4
+ module Services
5
+ class HostsManager
6
+ HOSTS_FILE = '/etc/hosts'
7
+
8
+ def self.remove(domain)
9
+ lines = File.read(HOSTS_FILE).lines
10
+ filtered = lines.reject { |l| l.include?(domain) }
11
+
12
+ return if lines == filtered
13
+
14
+ File.write('/tmp/hosts', filtered.join)
15
+ system("sudo mv /tmp/hosts #{HOSTS_FILE}")
16
+ end
17
+
18
+ def self.add(domain)
19
+ entry = "127.0.0.1\t#{domain}\n"
20
+ hosts = File.read(HOSTS_FILE)
21
+ return if hosts.include?(domain)
22
+
23
+ if Process.uid.zero?
24
+ File.open(HOSTS_FILE, 'a') { |f| f.puts entry }
25
+ else
26
+ system(%(echo "#{entry}" | sudo tee -a #{HOSTS_FILE} > /dev/null))
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stable
4
+ module Services
5
+ class ProcessManager
6
+ def self.start(target)
7
+ app = target.is_a?(String) ? AppRegistry.fetch(target) : target
8
+
9
+ path = app[:path]
10
+ port = app[:port]
11
+
12
+ ruby = app[:ruby]
13
+ gemset = Stable::Services::Ruby.gemset_for(app)
14
+
15
+ if ruby && gemset
16
+ Stable::Services::Ruby.ensure_rvm!
17
+ system("bash -lc 'rvm #{ruby}@#{gemset} --create do true'")
18
+ rvm_cmd = "rvm #{ruby}@#{gemset} do"
19
+ elsif ruby
20
+ rvm_cmd = "rvm #{ruby} do"
21
+ else
22
+ rvm_cmd = nil
23
+ end
24
+
25
+ log_file = File.join(path, 'log', 'stable.log')
26
+ FileUtils.mkdir_p(File.dirname(log_file))
27
+
28
+ cmd = if rvm_cmd
29
+ "cd \"#{path}\" && #{rvm_cmd} bundle exec rails s -p #{port} -b 127.0.0.1"
30
+ else
31
+ "cd \"#{path}\" && bundle exec rails s -p #{port} -b 127.0.0.1"
32
+ end
33
+
34
+ pid = spawn('bash', '-lc', cmd, out: log_file, err: log_file)
35
+
36
+ Process.detach(pid)
37
+
38
+ AppRegistry.update(app[:name], started_at: Time.now.to_i, pid: pid)
39
+
40
+ pid
41
+ end
42
+
43
+ def self.stop(app)
44
+ pid = app[:pid]
45
+ return unless pid
46
+
47
+ output = `lsof -i tcp:#{app[:port]} -t`.strip
48
+ if output.empty?
49
+ puts "No app running on port #{app[:port]}"
50
+ else
51
+ output.split("\n").each { |pid| Process.kill('TERM', pid.to_i) }
52
+ puts "Stopped #{app[:name]} on port #{app[:port]}"
53
+ end
54
+
55
+ AppRegistry.update(app[:name], started_at: nil, pid: nil)
56
+ rescue Errno::ESRCH
57
+ AppRegistry.update(app[:name], started_at: nil, pid: nil)
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stable
4
+ module Services
5
+ module Ruby
6
+ def self.ensure_version(version)
7
+ return if version.nil? || version.to_s.strip.empty?
8
+
9
+ # Check if version exists via rvm
10
+ installed = system("bash -lc 'rvm list strings | grep -q #{version}'")
11
+ return if installed
12
+
13
+ puts "Installing Ruby #{version}..."
14
+ success = system("bash -lc 'rvm install #{version}'")
15
+ raise "Failed to install Ruby #{version}" unless success
16
+ end
17
+
18
+ def self.rvm_available?
19
+ system("bash -lc 'command -v rvm > /dev/null'")
20
+ end
21
+
22
+ def self.rbenv_available?
23
+ system('command -v rbenv > /dev/null')
24
+ end
25
+
26
+ def self.ensure_rvm!
27
+ return if rvm_available?
28
+
29
+ puts 'RVM not found. Installing RVM...'
30
+
31
+ install_cmd = <<~CMD
32
+ curl -sSL https://get.rvm.io | bash -s stable
33
+ CMD
34
+
35
+ abort 'RVM installation failed' unless system(install_cmd)
36
+
37
+ rvm_script = File.expand_path('~/.rvm/scripts/rvm')
38
+ abort 'RVM installed but could not be loaded' unless File.exist?(rvm_script)
39
+
40
+ ENV['PATH'] = "#{File.expand_path('~/.rvm/bin')}:#{ENV['PATH']}"
41
+
42
+ system(%(bash -lc "source #{rvm_script} && rvm --version")) || abort('RVM installed but not functional')
43
+ end
44
+
45
+ def self.ensure_ruby_installed!(version)
46
+ return if system("rvm list strings | grep ruby-#{version} > /dev/null")
47
+
48
+ puts "Installing Ruby #{version}..."
49
+ system("rvm install #{version}") || abort("Failed to install Ruby #{version}")
50
+ end
51
+
52
+ def self.ensure_rvm_ruby!(version)
53
+ system("bash -lc 'rvm list strings | grep -q #{version} || rvm install #{version}'")
54
+ end
55
+
56
+ def self.ensure_rbenv_ruby!(version)
57
+ system("rbenv versions | grep -q #{version} || rbenv install #{version}")
58
+ end
59
+
60
+ def self.rvm_script
61
+ File.expand_path('~/.rvm/scripts/rvm')
62
+ end
63
+
64
+ # Return a command prefix that sources RVM and executes the given ruby@gemset
65
+ # Example: "source /Users/me/.rvm/scripts/rvm && rvm 3.4.4@myapp do"
66
+ def self.rvm_prefix(ruby, gemset = nil)
67
+ gemset_part = gemset ? "@#{gemset}" : ''
68
+ "source #{rvm_script} && rvm #{ruby}#{gemset_part} do"
69
+ end
70
+
71
+ def self.detect_ruby_version(path)
72
+ rv = File.join(path, '.ruby-version')
73
+ return File.read(rv).strip if File.exist?(rv)
74
+
75
+ gemfile = File.join(path, 'Gemfile')
76
+ if File.exist?(gemfile)
77
+ ruby_line = File.read(gemfile)[/^ruby ['"](.+?)['"]/, 1]
78
+ return ruby_line if ruby_line
79
+ end
80
+
81
+ nil
82
+ end
83
+
84
+ def self.gemset_for(app)
85
+ gemset_file = File.join(app[:path], '.ruby-gemset')
86
+ return File.read(gemset_file).strip if File.exist?(gemset_file)
87
+
88
+ nil
89
+ end
90
+
91
+ def self.rvm_exec(app, ruby)
92
+ gemset = gemset_for(app)
93
+
94
+ if gemset
95
+ "rvm #{ruby}@#{gemset} do"
96
+ else
97
+ "rvm #{ruby} do"
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stable
4
+ module Services
5
+ class SetupRunner
6
+ def call
7
+ ensure_directories
8
+ ensure_apps_registry
9
+ ensure_caddyfile
10
+ # start or ensure caddy is running like original CLI
11
+ Stable::Services::CaddyManager.ensure_running!
12
+ puts "Caddy home initialized at #{Stable::Paths.root}"
13
+ self.class.ensure_dependencies!
14
+ end
15
+
16
+ def self.ensure_dependencies!
17
+ new.send(:ensure_dependencies!)
18
+ end
19
+
20
+ private
21
+
22
+ def ensure_directories
23
+ path = Stable::Paths.certs_dir
24
+ FileUtils.mkdir_p(path) unless Dir.exist?(path)
25
+ end
26
+
27
+ def ensure_apps_registry
28
+ path = Stable::Paths.apps_file
29
+ File.write(path, {}.to_yaml) unless File.exist?(path)
30
+ end
31
+
32
+ def ensure_caddyfile
33
+ path = Stable::Paths.caddyfile
34
+ return if File.exist?(path)
35
+
36
+ File.write(path, <<~CADDY)
37
+ {
38
+ auto_https off
39
+ }
40
+ CADDY
41
+ end
42
+
43
+ def ensure_dependencies!
44
+ unless system('which brew > /dev/null')
45
+ puts 'Homebrew is required. Install it first: https://brew.sh'
46
+ exit 1
47
+ end
48
+
49
+ # --- Install Caddy ---
50
+ unless system('which caddy > /dev/null')
51
+ puts 'Installing Caddy...'
52
+ system('brew install caddy')
53
+ end
54
+
55
+ # --- Install mkcert ---
56
+ unless system('which mkcert > /dev/null')
57
+ puts 'Installing mkcert...'
58
+ system('brew install mkcert nss')
59
+ end
60
+
61
+ # Always ensure mkcert CA is installed
62
+ system('mkcert -install')
63
+
64
+ # --- Install PostgreSQL ---
65
+ unless system('which psql > /dev/null')
66
+ puts 'Installing PostgreSQL...'
67
+ system('brew install postgresql')
68
+ system('brew services start postgresql')
69
+ end
70
+
71
+ # --- Install MySQL ---
72
+ unless system('which mysql > /dev/null')
73
+ puts 'Installing MySQL...'
74
+ system('brew install mysql')
75
+ system('brew services start mysql')
76
+ end
77
+
78
+ puts '✅ All dependencies are installed and running.'
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stable
4
+ module System
5
+ class Shell
6
+ def self.run(cmd)
7
+ puts "→ #{cmd}"
8
+ success = system(cmd)
9
+ raise "Command failed: #{cmd}" unless success
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'io/console'
4
+
5
+ module Stable
6
+ module Utils
7
+ class Prompts
8
+ def self.mysql_root_credentials
9
+ print 'Enter MySQL root username (default: root): '
10
+ user = $stdin.gets.strip
11
+ user = 'root' if user.empty?
12
+
13
+ print 'Enter MySQL root password (leave blank if none): '
14
+ password = $stdin.noecho(&:gets).chomp
15
+ puts
16
+
17
+ { user: user, password: password }
18
+ end
19
+ end
20
+ end
21
+ end
data/lib/stable.rb CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Stable
4
4
  def self.root
5
- File.expand_path('~/.stable')
5
+ Paths.root
6
6
  end
7
7
  end
8
8
 
@@ -12,3 +12,22 @@ 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'
16
+
17
+ Dir[File.join(__dir__, 'stable', 'services', '**', '*.rb')].sort.each { |f| require f }
18
+ Dir[File.join(__dir__, 'stable', 'commands', '**', '*.rb')].sort.each { |f| require f }
19
+ Dir[File.join(__dir__, 'stable', 'system', '**', '*.rb')].sort.each { |f| require f }
20
+ Dir[File.join(__dir__, 'stable', 'utils', '**', '*.rb')].sort.each { |f| require f }
21
+ Dir[File.join(__dir__, 'stable', 'config', '**', '*.rb')].sort.each { |f| require f }
22
+
23
+ AppRegistry = Stable::Services::AppRegistry unless defined?(::AppRegistry)
24
+ HostsManager = Stable::Services::HostsManager unless defined?(::HostsManager)
25
+ CaddyManager = Stable::Services::CaddyManager unless defined?(::CaddyManager)
26
+ ProcessManager = Stable::Services::ProcessManager unless defined?(::ProcessManager)
27
+ AppCreator = Stable::Services::AppCreator unless defined?(::AppCreator)
28
+ AppStarter = Stable::Services::AppStarter unless defined?(::AppStarter)
29
+ AppStopper = Stable::Services::AppStopper unless defined?(::AppStopper)
30
+ AppRemover = Stable::Services::AppRemover unless defined?(::AppRemover)
31
+ Database = Stable::Services::Database unless defined?(::Database)
32
+ Ruby = Stable::Services::Ruby unless defined?(::Ruby)
33
+ Commands = Stable::Commands unless defined?(::Commands)