stable-cli-rails 0.8.4 → 0.8.5

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ea91d16ba356305a67c3627e75bfe16f2d780c0523d5420485accf4b4b70a2ca
4
- data.tar.gz: c7344e77e0294a139d14e588fb28706b9c2e22cb5b1b00ae0aaa8e05d182b74a
3
+ metadata.gz: f9eaea11c07941f7a09145d69497c0aff9ee2d85cc2d686fe238b5031e0c554b
4
+ data.tar.gz: c9ba35f2b2a9ae12be1070d9811fc75e66d21c4f751fc99a2ecc0cad59128e7d
5
5
  SHA512:
6
- metadata.gz: ab9bfc8f6f5c5588ad6f9b8819ea4b504653ebe40e885e0796c51082e33b8a4e02189aca720dbed0edb971e558c6f4e23d2d405189ab53301eef6283fe3d4280
7
- data.tar.gz: 8de8596f02772e8a98538ec45b52648869dde114bc65cf5958abff944a5c4c52389c278d1c821589952d0ef347974f22a7c0f9bb25f90ef7159370bfb10130ec
6
+ metadata.gz: 866da469383dc2e71c0bc766a813391586be2f0af2447b8a063565c4f30d22b6cb45f113c8a53736bd5b8e6dfb1dcf647b5ee42667b5ad2601b1d703cad31f0b
7
+ data.tar.gz: fec6d17ade09f6bf898eed2b12de5ca060e249b5e3ca5b6cf72fdf78778dd743ff23f147e410767adfd8ae672cd33c640027b639c2c99c9f3dcfad0f5b29a3b1
data/lib/stable/cli.rb CHANGED
@@ -136,8 +136,22 @@ module Stable
136
136
  end
137
137
 
138
138
  desc 'open APP', 'Open a running app in the browser'
139
- def open(app_name)
140
- Stable::Commands::Open.new(app_name).call
139
+ def open(name)
140
+ Stable::Commands::Open.new(name).call
141
+ end
142
+
143
+ desc 'share APP [PROVIDER]', 'Share app via public tunnel'
144
+ method_option :provider, type: :string, default: nil, desc: 'Tunnel provider (ngrok, stable)'
145
+ method_option :qrcode, type: :boolean, default: false, desc: 'Generate a Qr code for the shared URL'
146
+ def share(name, provider = nil, qrcode: false)
147
+ provider ||= options[:provider] || 'ngrok'
148
+ Commands::Share.new(name, provider: provider.to_sym, qrcode: options[:qrcode]).call
149
+ end
150
+
151
+ desc 'workdir APP', 'Open the app folder in a code editor'
152
+ method_option :editor, type: :string, default: 'vscode', desc: 'Code editor to use (vscode, sublime, atom, etc.)'
153
+ def workdir(name)
154
+ Commands::Workdir.new(name, options[:editor]).call
141
155
  end
142
156
 
143
157
  private
@@ -11,94 +11,7 @@ module Stable
11
11
  end
12
12
 
13
13
  def call
14
- app = Services::AppRegistry.find(@name)
15
- abort 'App not found' unless app
16
-
17
- display_warning(app)
18
- return unless confirm_destruction
19
-
20
- puts "\nšŸ—‘ļø Destroying #{@name}..."
21
- perform_destruction(app)
22
- puts "āœ… Successfully destroyed #{@name}"
23
- end
24
-
25
- private
26
-
27
- def display_warning(app)
28
- puts "āš ļø WARNING: This will permanently delete the application '#{@name}'"
29
- puts " Path: #{app[:path]}"
30
- puts " Domain: #{app[:domain]}"
31
- puts ' This action CANNOT be undone!'
32
- puts ''
33
- end
34
-
35
- def confirm_destruction
36
- print "Type '#{@name}' to confirm destruction: "
37
- confirmation = $stdin.gets&.strip
38
- puts ''
39
-
40
- if confirmation == @name
41
- true
42
- else
43
- puts "āŒ Destruction cancelled - confirmation didn't match"
44
- false
45
- end
46
- end
47
-
48
- def perform_destruction(app)
49
- # Stop the app if running
50
- Services::ProcessManager.stop(app)
51
-
52
- # Remove from infrastructure
53
- Services::HostsManager.remove(app[:domain])
54
- Services::CaddyManager.remove(app[:domain])
55
- Services::AppRegistry.remove(@name)
56
-
57
- # Clean up RVM gemset
58
- cleanup_rvm_gemset(app)
59
-
60
- # Delete the project directory
61
- delete_project_directory(app[:path])
62
-
63
- # Reload Caddy
64
- Services::CaddyManager.reload
65
- end
66
-
67
- def cleanup_rvm_gemset(app)
68
- # Skip RVM operations in test mode
69
- return if ENV['STABLE_TEST_MODE']
70
-
71
- # Only clean up RVM gemsets on Unix-like systems (macOS/Linux)
72
- # Windows uses different Ruby version managers
73
- return unless Stable::Utils::Platform.unix?
74
-
75
- ruby_version = app[:ruby]
76
- # Handle different ruby version formats (e.g., "3.4.7", "ruby-3.4.7")
77
- clean_ruby_version = ruby_version.to_s.sub(/^ruby-/, '')
78
- gemset_name = "#{clean_ruby_version}@#{@name}"
79
-
80
- puts " Cleaning up RVM gemset #{gemset_name}..."
81
- begin
82
- # Use system to run RVM command to delete the gemset
83
- system("bash -lc 'source ~/.rvm/scripts/rvm && rvm gemset delete #{gemset_name} --force' 2>/dev/null || true")
84
- puts " āœ… RVM gemset #{gemset_name} cleaned up"
85
- rescue StandardError => e
86
- puts " āš ļø Could not clean up RVM gemset #{gemset_name}: #{e.message}"
87
- end
88
- end
89
-
90
- def delete_project_directory(path)
91
- if ENV['STABLE_TEST_MODE']
92
- puts ' Deleting project directory...'
93
- return
94
- end
95
-
96
- if File.exist?(path)
97
- puts ' Deleting project directory...'
98
- FileUtils.rm_rf(path)
99
- else
100
- puts ' Project directory not found (already deleted?)'
101
- end
14
+ Services::AppDestroyer.new(@name).call
102
15
  end
