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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +24 -0
- data/LICENSE.txt +21 -0
- data/README.md +59 -0
- data/exe/opinionated_rails +7 -0
- data/lib/opinionated_rails/answer_shell.rb +131 -0
- data/lib/opinionated_rails/cli.rb +291 -0
- data/lib/opinionated_rails/context.rb +39 -0
- data/lib/opinionated_rails/error.rb +6 -0
- data/lib/opinionated_rails/generators/file_writer.rb +14 -0
- data/lib/opinionated_rails/generators/rails_new_invoker.rb +26 -0
- data/lib/opinionated_rails/kamal_config.rb +94 -0
- data/lib/opinionated_rails/manifest.rb +42 -0
- data/lib/opinionated_rails/pins.rb +83 -0
- data/lib/opinionated_rails/prerequisites.rb +39 -0
- data/lib/opinionated_rails/rails_version_guard.rb +49 -0
- data/lib/opinionated_rails/step.rb +41 -0
- data/lib/opinionated_rails/step_graph.rb +53 -0
- data/lib/opinionated_rails/steps/addons/auth_step.rb +170 -0
- data/lib/opinionated_rails/steps/addons/ci_step.rb +226 -0
- data/lib/opinionated_rails/steps/addons/docker_kamal_step.rb +133 -0
- data/lib/opinionated_rails/steps/addons/docs_step.rb +35 -0
- data/lib/opinionated_rails/steps/addons/factory_or_fixtures_step.rb +128 -0
- data/lib/opinionated_rails/steps/addons/faker_step.rb +55 -0
- data/lib/opinionated_rails/steps/addons/js_lint_step.rb +132 -0
- data/lib/opinionated_rails/steps/addons/mailer_step.rb +183 -0
- data/lib/opinionated_rails/steps/addons/pagination_step.rb +116 -0
- data/lib/opinionated_rails/steps/addons/pr_issue_templates_step.rb +43 -0
- data/lib/opinionated_rails/steps/addons/rubocop_step.rb +111 -0
- data/lib/opinionated_rails/steps/addons/secrets_handling_step.rb +138 -0
- data/lib/opinionated_rails/steps/addons/services_step.rb +64 -0
- data/lib/opinionated_rails/steps/addons/solid_stack_step.rb +252 -0
- data/lib/opinionated_rails/steps/addons/spa_controller_step.rb +87 -0
- data/lib/opinionated_rails/steps/addons/staging_env_step.rb +108 -0
- data/lib/opinionated_rails/steps/addons/tailwind_step.rb +148 -0
- data/lib/opinionated_rails/steps/addons/vcr_step.rb +170 -0
- data/lib/opinionated_rails/steps/core/app_name_step.rb +85 -0
- data/lib/opinionated_rails/steps/core/app_type_step.rb +51 -0
- data/lib/opinionated_rails/steps/core/database_step.rb +53 -0
- data/lib/opinionated_rails/steps/core/frontend_step.rb +282 -0
- data/lib/opinionated_rails/steps/core/testing_step.rb +82 -0
- data/lib/opinionated_rails/templates/docs/STYLE.md +39 -0
- data/lib/opinionated_rails/templates/github/ISSUE_TEMPLATE/bug_report.md +25 -0
- data/lib/opinionated_rails/templates/github/ISSUE_TEMPLATE/feature_request.md +17 -0
- data/lib/opinionated_rails/templates/github/PULL_REQUEST_TEMPLATE.md +9 -0
- data/lib/opinionated_rails/templates/rubocop/custom_overrides.yml +5 -0
- data/lib/opinionated_rails/templates/services/application_service.rb +50 -0
- data/lib/opinionated_rails/templates/services/result.rb +44 -0
- data/lib/opinionated_rails/version.rb +5 -0
- data/lib/opinionated_rails.rb +41 -0
- 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
|