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
data/lib/stable/cli.rb
CHANGED
|
@@ -10,12 +10,10 @@ require_relative 'registry'
|
|
|
10
10
|
|
|
11
11
|
module Stable
|
|
12
12
|
class CLI < Thor
|
|
13
|
-
HOSTS_FILE = '/etc/hosts'
|
|
14
|
-
|
|
15
13
|
def initialize(*)
|
|
16
14
|
super
|
|
17
15
|
Stable::Bootstrap.run!
|
|
18
|
-
ensure_dependencies!
|
|
16
|
+
Services::SetupRunner.ensure_dependencies!
|
|
19
17
|
dedupe_registry!
|
|
20
18
|
end
|
|
21
19
|
|
|
@@ -32,174 +30,13 @@ module Stable
|
|
|
32
30
|
method_option :postgres, type: :boolean, default: false, desc: 'Use Postgres for the database'
|
|
33
31
|
method_option :mysql, type: :boolean, default: false, desc: 'Use MySQL for the database'
|
|
34
32
|
def new(name, ruby: RUBY_VERSION, rails: nil, port: nil)
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
54
|
-
abort "Folder already exists: #{app_path}" if File.exist?(app_path)
|
|
55
|
-
|
|
56
|
-
# --- Ensure RVM and Ruby ---
|
|
57
|
-
ensure_rvm!
|
|
58
|
-
puts "Using Ruby #{ruby} with RVM gemset #{name}..."
|
|
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)
|
|
61
|
-
# --- Install Rails in gemset if needed ---
|
|
62
|
-
rails_version = rails || 'latest'
|
|
63
|
-
rails_check = system("bash -lc '#{ruby_cmd} gem list -i rails#{rails ? " -v #{rails}" : ''}'")
|
|
64
|
-
unless rails_check
|
|
65
|
-
puts "Installing Rails #{rails_version} in gemset..."
|
|
66
|
-
system("bash -lc '#{ruby_cmd} gem install rails #{rails ? "-v #{rails}" : ''}'") or abort('Failed to install Rails')
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
# --- Create Rails app ---
|
|
70
|
-
puts "Creating Rails app #{name} (Ruby #{ruby})..."
|
|
71
|
-
system("bash -lc '#{ruby_cmd} rails new #{app_path}'") or abort('Rails app creation failed')
|
|
72
|
-
|
|
73
|
-
# --- Add .ruby-version and .ruby-gemset ---
|
|
74
|
-
Dir.chdir(app_path) do
|
|
75
|
-
File.write('.ruby-version', "#{ruby}\n")
|
|
76
|
-
File.write('.ruby-gemset', "#{name}\n")
|
|
77
|
-
|
|
78
|
-
# --- Install gems inside gemset ---
|
|
79
|
-
puts 'Running bundle install...'
|
|
80
|
-
system("bash -lc '#{ruby_cmd} bundle install --jobs=4 --retry=3'") or abort('bundle install failed')
|
|
81
|
-
end
|
|
82
|
-
|
|
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')
|
|
160
|
-
|
|
161
|
-
# --- Host entry & certificate ---
|
|
162
|
-
add_host_entry(domain)
|
|
163
|
-
generate_cert(domain) unless options[:skip_ssl]
|
|
164
|
-
update_caddyfile(domain, port)
|
|
165
|
-
ensure_caddy_running!
|
|
166
|
-
caddy_reload
|
|
167
|
-
|
|
168
|
-
# --- Start Rails server ---
|
|
169
|
-
puts "Starting Rails server for #{name} on port #{port}..."
|
|
170
|
-
log_file = File.join(app_path, 'log', 'stable.log')
|
|
171
|
-
FileUtils.mkdir_p(File.dirname(log_file))
|
|
172
|
-
|
|
173
|
-
abort "Port #{port} is already in use. Choose another port." if app_running?({ port: port })
|
|
174
|
-
|
|
175
|
-
pid = spawn(
|
|
176
|
-
'bash',
|
|
177
|
-
'-lc',
|
|
178
|
-
"cd #{app_path} && #{ruby_cmd} bundle exec rails s -p #{port} -b 127.0.0.1",
|
|
179
|
-
out: log_file,
|
|
180
|
-
err: log_file
|
|
181
|
-
)
|
|
182
|
-
Process.detach(pid)
|
|
183
|
-
|
|
184
|
-
app[:started_at] = Time.now.to_i
|
|
185
|
-
Registry.save(apps)
|
|
186
|
-
|
|
187
|
-
sleep 1.5
|
|
188
|
-
|
|
189
|
-
wait_for_port(port)
|
|
190
|
-
puts "✔ #{name} running at https://#{domain}"
|
|
33
|
+
safe_name = Validators::AppName.call!(name)
|
|
34
|
+
Commands::New.new(safe_name, options).call
|
|
191
35
|
end
|
|
192
36
|
|
|
193
37
|
desc 'list', 'List detected apps'
|
|
194
38
|
def list
|
|
195
|
-
|
|
196
|
-
if apps.empty?
|
|
197
|
-
puts 'No apps found.'
|
|
198
|
-
else
|
|
199
|
-
apps.each do |app|
|
|
200
|
-
puts "#{app[:name]} -> https://#{app[:domain]}"
|
|
201
|
-
end
|
|
202
|
-
end
|
|
39
|
+
Commands::List.new.call
|
|
203
40
|
end
|
|
204
41
|
|
|
205
42
|
desc 'add FOLDER', 'Add a Rails app folder'
|
|
@@ -212,175 +49,90 @@ module Stable
|
|
|
212
49
|
|
|
213
50
|
puts "Detected gemset: #{File.read('.ruby-gemset').strip}" if File.exist?('.ruby-gemset')
|
|
214
51
|
|
|
215
|
-
apps = Registry.apps
|
|
216
52
|
name = File.basename(folder)
|
|
217
53
|
domain = "#{name}.test"
|
|
218
54
|
|
|
219
|
-
if
|
|
55
|
+
if Services::AppRegistry.all.any? { |a| a[:path] == folder }
|
|
220
56
|
puts "App already exists: #{name}"
|
|
221
57
|
return
|
|
222
58
|
end
|
|
223
59
|
|
|
224
60
|
port = next_free_port
|
|
225
|
-
ruby = detect_ruby_version(folder)
|
|
61
|
+
ruby = Stable::Services::Ruby.detect_ruby_version(folder)
|
|
226
62
|
|
|
227
|
-
|
|
228
|
-
|
|
63
|
+
app = { name: name, path: folder, domain: domain, port: port, ruby: ruby }
|
|
64
|
+
Services::AppRegistry.add_app(app)
|
|
229
65
|
puts "Added #{name} -> https://#{domain} (port #{port})"
|
|
230
66
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
caddy_reload
|
|
67
|
+
Services::HostsManager.add(domain)
|
|
68
|
+
Services::CaddyManager.add_app(name, skip_ssl: options[:skip_ssl])
|
|
69
|
+
Services::CaddyManager.reload
|
|
235
70
|
end
|
|
236
71
|
|
|
237
72
|
desc 'remove NAME', 'Remove an app by name'
|
|
238
73
|
def remove(name)
|
|
239
|
-
|
|
240
|
-
app = apps.find { |a| a[:name] == name }
|
|
241
|
-
if app.nil?
|
|
242
|
-
puts "No app found with name #{name}"
|
|
243
|
-
return
|
|
244
|
-
end
|
|
245
|
-
|
|
246
|
-
new_apps = apps.reject { |a| a[:name] == name }
|
|
247
|
-
Registry.save(new_apps)
|
|
248
|
-
puts "Removed #{name}"
|
|
249
|
-
|
|
250
|
-
remove_host_entry(app[:domain])
|
|
251
|
-
remove_caddy_entry(app[:domain])
|
|
252
|
-
caddy_reload
|
|
74
|
+
Commands::Remove.new(name).call
|
|
253
75
|
end
|
|
254
76
|
|
|
255
77
|
desc 'start NAME', 'Start a Rails app with its correct Ruby version'
|
|
256
78
|
def start(name)
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
port = app[:port] || next_free_port
|
|
261
|
-
ruby = app[:ruby]
|
|
262
|
-
path = app[:path]
|
|
263
|
-
|
|
264
|
-
if app_running?(app)
|
|
265
|
-
puts "#{name} is already running on https://#{app[:domain]} (port #{port})"
|
|
266
|
-
return
|
|
267
|
-
end
|
|
268
|
-
|
|
269
|
-
gemset = gemset_for(app)
|
|
270
|
-
|
|
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"
|
|
277
|
-
end
|
|
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
|
-
|
|
284
|
-
pid = spawn(
|
|
285
|
-
'bash',
|
|
286
|
-
'-lc',
|
|
287
|
-
<<~CMD,
|
|
288
|
-
cd "#{path}"
|
|
289
|
-
#{rvm_cmd} bundle exec rails s \
|
|
290
|
-
-p #{port} \
|
|
291
|
-
-b 127.0.0.1
|
|
292
|
-
CMD
|
|
293
|
-
out: log_file,
|
|
294
|
-
err: log_file
|
|
295
|
-
)
|
|
296
|
-
|
|
297
|
-
Process.detach(pid)
|
|
298
|
-
|
|
299
|
-
wait_for_port(port, timeout: 30)
|
|
300
|
-
|
|
301
|
-
app[:started_at] = Time.now.to_i
|
|
302
|
-
Registry.save(Registry.apps)
|
|
303
|
-
|
|
304
|
-
generate_cert(app[:domain])
|
|
305
|
-
update_caddyfile(app[:domain], port)
|
|
306
|
-
caddy_reload
|
|
79
|
+
Commands::Start.new(name).call
|
|
80
|
+
end
|
|
307
81
|
|
|
308
|
-
|
|
82
|
+
desc 'restart NAME', 'Restart a Rails app'
|
|
83
|
+
def restart(name)
|
|
84
|
+
Commands::Restart.new(name).call
|
|
309
85
|
end
|
|
310
86
|
|
|
311
87
|
desc 'stop NAME', 'Stop a Rails app (default port 3000)'
|
|
312
88
|
def stop(name)
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
output = `lsof -i tcp:#{app[:port]} -t`.strip
|
|
316
|
-
if output.empty?
|
|
317
|
-
puts "No app running on port #{app[:port]}"
|
|
318
|
-
else
|
|
319
|
-
output.split("\n").each { |pid| Process.kill('TERM', pid.to_i) }
|
|
320
|
-
puts "Stopped #{name}"
|
|
321
|
-
end
|
|
89
|
+
Commands::Stop.new(name).call
|
|
322
90
|
end
|
|
323
91
|
|
|
324
92
|
desc 'setup', 'Sets up Caddy and local trusted certificates'
|
|
325
93
|
def setup
|
|
326
|
-
|
|
327
|
-
File.write(Stable::Paths.caddyfile, '') unless File.exist?(Stable::Paths.caddyfile)
|
|
328
|
-
ensure_caddy_running!
|
|
329
|
-
puts "Caddy home initialized at #{Stable::Paths.root}"
|
|
94
|
+
Commands::Setup.new.call
|
|
330
95
|
end
|
|
331
96
|
|
|
332
97
|
desc 'caddy reload', 'Reloads Caddy after adding/removing apps'
|
|
333
98
|
def caddy_reload
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
puts 'Caddy reloaded'
|
|
337
|
-
else
|
|
338
|
-
puts 'Caddy not found. Install Caddy first.'
|
|
339
|
-
end
|
|
99
|
+
Services::CaddyManager.reload
|
|
100
|
+
puts 'Caddy reloaded'
|
|
340
101
|
end
|
|
341
102
|
|
|
342
103
|
desc 'secure DOMAIN', 'Generate trusted local HTTPS cert for a specific folder/domain'
|
|
343
104
|
def secure(domain)
|
|
344
|
-
|
|
105
|
+
apps = Services::AppRegistry.all
|
|
106
|
+
app = apps.find { |a| a[:domain] == domain }
|
|
107
|
+
app ||= apps.find { |a| a[:name] == domain }
|
|
108
|
+
app ||= apps.find { |a| a[:domain] == "#{domain}.test" }
|
|
109
|
+
|
|
345
110
|
unless app
|
|
346
111
|
puts "No app found with domain #{domain}"
|
|
347
112
|
return
|
|
348
113
|
end
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
114
|
+
|
|
115
|
+
Services::CaddyManager.add_app(app[:name], skip_ssl: true)
|
|
116
|
+
Services::CaddyManager.reload
|
|
117
|
+
puts "Secured https://#{app[:domain]}"
|
|
352
118
|
end
|
|
353
119
|
|
|
354
120
|
desc 'doctor', 'Check Stable system health'
|
|
355
121
|
def doctor
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
puts "Ruby version: #{RUBY_VERSION}"
|
|
359
|
-
puts "RVM: #{rvm_available? ? 'yes' : 'no'}"
|
|
360
|
-
puts "rbenv: #{rbenv_available? ? 'yes' : 'no'}"
|
|
361
|
-
puts "Caddy: #{system('which caddy > /dev/null') ? 'yes' : 'no'}"
|
|
362
|
-
puts "mkcert: #{system('which mkcert > /dev/null') ? 'yes' : 'no'}"
|
|
363
|
-
|
|
364
|
-
Registry.apps.each do |app|
|
|
365
|
-
state = boot_state(app)
|
|
366
|
-
ruby = app[:ruby] || 'default'
|
|
367
|
-
port = app[:port]
|
|
368
|
-
|
|
369
|
-
puts "#{app[:name]} → Ruby #{ruby} | port #{port} | #{state}"
|
|
370
|
-
end
|
|
122
|
+
Commands::Doctor.new.call
|
|
371
123
|
end
|
|
372
124
|
|
|
373
125
|
desc 'upgrade-ruby NAME VERSION', 'Upgrade Ruby for an app'
|
|
374
126
|
def upgrade_ruby(name, version)
|
|
375
|
-
app =
|
|
127
|
+
app = Services::AppRegistry.find(name)
|
|
376
128
|
unless app
|
|
377
129
|
puts "No app named #{name}"
|
|
378
130
|
return
|
|
379
131
|
end
|
|
380
132
|
|
|
381
|
-
if rvm_available?
|
|
133
|
+
if Stable::Services::Ruby.rvm_available?
|
|
382
134
|
system("bash -lc 'rvm install #{version}'")
|
|
383
|
-
elsif rbenv_available?
|
|
135
|
+
elsif Stable::Services::Ruby.rbenv_available?
|
|
384
136
|
system("rbenv install #{version}")
|
|
385
137
|
else
|
|
386
138
|
puts 'No Ruby version manager found'
|
|
@@ -388,161 +140,15 @@ module Stable
|
|
|
388
140
|
end
|
|
389
141
|
|
|
390
142
|
File.write(File.join(app[:path], '.ruby-version'), version)
|
|
391
|
-
|
|
392
|
-
Registry.save(Registry.apps)
|
|
143
|
+
Services::AppRegistry.update(name, ruby: version)
|
|
393
144
|
|
|
394
145
|
puts "#{name} now uses Ruby #{version}"
|
|
395
146
|
end
|
|
396
147
|
|
|
397
148
|
private
|
|
398
149
|
|
|
399
|
-
def add_host_entry(domain)
|
|
400
|
-
entry = "127.0.0.1\t#{domain}"
|
|
401
|
-
hosts = File.read(HOSTS_FILE)
|
|
402
|
-
unless hosts.include?(domain)
|
|
403
|
-
puts "Adding #{domain} to #{HOSTS_FILE}..."
|
|
404
|
-
File.open(HOSTS_FILE, 'a') { |f| f.puts entry }
|
|
405
|
-
system('dscacheutil -flushcache; sudo killall -HUP mDNSResponder')
|
|
406
|
-
end
|
|
407
|
-
rescue Errno::EACCES
|
|
408
|
-
ensure_hosts_entry(domain)
|
|
409
|
-
end
|
|
410
|
-
|
|
411
|
-
def remove_host_entry(domain)
|
|
412
|
-
hosts = File.read(HOSTS_FILE)
|
|
413
|
-
new_hosts = hosts.lines.reject { |line| line.include?(domain) }.join
|
|
414
|
-
File.write(HOSTS_FILE, new_hosts)
|
|
415
|
-
system('dscacheutil -flushcache; sudo killall -HUP mDNSResponder')
|
|
416
|
-
rescue Errno::EACCES
|
|
417
|
-
puts "Permission denied updating #{HOSTS_FILE}. Run 'sudo stable remove #{domain}' to remove hosts entry."
|
|
418
|
-
end
|
|
419
|
-
|
|
420
|
-
def ensure_hosts_entry(domain)
|
|
421
|
-
entry = "127.0.0.1\t#{domain}"
|
|
422
|
-
|
|
423
|
-
hosts = File.read(HOSTS_FILE)
|
|
424
|
-
return if hosts.include?(domain)
|
|
425
|
-
|
|
426
|
-
if Process.uid.zero?
|
|
427
|
-
File.open(HOSTS_FILE, 'a') { |f| f.puts entry }
|
|
428
|
-
else
|
|
429
|
-
system(%(echo "#{entry}" | sudo tee -a #{HOSTS_FILE} > /dev/null))
|
|
430
|
-
end
|
|
431
|
-
|
|
432
|
-
system('dscacheutil -flushcache; sudo killall -HUP mDNSResponder')
|
|
433
|
-
end
|
|
434
|
-
|
|
435
|
-
def secure_app(domain, _folder, port)
|
|
436
|
-
ensure_certs_dir!
|
|
437
|
-
|
|
438
|
-
cert_path = File.join(Stable::Paths.certs_dir, "#{domain}.pem")
|
|
439
|
-
key_path = File.join(Stable::Paths.certs_dir, "#{domain}-key.pem")
|
|
440
|
-
|
|
441
|
-
# Generate certificates if missing
|
|
442
|
-
if system('which mkcert > /dev/null')
|
|
443
|
-
unless File.exist?(cert_path) && File.exist?(key_path)
|
|
444
|
-
system("mkcert -cert-file #{cert_path} -key-file #{key_path} #{domain}")
|
|
445
|
-
end
|
|
446
|
-
else
|
|
447
|
-
puts 'mkcert not found. Please install mkcert.'
|
|
448
|
-
return
|
|
449
|
-
end
|
|
450
|
-
|
|
451
|
-
# Auto-add Caddy block if not already in Caddyfile
|
|
452
|
-
add_caddy_block(domain, cert_path, key_path, port)
|
|
453
|
-
caddy_reload
|
|
454
|
-
end
|
|
455
|
-
|
|
456
|
-
def add_caddy_block(domain, cert, key, port)
|
|
457
|
-
caddyfile = Stable::Paths.caddyfile
|
|
458
|
-
FileUtils.touch(caddyfile) unless File.exist?(caddyfile)
|
|
459
|
-
content = File.read(caddyfile)
|
|
460
|
-
|
|
461
|
-
return if content.include?(domain) # don't duplicate
|
|
462
|
-
|
|
463
|
-
block = <<~CADDY
|
|
464
|
-
|
|
465
|
-
https://#{domain} {
|
|
466
|
-
reverse_proxy 127.0.0.1:#{port}
|
|
467
|
-
tls #{cert} #{key}
|
|
468
|
-
}
|
|
469
|
-
CADDY
|
|
470
|
-
|
|
471
|
-
File.write(caddyfile, content + block)
|
|
472
|
-
system("caddy fmt --overwrite #{caddyfile}")
|
|
473
|
-
end
|
|
474
|
-
|
|
475
|
-
# Remove Caddyfile entry for the domain
|
|
476
|
-
def remove_caddy_entry(domain)
|
|
477
|
-
return unless File.exist?(Stable::Paths.caddyfile)
|
|
478
|
-
|
|
479
|
-
content = File.read(Stable::Paths.caddyfile)
|
|
480
|
-
# Remove block starting with https://<domain> { ... }
|
|
481
|
-
regex = %r{
|
|
482
|
-
https://#{Regexp.escape(domain)}\s*\{
|
|
483
|
-
.*?
|
|
484
|
-
\}
|
|
485
|
-
}mx
|
|
486
|
-
|
|
487
|
-
new_content = content.gsub(regex, '')
|
|
488
|
-
|
|
489
|
-
File.write(Stable::Paths.caddyfile, new_content)
|
|
490
|
-
end
|
|
491
|
-
|
|
492
|
-
def ensure_dependencies!
|
|
493
|
-
unless system('which brew > /dev/null')
|
|
494
|
-
puts 'Homebrew is required. Install it first: https://brew.sh'
|
|
495
|
-
exit 1
|
|
496
|
-
end
|
|
497
|
-
|
|
498
|
-
# --- Install Caddy ---
|
|
499
|
-
unless system('which caddy > /dev/null')
|
|
500
|
-
puts 'Installing Caddy...'
|
|
501
|
-
system('brew install caddy')
|
|
502
|
-
end
|
|
503
|
-
|
|
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
|
|
510
|
-
|
|
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.'
|
|
526
|
-
end
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
def ensure_caddy_running!
|
|
530
|
-
api_port = 2019
|
|
531
|
-
|
|
532
|
-
# Check if Caddy API is reachable
|
|
533
|
-
require 'socket'
|
|
534
|
-
begin
|
|
535
|
-
TCPSocket.new('127.0.0.1', api_port).close
|
|
536
|
-
puts 'Caddy already running.'
|
|
537
|
-
rescue Errno::ECONNREFUSED
|
|
538
|
-
puts 'Starting Caddy in background...'
|
|
539
|
-
system("caddy run --config #{Stable::Paths.caddyfile} --adapter caddyfile --watch --resume &")
|
|
540
|
-
sleep 3
|
|
541
|
-
end
|
|
542
|
-
end
|
|
543
|
-
|
|
544
150
|
def next_free_port
|
|
545
|
-
used_ports =
|
|
151
|
+
used_ports = Services::AppRegistry.all.map { |a| a[:port] }
|
|
546
152
|
port = 3000
|
|
547
153
|
port += 1 while used_ports.include?(port) || port_in_use?(port)
|
|
548
154
|
port
|
|
@@ -552,207 +158,8 @@ module Stable
|
|
|
552
158
|
system("lsof -i tcp:#{port} > /dev/null 2>&1")
|
|
553
159
|
end
|
|
554
160
|
|
|
555
|
-
def generate_cert(domain)
|
|
556
|
-
cert_path = File.join(Stable::Paths.certs_dir, "#{domain}.pem")
|
|
557
|
-
key_path = File.join(Stable::Paths.certs_dir, "#{domain}-key.pem")
|
|
558
|
-
FileUtils.mkdir_p(Stable::Paths.certs_dir)
|
|
559
|
-
|
|
560
|
-
return if File.exist?(cert_path) && File.exist?(key_path)
|
|
561
|
-
|
|
562
|
-
if system('which mkcert > /dev/null')
|
|
563
|
-
system("mkcert -cert-file #{cert_path} -key-file #{key_path} #{domain}")
|
|
564
|
-
else
|
|
565
|
-
puts 'mkcert not found. Please install mkcert.'
|
|
566
|
-
end
|
|
567
|
-
end
|
|
568
|
-
|
|
569
|
-
def update_caddyfile(domain, port)
|
|
570
|
-
caddyfile = Stable::Paths.caddyfile
|
|
571
|
-
FileUtils.touch(caddyfile) unless File.exist?(caddyfile)
|
|
572
|
-
content = File.read(caddyfile)
|
|
573
|
-
|
|
574
|
-
# remove existing block for domain
|
|
575
|
-
regex = %r{
|
|
576
|
-
https://#{Regexp.escape(domain)}\s*\{
|
|
577
|
-
.*?
|
|
578
|
-
\}
|
|
579
|
-
}mx
|
|
580
|
-
|
|
581
|
-
content = content.gsub(regex, '')
|
|
582
|
-
|
|
583
|
-
# add new block
|
|
584
|
-
cert_path = File.join(Stable::Paths.certs_dir, "#{domain}.pem")
|
|
585
|
-
key_path = File.join(Stable::Paths.certs_dir, "#{domain}-key.pem")
|
|
586
|
-
block = <<~CADDY
|
|
587
|
-
|
|
588
|
-
https://#{domain} {
|
|
589
|
-
reverse_proxy 127.0.0.1:#{port}
|
|
590
|
-
tls #{cert_path} #{key_path}
|
|
591
|
-
}
|
|
592
|
-
CADDY
|
|
593
|
-
|
|
594
|
-
File.write(caddyfile, content + block)
|
|
595
|
-
system("caddy fmt --overwrite #{caddyfile}")
|
|
596
|
-
end
|
|
597
|
-
|
|
598
|
-
def ensure_certs_dir!
|
|
599
|
-
certs_dir = Stable::Paths.certs_dir
|
|
600
|
-
FileUtils.mkdir_p(certs_dir)
|
|
601
|
-
|
|
602
|
-
begin
|
|
603
|
-
FileUtils.chown_R(Etc.getlogin, nil, certs_dir)
|
|
604
|
-
rescue StandardError => e
|
|
605
|
-
puts "Could not change ownership: #{e.message}"
|
|
606
|
-
end
|
|
607
|
-
|
|
608
|
-
# Restrict permissions for security
|
|
609
|
-
Dir.glob("#{certs_dir}/*.pem").each do |pem|
|
|
610
|
-
FileUtils.chmod(0o600, pem)
|
|
611
|
-
end
|
|
612
|
-
end
|
|
613
|
-
|
|
614
|
-
def wait_for_port(port, timeout: 20)
|
|
615
|
-
require 'socket'
|
|
616
|
-
start = Time.now
|
|
617
|
-
|
|
618
|
-
loop do
|
|
619
|
-
TCPSocket.new('127.0.0.1', port).close
|
|
620
|
-
return
|
|
621
|
-
rescue Errno::ECONNREFUSED
|
|
622
|
-
raise "Rails never bound port #{port}. Check log/stable.log" if Time.now - start > timeout
|
|
623
|
-
|
|
624
|
-
sleep 0.5
|
|
625
|
-
end
|
|
626
|
-
end
|
|
627
|
-
|
|
628
|
-
def ensure_rvm!
|
|
629
|
-
return if system('which rvm > /dev/null')
|
|
630
|
-
|
|
631
|
-
puts 'RVM not found. Installing RVM...'
|
|
632
|
-
|
|
633
|
-
install_cmd = <<~CMD
|
|
634
|
-
curl -sSL https://get.rvm.io | bash -s stable
|
|
635
|
-
CMD
|
|
636
|
-
|
|
637
|
-
abort 'RVM installation failed' unless system(install_cmd)
|
|
638
|
-
|
|
639
|
-
# Load RVM into current process
|
|
640
|
-
rvm_script = File.expand_path('~/.rvm/scripts/rvm')
|
|
641
|
-
abort 'RVM installed but could not be loaded' unless File.exist?(rvm_script)
|
|
642
|
-
|
|
643
|
-
ENV['PATH'] = "#{File.expand_path('~/.rvm/bin')}:#{ENV['PATH']}"
|
|
644
|
-
|
|
645
|
-
system(%(bash -lc "source #{rvm_script} && rvm --version")) ||
|
|
646
|
-
abort('RVM installed but not functional')
|
|
647
|
-
end
|
|
648
|
-
|
|
649
|
-
def ensure_ruby_installed!(version)
|
|
650
|
-
return if system("rvm list strings | grep ruby-#{version} > /dev/null")
|
|
651
|
-
|
|
652
|
-
puts "Installing Ruby #{version}..."
|
|
653
|
-
system("rvm install #{version}") || abort("Failed to install Ruby #{version}")
|
|
654
|
-
end
|
|
655
|
-
|
|
656
|
-
def detect_ruby_version(path)
|
|
657
|
-
rv = File.join(path, '.ruby-version')
|
|
658
|
-
return File.read(rv).strip if File.exist?(rv)
|
|
659
|
-
|
|
660
|
-
gemfile = File.join(path, 'Gemfile')
|
|
661
|
-
if File.exist?(gemfile)
|
|
662
|
-
ruby_line = File.read(gemfile)[/^ruby ['"](.+?)['"]/, 1]
|
|
663
|
-
return ruby_line if ruby_line
|
|
664
|
-
end
|
|
665
|
-
|
|
666
|
-
nil
|
|
667
|
-
end
|
|
668
|
-
|
|
669
|
-
def rvm_available?
|
|
670
|
-
system("bash -lc 'command -v rvm > /dev/null'")
|
|
671
|
-
end
|
|
672
|
-
|
|
673
|
-
def rbenv_available?
|
|
674
|
-
system('command -v rbenv > /dev/null')
|
|
675
|
-
end
|
|
676
|
-
|
|
677
|
-
def ensure_rvm_ruby!(version)
|
|
678
|
-
system("bash -lc 'rvm list strings | grep -q #{version} || rvm install #{version}'")
|
|
679
|
-
end
|
|
680
|
-
|
|
681
|
-
def ensure_rbenv_ruby!(version)
|
|
682
|
-
system("rbenv versions | grep -q #{version} || rbenv install #{version}")
|
|
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
161
|
def dedupe_registry!
|
|
703
|
-
|
|
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 }
|
|
162
|
+
Services::AppRegistry.dedupe
|
|
756
163
|
end
|
|
757
164
|
end
|
|
758
165
|
end
|