103
16
  end
104
17
  end
@@ -9,47 +9,7 @@ module Stable
9
9
  end
10
10
 
11
11
  def call
12
- app = Services::AppRegistry.find(@app_name)
13
- abort "App '#{@app_name}' not found" unless app
14
-
15
- abort "App '#{@app_name}' is not running" unless app[:pid] && process_alive?(app[:pid])
16
- url = build_url(app)
17
- open_browser(url)
18
- puts "āœ” Opened #{url}"
19
- end
20
-
21
- private
22
-
23
- def build_url(app)
24
- scheme = app[:skip_ssl] ? 'http' : 'https'
25
- if app[:domain]
26
- "#{scheme}://#{app[:domain]}"
27
- else
28
- "#{scheme}://127.0.0.1:#{app[:port]}"
29
- end
30
- end
31
-
32
- def open_browser(url)
33
- cmd =
34
- case RbConfig::CONFIG['host_os']
35
- when /darwin/
36
- "open #{url}"
37
- when /linux/
38
- "xdg-open #{url}"
39
- when /mswin|mingw/
40
- "start #{url}"
41
- else
42
- abort 'Unsupported OS'
43
- end
44
-
45
- system(cmd) || abort('Failed to open browser')
46
- end
47
-
48
- def process_alive?(pid)
49
- Process.kill(0, pid)
50
- true
51
- rescue Errno::ESRCH
52
- false
12
+ Services::AppOpener.new(@app_name).call
53
13
  end
54
14
  end
55
15
  end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stable
4
+ module Commands
5
+ # Share app's public url
6
+ class Share
7
+ def initialize(app_name, provider: :ngrok, qrcode: false)
8
+ @app_name = app_name
9
+ @provider = provider
10
+ @qrcode = qrcode
11
+ end
12
+
13
+ def call
14
+ app = Services::AppRegistry.find(@app_name)
15
+ abort "App '#{@app_name}' not found" unless app
16
+ abort "App '#{@app_name}' is not running" unless running?(app)
17
+ Services::Rails::HostAuthorization.allow_ngrok!(app[:path])
18
+ Services::ProcessManager.stop(app) # stop the app
19
+ Services::ProcessManager.start(app) # restart the app
20
+
21
+ # Pass the real app port here
22
+ url = Services::Tunneling::Manager
23
+ .new(provider: @provider)
24
+ .expose_domain(app[:domain], port: app[:port], skip_ssl: app[:skip_ssl])
25
+
26
+ puts "🌐 Shared #{@app_name} at:"
27
+ puts " #{url}"
28
+
29
+ return unless @qrcode
30
+
31
+ Services::Cli::QrCode.print(url)
32
+ end
33
+
34
+ private
35
+
36
+ def running?(app)
37
+ pid = app[:pid]
38
+ return false unless pid
39
+
40
+ Process.kill(0, pid)
41
+ true
42
+ rescue Errno::ESRCH
43
+ false
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stable
4
+ module Commands
5
+ # Unshare app's public url
6
+ class UnShare
7
+ def initialize(app_name, provider: :ngrok)
8
+ @app_name = app_name
9
+ @provider = provider
10
+ end
11
+
12
+ def call
13
+ app = Services::AppRegistry.find(@app_name)
14
+ abort "App '#{@app_name}' not found" unless app
15
+ Stable::Services::Rails::HostAuthorization.remove_ngrok!(app[:path])
16
+ end
17
+ end
18
+ end
19
+ end
@@ -12,149 +12,7 @@ module Stable
12
12
  end
