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.
- checksums.yaml +4 -4
- data/bin/stable +3 -2
- data/lib/stable/cli.rb +37 -630
- data/lib/stable/commands/doctor.rb +39 -0
- data/lib/stable/commands/list.rb +47 -0
- data/lib/stable/commands/new.rb +16 -0
- data/lib/stable/commands/remove.rb +24 -0
- data/lib/stable/commands/restart.rb +15 -0
- data/lib/stable/commands/setup.rb +11 -0
- data/lib/stable/commands/start.rb +15 -0
- data/lib/stable/commands/stop.rb +15 -0
- data/lib/stable/config/paths.rb +9 -0
- data/lib/stable/registry.rb +12 -2
- data/lib/stable/services/app_creator.rb +163 -0
- data/lib/stable/services/app_registry.rb +86 -0
- data/lib/stable/services/app_remover.rb +21 -0
- data/lib/stable/services/app_restarter.rb +32 -0
- data/lib/stable/services/app_starter.rb +95 -0
- data/lib/stable/services/app_stopper.rb +17 -0
- data/lib/stable/services/caddy_manager.rb +161 -0
- data/lib/stable/services/database/base.rb +47 -0
- data/lib/stable/services/database/mysql.rb +36 -0
- data/lib/stable/services/database/postgres.rb +14 -0
- data/lib/stable/services/dependency_checker.rb +59 -0
- data/lib/stable/services/hosts_manager.rb +31 -0
- data/lib/stable/services/process_manager.rb +61 -0
- data/lib/stable/services/ruby.rb +102 -0
- data/lib/stable/services/setup_runner.rb +82 -0
- data/lib/stable/system/shell.rb +13 -0
- data/lib/stable/utils/prompts.rb +21 -0
- data/lib/stable/validators/app_name.rb +49 -0
- data/lib/stable.rb +21 -1
- metadata +56 -14
|
@@ -0,0 +1,161 @@
|
|
|
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
|
+
|
|
135
|
+
sleep 0.1
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def ensure_certs_dir!
|
|
140
|
+
certs_dir = Stable::Paths.certs_dir
|
|
141
|
+
FileUtils.mkdir_p(certs_dir)
|
|
142
|
+
|
|
143
|
+
begin
|
|
144
|
+
FileUtils.chown_R(Etc.getlogin, nil, certs_dir)
|
|
145
|
+
rescue StandardError
|
|
146
|
+
nil
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
Dir.glob("#{certs_dir}/*.pem").each do |pem|
|
|
150
|
+
mode = pem.end_with?('-key.pem') ? 0o600 : 0o644
|
|
151
|
+
begin
|
|
152
|
+
FileUtils.chmod(mode, pem)
|
|
153
|
+
rescue StandardError
|
|
154
|
+
nil
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
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
|
+
@database_name = @app_name
|
|
11
|
+
.downcase
|
|
12
|
+
.gsub(/[^a-z0-9_]/, '_')
|
|
13
|
+
.gsub(/_+/, '_')
|
|
14
|
+
.gsub(/^_+|_+$/, '')
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def prepare
|
|
18
|
+
System::Shell.run(
|
|
19
|
+
"cd #{@app_path} && bundle exec rails db:prepare"
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
protected
|
|
24
|
+
|
|
25
|
+
def write_database_yml(creds)
|
|
26
|
+
config = {
|
|
27
|
+
'default' => base_config(creds),
|
|
28
|
+
'development' => base_config(creds),
|
|
29
|
+
'test' => base_config(creds).merge(
|
|
30
|
+
'database' => "#{@database_name}_test"
|
|
31
|
+
),
|
|
32
|
+
'production' => base_config(creds).merge(
|
|
33
|
+
'database' => "#{@database_name}_production"
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
path = File.join(@app_path, 'config/database.yml')
|
|
38
|
+
File.write(path, config.to_yaml)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def base_config(_creds)
|
|
42
|
+
raise NotImplementedError
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
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 #{@database_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' => @database_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,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.fetch('PATH', nil)}"
|
|
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)
|
|
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,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
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stable
|
|
4
|
+
module Validators
|
|
5
|
+
class AppName
|
|
6
|
+
VALID_PATTERN = /\A[a-z0-9]+(-[a-z0-9]+)*\z/
|
|
7
|
+
MAX_LENGTH = 63
|
|
8
|
+
|
|
9
|
+
def self.call!(name)
|
|
10
|
+
normalized = normalize(name)
|
|
11
|
+
|
|
12
|
+
unless valid?(normalized)
|
|
13
|
+
raise Thor::Error, <<~MSG
|
|
14
|
+
Invalid app name: "#{name}"
|
|
15
|
+
|
|
16
|
+
Use only:
|
|
17
|
+
- lowercase letters (a-z)
|
|
18
|
+
- numbers (0-9)
|
|
19
|
+
- hyphens (-)
|
|
20
|
+
|
|
21
|
+
Rules:
|
|
22
|
+
- no spaces or underscores
|
|
23
|
+
- cannot start or end with a hyphen
|
|
24
|
+
- max #{MAX_LENGTH} characters
|
|
25
|
+
|
|
26
|
+
Example:
|
|
27
|
+
stable new my-app
|
|
28
|
+
MSG
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
normalized
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.normalize(name)
|
|
35
|
+
name
|
|
36
|
+
.downcase
|
|
37
|
+
.strip
|
|
38
|
+
.gsub(/\s+/, '-') # spaces → hyphens
|
|
39
|
+
.gsub(/[^a-z0-9-]/, '') # drop invalid chars
|
|
40
|
+
.gsub(/-+/, '-') # collapse hyphens
|
|
41
|
+
.gsub(/\A-|-+\z/, '') # trim hyphens
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.valid?(name)
|
|
45
|
+
name.length <= MAX_LENGTH && VALID_PATTERN.match?(name)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|