railsmaker-core 0.0.1

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.
Files changed (59) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +169 -0
  3. data/bin/railsmaker +344 -0
  4. data/lib/railsmaker/generators/app_generator.rb +157 -0
  5. data/lib/railsmaker/generators/auth_generator.rb +97 -0
  6. data/lib/railsmaker/generators/base_generator.rb +39 -0
  7. data/lib/railsmaker/generators/concerns/gsub_validation.rb +24 -0
  8. data/lib/railsmaker/generators/litestream_generator.rb +89 -0
  9. data/lib/railsmaker/generators/mailjet_generator.rb +68 -0
  10. data/lib/railsmaker/generators/opentelemetry_generator.rb +79 -0
  11. data/lib/railsmaker/generators/plausible_generator.rb +33 -0
  12. data/lib/railsmaker/generators/plausible_instrumentation_generator.rb +28 -0
  13. data/lib/railsmaker/generators/sentry_generator.rb +46 -0
  14. data/lib/railsmaker/generators/server_command_generator.rb +178 -0
  15. data/lib/railsmaker/generators/signoz_generator.rb +33 -0
  16. data/lib/railsmaker/generators/signoz_opentelemetry_generator.rb +37 -0
  17. data/lib/railsmaker/generators/templates/app/credentials.example.yml +14 -0
  18. data/lib/railsmaker/generators/templates/app/main_index.html.erb +71 -0
  19. data/lib/railsmaker/generators/templates/auth/app/controllers/omniauth_callbacks_controller.rb +18 -0
  20. data/lib/railsmaker/generators/templates/litestream/litestream.yml.erb +32 -0
  21. data/lib/railsmaker/generators/templates/opentelemetry/lograge.rb.erb +9 -0
  22. data/lib/railsmaker/generators/templates/shell_scripts/plausible.sh.erb +51 -0
  23. data/lib/railsmaker/generators/templates/shell_scripts/signoz.sh.erb +38 -0
  24. data/lib/railsmaker/generators/templates/shell_scripts/signoz_opentelemetry.sh.erb +49 -0
  25. data/lib/railsmaker/generators/templates/ui/app/assets/images/og-image.webp +0 -0
  26. data/lib/railsmaker/generators/templates/ui/app/assets/images/plausible-screenshot.png +0 -0
  27. data/lib/railsmaker/generators/templates/ui/app/assets/images/signoz-screenshot.png +0 -0
  28. data/lib/railsmaker/generators/templates/ui/app/controllers/demo_controller.rb +7 -0
  29. data/lib/railsmaker/generators/templates/ui/app/controllers/pages_controller.rb +4 -0
  30. data/lib/railsmaker/generators/templates/ui/app/helpers/seo_helper.rb +39 -0
  31. data/lib/railsmaker/generators/templates/ui/app/javascript/controllers/flash_controller.js +14 -0
  32. data/lib/railsmaker/generators/templates/ui/app/javascript/controllers/index.js +11 -0
  33. data/lib/railsmaker/generators/templates/ui/app/javascript/controllers/scroll_fade_controller.js +27 -0
  34. data/lib/railsmaker/generators/templates/ui/app/views/clearance_mailer/change_password.html.erb +8 -0
  35. data/lib/railsmaker/generators/templates/ui/app/views/clearance_mailer/change_password.text.erb +5 -0
  36. data/lib/railsmaker/generators/templates/ui/app/views/demo/analytics.html.erb +214 -0
  37. data/lib/railsmaker/generators/templates/ui/app/views/demo/index.html.erb +312 -0
  38. data/lib/railsmaker/generators/templates/ui/app/views/demo/support.html.erb +147 -0
  39. data/lib/railsmaker/generators/templates/ui/app/views/layouts/_navbar.html.erb +193 -0
  40. data/lib/railsmaker/generators/templates/ui/app/views/layouts/application.html.erb +62 -0
  41. data/lib/railsmaker/generators/templates/ui/app/views/main/index.html.erb +320 -0
  42. data/lib/railsmaker/generators/templates/ui/app/views/pages/privacy.html.erb +63 -0
  43. data/lib/railsmaker/generators/templates/ui/app/views/pages/terms.html.erb +54 -0
  44. data/lib/railsmaker/generators/templates/ui/app/views/passwords/create.html.erb +9 -0
  45. data/lib/railsmaker/generators/templates/ui/app/views/passwords/edit.html.erb +21 -0
  46. data/lib/railsmaker/generators/templates/ui/app/views/passwords/new.html.erb +26 -0
  47. data/lib/railsmaker/generators/templates/ui/app/views/sessions/new.html.erb +49 -0
  48. data/lib/railsmaker/generators/templates/ui/app/views/shared/_auth_layout.html.erb +24 -0
  49. data/lib/railsmaker/generators/templates/ui/app/views/shared/_flash.html.erb +19 -0
  50. data/lib/railsmaker/generators/templates/ui/app/views/shared/_footer.html.erb +52 -0
  51. data/lib/railsmaker/generators/templates/ui/app/views/shared/_structured_data.html.erb +20 -0
  52. data/lib/railsmaker/generators/templates/ui/app/views/users/new.html.erb +49 -0
  53. data/lib/railsmaker/generators/templates/ui/config/sitemap.rb +33 -0
  54. data/lib/railsmaker/generators/templates/ui/public/icon.png +0 -0
  55. data/lib/railsmaker/generators/templates/ui/public/icon.svg +5 -0
  56. data/lib/railsmaker/generators/templates/ui/public/robots.txt +8 -0
  57. data/lib/railsmaker/generators/ui_generator.rb +68 -0
  58. data/lib/railsmaker.rb +29 -0
  59. metadata +359 -0
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsMaker
4
+ module Generators
5
+ class AuthGenerator < BaseGenerator
6
+ source_root File.expand_path('templates/auth', __dir__)
7
+
8
+ def add_gems
9
+ gem_group :default do
10
+ gem 'clearance', '~> 2.9.3'
11
+ gem 'omniauth', '~> 2.1.2'
12
+ gem 'omniauth-google-oauth2', '~> 1.2.1'
13
+ gem 'omniauth-rails_csrf_protection', '~> 1.0.2'
14
+ end
15
+
16
+ run 'bundle install'
17
+ end
18
+
19
+ def setup_clearance
20
+ generate 'clearance:install'
21
+ rake 'db:migrate'
22
+ generate 'clearance:views'
23
+ end
24
+
25
+ def configure_clearance
26
+ gsub_file 'config/initializers/clearance.rb',
27
+ 'config.mailer_sender = "reply@example.com"',
28
+ 'config.mailer_sender = Rails.application.credentials.dig(:app, :mailer_sender)'
29
+
30
+ inject_into_file 'config/initializers/clearance.rb', after: 'Clearance.configure do |config|' do
31
+ "\n config.redirect_url = \"/demo\""
32
+ end
33
+ end
34
+
35
+ def setup_omniauth
36
+ create_file 'config/initializers/omniauth.rb' do
37
+ <<~RUBY
38
+ # frozen_string_literal: true
39
+
40
+ Rails.application.config.middleware.use OmniAuth::Builder do
41
+ provider :google_oauth2,
42
+ Rails.application.credentials.dig(:google_oauth, :client_id),
43
+ Rails.application.credentials.dig(:google_oauth, :client_secret),
44
+ {
45
+ scope: "email",
46
+ prompt: "select_account"
47
+ }
48
+ end
49
+
50
+ OmniAuth.config.allowed_request_methods = %i[get]
51
+ RUBY
52
+ end
53
+
54
+ generate 'migration', 'AddOmniauthToUsers provider:string uid:string'
55
+ inject_into_file Dir['db/migrate/*add_omniauth_to_users.rb'].first,
56
+ after: "add_column :users, :uid, :string\n" do
57
+ <<-RUBY
58
+ add_index :users, [:provider, :uid], unique: true
59
+ RUBY
60
+ end
61
+
62
+ inject_into_file 'app/models/user.rb', after: "include Clearance::User\n" do
63
+ <<-RUBY
64
+
65
+ def self.from_omniauth(auth)
66
+ where(provider: auth.provider, uid: auth.uid).first_or_create do |user|
67
+ user.email = auth.info.email
68
+ user.password = SecureRandom.hex(10)
69
+ end
70
+ end
71
+ RUBY
72
+ end
73
+
74
+ rake 'db:migrate'
75
+ end
76
+
77
+ def add_omniauth_controller
78
+ template 'app/controllers/omniauth_callbacks_controller.rb',
79
+ 'app/controllers/omniauth_callbacks_controller.rb'
80
+ end
81
+
82
+ def update_routes
83
+ route <<~RUBY
84
+ # OmniAuth callback routes
85
+ get "auth/:provider/callback", to: "omniauth_callbacks#google_oauth2", constraints: { provider: "google_oauth2" }
86
+ get 'auth/failure', to: 'omniauth_callbacks#failure'
87
+ RUBY
88
+ end
89
+
90
+ def git_commit
91
+ git add: '.', commit: %(-m 'Add authentication with Clearance and OmniAuth')
92
+
93
+ say 'Successfully added authentication with Clearance and OmniAuth', :green
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsMaker
4
+ module Generators
5
+ class BaseGenerator < Rails::Generators::Base
6
+ class BaseGeneratorError < StandardError; end
7
+
8
+ include Rails::Generators::Actions
9
+ include GsubValidation
10
+
11
+ def self.default_command_options
12
+ { abort_on_failure: true }
13
+ end
14
+
15
+ protected
16
+
17
+ def run(*args)
18
+ options = args.extract_options!
19
+ options = self.class.default_command_options.merge(options)
20
+ super(*args, options.merge(force: true)) # do not ask for confirmation on overrides
21
+ end
22
+
23
+ private
24
+
25
+ def check_required_env_vars(required_vars)
26
+ missing_vars = required_vars.reject { |var| ENV[var].present? }
27
+
28
+ return if missing_vars.empty?
29
+
30
+ say "\nError: Missing required environment variables:", :red
31
+ missing_vars.each { |var| say " - #{var}", :red }
32
+ say "\nPlease set these environment variables before continuing:", :red
33
+ missing_vars.each { |var| say " export #{var}=your-value", :yellow }
34
+
35
+ raise BaseGeneratorError, 'Missing required environment variables'
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsMaker
4
+ module Generators
5
+ module GsubValidation
6
+ def validate_gsub_strings(validations)
7
+ validations.each do |validation|
8
+ file_path = File.join(destination_root, validation.fetch(:file))
9
+
10
+ unless File.exist?(file_path)
11
+ raise "Required file not found: #{validation.fetch(:file)}. Maybe a dependency changed?"
12
+ end
13
+
14
+ content = File.read(file_path)
15
+ validation.fetch(:patterns).each do |pattern|
16
+ unless content.include?(pattern)
17
+ raise "Expected to find '#{pattern}' in #{validation.fetch(:file)} but didn't. Maybe a dependency changed?"
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsMaker
4
+ module Generators
5
+ class LitestreamGenerator < BaseGenerator
6
+ source_root File.expand_path('templates/litestream', __dir__)
7
+
8
+ class_option :bucketname, type: :string, required: true, desc: 'Litestream bucketname'
9
+ class_option :name, type: :string, required: true, desc: 'Application name for volume and bucket naming'
10
+ class_option :ip, type: :string, required: true, desc: 'Server IP address'
11
+
12
+ def check_required_env_vars
13
+ super(%w[
14
+ LITESTREAM_ACCESS_KEY_ID
15
+ LITESTREAM_SECRET_ACCESS_KEY
16
+ LITESTREAM_ENDPOINT
17
+ LITESTREAM_REGION
18
+ ])
19
+ end
20
+
21
+ def create_litestream_config
22
+ template 'litestream.yml.erb', 'config/litestream.yml'
23
+ end
24
+
25
+ def add_kamal_secrets
26
+ inject_into_file '.kamal/secrets', after: "RAILS_MASTER_KEY=$(cat config/master.key)\n" do
27
+ <<~YAML
28
+
29
+ # Litestream credentials for S3-compatible storage
30
+ LITESTREAM_ACCESS_KEY_ID=$LITESTREAM_ACCESS_KEY_ID
31
+ LITESTREAM_SECRET_ACCESS_KEY=$LITESTREAM_SECRET_ACCESS_KEY
32
+ LITESTREAM_ENDPOINT=$LITESTREAM_ENDPOINT
33
+ LITESTREAM_REGION=$LITESTREAM_REGION
34
+ LITESTREAM_BUCKET_NAME=#{options[:bucketname]}
35
+ YAML
36
+ end
37
+ end
38
+
39
+ def add_to_deployment
40
+ validations = [
41
+ {
42
+ file: 'config/deploy.yml',
43
+ patterns: ["bin/rails dbconsole\"\n", "arch: amd64\n"]
44
+ }
45
+ ]
46
+
47
+ validate_gsub_strings(validations)
48
+
49
+ inject_into_file 'config/deploy.yml', after: "arch: amd64\n" do
50
+ <<~YAML
51
+
52
+ accessories:
53
+ litestream:
54
+ image: litestream/litestream:0.3
55
+ host: #{options[:ip]}
56
+ volumes:
57
+ - "#{options[:name].underscore}_storage:/rails/storage"
58
+ files:
59
+ - config/litestream.yml:/etc/litestream.yml
60
+ cmd: replicate -config /etc/litestream.yml
61
+ env:
62
+ secret:
63
+ - LITESTREAM_ACCESS_KEY_ID
64
+ - LITESTREAM_SECRET_ACCESS_KEY
65
+ - LITESTREAM_ENDPOINT
66
+ - LITESTREAM_REGION
67
+ - LITESTREAM_BUCKET_NAME
68
+ YAML
69
+ end
70
+
71
+ inject_into_file 'config/deploy.yml', after: "bin/rails dbconsole\"\n" do
72
+ <<-YAML
73
+ restore-db-app: accessory exec litestream "restore -if-replica-exists -config /etc/litestream.yml /rails/storage/production.sqlite3"
74
+ restore-db-cache: accessory exec litestream "restore -if-replica-exists -config /etc/litestream.yml /rails/storage/production_cache.sqlite3"
75
+ restore-db-queue: accessory exec litestream "restore -if-replica-exists -config /etc/litestream.yml /rails/storage/production_queue.sqlite3"
76
+ restore-db-cable: accessory exec litestream "restore -if-replica-exists -config /etc/litestream.yml /rails/storage/production_cable.sqlite3"
77
+ restore-db-ownership: server exec "sudo chown -R 1000:1000 /var/lib/docker/volumes/#{options[:name].underscore}_storage/_data/"
78
+ YAML
79
+ end
80
+ end
81
+
82
+ def git_commit
83
+ git add: '.', commit: %(-m 'Add Litestream configuration')
84
+
85
+ say 'Successfully added Litestream configuration', :green
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsMaker
4
+ module Generators
5
+ class MailjetGenerator < BaseGenerator
6
+ source_root File.expand_path('templates/mailjet', __dir__)
7
+
8
+ class_option :name, type: :string, required: true, desc: 'Name of the service for email sender'
9
+ class_option :domain, type: :string, required: true, desc: 'Host domain for the application'
10
+
11
+ def initialize(*args)
12
+ super
13
+ @name = options[:name]
14
+ @domain = options[:domain]
15
+ end
16
+
17
+ def add_gem
18
+ gem_group :default do
19
+ gem 'mailjet', '~> 1.8'
20
+ end
21
+
22
+ run 'bundle install'
23
+ end
24
+
25
+ def create_initializer
26
+ create_file 'config/initializers/mailjet.rb' do
27
+ <<~RUBY
28
+ # frozen_string_literal: true
29
+
30
+ Mailjet.configure do |config|
31
+ config.api_key = Rails.application.credentials.dig(:mailjet, :api_key)
32
+ config.secret_key = Rails.application.credentials.dig(:mailjet, :secret_key)
33
+ config.default_from = Rails.application.credentials.dig(:app, :mailer_sender)
34
+ config.api_version = "v3.1"
35
+ end
36
+ RUBY
37
+ end
38
+ end
39
+
40
+ def configure_mailer
41
+ environment(nil, env: 'production') do
42
+ <<~RUBY
43
+ # Mailjet API configuration
44
+ config.action_mailer.delivery_method = :mailjet_api
45
+ RUBY
46
+ end
47
+
48
+ gsub_file 'app/mailers/application_mailer.rb',
49
+ /default from: .+$/,
50
+ 'default from: Rails.application.credentials.dig(:app, :mailer_sender)'
51
+
52
+ gsub_file 'config/environments/production.rb',
53
+ /config\.action_mailer\.default_url_options = \{ host: .+\}/,
54
+ 'config.action_mailer.default_url_options = { host: Rails.application.credentials.dig(:app, :host) }'
55
+ end
56
+
57
+ def git_commit
58
+ git add: '.', commit: %(-m 'Add Mailjet configuration')
59
+
60
+ say 'Successfully added Mailjet configuration', :green
61
+ end
62
+
63
+ private
64
+
65
+ attr_reader :name, :domain
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsMaker
4
+ module Generators
5
+ class OpentelemetryGenerator < BaseGenerator
6
+ source_root File.expand_path('templates/opentelemetry', __dir__)
7
+
8
+ class_option :name, type: :string, required: true, desc: 'Name of the application'
9
+
10
+ def add_kamal_config
11
+ validations = [
12
+ {
13
+ file: 'config/deploy.yml',
14
+ patterns: [
15
+ "web:\n",
16
+ "SOLID_QUEUE_IN_PUMA: true\n"
17
+ ]
18
+ }
19
+ ]
20
+
21
+ validate_gsub_strings(validations)
22
+
23
+ inject_into_file 'config/deploy.yml', after: "web:\n" do
24
+ <<-YAML
25
+ options:
26
+ "add-host": host.docker.internal:host-gateway
27
+ YAML
28
+ end
29
+
30
+ inject_into_file 'config/deploy.yml', after: "SOLID_QUEUE_IN_PUMA: true\n" do
31
+ <<-YAML
32
+ # OpenTelemetry env vars
33
+ OTEL_EXPORTER: otlp
34
+ OTEL_SERVICE_NAME: #{options[:name]}
35
+ OTEL_EXPORTER_OTLP_ENDPOINT: http://host.docker.internal:4318
36
+ YAML
37
+ end
38
+ end
39
+
40
+ def add_gems
41
+ gem_group :default do
42
+ gem 'opentelemetry-sdk', '~> 1.6.0'
43
+ gem 'opentelemetry-exporter-otlp', '~> 0.29.1'
44
+ gem 'opentelemetry-instrumentation-all', '~> 0.72.0'
45
+
46
+ gem 'lograge', '~> 0.14.0'
47
+ gem 'logstash-event', '~> 1.2.02'
48
+ end
49
+
50
+ run 'bundle install'
51
+ end
52
+
53
+ def configure_opentelemetry
54
+ environment_file = 'config/environment.rb'
55
+
56
+ prepend_to_file environment_file, "require 'opentelemetry/sdk'\n"
57
+ inject_into_file environment_file, before: 'Rails.application.initialize!' do
58
+ <<~RUBY
59
+
60
+ OpenTelemetry::SDK.configure do |c|
61
+ c.use_all unless Rails.env.development? || Rails.env.test?
62
+ end
63
+
64
+ RUBY
65
+ end
66
+ end
67
+
68
+ def setup_lograge
69
+ template 'lograge.rb.erb', 'config/initializers/lograge.rb'
70
+ end
71
+
72
+ def git_commit
73
+ git add: '.', commit: %(-m 'Add OpenTelemetry')
74
+
75
+ say 'Successfully added OpenTelemetry configuration', :green
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsMaker
4
+ module Generators
5
+ class PlausibleGenerator < ServerCommandGenerator
6
+ source_root File.expand_path('templates/shell_scripts', __dir__)
7
+
8
+ class_option :analytics_host, type: :string, required: true,
9
+ desc: 'Domain where Plausible Analytics will be hosted'
10
+
11
+ def initialize(*args)
12
+ super
13
+ @analytics_host = options[:analytics_host]
14
+ end
15
+
16
+ private
17
+
18
+ def script_name
19
+ 'plausible'
20
+ end
21
+
22
+ def check_path
23
+ '~/plausible-ce'
24
+ end
25
+
26
+ def title
27
+ "Installing Plausible Analytics on remote server #{options[:ssh_user]}@#{options[:ssh_host]}"
28
+ end
29
+
30
+ attr_reader :analytics_host
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsMaker
4
+ module Generators
5
+ class PlausibleInstrumentationGenerator < BaseGenerator
6
+ class_option :domain, type: :string, required: true, desc: 'Domain of your application'
7
+ class_option :analytics, type: :string, required: true, desc: 'Domain where Plausible is hosted'
8
+
9
+ def add_plausible_script
10
+ content = <<~HTML.indent(4)
11
+ <%# Plausible Analytics %>
12
+ <script defer data-domain="#{options[:domain]}" src="https://#{options[:analytics]}/js/script.file-downloads.outbound-links.pageview-props.revenue.tagged-events.js"></script>
13
+ <script>window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments) }</script>
14
+ HTML
15
+
16
+ gsub_file 'app/views/layouts/application.html.erb',
17
+ %r{<%# Plausible Analytics %>.*?</script>\s*<script>.*?</script>}m,
18
+ content.strip.to_s
19
+ end
20
+
21
+ def git_commit
22
+ git add: '.', commit: %(-m 'Add Plausible Analytics')
23
+
24
+ say 'Successfully added Plausible Analytics', :green
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsMaker
4
+ module Generators
5
+ class SentryGenerator < BaseGenerator
6
+ def add_gems
7
+ gem_group :default do
8
+ gem 'sentry-ruby', '~> 5.22.3'
9
+ gem 'sentry-rails', '~> 5.22.3'
10
+ end
11
+
12
+ run 'bundle install'
13
+ end
14
+
15
+ def generate_sentry_initializer
16
+ generate 'sentry'
17
+
18
+ validations = [
19
+ {
20
+ file: 'config/initializers/sentry.rb',
21
+ patterns: [
22
+ 'Sentry.init'
23
+ ]
24
+ }
25
+ ]
26
+
27
+ validate_gsub_strings(validations)
28
+ end
29
+
30
+ def configure_sentry
31
+ gsub_file 'config/initializers/sentry.rb', /Sentry\.init.*end\n/m do
32
+ <<~RUBY
33
+ Sentry.init do |config|
34
+ config.dsn = Rails.application.credentials.dig(:sentry_dsn)
35
+ config.breadcrumbs_logger = [ :active_support_logger, :http_logger ]
36
+ end
37
+ RUBY
38
+ end
39
+ end
40
+
41
+ def git_commit
42
+ git add: '.', commit: %(-m 'Add Sentry')
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'English'
4
+ require 'fileutils'
5
+ require 'base64'
6
+ require 'securerandom'
7
+
8
+ module RailsMaker
9
+ module Generators
10
+ class ServerCommandGenerator < BaseGenerator
11
+ source_root File.expand_path('templates/shell_scripts', __dir__)
12
+
13
+ class_option :ssh_host, type: :string, required: true, desc: 'SSH host'
14
+ class_option :ssh_user, type: :string, required: true, desc: 'SSH user'
15
+ class_option :key_path, type: :string, desc: 'Path to SSH private key (optional)'
16
+ class_option :force, type: :boolean, default: false,
17
+ desc: 'Force installation even if already installed'
18
+
19
+ def execute_script
20
+ return unless ssh_available?
21
+
22
+ say "⚠️ WARNING: This command will SSH into #{options[:ssh_user]}@#{options[:ssh_host]} and install software.",
23
+ :yellow
24
+ return unless yes?('Do you want to proceed? (y/N)')
25
+
26
+ # Generate random suffix for tmp files
27
+ random_suffix = SecureRandom.hex(8)
28
+
29
+ remote_files_content = remote_files.map do |file|
30
+ tmp_filename = "#{file[:filename]}.#{random_suffix}"
31
+ template file[:template], "/tmp/#{tmp_filename}"
32
+ content = File.read("/tmp/#{tmp_filename}")
33
+ FileUtils.rm("/tmp/#{tmp_filename}")
34
+ [tmp_filename, Base64.strict_encode64(content)]
35
+ end.to_h
36
+
37
+ file_commands = remote_files_content.map do |filename, content|
38
+ [
39
+ "echo '#{content}' | base64 -d > /tmp/#{filename}",
40
+ filename.end_with?("install_script.sh.#{random_suffix}") ? "chmod +x /tmp/#{filename}" : nil
41
+ ]
42
+ end.flatten.compact
43
+
44
+ execute_remote_commands(
45
+ [
46
+ *file_commands,
47
+ "/tmp/install_script.sh.#{random_suffix}",
48
+ *remote_files_content.keys.map { |filename| "rm /tmp/#{filename}" }
49
+ ],
50
+ title: title,
51
+ check_path: check_path,
52
+ force: options[:force]
53
+ )
54
+ end
55
+
56
+ protected
57
+
58
+ def script_name
59
+ raise NotImplementedError, 'Subclasses must implement #script_name'
60
+ end
61
+
62
+ def check_path
63
+ nil
64
+ end
65
+
66
+ def title
67
+ "Executing #{script_name} script"
68
+ end
69
+
70
+ def config_files
71
+ []
72
+ end
73
+
74
+ def remote_files
75
+ [
76
+ *config_files,
77
+ {
78
+ template: "#{script_name}.sh.erb",
79
+ filename: 'install_script.sh'
80
+ }
81
+ ]
82
+ end
83
+
84
+ def install_commands
85
+ [
86
+ '/tmp/install_script.sh',
87
+ *remote_files.map { |file| "rm /tmp/#{file[:filename]}" }
88
+ ]
89
+ end
90
+
91
+ private
92
+
93
+ def ssh_available?
94
+ return true if system('which ssh', out: File::NULL)
95
+
96
+ say_status 'error', 'SSH client not found. Please install SSH first.', :red
97
+ false
98
+ end
99
+
100
+ def ssh_destination
101
+ "#{options[:ssh_user]}@#{options[:ssh_host]}"
102
+ end
103
+
104
+ def ssh_options
105
+ opts = [
106
+ 'StrictHostKeyChecking=accept-new',
107
+ 'ConnectTimeout=10'
108
+ ]
109
+ opts.map { |opt| "-o #{opt}" }.join(' ')
110
+ end
111
+
112
+ def installation_exists?(check_path)
113
+ return false unless check_path
114
+
115
+ "[ -d #{check_path} ]"
116
+ end
117
+
118
+ def execute_remote_commands(commands, options = {})
119
+ if options[:key_path] && !File.exist?(File.expand_path(options[:key_path]))
120
+ say_status 'error', "SSH key not found: #{options[:key_path]}", :red
121
+ raise BaseGeneratorError, 'SSH key not found'
122
+ end
123
+
124
+ title = options[:title] || 'Executing remote commands'
125
+ say_status 'start', title, :blue
126
+
127
+ script_content = []
128
+
129
+ if options[:check_path] && !options[:force]
130
+ script_content << <<~SHELL
131
+ if #{installation_exists?(options[:check_path])}; then
132
+ echo "Installation already exists at #{options[:check_path]}"
133
+ echo "Use --force to reinstall"
134
+ exit 0
135
+ fi
136
+ SHELL
137
+ end
138
+
139
+ script_content += commands.map do |cmd|
140
+ <<~SHELL
141
+ echo "→ Executing: #{cmd}"
142
+ if ! #{cmd}; then
143
+ echo "✗ Command failed: #{cmd}"
144
+ raise BaseGeneratorError, "Command failed: #{cmd}"
145
+ fi
146
+ SHELL
147
+ end
148
+
149
+ script_content << 'exit 0'
150
+
151
+ ssh_cmd = ['ssh']
152
+ ssh_cmd << "-i #{options[:key_path]}" if options[:key_path]
153
+ ssh_cmd << ssh_options
154
+ ssh_cmd << ssh_destination
155
+ ssh_cmd << "'#{script_content.join("\n")}'"
156
+
157
+ success = system(ssh_cmd.join(' '))
158
+
159
+ if success
160
+ say_status 'success', 'All commands completed successfully', :green
161
+ else
162
+ say_status 'error', "Command failed with exit status #{$CHILD_STATUS.exitstatus}", :red
163
+ case $CHILD_STATUS.exitstatus
164
+ when 255
165
+ say ' → Could not connect to the server. Please check:', :red
166
+ say ' • SSH host and user are correct'
167
+ say ' • Your authentication credentials are valid'
168
+ say ' • Server is reachable and SSH port (22) is open'
169
+ when 126, 127
170
+ say ' → Command not found or not executable', :red
171
+ say ' • Ensure all required software is installed on the remote server'
172
+ end
173
+ exit $CHILD_STATUS.exitstatus
174
+ end
175
+ end
176
+ end
177
+ end
178
+ end