13
13
 
14
14
  def call
15
- app = Services::AppRegistry.find(@name)
16
- unless app
17
- puts "No app named #{@name}"
18
- return
19
- end
20
-
21
- current_version = app[:ruby] || RUBY_VERSION
22
-
23
- puts "#{action(current_version, @version)} #{@name} from Ruby #{current_version} to #{@version}..."
24
- puts ''
25
-
26
- # Install the target Ruby version if needed
27
- platform = Stable::Utils::Platform.current
28
-
29
- if platform == :windows
30
- puts 'āš ļø Windows detected - Ruby version managers work differently on Windows'
31
- puts " Please manually install Ruby #{@version} using RubyInstaller or your preferred method"
32
- puts ' Recommended: https://rubyinstaller.org/'
33
- puts ' Then update your PATH to use the new Ruby version'
34
- puts ''
35
- puts "After installing Ruby #{@version}, update the app configuration manually:"
36
- puts " - Edit .ruby-version file to contain: #{@version}"
37
- puts ' - Run: bundle install (in the app directory)'
38
- return
39
- end
40
-
41
- if Stable::Services::Ruby.rvm_available?
42
- puts "Ensuring Ruby #{@version} is available..."
43
- system("bash -lc 'rvm install #{@version}'") unless ENV['STABLE_TEST_MODE']
44
- elsif Stable::Services::Ruby.rbenv_available?
45
- puts "Ensuring Ruby #{@version} is available..."
46
- system("rbenv install #{@version}") unless ENV['STABLE_TEST_MODE']
47
- else
48
- puts 'āŒ No supported Ruby version manager found'
49
- puts ' On macOS/Linux, install RVM (https://rvm.io/) or rbenv (https://github.com/rbenv/rbenv)'
50
- puts ' On Windows, use RubyInstaller (https://rubyinstaller.org/)'
51
- return
52
- end
53
-
54
- # Remove current Ruby environment and install new one fresh
55
- puts "šŸ”„ Upgrading #{@name} from Ruby #{current_version} to #{@version}..."
56
-
57
- # 1. Remove current Ruby version/gemset
58
- cleanup_rvm_gemset(app)
59
-
60
- # 2. Install new Ruby version
61
- setup_new_ruby_version(app, @version)
62
-
63
- puts ''
64
- puts "āœ… #{@name} #{past_tense_action(action(current_version, @version))} to Ruby #{@version}!"
65
- puts " Old gemset cleared, fresh #{@version}@#{@name} gemset created with gems"
66
- puts ''
67
- puts "Start with: stable start #{@name}"
68
- end
69
-
70
- private
71
-
72
- def cleanup_rvm_gemset(app)
73
- # Skip RVM operations in test mode
74
- return if ENV['STABLE_TEST_MODE']
75
-
76
- # Only clean up RVM gemsets on Unix-like systems (macOS/Linux)
77
- # Windows uses different Ruby version managers
78
- return unless Stable::Utils::Platform.unix?
79
-
80
- ruby_version = app[:ruby]
81
- # Handle different ruby version formats (e.g., "3.4.7", "ruby-3.4.7")
82
- clean_ruby_version = ruby_version.to_s.sub(/^ruby-/, '')
83
- gemset_name = "#{clean_ruby_version}@#{@name}"
84
-
85
- puts " Cleaning up RVM gemset #{gemset_name}..."
86
- begin
87
- # Use system to run RVM command to delete the gemset
88
- system("bash -lc 'source ~/.rvm/scripts/rvm && rvm gemset delete #{gemset_name} --force' 2>/dev/null || true")
89
- puts " āœ… RVM gemset #{gemset_name} cleaned up"
90
- rescue StandardError => e
91
- puts " āš ļø Could not clean up RVM gemset #{gemset_name}: #{e.message}"
92
- end
93
- end
94
-
95
- def setup_new_ruby_version(app, new_version)
96
- unless ENV['STABLE_TEST_MODE']
97
- Stable::Services::Ruby.ensure_version(new_version)
98
- Stable::Services::Ruby.ensure_rvm!
99
-
100
- # Create gemset
101
- Stable::System::Shell.run("bash -lc 'source #{Stable::Services::Ruby.rvm_script} && rvm #{new_version} do rvm gemset create #{@name} || true'")
102
-
103
- rvm_cmd = Stable::Services::Ruby.rvm_prefix(new_version, @name)
104
-
105
- # Install Bundler
106
- Stable::System::Shell.run("bash -lc '#{rvm_cmd} gem install bundler --no-document'")
107
-
108
- # Run bundle install
109
- Stable::System::Shell.run(rvm_run('bundle install --jobs=4 --retry=3', chdir: app[:path]))
110
- end
111
-
112
- # Update app configuration
113
- unless ENV['STABLE_TEST_MODE']
114
- Dir.chdir(app[:path]) do
115
- File.write('.ruby-version', "#{new_version}\n")
116
- File.write('.ruby-gemset', "#{@name}\n")
117
- end
118
- end
119
-
120
- # Update registry
121
- Services::AppRegistry.update(@name, ruby: new_version)
122
- puts " āœ… New Ruby #{new_version} environment set up with gems"
123
- end
124
-
125
- def rvm_run(cmd, chdir: nil)
126
- cd = chdir ? "cd #{chdir} && " : ''
127
- "bash -lc '#{cd}source #{Dir.home}/.rvm/scripts/rvm && rvm #{@version}@#{@name} do #{cmd}'"
128
- end
129
-
130
- def action(current_version, new_version)
131
- current_parts = current_version.split('.').map(&:to_i)
132
- new_parts = new_version.split('.').map(&:to_i)
133
-
134
- if new_parts[0] > current_parts[0] ||
135
- (new_parts[0] == current_parts[0] && new_parts[1] > current_parts[1]) ||
136
- (new_parts[0] == current_parts[0] && new_parts[1] == current_parts[1] && new_parts[2] > current_parts[2])
137
- 'Upgrading'
138
- elsif new_parts[0] < current_parts[0] ||
139
- (new_parts[0] == current_parts[0] && new_parts[1] < current_parts[1]) ||
140
- (new_parts[0] == current_parts[0] && new_parts[1] == current_parts[1] && new_parts[2] < current_parts[2])
141
- 'Downgrading'
142
- else
143
- 'Switching'
144
- end
145
- end
146
-
147
- def past_tense_action(action)
148
- case action
149
- when 'Upgrading'
150
- 'upgraded'
151
- when 'Downgrading'
152
- 'downgraded'
153
- when 'Switching'
154
- 'switched'
155
- else
156
- 'updated'
157
- end
15
+ Services::AppUpgrader.new(@name, @version).call
158
16
  end
