stable-cli-rails 0.1.0

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: cb7161cbe3a5e9ed3d651e059f0884fd065cf1281a13baf1ee7f518e47ce66c4
4
+ data.tar.gz: 882e21ccd55072e66bf5f0e4dac8b023b41da126667d17de930876d69827d536
5
+ SHA512:
6
+ metadata.gz: 4f093a5af1f22b2212280b57bc69afd934b44525378a707c5c0bb399b3645b4ee936aea242e316628015930b3ab4ee3ea0f99bd798dc1aa8514ef98aea775c30
7
+ data.tar.gz: c87973bf27d5f0d297ebb7104d0197d5432ce360f278d0a5711fe639c928ee58a363a16f1bf07857e79c79ded85849d6721416be00e549019ae522d171a0fe57
data/README.md ADDED
@@ -0,0 +1,184 @@
1
+ # Stable CLI (macOS)
2
+
3
+ Stable is a CLI tool to manage local Rails applications with automatic Caddy setup on macOS, local trusted HTTPS certificates, and easy start/stop functionality.
4
+
5
+ ## Features
6
+
7
+ - Add and remove Rails apps.
8
+ - Automatically generate and manage local HTTPS certificates using `mkcert`.
9
+ - Automatically update `/etc/hosts` for `.test` domains.
10
+ - Start Rails apps with integrated Caddy reverse proxy.
11
+ - Reload Caddy after adding/removing apps.
12
+ - List all registered apps.
13
+
14
+ ## Installation
15
+
16
+ ### From source
17
+
18
+ ```bash
19
+ # Clone the repository
20
+ git clone git@github.com:dannysimfukwe/stable-rails.git
21
+ cd stable-rails
22
+
23
+ # Install dependencies
24
+ bundle install
25
+ ```
26
+
27
+ ### As a gem
28
+
29
+ ```bash
30
+ gem install stable
31
+ ```
32
+
33
+ ## Setup
34
+
35
+ Initialize Caddy home and required directories:
36
+
37
+ ```bash
38
+ stable setup
39
+ ```
40
+
41
+ This will create:
42
+ - `~/StableCaddy/` for Caddy configuration.
43
+ - `~/StableCaddy/certs` for generated certificates.
44
+ - `~/StableCaddy/Caddyfile` for Caddy configuration.
45
+
46
+ ## CLI Commands
47
+
48
+ ### List apps
49
+
50
+ ```bash
51
+ stable list
52
+ ```
53
+
54
+ Lists all registered apps and their domains.
55
+
56
+ ### Add a Rails app
57
+
58
+ ```bash
59
+ stable add /path/to/rails_app
60
+ ```
61
+
62
+ This will:
63
+ - Register the app.
64
+ - Add a `/etc/hosts` entry.
65
+ - Generate local trusted HTTPS certificates.
66
+ - Add a Caddy reverse proxy block.
67
+ - Reload Caddy.
68
+
69
+ ### Remove a Rails app
70
+
71
+ ```bash
72
+ stable remove app_name
73
+ ```
74
+
75
+ This will:
76
+ - Remove the app from registry.
77
+ - Remove `/etc/hosts` entry.
78
+ - Remove the Caddy reverse proxy block.
79
+ - Reload Caddy.
80
+
81
+ ### Start an app
82
+
83
+ ```bash
84
+ rvmsudo stable start app_name
85
+ ```
86
+
87
+ Starts the Rails server on the assigned port and ensures Caddy is running with the proper reverse proxy. Rails logs can be viewed in your terminal.
88
+
89
+ ### Stop an app
90
+
91
+ ```bash
92
+ stable stop app_name
93
+ ```
94
+
95
+ Stops the Rails server running on the assigned port.
96
+
97
+ ### Secure an app manually
98
+
99
+ ```bash
100
+ rvmsudo stable secure app_name.test
101
+ ```
102
+
103
+ Generates or updates trusted local HTTPS certificates and reloads Caddy.
104
+
105
+ ### Reload Caddy
106
+
107
+ ```bash
108
+ stable caddy reload
109
+ ```
110
+
111
+ Reloads Caddy configuration after changes.
112
+
113
+ ### Health check
114
+
115
+ ```bash
116
+ stable doctor
117
+ ```
118
+
119
+ Checks the environment, RVM/Ruby, Caddy, mkcert, and app readiness.
120
+
121
+ ### Upgrade Ruby for an app
122
+
123
+ ```bash
124
+ stable upgrade-ruby myapp 3.4.4
125
+ ```
126
+
127
+ Upgrades the Ruby version for a specific app, updating `.ruby-version` and ensuring gemset compatibility.
128
+
129
+ ### Create a new Rails app
130
+
131
+ ```bash
132
+ stable new myapp [--ruby 3.4.4] [--rails 7.0.7.1] [--skip-ssl]
133
+ ```
134
+
135
+ Creates a new Rails app, generates `.ruby-version`, installs Rails, adds the app to Stable, and optionally secures it with HTTPS.
136
+
137
+ ## Paths
138
+
139
+ - Caddy home: `~/StableCaddy`
140
+ - Caddyfile: `~/StableCaddy/Caddyfile`
141
+ - Certificates: `~/StableCaddy/certs`
142
+ - Registered apps: `~/StableCaddy/apps.yml`
143
+
144
+ ## Dependencies
145
+
146
+ - Homebrew
147
+ - Caddy
148
+ - mkcert
149
+ - RVM (or rbenv fallback)
150
+
151
+ `ensure_dependencies!` will install missing dependencies automatically.
152
+
153
+ ## Known Issues
154
+
155
+ - Sometimes you may see:
156
+ ```
157
+ TCPSocket#initialize: Connection refused - connect(2) for "127.0.0.1" port 300.. (Errno::ECONNREFUSED)
158
+ ```
159
+ This usually disappears after a few seconds when Caddy reloads. If it persists, run:
160
+
161
+ ```bash
162
+ rvmsudo stable secure myapp.test
163
+ ```
164
+
165
+ - Some commands may need to be run consecutively for proper setup:
166
+ ```bash
167
+ stable setup
168
+ rvmsudo stable add myapp
169
+ rvmsudo stable secure myapp.test
170
+ stable start myapp
171
+ ```
172
+
173
+ - PATH warnings from RVM may appear on the first run. Make sure your shell is properly configured for RVM.
174
+
175
+ ## Notes
176
+
177
+ - Make sure to run `stable setup` initially.
178
+ - Requires `sudo` to modify `/etc/hosts`.
179
+ - Rails apps are started on ports assigned by Stable (default 3000+).
180
+ - Domains are automatically suffixed with `.test`.
181
+
182
+ ## License
183
+
184
+ MIT License
data/bin/stable ADDED
@@ -0,0 +1,4 @@
1
+
2
+ #!/usr/bin/env ruby
3
+ require "stable"
4
+ Stable::CLI.start(ARGV)
@@ -0,0 +1,36 @@
1
+ require "fileutils"
2
+
3
+ module Stable
4
+ module Bootstrap
5
+ def self.run!
6
+ FileUtils.mkdir_p(Paths.root)
7
+ FileUtils.mkdir_p(Paths.caddy_dir)
8
+ FileUtils.mkdir_p(Paths.certs_dir)
9
+
10
+ unless File.exist?(Paths.apps_file)
11
+ File.write(Paths.apps_file, "--- []\n")
12
+ end
13
+
14
+ unless File.exist?(Paths.caddyfile)
15
+ File.write(Paths.caddyfile, "")
16
+ end
17
+
18
+ disable_rvm_autolibs!
19
+ end
20
+
21
+ def self.disable_rvm_autolibs!
22
+ return unless system("which rvm > /dev/null")
23
+
24
+ # Only run once
25
+ marker = File.join(Paths.root, ".rvm_autolibs_disabled")
26
+ return if File.exist?(marker)
27
+
28
+ puts "Configuring RVM (disabling autolibs)..."
29
+
30
+ system("bash -lc 'rvm autolibs disable'")
31
+ system("bash -lc 'echo rvm_silence_path_mismatch_check_flag=1 >> ~/.rvmrc'")
32
+
33
+ FileUtils.touch(marker)
34
+ end
35
+ end
36
+ end
data/lib/stable/cli.rb ADDED
@@ -0,0 +1,545 @@
1
+ require "thor"
2
+ require "etc"
3
+ require "fileutils"
4
+ require_relative "scanner"
5
+ require_relative "registry"
6
+
7
+ module Stable
8
+ class CLI < Thor
9
+
10
+ HOSTS_FILE = "/etc/hosts".freeze
11
+
12
+ def initialize(*)
13
+ super
14
+ Stable::Bootstrap.run!
15
+ ensure_dependencies!
16
+ end
17
+
18
+ def self.exit_on_failure?
19
+ true
20
+ end
21
+
22
+ desc "new NAME", "Create, secure, and run a new Rails app"
23
+ method_option :ruby, type: :string, desc: "Ruby version (defaults to current Ruby)"
24
+ method_option :rails, type: :string, desc: "Rails version to install (optional)"
25
+ method_option :port, type: :numeric, desc: "Port to run Rails app on"
26
+ method_option :skip_ssl, type: :boolean, default: false, desc: "Skip HTTPS setup"
27
+ def new(name, ruby: RUBY_VERSION, rails: nil, port: nil)
28
+ port ||= next_free_port
29
+ app_path = File.expand_path(name)
30
+
31
+ abort "Folder already exists: #{app_path}" if File.exist?(app_path)
32
+
33
+ # --- Ensure RVM and Ruby ---
34
+ ensure_rvm!
35
+ puts "Using Ruby #{ruby} with RVM gemset #{name}..."
36
+ system("bash -lc 'rvm #{ruby}@#{name} --create do true'") or abort("Failed to create RVM gemset")
37
+
38
+ # --- Install Rails in gemset if needed ---
39
+ rails_version = rails || "latest"
40
+ rails_check = system("bash -lc 'rvm #{ruby}@#{name} do gem list -i rails#{rails ? " -v #{rails}" : ""}'")
41
+ unless rails_check
42
+ puts "Installing Rails #{rails_version} in gemset..."
43
+ system("bash -lc 'rvm #{ruby}@#{name} do gem install rails #{rails ? "-v #{rails}" : ""}'") or abort("Failed to install Rails")
44
+ end
45
+
46
+ # --- Create Rails app ---
47
+ puts "Creating Rails app #{name} (Ruby #{ruby})..."
48
+ system("bash -lc 'rvm #{ruby}@#{name} do rails new #{app_path}'") or abort("Rails app creation failed")
49
+
50
+ # --- Add .ruby-version and .ruby-gemset ---
51
+ Dir.chdir(app_path) do
52
+ File.write(".ruby-version", "#{ruby}\n")
53
+ File.write(".ruby-gemset", "#{name}\n")
54
+
55
+ # --- Install gems inside gemset ---
56
+ puts "Running bundle install..."
57
+ system("bash -lc 'rvm #{ruby}@#{name} do bundle install --jobs=4 --retry=3'") or abort("bundle install failed")
58
+ end
59
+
60
+ # --- Add app to registry ---
61
+ domain = "#{name}.test"
62
+ apps = Registry.apps
63
+ apps << { name: name, path: app_path, domain: domain, port: port, ruby: ruby }
64
+ Registry.save(apps)
65
+
66
+ # --- Host entry & certificate ---
67
+ add_host_entry(domain)
68
+ generate_cert(domain) unless options[:skip_ssl]
69
+ update_caddyfile(domain, port)
70
+ ensure_caddy_running!
71
+ caddy_reload
72
+
73
+ # --- Start Rails server ---
74
+ puts "Starting Rails server for #{name} on port #{port}..."
75
+ log_file = File.join(app_path, "log", "stable.log")
76
+ FileUtils.mkdir_p(File.dirname(log_file))
77
+ pid = spawn("bash -lc 'rvm #{ruby}@#{name} do cd #{app_path} && bundle exec rails s -p #{port} >> #{log_file} 2>&1'")
78
+ Process.detach(pid)
79
+
80
+ wait_for_port(port)
81
+ puts "✔ #{name} running at https://#{domain}"
82
+ end
83
+
84
+
85
+ desc "list", "List detected apps"
86
+ def list
87
+ apps = Registry.apps
88
+ if apps.empty?
89
+ puts "No apps found."
90
+ else
91
+ apps.each do |app|
92
+ puts "#{app[:name]} -> https://#{app[:domain]}"
93
+ end
94
+ end
95
+ end
96
+
97
+ desc "add FOLDER", "Add a Rails app folder"
98
+ def add(folder)
99
+ folder = File.expand_path(folder)
100
+ unless File.exist?(File.join(folder, "config", "application.rb"))
101
+ puts "Not a Rails app: #{folder}"
102
+ return
103
+ end
104
+
105
+ apps = Registry.apps
106
+ name = File.basename(folder)
107
+ domain = "#{name}.test"
108
+
109
+ if apps.any? { |a| a[:path] == folder }
110
+ puts "App already exists: #{name}"
111
+ return
112
+ end
113
+
114
+ port = next_free_port
115
+ ruby = detect_ruby_version(folder)
116
+
117
+ apps << { name: name, path: folder, domain: domain, port: port, ruby: ruby }
118
+ Registry.save(apps)
119
+ puts "Added #{name} -> https://#{domain} (port #{port})"
120
+
121
+ add_host_entry(domain)
122
+ generate_cert(domain)
123
+ update_caddyfile(domain, port)
124
+ caddy_reload
125
+ end
126
+
127
+ desc "remove NAME", "Remove an app by name"
128
+ def remove(name)
129
+ apps = Registry.apps
130
+ app = apps.find { |a| a[:name] == name }
131
+ if app.nil?
132
+ puts "No app found with name #{name}"
133
+ return
134
+ end
135
+
136
+ new_apps = apps.reject { |a| a[:name] == name }
137
+ Registry.save(new_apps)
138
+ puts "Removed #{name}"
139
+
140
+ remove_host_entry(app[:domain])
141
+ remove_caddy_entry(app[:domain])
142
+ caddy_reload
143
+ end
144
+
145
+ desc "start NAME", "Start a Rails app with its correct Ruby version"
146
+ def start(name)
147
+ app = Registry.apps.find { |a| a[:name] == name }
148
+ unless app
149
+ puts "No app found with name #{name}"
150
+ return
151
+ end
152
+
153
+ port = app[:port] || next_free_port
154
+ ruby = app[:ruby]
155
+
156
+ puts "Starting #{name} on port #{port}#{ruby ? " (Ruby #{ruby})" : ""}..."
157
+
158
+ log_file = File.join(app[:path], "log", "stable.log")
159
+ FileUtils.mkdir_p(File.dirname(log_file))
160
+
161
+ ruby_exec =
162
+ if ruby
163
+ if rvm_available?
164
+ ensure_rvm_ruby!(ruby)
165
+ "rvm #{ruby}@#{name} do"
166
+ elsif rbenv_available?
167
+ ensure_rbenv_ruby!(ruby)
168
+ "RBENV_VERSION=#{ruby}"
169
+ else
170
+ puts "No Ruby version manager found (rvm or rbenv)"
171
+ return
172
+ end
173
+ end
174
+
175
+ cmd = <<~CMD
176
+ cd #{app[:path]} &&
177
+ #{ruby_exec} bundle exec rails s -p #{port}
178
+ CMD
179
+
180
+ pid = spawn(
181
+ "bash",
182
+ "-lc",
183
+ cmd,
184
+ out: log_file,
185
+ err: log_file
186
+ )
187
+
188
+ Process.detach(pid)
189
+
190
+ generate_cert(app[:domain])
191
+ update_caddyfile(app[:domain], port)
192
+ wait_for_port(port)
193
+ caddy_reload
194
+
195
+ puts "#{name} started on https://#{app[:domain]}"
196
+ end
197
+
198
+ desc "stop NAME", "Stop a Rails app (default port 3000)"
199
+ def stop(name)
200
+ app = Registry.apps.find { |a| a[:name] == name }
201
+
202
+ output = `lsof -i tcp:#{app[:port]} -t`.strip
203
+ if output.empty?
204
+ puts "No app running on port #{app[:port]}"
205
+ else
206
+ output.split("\n").each { |pid| Process.kill("TERM", pid.to_i) }
207
+ puts "Stopped #{name}"
208
+ end
209
+ end
210
+
211
+ desc "setup", "Sets up Caddy and local trusted certificates"
212
+ def setup
213
+ FileUtils.mkdir_p(Stable::Paths.root)
214
+ File.write(Stable::Paths.caddyfile, "") unless File.exist?(Stable::Paths.caddyfile)
215
+ ensure_caddy_running!
216
+ puts "Caddy home initialized at #{Stable::Paths.root}"
217
+ end
218
+
219
+ desc "caddy reload", "Reloads Caddy after adding/removing apps"
220
+ def caddy_reload
221
+ if system("which caddy > /dev/null")
222
+ system("caddy reload --config #{Stable::Paths.caddyfile}")
223
+ puts "Caddy reloaded"
224
+ else
225
+ puts "Caddy not found. Install Caddy first."
226
+ end
227
+ end
228
+
229
+ desc "secure DOMAIN", "Generate trusted local HTTPS cert for a specific folder/domain"
230
+ def secure(domain)
231
+ app = Registry.apps.find { |a| a[:domain] == domain }
232
+ unless app
233
+ puts "No app found with domain #{domain}"
234
+ return
235
+ end
236
+ secure_app(domain, app[:path], app[:port])
237
+ caddy_reload
238
+ puts "Secured https://#{domain}"
239
+ end
240
+
241
+
242
+ desc "doctor", "Check Stable system health"
243
+ def doctor
244
+ puts "Stable doctor\n\n"
245
+
246
+ puts "Ruby version: #{RUBY_VERSION}"
247
+ puts "RVM: #{rvm_available? ? "yes" : "no"}"
248
+ puts "rbenv: #{rbenv_available? ? "yes" : "no"}"
249
+ puts "Caddy: #{system("which caddy > /dev/null") ? "yes" : "no"}"
250
+ puts "mkcert: #{system("which mkcert > /dev/null") ? "yes" : "no"}"
251
+
252
+ Registry.apps.each do |app|
253
+ status = port_in_use?(app[:port]) ? "running" : "stopped"
254
+ puts "#{app[:name]} → Ruby #{app[:ruby] || "default"} (#{status})"
255
+ end
256
+ end
257
+
258
+ desc "upgrade-ruby NAME VERSION", "Upgrade Ruby for an app"
259
+ def upgrade_ruby(name, version)
260
+ app = Registry.apps.find { |a| a[:name] == name }
261
+ unless app
262
+ puts "No app named #{name}"
263
+ return
264
+ end
265
+
266
+ if rvm_available?
267
+ system("bash -lc 'rvm install #{version}'")
268
+ elsif rbenv_available?
269
+ system("rbenv install #{version}")
270
+ else
271
+ puts "No Ruby version manager found"
272
+ return
273
+ end
274
+
275
+ File.write(File.join(app[:path], ".ruby-version"), version)
276
+ app[:ruby] = version
277
+ Registry.save(Registry.apps)
278
+
279
+ puts "#{name} now uses Ruby #{version}"
280
+ end
281
+
282
+ private
283
+
284
+ def add_host_entry(domain)
285
+ entry = "127.0.0.1\t#{domain}"
286
+ hosts = File.read(HOSTS_FILE)
287
+ unless hosts.include?(domain)
288
+ puts "Adding #{domain} to #{HOSTS_FILE}..."
289
+ File.open(HOSTS_FILE, "a") { |f| f.puts entry }
290
+ system("dscacheutil -flushcache; sudo killall -HUP mDNSResponder")
291
+ end
292
+ rescue Errno::EACCES
293
+ ensure_hosts_entry(domain)
294
+ end
295
+
296
+ def remove_host_entry(domain)
297
+ begin
298
+ hosts = File.read(HOSTS_FILE)
299
+ new_hosts = hosts.lines.reject { |line| line.include?(domain) }.join
300
+ File.write(HOSTS_FILE, new_hosts)
301
+ system("dscacheutil -flushcache; sudo killall -HUP mDNSResponder")
302
+ rescue Errno::EACCES
303
+ puts "Permission denied updating #{HOSTS_FILE}. Run 'sudo stable remove #{domain}' to remove hosts entry."
304
+ end
305
+ end
306
+
307
+ def ensure_hosts_entry(domain)
308
+ entry = "127.0.0.1\t#{domain}"
309
+
310
+ hosts = File.read(HOSTS_FILE)
311
+ return if hosts.include?(domain)
312
+
313
+ if Process.uid.zero?
314
+ File.open(HOSTS_FILE, "a") { |f| f.puts entry }
315
+ else
316
+ system(%(echo "#{entry}" | sudo tee -a #{HOSTS_FILE} > /dev/null))
317
+ end
318
+
319
+ system("dscacheutil -flushcache; sudo killall -HUP mDNSResponder")
320
+ end
321
+
322
+ def secure_app(domain, _folder, port)
323
+ ensure_certs_dir!
324
+
325
+ cert_path = File.join(Stable::Paths.certs_dir, "#{domain}.pem")
326
+ key_path = File.join(Stable::Paths.certs_dir, "#{domain}-key.pem")
327
+
328
+ # Generate certificates if missing
329
+ if system("which mkcert > /dev/null")
330
+ unless File.exist?(cert_path) && File.exist?(key_path)
331
+ system("mkcert -cert-file #{cert_path} -key-file #{key_path} #{domain}")
332
+ end
333
+ else
334
+ puts "mkcert not found. Please install mkcert."
335
+ return
336
+ end
337
+
338
+ # Auto-add Caddy block if not already in Caddyfile
339
+ add_caddy_block(domain, cert_path, key_path, port)
340
+ caddy_reload
341
+ end
342
+
343
+ def add_caddy_block(domain, cert, key, port)
344
+ caddyfile = Stable::Paths.caddyfile
345
+ FileUtils.touch(caddyfile) unless File.exist?(caddyfile)
346
+ content = File.read(caddyfile)
347
+
348
+ return if content.include?(domain) # don't duplicate
349
+
350
+ block = <<~CADDY
351
+
352
+ https://#{domain} {
353
+ reverse_proxy 127.0.0.1:#{port}
354
+ tls #{cert} #{key}
355
+ }
356
+ CADDY
357
+
358
+ File.write(caddyfile, content + block)
359
+ system("caddy fmt --overwrite #{caddyfile}")
360
+ end
361
+
362
+
363
+ # Remove Caddyfile entry for the domain
364
+ def remove_caddy_entry(domain)
365
+ return unless File.exist?(Stable::Paths.caddyfile)
366
+ content = File.read(Stable::Paths.caddyfile)
367
+ # Remove block starting with https://<domain> { ... }
368
+ new_content = content.gsub(/https:\/\/#{Regexp.escape(domain)}\s*\{[^\}]*\}/m, "")
369
+ File.write(Stable::Paths.caddyfile, new_content)
370
+ end
371
+
372
+ def ensure_dependencies!
373
+ unless system("which brew > /dev/null")
374
+ puts "Homebrew is required. Install it first: https://brew.sh"
375
+ exit 1
376
+ end
377
+
378
+ unless system("which caddy > /dev/null")
379
+ puts "Installing Caddy..."
380
+ system("brew install caddy")
381
+ end
382
+
383
+ unless system("which mkcert > /dev/null")
384
+ puts "Installing mkcert..."
385
+ system("brew install mkcert nss")
386
+ system("mkcert -install")
387
+ end
388
+ end
389
+ def ensure_caddy_running!
390
+ api_port = 2019
391
+
392
+ # Check if Caddy API is reachable
393
+ require 'socket'
394
+ begin
395
+ TCPSocket.new('127.0.0.1', api_port).close
396
+ puts "Caddy already running."
397
+ rescue Errno::ECONNREFUSED
398
+ puts "Starting Caddy in background..."
399
+ system("caddy run --config #{Stable::Paths.caddyfile} --adapter caddyfile --watch --resume &")
400
+ sleep 3
401
+ end
402
+ end
403
+
404
+
405
+ def next_free_port
406
+ used_ports = Registry.apps.map { |a| a[:port] }
407
+ port = 3000
408
+ port += 1 while used_ports.include?(port) || port_in_use?(port)
409
+ port
410
+ end
411
+
412
+ def port_in_use?(port)
413
+ system("lsof -i tcp:#{port} > /dev/null 2>&1")
414
+ end
415
+
416
+ def generate_cert(domain)
417
+ cert_path = File.join(Stable::Paths.certs_dir, "#{domain}.pem")
418
+ key_path = File.join(Stable::Paths.certs_dir, "#{domain}-key.pem")
419
+ FileUtils.mkdir_p(Stable::Paths.certs_dir)
420
+
421
+ unless File.exist?(cert_path) && File.exist?(key_path)
422
+ if system("which mkcert > /dev/null")
423
+ system("mkcert -cert-file #{cert_path} -key-file #{key_path} #{domain}")
424
+ else
425
+ puts "mkcert not found. Please install mkcert."
426
+ end
427
+ end
428
+ end
429
+
430
+ def update_caddyfile(domain, port)
431
+ caddyfile = Stable::Paths.caddyfile
432
+ FileUtils.touch(caddyfile) unless File.exist?(caddyfile)
433
+ content = File.read(caddyfile)
434
+
435
+ # remove existing block for domain
436
+ content.gsub!(/https:\/\/#{Regexp.escape(domain)}\s*\{[^\}]*\}/m, "")
437
+
438
+ # add new block
439
+ cert_path = File.join(Stable::Paths.certs_dir, "#{domain}.pem")
440
+ key_path = File.join(Stable::Paths.certs_dir, "#{domain}-key.pem")
441
+ block = <<~CADDY
442
+
443
+ https://#{domain} {
444
+ reverse_proxy 127.0.0.1:#{port}
445
+ tls #{cert_path} #{key_path}
446
+ }
447
+ CADDY
448
+
449
+ File.write(caddyfile, content + block)
450
+ system("caddy fmt --overwrite #{caddyfile}")
451
+ end
452
+
453
+ def ensure_certs_dir!
454
+ certs_dir = Stable::Paths.certs_dir
455
+ FileUtils.mkdir_p(certs_dir)
456
+
457
+ begin
458
+ FileUtils.chown_R(Etc.getlogin, nil, certs_dir)
459
+ rescue => e
460
+ puts "Could not change ownership: #{e.message}"
461
+ end
462
+
463
+ # Restrict permissions for security
464
+ Dir.glob("#{certs_dir}/*.pem").each do |pem|
465
+ FileUtils.chmod(0600, pem)
466
+ end
467
+ end
468
+
469
+ def wait_for_port(port, timeout: 5)
470
+ require 'socket'
471
+ start_time = Time.now
472
+ loop do
473
+ begin
474
+ TCPSocket.new('127.0.0.1', port).close
475
+ break
476
+ rescue Errno::ECONNREFUSED
477
+ raise "Timeout waiting for port #{port}" if Time.now - start_time > timeout
478
+ sleep 0.1
479
+ end
480
+ end
481
+ end
482
+
483
+ def ensure_rvm!
484
+ return if system("which rvm > /dev/null")
485
+
486
+ puts "RVM not found. Installing RVM..."
487
+
488
+ install_cmd = <<~CMD
489
+ curl -sSL https://get.rvm.io | bash -s stable
490
+ CMD
491
+
492
+ unless system(install_cmd)
493
+ abort "RVM installation failed"
494
+ end
495
+
496
+ # Load RVM into current process
497
+ rvm_script = File.expand_path("~/.rvm/scripts/rvm")
498
+ unless File.exist?(rvm_script)
499
+ abort "RVM installed but could not be loaded"
500
+ end
501
+
502
+ ENV["PATH"] = "#{File.expand_path('~/.rvm/bin')}:#{ENV['PATH']}"
503
+
504
+ system(%(bash -lc "source #{rvm_script} && rvm --version")) ||
505
+ abort("RVM installed but not functional")
506
+ end
507
+
508
+ def ensure_ruby_installed!(version)
509
+ return if system("rvm list strings | grep ruby-#{version} > /dev/null")
510
+
511
+ puts "Installing Ruby #{version}..."
512
+ system("rvm install #{version}") || abort("Failed to install Ruby #{version}")
513
+ end
514
+
515
+ def detect_ruby_version(path)
516
+ rv = File.join(path, ".ruby-version")
517
+ return File.read(rv).strip if File.exist?(rv)
518
+
519
+ gemfile = File.join(path, "Gemfile")
520
+ if File.exist?(gemfile)
521
+ ruby_line = File.read(gemfile)[/^ruby ['"](.+?)['"]/, 1]
522
+ return ruby_line if ruby_line
523
+ end
524
+
525
+ nil
526
+ end
527
+
528
+ def rvm_available?
529
+ system("bash -lc 'command -v rvm > /dev/null'")
530
+ end
531
+
532
+ def rbenv_available?
533
+ system("command -v rbenv > /dev/null")
534
+ end
535
+
536
+ def ensure_rvm_ruby!(version)
537
+ system("bash -lc 'rvm list strings | grep -q #{version} || rvm install #{version}'")
538
+ end
539
+
540
+ def ensure_rbenv_ruby!(version)
541
+ system("rbenv versions | grep -q #{version} || rbenv install #{version}")
542
+ end
543
+
544
+ end
545
+ end
@@ -0,0 +1,23 @@
1
+ module Stable
2
+ module Paths
3
+ def self.root
4
+ File.expand_path("~/StableCaddy")
5
+ end
6
+
7
+ def self.caddy_dir
8
+ root
9
+ end
10
+
11
+ def self.caddyfile
12
+ File.join(caddy_dir, "Caddyfile")
13
+ end
14
+
15
+ def self.certs_dir
16
+ File.join(root, "certs")
17
+ end
18
+
19
+ def self.apps_file
20
+ File.join(root, "apps.yml")
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,19 @@
1
+ require "yaml"
2
+ require "fileutils"
3
+
4
+ module Stable
5
+ class Registry
6
+ def self.file_path
7
+ File.join(Stable.root, "apps.yml")
8
+ end
9
+
10
+ def self.save(apps)
11
+ FileUtils.mkdir_p(Stable.root)
12
+ File.write(file_path, apps.to_yaml)
13
+ end
14
+
15
+ def self.apps
16
+ File.exist?(file_path) ? YAML.load_file(file_path) : []
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,8 @@
1
+
2
+ module Stable
3
+ class Scanner
4
+ def self.run
5
+ Registry.save([])
6
+ end
7
+ end
8
+ end
data/lib/stable.rb ADDED
@@ -0,0 +1,14 @@
1
+ module Stable
2
+ def self.root
3
+ File.expand_path("~/.stable")
4
+ end
5
+ end
6
+
7
+ require "fileutils"
8
+ require_relative "stable/paths"
9
+ require_relative "stable/cli"
10
+ require_relative "stable/registry"
11
+ require_relative "stable/scanner"
12
+ require_relative "stable/bootstrap"
13
+
14
+
metadata ADDED
@@ -0,0 +1,121 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: stable-cli-rails
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Danny Simfukwe
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: thor
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: fileutils
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ description: "# Stable CLI (macOS)\n\nStable is a CLI tool to manage local Rails applications
41
+ with automatic Caddy setup on macOS, local trusted HTTPS certificates, and easy
42
+ start/stop functionality.\n\n## Features\n\n- Add and remove Rails apps.\n- Automatically
43
+ generate and manage local HTTPS certificates using `mkcert`.\n- Automatically update
44
+ `/etc/hosts` for `.test` domains.\n- Start Rails apps with integrated Caddy reverse
45
+ proxy.\n- Reload Caddy after adding/removing apps.\n- List all registered apps.\n\n##
46
+ Installation\n\n### From source\n\n```bash\n# Clone the repository\ngit clone git@github.com:dannysimfukwe/stable-rails.git\ncd
47
+ stable-rails\n\n# Install dependencies\nbundle install\n```\n\n### As a gem\n\n```bash\ngem
48
+ install stable\n```\n\n## Setup\n\nInitialize Caddy home and required directories:\n\n```bash\nstable
49
+ setup\n```\n\nThis will create: \n- `~/StableCaddy/` for Caddy configuration. \n-
50
+ `~/StableCaddy/certs` for generated certificates. \n- `~/StableCaddy/Caddyfile`
51
+ for Caddy configuration. \n\n## CLI Commands\n\n### List apps\n\n```bash\nstable
52
+ list\n```\n\nLists all registered apps and their domains.\n\n### Add a Rails app\n\n```bash\nstable
53
+ add /path/to/rails_app\n```\n\nThis will: \n- Register the app. \n- Add a `/etc/hosts`
54
+ entry. \n- Generate local trusted HTTPS certificates. \n- Add a Caddy reverse
55
+ proxy block. \n- Reload Caddy.\n\n### Remove a Rails app\n\n```bash\nstable remove
56
+ app_name\n```\n\nThis will: \n- Remove the app from registry. \n- Remove `/etc/hosts`
57
+ entry. \n- Remove the Caddy reverse proxy block. \n- Reload Caddy.\n\n### Start
58
+ an app\n\n```bash\nrvmsudo stable start app_name\n```\n\nStarts the Rails server
59
+ on the assigned port and ensures Caddy is running with the proper reverse proxy.
60
+ Rails logs can be viewed in your terminal.\n\n### Stop an app\n\n```bash\nstable
61
+ stop app_name\n```\n\nStops the Rails server running on the assigned port.\n\n###
62
+ Secure an app manually\n\n```bash\nrvmsudo stable secure app_name.test\n```\n\nGenerates
63
+ or updates trusted local HTTPS certificates and reloads Caddy.\n\n### Reload Caddy\n\n```bash\nstable
64
+ caddy reload\n```\n\nReloads Caddy configuration after changes.\n\n### Health check\n\n```bash\nstable
65
+ doctor\n```\n\nChecks the environment, RVM/Ruby, Caddy, mkcert, and app readiness.\n\n###
66
+ Upgrade Ruby for an app\n\n```bash\nstable upgrade-ruby myapp 3.4.4\n```\n\nUpgrades
67
+ the Ruby version for a specific app, updating `.ruby-version` and ensuring gemset
68
+ compatibility.\n\n### Create a new Rails app\n\n```bash\nstable new myapp [--ruby
69
+ 3.4.4] [--rails 7.0.7.1] [--skip-ssl]\n```\n\nCreates a new Rails app, generates
70
+ `.ruby-version`, installs Rails, adds the app to Stable, and optionally secures
71
+ it with HTTPS.\n\n## Paths\n\n- Caddy home: `~/StableCaddy` \n- Caddyfile: `~/StableCaddy/Caddyfile`
72
+ \ \n- Certificates: `~/StableCaddy/certs` \n- Registered apps: `~/StableCaddy/apps.yml`
73
+ \ \n\n## Dependencies\n\n- Homebrew \n- Caddy \n- mkcert \n- RVM (or rbenv fallback)\n\n`ensure_dependencies!`
74
+ will install missing dependencies automatically.\n\n## Known Issues\n\n- Sometimes
75
+ you may see: \n```\nTCPSocket#initialize: Connection refused - connect(2) for \"127.0.0.1\"
76
+ port 300.. (Errno::ECONNREFUSED)\n```\nThis usually disappears after a few seconds
77
+ when Caddy reloads. If it persists, run:\n\n```bash\nrvmsudo stable secure myapp.test\n```\n\n-
78
+ Some commands may need to be run consecutively for proper setup: \n```bash\nstable
79
+ setup\nrvmsudo stable add myapp\nrvmsudo stable secure myapp.test\nstable start
80
+ myapp\n```\n\n- PATH warnings from RVM may appear on the first run. Make sure your
81
+ shell is properly configured for RVM.\n\n## Notes\n\n- Make sure to run `stable
82
+ setup` initially. \n- Requires `sudo` to modify `/etc/hosts`. \n- Rails apps are
83
+ started on ports assigned by Stable (default 3000+). \n- Domains are automatically
84
+ suffixed with `.test`. \n\n## License\n\nMIT License\n"
85
+ email:
86
+ - dannysimfukwe@gmail.com
87
+ executables:
88
+ - stable
89
+ extensions: []
90
+ extra_rdoc_files: []
91
+ files:
92
+ - README.md
93
+ - bin/stable
94
+ - lib/stable.rb
95
+ - lib/stable/bootstrap.rb
96
+ - lib/stable/cli.rb
97
+ - lib/stable/paths.rb
98
+ - lib/stable/registry.rb
99
+ - lib/stable/scanner.rb
100
+ homepage: https://github.com/dannysimfukwe/stable-rails
101
+ licenses:
102
+ - MIT
103
+ metadata: {}
104
+ rdoc_options: []
105
+ require_paths:
106
+ - lib
107
+ required_ruby_version: !ruby/object:Gem::Requirement
108
+ requirements:
109
+ - - ">="
110
+ - !ruby/object:Gem::Version
111
+ version: '0'
112
+ required_rubygems_version: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - ">="
115
+ - !ruby/object:Gem::Version
116
+ version: '0'
117
+ requirements: []
118
+ rubygems_version: 3.6.7
119
+ specification_version: 4
120
+ summary: CLI tool to manage local Rails apps with automatic Caddy and HTTPS setup
121
+ test_files: []