opinionated_rails 0.1.4

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 (51) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +24 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +59 -0
  5. data/exe/opinionated_rails +7 -0
  6. data/lib/opinionated_rails/answer_shell.rb +131 -0
  7. data/lib/opinionated_rails/cli.rb +291 -0
  8. data/lib/opinionated_rails/context.rb +39 -0
  9. data/lib/opinionated_rails/error.rb +6 -0
  10. data/lib/opinionated_rails/generators/file_writer.rb +14 -0
  11. data/lib/opinionated_rails/generators/rails_new_invoker.rb +26 -0
  12. data/lib/opinionated_rails/kamal_config.rb +94 -0
  13. data/lib/opinionated_rails/manifest.rb +42 -0
  14. data/lib/opinionated_rails/pins.rb +83 -0
  15. data/lib/opinionated_rails/prerequisites.rb +39 -0
  16. data/lib/opinionated_rails/rails_version_guard.rb +49 -0
  17. data/lib/opinionated_rails/step.rb +41 -0
  18. data/lib/opinionated_rails/step_graph.rb +53 -0
  19. data/lib/opinionated_rails/steps/addons/auth_step.rb +170 -0
  20. data/lib/opinionated_rails/steps/addons/ci_step.rb +226 -0
  21. data/lib/opinionated_rails/steps/addons/docker_kamal_step.rb +133 -0
  22. data/lib/opinionated_rails/steps/addons/docs_step.rb +35 -0
  23. data/lib/opinionated_rails/steps/addons/factory_or_fixtures_step.rb +128 -0
  24. data/lib/opinionated_rails/steps/addons/faker_step.rb +55 -0
  25. data/lib/opinionated_rails/steps/addons/js_lint_step.rb +132 -0
  26. data/lib/opinionated_rails/steps/addons/mailer_step.rb +183 -0
  27. data/lib/opinionated_rails/steps/addons/pagination_step.rb +116 -0
  28. data/lib/opinionated_rails/steps/addons/pr_issue_templates_step.rb +43 -0
  29. data/lib/opinionated_rails/steps/addons/rubocop_step.rb +111 -0
  30. data/lib/opinionated_rails/steps/addons/secrets_handling_step.rb +138 -0
  31. data/lib/opinionated_rails/steps/addons/services_step.rb +64 -0
  32. data/lib/opinionated_rails/steps/addons/solid_stack_step.rb +252 -0
  33. data/lib/opinionated_rails/steps/addons/spa_controller_step.rb +87 -0
  34. data/lib/opinionated_rails/steps/addons/staging_env_step.rb +108 -0
  35. data/lib/opinionated_rails/steps/addons/tailwind_step.rb +148 -0
  36. data/lib/opinionated_rails/steps/addons/vcr_step.rb +170 -0
  37. data/lib/opinionated_rails/steps/core/app_name_step.rb +85 -0
  38. data/lib/opinionated_rails/steps/core/app_type_step.rb +51 -0
  39. data/lib/opinionated_rails/steps/core/database_step.rb +53 -0
  40. data/lib/opinionated_rails/steps/core/frontend_step.rb +282 -0
  41. data/lib/opinionated_rails/steps/core/testing_step.rb +82 -0
  42. data/lib/opinionated_rails/templates/docs/STYLE.md +39 -0
  43. data/lib/opinionated_rails/templates/github/ISSUE_TEMPLATE/bug_report.md +25 -0
  44. data/lib/opinionated_rails/templates/github/ISSUE_TEMPLATE/feature_request.md +17 -0
  45. data/lib/opinionated_rails/templates/github/PULL_REQUEST_TEMPLATE.md +9 -0
  46. data/lib/opinionated_rails/templates/rubocop/custom_overrides.yml +5 -0
  47. data/lib/opinionated_rails/templates/services/application_service.rb +50 -0
  48. data/lib/opinionated_rails/templates/services/result.rb +44 -0
  49. data/lib/opinionated_rails/version.rb +5 -0
  50. data/lib/opinionated_rails.rb +41 -0
  51. metadata +141 -0
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpinionatedRails
4
+ module Steps
5
+ module Addons
6
+ class SecretsHandlingStep < Step
7
+ def self.id = :secrets_handling
8
+ def self.tier = :addon
9
+ def self.needs = []
10
+ def self.summary = "Configure secrets handling"
11
+ def self.description = "Chooses Rails encrypted credentials or dotenv for managing secrets."
12
+ def self.prompts = "Secrets handling: Rails credentials or dotenv (default: Rails credentials)"
13
+
14
+ DOTENV_RAILS_VERSION = "3.2.0".freeze
15
+
16
+ ENV_EXAMPLE_HEADER = <<~ENV
17
+ # Copy this file to .env and fill in real values locally.
18
+ # .env is gitignored; this file (.env.example) is committed with placeholders only.
19
+ ENV
20
+
21
+ ENV_GLOB_PATTERNS = %w[ .env* /.env* ].freeze
22
+ ENV_IGNORE_PATTERNS = (%w[ .env /.env ] + ENV_GLOB_PATTERNS).freeze
23
+ ENV_EXAMPLE_UNIGNORE = %w[ !.env.example !/.env.example ].freeze
24
+
25
+ def if_condition(_context) = true
26
+
27
+ def prompt(context)
28
+ shell = context.shell
29
+ shell.say("Choose how secrets are handled:")
30
+ shell.say(" 1. Rails credentials (default)")
31
+ shell.say(" 2. dotenv")
32
+
33
+ choice = shell.ask("Secrets handling (default: Rails credentials):").to_s.strip
34
+ choice = "1" if choice.empty?
35
+
36
+ loop do
37
+ case choice.downcase
38
+ when "rails credentials", "rails_credentials", "1"
39
+ context.answer(:secrets_handling, :rails_credentials)
40
+ break
41
+ when "dotenv", "2"
42
+ context.answer(:secrets_handling, :dotenv)
43
+ break
44
+ else
45
+ shell.say("Invalid. Enter 'Rails credentials', 'dotenv', 1, or 2.", :red)
46
+ choice = shell.ask("Secrets handling (default: Rails credentials):").to_s.strip
47
+ choice = "1" if choice.empty?
48
+ end
49
+ end
50
+ end
51
+
52
+ def generate(context)
53
+ strategy = context.get(:secrets_handling) || :rails_credentials
54
+
55
+ case strategy
56
+ when :dotenv
57
+ install_dotenv(context)
58
+ else
59
+ check_rails_credentials(context)
60
+ end
61
+
62
+ context.set_output(:secrets_handling, { secrets_handling: strategy })
63
+ end
64
+
65
+ def outputs(context)
66
+ context.output(:secrets_handling)
67
+ end
68
+
69
+ private
70
+
71
+ def check_rails_credentials(context)
72
+ master_key = File.join(context.app_path, "config/master.key")
73
+ credentials = File.join(context.app_path, "config/credentials.yml.enc")
74
+ return if File.exist?(master_key) && File.exist?(credentials)
75
+
76
+ context.shell.say(
77
+ "Warning: config/master.key or config/credentials.yml.enc is missing — Rails credentials may not work as expected.",
78
+ :yellow
79
+ )
80
+ end
81
+
82
+ def install_dotenv(context)
83
+ add_dev_test_group_gem(context, "gem \"dotenv-rails\", \"#{DOTENV_RAILS_VERSION}\"")
84
+ write_env_example(context)
85
+ gitignore_dotenv(context)
86
+ end
87
+
88
+ def write_env_example(context)
89
+ path = File.join(context.app_path, ".env.example")
90
+ return if File.exist?(path)
91
+
92
+ File.write(path, ENV_EXAMPLE_HEADER)
93
+ end
94
+
95
+ def gitignore_dotenv(context)
96
+ path = File.join(context.app_path, ".gitignore")
97
+ return unless File.exist?(path)
98
+
99
+ content = File.read(path)
100
+ patterns = content.lines.map(&:strip)
101
+ additions = []
102
+ additions << ".env" unless patterns.intersect?(ENV_IGNORE_PATTERNS)
103
+ # A glob like `.env*` would also hide the committed template.
104
+ if patterns.intersect?(ENV_GLOB_PATTERNS) && !patterns.intersect?(ENV_EXAMPLE_UNIGNORE)
105
+ additions << "!/.env.example"
106
+ end
107
+ return if additions.empty?
108
+
109
+ File.open(path, "a") do |f|
110
+ f.print("\n") unless content.empty? || content.end_with?("\n")
111
+ additions.each { |line| f.puts(line) }
112
+ end
113
+ end
114
+
115
+ def add_dev_test_group_gem(context, gem_line)
116
+ gemfile_path = File.join(context.app_path, "Gemfile")
117
+ return unless File.exist?(gemfile_path)
118
+
119
+ content = File.read(gemfile_path)
120
+ return if content.include?(gem_line)
121
+
122
+ lines = content.lines
123
+ group_start = lines.index { |line| line.strip == "group :development, :test do" }
124
+
125
+ if group_start
126
+ end_index = (group_start + 1...lines.size).find { |i| lines[i].strip == "end" }
127
+ lines.insert(end_index, " #{gem_line}\n")
128
+ else
129
+ lines << "\n" unless lines.last.nil? || lines.last.end_with?("\n")
130
+ lines << "\ngroup :development, :test do\n #{gem_line}\nend\n"
131
+ end
132
+
133
+ File.write(gemfile_path, lines.join)
134
+ end
135
+ end
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpinionatedRails
4
+ module Steps
5
+ module Addons
6
+ class ServicesStep < Step
7
+ def self.id = :services
8
+ def self.tier = :addon
9
+ def self.needs = []
10
+ def self.summary = "Add an application service architecture"
11
+ def self.description = "Installs an ApplicationService base class and an in-house Result object."
12
+ def self.prompts = "Add an application service architecture? (default: no)"
13
+
14
+ TEMPLATES_DIR = File.expand_path("../../templates/services", __dir__)
15
+
16
+ FILES = {
17
+ "application_service.rb" => "app/services/application_service.rb",
18
+ "result.rb" => "app/services/result.rb"
19
+ }.freeze
20
+
21
+ def if_condition(_context) = true
22
+
23
+ def prompt(context)
24
+ shell = context.shell
25
+ choice = shell.ask("Add an application service architecture? (default: no):").to_s.strip
26
+ choice = "no" if choice.empty?
27
+
28
+ loop do
29
+ case choice.downcase
30
+ when "yes", "y"
31
+ context.answer(:services, true)
32
+ break
33
+ when "no", "n"
34
+ context.answer(:services, false)
35
+ break
36
+ else
37
+ shell.say("Invalid. Enter 'yes' or 'no'.", :red)
38
+ choice = shell.ask("Add an application service architecture? (default: no):").to_s.strip
39
+ choice = "no" if choice.empty?
40
+ end
41
+ end
42
+ end
43
+
44
+ def generate(context)
45
+ enabled = context.get(:services) == true
46
+
47
+ if enabled
48
+ writer = OpinionatedRails::Generators::FileWriter.new
49
+ FILES.each do |source, destination|
50
+ content = File.read(File.join(TEMPLATES_DIR, source))
51
+ writer.write(File.join(context.app_path, destination), content)
52
+ end
53
+ end
54
+
55
+ context.set_output(:services, { services: enabled })
56
+ end
57
+
58
+ def outputs(context)
59
+ context.output(:services)
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,252 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpinionatedRails
4
+ module Steps
5
+ module Addons
6
+ class SolidStackStep < Step
7
+ def self.id = :solid_stack
8
+ def self.tier = :addon
9
+ def self.needs = []
10
+ def self.summary = "Configure Solid Trifecta"
11
+ def self.description = "Sets up Solid Queue, Solid Cache, and Solid Cable for background jobs, caching, and Action Cable."
12
+ def self.prompts = "Background jobs/cache/cable: Solid Trifecta or Redis+Sidekiq (default: Solid)"
13
+
14
+ REDIS_VERSION = "6.0.0".freeze
15
+ SIDEKIQ_VERSION = "8.1.7".freeze
16
+
17
+ # `config.cache_store`/`config.active_job.queue_adapter` only get rewritten to their
18
+ # Solid variants by `solid_cache:install`/`solid_queue:install`, which are `after_bundle`
19
+ # hooks that never run under `rails new --skip-bundle` (our fast/no-network test path).
20
+ # So both the "Solid already installed" anchor and the untouched Rails default (still
21
+ # commented out) are handled here.
22
+ CACHE_STORE_ANCHORS = [
23
+ "config.cache_store = :solid_cache_store",
24
+ "# config.cache_store = :mem_cache_store"
25
+ ].freeze
26
+ CACHE_STORE_REPLACEMENT = "config.cache_store = :redis_cache_store, " \
27
+ "{ url: ENV.fetch(\"REDIS_URL\", \"redis://localhost:6379/1\") }"
28
+
29
+ QUEUE_ADAPTER_ANCHORS = [
30
+ " config.active_job.queue_adapter = :solid_queue\n config.solid_queue.connects_to = { database: { writing: :queue } }\n",
31
+ " # config.active_job.queue_adapter = :resque\n"
32
+ ].freeze
33
+ QUEUE_ADAPTER_REPLACEMENT = " config.active_job.queue_adapter = :sidekiq\n"
34
+
35
+ # Likewise, `config/cable.yml`'s production block only becomes `adapter: solid_cable`
36
+ # once `solid_cable:install` (also `after_bundle`) has run; otherwise it's still Rails'
37
+ # own un-swapped default, which already reads `adapter: redis` — nothing to do there.
38
+ CABLE_PRODUCTION_BLOCK = <<~YAML
39
+ production:
40
+ adapter: solid_cable
41
+ connects_to:
42
+ database:
43
+ writing: cable
44
+ polling_interval: 0.1.seconds
45
+ message_retention: 1.day
46
+ YAML
47
+
48
+ CABLE_PRODUCTION_REPLACEMENT = <<~YAML
49
+ production:
50
+ adapter: redis
51
+ url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %>
52
+ YAML
53
+
54
+ SIDEKIQ_INITIALIZER = <<~RUBY
55
+ # frozen_string_literal: true
56
+
57
+ # Production must be told where Redis is, or jobs and cable silently talk to nothing. The Docker
58
+ # build runs assets:precompile with SECRET_KEY_BASE_DUMMY and no Redis, so that case is exempt.
59
+ redis_url = ENV.fetch("REDIS_URL") do
60
+ raise "REDIS_URL is not set" if Rails.env.production? && ENV["SECRET_KEY_BASE_DUMMY"].to_s.empty?
61
+
62
+ "redis://localhost:6379/1"
63
+ end
64
+
65
+ Sidekiq.configure_server do |config|
66
+ config.redis = { url: redis_url }
67
+ end
68
+
69
+ Sidekiq.configure_client do |config|
70
+ config.redis = { url: redis_url }
71
+ end
72
+ RUBY
73
+
74
+ SIDEKIQ_WEB_ROUTE_COMMENT = " # Gate this behind authentication before enabling — Sidekiq::Web has no built-in auth.\n" \
75
+ " # mount Sidekiq::Web => \"/sidekiq\"\n"
76
+
77
+ SOLID_GEMS = %w[ solid_cache solid_queue solid_cable ].freeze
78
+ SOLID_FILES = %w[
79
+ config/cache.yml config/queue.yml config/recurring.yml
80
+ db/cache_schema.rb db/queue_schema.rb db/cable_schema.rb
81
+ bin/jobs
82
+ ].freeze
83
+ SOLID_DATABASE_KEYS = %w[ cache queue cable ].freeze
84
+
85
+ def if_condition(_context) = true
86
+
87
+ def prompt(context)
88
+ shell = context.shell
89
+ shell.say("Choose a background jobs/cache/cable stack:")
90
+ shell.say(" 1. Solid Trifecta (default)")
91
+ shell.say(" 2. Redis + Sidekiq")
92
+
93
+ choice = shell.ask("Stack (default: Solid):").to_s.strip
94
+ choice = "1" if choice.empty?
95
+
96
+ loop do
97
+ case choice.downcase
98
+ when "solid", "1"
99
+ context.answer(:solid_stack, :solid)
100
+ break
101
+ when "redis", "2"
102
+ context.answer(:solid_stack, :redis)
103
+ break
104
+ else
105
+ shell.say("Invalid. Enter 'Solid', 'Redis', 1, or 2.", :red)
106
+ choice = shell.ask("Stack (default: Solid):").to_s.strip
107
+ choice = "1" if choice.empty?
108
+ end
109
+ end
110
+ end
111
+
112
+ def generate(context)
113
+ strategy = context.get(:solid_stack) || :solid
114
+
115
+ install_redis_and_sidekiq(context) if strategy == :redis
116
+
117
+ context.set_output(:solid_stack, { solid_stack: strategy })
118
+ end
119
+
120
+ def outputs(context)
121
+ context.output(:solid_stack)
122
+ end
123
+
124
+ private
125
+
126
+ def install_redis_and_sidekiq(context)
127
+ add_top_level_gem(context, "gem \"redis\", \"#{REDIS_VERSION}\"")
128
+ add_top_level_gem(context, "gem \"sidekiq\", \"#{SIDEKIQ_VERSION}\"")
129
+
130
+ swap_cache_store(context)
131
+ swap_queue_adapter(context)
132
+ swap_cable_adapter(context)
133
+ write_sidekiq_initializer(context)
134
+ add_commented_sidekiq_route(context)
135
+ remove_solid_leftovers(context)
136
+ end
137
+
138
+ # The Redis stack replaces Solid entirely; leaving the gems, configs and database
139
+ # blocks behind makes other environments (e.g. staging) boot solid_cable without config.
140
+ def remove_solid_leftovers(context)
141
+ remove_solid_gems(context)
142
+ SOLID_FILES.each { |file| FileUtils.rm_f(File.join(context.app_path, file)) }
143
+ remove_solid_database_blocks(context)
144
+ end
145
+
146
+ def remove_solid_gems(context)
147
+ path = File.join(context.app_path, "Gemfile")
148
+ return unless File.exist?(path)
149
+
150
+ pattern = /\Agem\s+["'](?:#{SOLID_GEMS.join('|')})["']/
151
+ File.write(path, File.read(path).lines.reject { |line| line.match?(pattern) }.join)
152
+ end
153
+
154
+ def remove_solid_database_blocks(context)
155
+ path = File.join(context.app_path, "config/database.yml")
156
+ return unless File.exist?(path)
157
+
158
+ keys = SOLID_DATABASE_KEYS.join("|")
159
+ content = File.read(path).gsub(/^ (?:#{keys}):\n(?: .*\n|\s*\n)*?(?= \S|\S|\z)/, "")
160
+ File.write(path, content)
161
+ end
162
+
163
+ def swap_cache_store(context)
164
+ swap_first_matching_anchor(
165
+ context,
166
+ File.join(context.app_path, "config/environments/production.rb"),
167
+ CACHE_STORE_ANCHORS,
168
+ CACHE_STORE_REPLACEMENT
169
+ )
170
+ end
171
+
172
+ def swap_queue_adapter(context)
173
+ swap_first_matching_anchor(
174
+ context,
175
+ File.join(context.app_path, "config/environments/production.rb"),
176
+ QUEUE_ADAPTER_ANCHORS,
177
+ QUEUE_ADAPTER_REPLACEMENT
178
+ )
179
+ end
180
+
181
+ # If Rails rewords the text we match, the swap would silently do nothing and leave the app on
182
+ # Solid while the Gemfile says Redis; say so instead.
183
+ def swap_first_matching_anchor(context, path, anchors, replacement)
184
+ relative = path.delete_prefix("#{context.app_path}/")
185
+ return warn_not_swapped(context, relative, replacement) unless File.exist?(path)
186
+
187
+ content = File.read(path)
188
+ return if content.include?(replacement)
189
+
190
+ anchor = anchors.find { |candidate| content.include?(candidate) }
191
+ return warn_not_swapped(context, relative, replacement) unless anchor
192
+
193
+ File.write(path, content.sub(anchor, replacement))
194
+ end
195
+
196
+ def warn_not_swapped(context, relative, replacement)
197
+ context.shell.say(
198
+ "Warning: could not switch #{relative} to Redis (expected Rails text not found). Set it by hand: #{replacement.strip}",
199
+ :yellow
200
+ )
201
+ end
202
+
203
+ def swap_cable_adapter(context)
204
+ path = File.join(context.app_path, "config/cable.yml")
205
+ return unless File.exist?(path)
206
+
207
+ content = File.read(path)
208
+ # Un-swapped Rails defaults (e.g. --skip-bundle) already read `adapter: redis`.
209
+ unless content.include?(CABLE_PRODUCTION_BLOCK)
210
+ unless content.include?("adapter: redis")
211
+ context.shell.say("Warning: config/cable.yml has no Solid Cable or Redis production block; set `adapter: redis` by hand.", :yellow)
212
+ end
213
+ return
214
+ end
215
+
216
+ File.write(path, content.sub(CABLE_PRODUCTION_BLOCK, CABLE_PRODUCTION_REPLACEMENT))
217
+ end
218
+
219
+ def write_sidekiq_initializer(context)
220
+ path = File.join(context.app_path, "config/initializers/sidekiq.rb")
221
+ FileUtils.mkdir_p(File.dirname(path))
222
+ File.write(path, SIDEKIQ_INITIALIZER)
223
+ end
224
+
225
+ def add_commented_sidekiq_route(context)
226
+ path = File.join(context.app_path, "config/routes.rb")
227
+ return unless File.exist?(path)
228
+
229
+ content = File.read(path)
230
+ return if content.include?("mount Sidekiq::Web")
231
+
232
+ lines = content.lines
233
+ end_index = lines.rindex { |line| line.strip == "end" }
234
+ return unless end_index
235
+
236
+ lines.insert(end_index, SIDEKIQ_WEB_ROUTE_COMMENT)
237
+ File.write(path, lines.join)
238
+ end
239
+
240
+ def add_top_level_gem(context, gem_line)
241
+ gemfile_path = File.join(context.app_path, "Gemfile")
242
+ return unless File.exist?(gemfile_path)
243
+
244
+ content = File.read(gemfile_path)
245
+ return if content.include?(gem_line)
246
+
247
+ File.open(gemfile_path, "a") { |f| f.puts(gem_line) }
248
+ end
249
+ end
250
+ end
251
+ end
252
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpinionatedRails
4
+ module Steps
5
+ module Addons
6
+ class SpaControllerStep < Step
7
+ def self.id = :spa_controller
8
+ def self.tier = :addon
9
+ def self.needs = [ :frontend ]
10
+ def self.summary = "Add a SPA catch-all controller"
11
+ def self.description = "Generates the always-present spa_controller.rb#root action."
12
+ def self.prompts = "None"
13
+
14
+ def if_condition(context)
15
+ context.output(:app_type, :app_type) != :api_only
16
+ end
17
+
18
+ def prompt(_context); end
19
+
20
+ def generate(context)
21
+ unless if_condition(context)
22
+ context.set_output(:spa_controller, nil)
23
+ return
24
+ end
25
+
26
+ write_controller(context)
27
+ write_view(context)
28
+ append_route(context)
29
+
30
+ output_key = context.output(:frontend, :frontend) == :react ? :react_mount : :fallback_404
31
+ context.set_output(:spa_controller, { spa_controller: output_key })
32
+ end
33
+
34
+ def outputs(context)
35
+ context.output(:spa_controller)
36
+ end
37
+
38
+ private
39
+
40
+ def write_controller(context)
41
+ controller_path = File.join(context.app_path, "app/controllers/spa_controller.rb")
42
+ FileUtils.mkdir_p(File.dirname(controller_path))
43
+
44
+ controller = if context.output(:frontend, :frontend) == :react
45
+ "class SpaController < ApplicationController\n def root\n end\nend\n"
46
+ else
47
+ "class SpaController < ApplicationController\n def root\n render status: :not_found\n end\nend\n"
48
+ end
49
+
50
+ File.write(controller_path, controller)
51
+ end
52
+
53
+ def write_view(context)
54
+ view_dir = File.join(context.app_path, "app/views/spa")
55
+ FileUtils.mkdir_p(view_dir)
56
+
57
+ view = if context.output(:frontend, :frontend) == :react
58
+ "<div id=\"root\"></div>\n"
59
+ else
60
+ "<h1>Not Found</h1>\n"
61
+ end
62
+
63
+ File.write(File.join(view_dir, "root.html.erb"), view)
64
+ end
65
+
66
+ def append_route(context)
67
+ routes_path = File.join(context.app_path, "config/routes.rb")
68
+ return unless File.exist?(routes_path)
69
+
70
+ lines = File.readlines(routes_path)
71
+ return if lines.any? { |line| line.include?("spa#root") }
72
+
73
+ end_index = lines.rindex { |line| line.strip == "end" }
74
+ return unless end_index
75
+
76
+ # `*path` does not match "/", so a React app needs an explicit root.
77
+ routes = []
78
+ routes << " root \"spa#root\"\n" if context.output(:frontend, :frontend) == :react
79
+ routes << " get \"*path\", to: \"spa#root\", constraints: ->(req) { !req.xhr? && req.format.html? }\n"
80
+ lines.insert(end_index, *routes)
81
+
82
+ File.write(routes_path, lines.join)
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpinionatedRails
4
+ module Steps
5
+ module Addons
6
+ class StagingEnvStep < Step
7
+ def self.id = :staging_env
8
+ def self.tier = :addon
9
+ # Staging is seeded from production, so it must run after every step that edits
10
+ # production.rb / database.yml / cable.yml (stack swap, mailer, Docker).
11
+ def self.needs = [ :solid_stack, :mailer, :docker_kamal ]
12
+ def self.summary = "Add staging environment"
13
+ def self.description = "Creates a staging Rails environment file and configuration."
14
+ def self.prompts = "Add a staging environment? (default: no)"
15
+
16
+ REVIEW_COMMENT = <<~RUBY
17
+ # Seeded from config/environments/production.rb by opinionated_rails.
18
+ # Review and adjust host names, credentials scoping, and any production-only
19
+ # assumptions before using this environment.
20
+
21
+ RUBY
22
+
23
+ MAIL_NOTICE = <<~RUBY
24
+ # Staging must not send real email through the production Postmark account. To test delivery,
25
+ # create a separate Postmark server for staging and use its token:
26
+ # config.action_mailer.delivery_method = :postmark
27
+ # config.action_mailer.postmark_settings = { api_token: ENV["POSTMARK_STAGING_API_TOKEN"] }
28
+ RUBY
29
+
30
+ # Files with a top-level `production:` block that needs a `staging:` twin; without it
31
+ # `RAILS_ENV=staging` fails to boot (e.g. solid_cable reading cable.yml).
32
+ STAGING_YAML_FILES = %w[ database cable cache queue recurring ].map { |name| "config/#{name}.yml" }.freeze
33
+
34
+ def if_condition(_context) = true
35
+
36
+ def prompt(context)
37
+ shell = context.shell
38
+ choice = shell.ask("Add a staging environment? (default: no):").to_s.strip
39
+ choice = "no" if choice.empty?
40
+
41
+ loop do
42
+ case choice.downcase
43
+ when "yes", "y"
44
+ context.answer(:staging_env, true)
45
+ break
46
+ when "no", "n"
47
+ context.answer(:staging_env, false)
48
+ break
49
+ else
50
+ shell.say("Invalid. Enter 'yes' or 'no'.", :red)
51
+ choice = shell.ask("Add a staging environment? (default: no):").to_s.strip
52
+ choice = "no" if choice.empty?
53
+ end
54
+ end
55
+ end
56
+
57
+ def generate(context)
58
+ enabled = context.get(:staging_env) == true
59
+
60
+ if enabled
61
+ write_staging_environment(context)
62
+ STAGING_YAML_FILES.each { |file| add_staging_yaml_block(File.join(context.app_path, file)) }
63
+ end
64
+
65
+ context.set_output(:staging_env, { staging_env: enabled })
66
+ end
67
+
68
+ def outputs(context)
69
+ context.output(:staging_env)
70
+ end
71
+
72
+ private
73
+
74
+ def write_staging_environment(context)
75
+ production_path = File.join(context.app_path, "config/environments/production.rb")
76
+ return unless File.exist?(production_path)
77
+
78
+ staging_path = File.join(context.app_path, "config/environments/staging.rb")
79
+ File.write(staging_path, REVIEW_COMMENT + isolate_mail(File.read(production_path)))
80
+ end
81
+
82
+ # Seeded from production, staging would deliver real email through the production Postmark account.
83
+ def isolate_mail(content)
84
+ content.gsub(/^(\s*)config\.action_mailer\.delivery_method = :postmark\n/) do
85
+ indent = Regexp.last_match(1)
86
+ MAIL_NOTICE.lines.map { |line| "#{indent}#{line}" }.join + "#{indent}config.action_mailer.delivery_method = :test\n"
87
+ end.gsub(/^\s*config\.action_mailer\.postmark_settings = .*\n/, "")
88
+ end
89
+
90
+ def add_staging_yaml_block(path)
91
+ return unless File.exist?(path)
92
+
93
+ lines = File.read(path).lines
94
+ return if lines.any? { |line| line.start_with?("staging:") }
95
+
96
+ start = lines.index { |line| line.start_with?("production:") }
97
+ return unless start
98
+
99
+ finish = (start + 1...lines.size).find { |i| lines[i].match?(/\A\S/) } || lines.size
100
+ block = lines[start...finish].join.rstrip.gsub("production", "staging")
101
+
102
+ lines[-1] = "#{lines.last.chomp}\n" unless lines.empty?
103
+ File.write(path, "#{lines.join}\n#{block}\n")
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end