159
17
  end
160
18
  end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stable
4
+ module Commands
5
+ # Open App's work director
6
+ class Workdir
7
+ EDITOR_COMMANDS = {
8
+ 'vscode' => 'code',
9
+ 'sublime' => 'subl',
10
+ 'atom' => 'atom'
11
+ }.freeze
12
+
13
+ def initialize(app_name, editor)
14
+ @app_name = app_name
15
+ @editor = editor.downcase
16
+ end
17
+
18
+ def call
19
+ app = Services::AppRegistry.find(@app_name)
20
+ abort "App '#{@app_name}' not found" unless app
21
+ abort "App path does not exist: #{app[:path]}" unless Dir.exist?(app[:path])
22
+
23
+ editor_cmd = EDITOR_COMMANDS[@editor] || @editor # support custom editors
24
+ unless system("which #{editor_cmd} > /dev/null 2>&1")
25
+ abort "Editor command not found: #{editor_cmd}"
26
+ end
27
+
28
+ puts "šŸš€ Opening #{@app_name} in #{@editor}..."
29
+ system("#{editor_cmd} #{app[:path]}")
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stable
4
+ module Services
5
+ # Service for destroying a Rails application
6
+ class AppDestroyer
7
+ def initialize(name)
8
+ @name = name
9
+ end
10
+
11
+ def call
12
+ app = AppRegistry.find(@name)
13
+ abort 'App not found' unless app
14
+
15
+ display_warning(app)
16
+ return unless confirm_destruction?
17
+
18
+ puts "\nšŸ—‘ļø Destroying #{@name}..."
19
+ perform_destruction(app)
20
+ puts "āœ… Successfully destroyed #{@name}"
21
+ end
22
+
23
+ private
24
+
25
+ def display_warning(app)
26
+ puts "āš ļø WARNING: This will permanently delete the application '#{@name}'"
27
+ puts " Path: #{app[:path]}"
28
+ puts " Domain: #{app[:domain]}"
29
+ puts ' This action CANNOT be undone!'
30
+ puts ''
31
+ end
32
+
33
+ def confirm_destruction?
34
+ print "Type '#{@name}' to confirm destruction: "
35
+ confirmation = $stdin.gets&.strip
36
+ puts ''
37
+
38
+ if confirmation == @name
39
+ true
40
+ else
41
+ puts "āŒ Destruction cancelled - confirmation didn't match"
42
+ false
43
+ end
44
+ end
45
+
46
+ def perform_destruction(app)
47
+ # Stop the app if running
48
+ ProcessManager.stop(app)
49
+
50
+ # Remove from infrastructure
51
+ HostsManager.remove(app[:domain])
52
+ CaddyManager.remove(app[:domain])
53
+ AppRegistry.remove(@name)
54
+
55
+ # Clean up RVM gemset
56
+ cleanup_rvm_gemset(app)
57
+
58
+ # Delete the project directory
59
+ delete_project_directory(app[:path])
60
+
61
+ # Reload Caddy
62
+ CaddyManager.reload
63
+ end
64
+
65
+ def cleanup_rvm_gemset(app)
66
+ return if ENV['STABLE_TEST_MODE']
67
+ return unless Utils::Platform.unix?
68
+
69
+ ruby_version = app[:ruby]
70
+ clean_ruby_version = ruby_version.to_s.sub(/^ruby-/, '')
71
+ gemset_name = "#{clean_ruby_version}@#{@name}"
72
+
73
+ puts " Cleaning up RVM gemset #{gemset_name}..."
74
+ begin
75
+ system("bash -lc 'source ~/.rvm/scripts/rvm && rvm gemset delete #{gemset_name} --force' 2>/dev/null || true")
76
+ puts " āœ… RVM gemset #{gemset_name} cleaned up"
77
+ rescue StandardError => e
78
+ puts " āš ļø Could not clean up RVM gemset #{gemset_name}: #{e.message}"
79
+ end
80
+ end
81
+
82
+ def delete_project_directory(path)
83
+ if ENV['STABLE_TEST_MODE']
84
+ puts ' Deleting project directory...'
85
+ return
86
+ end
87
+
88
+ if File.exist?(path)
89
+ puts ' Deleting project directory...'
90
+ FileUtils.rm_rf(path)
91
+ else
92
+ puts ' Project directory not found (already deleted?)'
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stable
4
+ module Services
5
+ # Service for opening Rails applications in a browser
6
+ class AppOpener
7
+ def initialize(app_name)
8
+ @app_name = app_name
9
+ end
10
+
11
+ def call
12
+ app = AppRegistry.find(@app_name)
13
+ abort "App '#{@app_name}' not found" unless app
14
+ abort "App '#{@app_name}' is not running" unless app[:pid] && process_alive?(app[:pid])
15
+ url = build_url(app)
16
+ open_browser(url)
17
+ puts "āœ” Opened #{url}"
18
+ end
19
+
20
+ private
21
+
22
+ def build_url(app)
23
+ scheme = app[:skip_ssl] ? 'http' : 'https'
24
+ if app[:domain]
25
+ "#{scheme}://#{app[:domain]}"
26
+ else
27
+ "#{scheme}://127.0.0.1:#{app[:port]}"
28
+ end
29
+ end
30
+
31
+ def open_browser(url)
32
+ cmd =
33
+ case RbConfig::CONFIG['host_os']
34
+ when /darwin/
35
+ "open #{url}"
36
+ when /linux/
37
+ "xdg-open #{url}"
38
+ when /mswin|mingw/
39
+ "start #{url}"
40
+ else
41
+ abort 'Unsupported OS'
42
+ end
43
+
44
+ system(cmd) || abort('Failed to open browser')
45
+ end
46
+
47
+ def process_alive?(pid)
48
+ Process.kill(0, pid)
49
+ true
50
+ rescue Errno::ESRCH
51
+ false
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stable
4
+ module Services
5
+ # Service for opening Rails applications in a browser
6
+ class AppUpgrader
7
+ def initialize(name, version)
8
+ @name = name
9
+ @version = version
10
+ end
11
+
12
+ def call
13
+ app = Services::AppRegistry.find(@name)
14
+ unless app
15
+ puts "No app named #{@name}"
16
+ return
17
+ end
18
+
19
+ current_version = app[:ruby] || RUBY_VERSION
20
+
21
+ puts "#{action(current_version, @version)} #{@name} from Ruby #{current_version} to #{@version}..."
22
+ puts ''
23
+
24
+ # Install the target Ruby version if needed
25
+ platform = Stable::Utils::Platform.current
26
+
27
+ if platform == :windows
28
+ puts 'āš ļø Windows detected - Ruby version managers work differently on Windows'
29
+ puts " Please manually install Ruby #{@version} using RubyInstaller or your preferred method"
30
+ puts ' Recommended: https://rubyinstaller.org/'
31
+ puts ' Then update your PATH to use the new Ruby version'
32
+ puts ''
33
+ puts "After installing Ruby #{@version}, update the app configuration manually:"
34
+ puts " - Edit .ruby-version file to contain: #{@version}"
35
+ puts ' - Run: bundle install (in the app directory)'
36
+ return
37
+ end
38
+
39
+ if Stable::Services::Ruby.rvm_available?
40
+ puts "Ensuring Ruby #{@version} is available..."
41
+ system("bash -lc 'rvm install #{@version}'") unless ENV['STABLE_TEST_MODE']
42
+ elsif Stable::Services::Ruby.rbenv_available?
43
+ puts "Ensuring Ruby #{@version} is available..."
44
+ system("rbenv install #{@version}") unless ENV['STABLE_TEST_MODE']
45
+ else
46
+ puts 'āŒ No supported Ruby version manager found'
47
+ puts ' On macOS/Linux, install RVM (https://rvm.io/) or rbenv (https://github.com/rbenv/rbenv)'
48
+ puts ' On Windows, use RubyInstaller (https://rubyinstaller.org/)'
49
+ return
50
+ end
51
+
52
+ # Remove current Ruby environment and install new one fresh
53
+ puts "šŸ”„ Upgrading #{@name} from Ruby #{current_version} to #{@version}..."
54
+
55
+ # 1. Remove current Ruby version/gemset
56
+ cleanup_rvm_gemset(app)
57
+
58
+ # 2. Install new Ruby version
59
+ setup_new_ruby_version(app, @version)
60
+
61
+ puts ''
62
+ puts "āœ… #{@name} #{past_tense_action(action(current_version, @version))} to Ruby #{@version}!"
63
+ puts " Old gemset cleared, fresh #{@version}@#{@name} gemset created with gems"
64
+ puts ''
65
+ puts "Start with: stable start #{@name}"
66
+ end
67
+
68
+ private
69
+
70
+ def cleanup_rvm_gemset(app)
71
+ # Skip RVM operations in test mode
72
+ return if ENV['STABLE_TEST_MODE']
73
+
74
+ # Only clean up RVM gemsets on Unix-like systems (macOS/Linux)
75
+ # Windows uses different Ruby version managers
76
+ return unless Stable::Utils::Platform.unix?
77
+
78
+ ruby_version = app[:ruby]
79
+ # Handle different ruby version formats (e.g., "3.4.7", "ruby-3.4.7")
80
+ clean_ruby_version = ruby_version.to_s.sub(/^ruby-/, '')
81
+ gemset_name = "#{clean_ruby_version}@#{@name}"
82
+
83
+ puts " Cleaning up RVM gemset #{gemset_name}..."
84
+ begin
85
+ # Use system to run RVM command to delete the gemset
86
+ system("bash -lc 'source ~/.rvm/scripts/rvm && rvm gemset delete #{gemset_name} --force' 2>/dev/null || true")
87
+ puts " āœ… RVM gemset #{gemset_name} cleaned up"
88
+ rescue StandardError => e
89
+ puts " āš ļø Could not clean up RVM gemset #{gemset_name}: #{e.message}"
90
+ end
91
+ end
92
+
93
+ def setup_new_ruby_version(app, new_version)
94
+ unless ENV['STABLE_TEST_MODE']
95
+ Stable::Services::Ruby.ensure_version(new_version)
96
+ Stable::Services::Ruby.ensure_rvm!
97
+
98
+ # Create gemset
99
+ Stable::System::Shell.run("bash -lc 'source #{Stable::Services::Ruby.rvm_script} && rvm #{new_version} do rvm gemset create #{@name} || true'")
100
+
101
+ rvm_cmd = Stable::Services::Ruby.rvm_prefix(new_version, @name)
102
+
103
+ # Install Bundler
104
+ Stable::System::Shell.run("bash -lc '#{rvm_cmd} gem install bundler --no-document'")
105
+
106
+ # Run bundle install
107
+ Stable::System::Shell.run(rvm_run('bundle install --jobs=4 --retry=3', chdir: app[:path]))
108
+ end
109
+
110
+ # Update app configuration
111
+ unless ENV['STABLE_TEST_MODE']
112
+ Dir.chdir(app[:path]) do
113
+ File.write('.ruby-version', "#{new_version}\n")
114
+ File.write('.ruby-gemset', "#{@name}\n")
115
+ end
116
+ end
117
+
118
+ # Update registry
119
+ Services::AppRegistry.update(@name, ruby: new_version)
120
+ puts " āœ… New Ruby #{new_version} environment set up with gems"
121
+ end
122
+
123
+ def rvm_run(cmd, chdir: nil)
124
+ cd = chdir ? "cd #{chdir} && " : ''
125
+ "bash -lc '#{cd}source #{Dir.home}/.rvm/scripts/rvm && rvm #{@version}@#{@name} do #{cmd}'"
126
+ end
127
+
128
+ def action(current_version, new_version)
129
+ current_parts = current_version.split('.').map(&:to_i)
130
+ new_parts = new_version.split('.').map(&:to_i)
131
+
132
+ if new_parts[0] > current_parts[0] ||
133
+ (new_parts[0] == current_parts[0] && new_parts[1] > current_parts[1]) ||
134
+ (new_parts[0] == current_parts[0] && new_parts[1] == current_parts[1] && new_parts[2] > current_parts[2])
135
+ 'Upgrading'
136
+ elsif new_parts[0] < current_parts[0] ||
137
+ (new_parts[0] == current_parts[0] && new_parts[1] < current_parts[1]) ||
138
+ (new_parts[0] == current_parts[0] && new_parts[1] == current_parts[1] && new_parts[2] < current_parts[2])
139
+ 'Downgrading'
140
+ else
141
+ 'Switching'
142
+ end
143
+ end
144
+
145
+ def past_tense_action(action)
146
+ case action
147
+ when 'Upgrading'
148
+ 'upgraded'
149
+ when 'Downgrading'
150
+ 'downgraded'
151
+ when 'Switching'
152
+ 'switched'
153
+ else
154
+ 'updated'
155
+ end
156
+ end
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rqrcode'
4
+
5
+ module Stable
6
+ module Services
7
+ module Cli
8
+ # Generate QR code
9
+ class QrCode
10
+ def self.print(url)
11
+ qr = RQRCode::QRCode.new(url)
12
+
13
+ puts
14
+ qr.modules.each do |row|
15
+ puts row.map { |cell| cell ? 'ā–ˆā–ˆ' : ' ' }.join
16
+ end
17
+ puts
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stable
4
+ module Services
5
+ module Rails
6
+ # Authorize ngro host
7
+ class HostAuthorization
8
+ MARKER_BEGIN = '# BEGIN Stable ngrok hosts'
9
+ MARKER_END = '# END Stable ngrok hosts'
10
+
11
+ def self.allow_ngrok!(app_path)
12
+ env_file = File.join(app_path, 'config/environments/development.rb')
13
+ return unless File.exist?(env_file)
14
+
15
+ content = File.read(env_file)
16
+ return if content.include?(MARKER_BEGIN)
17
+
18
+ # Only needed for Rails 6+
19
+ return unless rails_host_authorization_enabled?(app_path)
20
+
21
+ injection = <<~RUBY
22
+
23
+ #{MARKER_BEGIN}
24
+ config.hosts << ".ngrok-free.app"
25
+ config.hosts << ".ngrok.app"
26
+ #{MARKER_END}
27
+ RUBY
28
+
29
+ updated = content.sub(
30
+ /Rails\.application\.configure do\s*\n/,
31
+ "\\0#{injection}"
32
+ )
33
+
34
+ File.write(env_file, updated)
35
+ end
36
+
37
+ def self.remove_ngrok!(app_path)
38
+ env_file = File.join(app_path, 'config/environments/development.rb')
39
+ return unless File.exist?(env_file)
40
+
41
+ content = File.read(env_file)
42
+ return unless content.include?(MARKER_BEGIN)
43
+
44
+ cleaned = content.sub(
45
+ /\n\s*#{Regexp.escape(MARKER_BEGIN)}.*?#{Regexp.escape(MARKER_END)}\n/m,
46
+ "\n"
47
+ )
48
+
49
+ File.write(env_file, cleaned)
50
+ end
51
+
52
+ def self.rails_host_authorization_enabled?(app_path)
53
+ env_rb = File.join(app_path, 'config/application.rb')
54
+ return false unless File.exist?(env_rb)
55
+
56
+ content = File.read(env_rb)
57
+ content.include?('config.load_defaults 6') ||
58
+ content.include?('config.load_defaults 7') ||
59
+ content.include?('config.load_defaults 8')
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stable
4
+ module Services
5
+ module Tunneling
6
+ # Base manager
7
+ class BaseProvider
8
+ def expose(_port)
9
+ raise NotImplementedError
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stable
4
+ module Services
5
+ module Tunneling
6
+ # Tunneling manager
7
+ class Manager
8
+ def initialize(provider:)
9
+ @provider = provider.to_sym
10
+ end
11
+
12
+ # Expose the app domain using the correct port
13
+ def expose_domain(domain, port:, skip_ssl: false)
14
+ adapter.expose(domain, port: port, skip_ssl: skip_ssl)
15
+ end
16
+
17
+ private
18
+
19
+ def adapter
20
+ case @provider
21
+ when :ngrok
22
+ Providers::Ngrok.new
23
+ when :stable
24
+ Providers::Stable.new
25
+ else
26
+ abort "Unknown tunnel provider: #{@provider}"
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'json'
5
+ require 'uri'
6
+
7
+ module Stable
8
+ module Services
9
+ module Tunneling
10
+ module Providers
11
+ # ngrok provider
12
+ class Ngrok
13
+ NGROK_API = 'http://127.0.0.1:4040/api/tunnels'
14
+ MAX_RETRIES = 10
15
+ RETRY_DELAY = 0.5
16
+
17
+ # Expose the app via ngrok on the app's local port
18
+ # skip_ssl is kept for future use
19
+ def expose(domain, port:, skip_ssl: false)
20
+ return stub_url(domain) if ENV['STABLE_TEST_MODE']
21
+
22
+ if (tunnel = existing_tunnel)
23
+ validate_tunnel!(tunnel, port)
24
+ return tunnel['public_url']
25
+ end
26
+
27
+ start_ngrok(port)
28
+ wait_for_url || abort('Failed to obtain ngrok URL')
29
+ end
30
+
31
+ private
32
+
33
+ def existing_tunnel
34
+ resp = JSON.parse(Net::HTTP.get(URI(NGROK_API)))
35
+ resp['tunnels']&.find { |t| t['proto'] == 'https' }
36
+ rescue StandardError
37
+ nil
38
+ end
39
+
40
+ def validate_tunnel!(tunnel, port)
41
+ target = tunnel.dig('config', 'addr')
42
+ return if target == "http://localhost:#{port}"
43
+
44
+ abort <<~MSG
45
+ An ngrok tunnel is already running:
46
+
47
+ #{tunnel['public_url']} → #{target}
48
+
49
+ Free ngrok allows only one tunnel at a time.
50
+ Stop it first:
51
+ pkill ngrok
52
+ MSG
53
+ end
54
+
55
+ def start_ngrok(port)
56
+ @ngrok_pid = spawn(
57
+ 'ngrok', 'http', port.to_s, '--log=stdout',
58
+ out: '/dev/null', err: '/dev/null'
59
+ )
60
+ Process.detach(@ngrok_pid)
61
+ end
62
+
63
+ def wait_for_url
64
+ MAX_RETRIES.times do
65
+ if (tunnel = existing_tunnel)
66
+ return tunnel['public_url']
67
+ end
68
+
69
+ sleep RETRY_DELAY
70
+ end
71
+ nil
72
+ end
73
+
74
+ def stub_url(domain)
75
+ "https://#{domain}-stable-share.test"
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stable
4
+ module Services
5
+ module Tunneling
6
+ module Providers
7
+ # stable provider
8
+ class Stable
9
+ # skip_ssl is kept for future use
10
+ def expose(*)
11
+ abort 'Stable tunnels are not available yet'
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: stable-cli-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.4
4
+ version: 0.8.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Danny Simfukwe
@@ -9,6 +9,20 @@ bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rqrcode
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'
12
26
  - !ruby/object:Gem::Dependency
13
27
  name: thor
14
28
  requirement: !ruby/object:Gem::Requirement
@@ -45,29 +59,41 @@ files:
45
59
  - lib/stable/commands/remove.rb
46
60
  - lib/stable/commands/restart.rb
47
61
  - lib/stable/commands/setup.rb
62
+ - lib/stable/commands/share.rb
48
63
  - lib/stable/commands/start.rb
49
64
  - lib/stable/commands/stop.rb
65
+ - lib/stable/commands/unshare.rb
50
66
  - lib/stable/commands/upgrade_ruby.rb
67
+ - lib/stable/commands/workdir.rb
51
68
  - lib/stable/config/paths.rb
52
69
  - lib/stable/db_manager.rb
53
70
  - lib/stable/paths.rb
54
71
  - lib/stable/registry.rb
55
72
  - lib/stable/scanner.rb
56
73
  - lib/stable/services/app_creator.rb
74
+ - lib/stable/services/app_destroyer.rb
75
+ - lib/stable/services/app_opener.rb
57
76
  - lib/stable/services/app_registry.rb
58
77
  - lib/stable/services/app_remover.rb
59
78
  - lib/stable/services/app_restarter.rb
60
79
  - lib/stable/services/app_starter.rb
61
80
  - lib/stable/services/app_stopper.rb
81
+ - lib/stable/services/app_upgrader.rb
62
82
  - lib/stable/services/caddy_manager.rb
83
+ - lib/stable/services/cli/qrcode.rb
63
84
  - lib/stable/services/database/base.rb
64
85
  - lib/stable/services/database/mysql.rb
65
86
  - lib/stable/services/database/postgres.rb
66
87
  - lib/stable/services/dependency_checker.rb
67
88
  - lib/stable/services/hosts_manager.rb
68
89
  - lib/stable/services/process_manager.rb
90
+ - lib/stable/services/rails/host_authorization.rb
69
91
  - lib/stable/services/ruby.rb
70
92
  - lib/stable/services/setup_runner.rb
93
+ - lib/stable/services/tunneling/base_provider.rb
94
+ - lib/stable/services/tunneling/manager.rb
95
+ - lib/stable/services/tunneling/providers/ngrok.rb
96
+ - lib/stable/services/tunneling/providers/stable.rb
71
97
  - lib/stable/system/shell.rb
72
98
  - lib/stable/utils/package_manager.rb
73
99
  - lib/stable/utils/platform.rb
@@ -92,7 +118,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
92
118
  - !ruby/object:Gem::Version
93
119
  version: '0'
94
120
  requirements: []
95
- rubygems_version: 3.6.9
121
+ rubygems_version: 4.0.3
96
122
  specification_version: 4
97
123
  summary: Zero-config CLI tool to manage local Rails apps with automatic Caddy and
98
124
  HTTPS setup