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,282 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpinionatedRails
4
+ module Steps
5
+ module Core
6
+ class FrontendStep < Step
7
+ def self.id = :frontend
8
+ def self.summary = "Choose the frontend stack"
9
+ def self.description = "Prompts for Stimulus+Hotwire, React, or no frontend for API-only apps."
10
+ def self.tier = :core
11
+ def self.prompts = "Frontend: Stimulus+Hotwire (1) or React (2), default Stimulus; React also asks for a bundler"
12
+
13
+ BABEL_CONFIG = <<~JSON
14
+ {
15
+ "presets": [
16
+ ["@babel/preset-env", { "modules": false }],
17
+ ["@babel/preset-react", { "runtime": "automatic" }]
18
+ ]
19
+ }
20
+ JSON
21
+
22
+ WEBPACK_CONFIG = <<~JS
23
+ const path = require("path")
24
+ const webpack = require("webpack")
25
+
26
+ module.exports = {
27
+ mode: "production",
28
+ devtool: "source-map",
29
+ entry: {
30
+ application: "./app/javascript/application.jsx"
31
+ },
32
+ resolve: {
33
+ extensions: [".js", ".jsx"],
34
+ alias: { "@": path.resolve(__dirname, "app/javascript") }
35
+ },
36
+ module: {
37
+ rules: [
38
+ { test: /\.jsx?$/, exclude: /node_modules/, use: "babel-loader" }
39
+ ]
40
+ },
41
+ output: {
42
+ filename: "[name].js",
43
+ sourceMapFilename: "[file].map",
44
+ chunkFormat: "module",
45
+ path: path.resolve(__dirname, "app/assets/builds"),
46
+ },
47
+ plugins: [
48
+ new webpack.optimize.LimitChunkCountPlugin({
49
+ maxChunks: 1
50
+ })
51
+ ]
52
+ }
53
+ JS
54
+
55
+ BUNDLERS = %w[esbuild bun webpack].freeze
56
+
57
+ FRONTEND_OPTIONS = %w[stimulus react].freeze
58
+
59
+ def if_condition(context)
60
+ context.output(:app_type, :app_type) != :api_only
61
+ end
62
+
63
+ def prompt(context)
64
+ shell = context.shell
65
+ shell.say("Choose a frontend stack:")
66
+ shell.say(" 1. Stimulus + Hotwire (default)")
67
+ shell.say(" 2. React")
68
+
69
+ choice = shell.ask("Frontend (default: Stimulus):").to_s.strip
70
+ choice = "1" if choice.empty?
71
+
72
+ loop do
73
+ case choice.downcase
74
+ when "stimulus", "stimulus+hotwire", "1"
75
+ context.answer(:frontend, :stimulus_hotwire)
76
+ break
77
+ when "react", "2"
78
+ context.answer(:frontend, :react)
79
+ break
80
+ else
81
+ shell.say("Invalid. Enter 'Stimulus' or 'React', 1, or 2.", :red)
82
+ choice = shell.ask("Frontend (default: Stimulus):").to_s.strip
83
+ choice = "1" if choice.empty?
84
+ end
85
+ end
86
+
87
+ return unless context.get(:frontend) == :react
88
+
89
+ prompt_bundler(context)
90
+ end
91
+
92
+ def generate(context)
93
+ type = if if_condition(context)
94
+ context.get(:frontend) || :none
95
+ else
96
+ :none
97
+ end
98
+
99
+ data = { frontend: type }
100
+ data[:bundler] = context.get(:bundler) if type == :react
101
+
102
+ context.set_output(:frontend, data)
103
+
104
+ return if type == :none
105
+
106
+ install_react(context) if type == :react
107
+ end
108
+
109
+ def outputs(context)
110
+ context.output(:frontend)
111
+ end
112
+
113
+ private
114
+
115
+ def prompt_bundler(context)
116
+ shell = context.shell
117
+ shell.say("Choose a JavaScript bundler:")
118
+ BUNDLERS.each_with_index do |name, index|
119
+ shell.say(" #{index + 1}. #{name.capitalize}")
120
+ end
121
+
122
+ choice = shell.ask("Bundler (default: esbuild):").to_s.strip
123
+ choice = "esbuild" if choice.empty?
124
+
125
+ loop do
126
+ choice = BUNDLERS[choice.to_i - 1] if %w[1 2 3 4].include?(choice)
127
+
128
+ if BUNDLERS.include?(choice.downcase)
129
+ context.answer(:bundler, choice.downcase.to_sym)
130
+ break
131
+ end
132
+
133
+ shell.say("Invalid. Enter #{BUNDLERS.join(', ')}.", :red)
134
+ choice = shell.ask("Bundler (default: esbuild):").to_s.strip
135
+ end
136
+ end
137
+
138
+ def install_react(context)
139
+ bundler = context.output(:frontend, :bundler)
140
+
141
+ update_package_json(context, bundler)
142
+ add_esbuild_alias(context) if bundler == :esbuild
143
+ configure_webpack(context) if bundler == :webpack
144
+ configure_bun(context) if bundler == :bun
145
+ warn_non_esbuild_alias(context, bundler) if bundler == :bun
146
+ write_js_files(context)
147
+ write_jsconfig(context)
148
+ create_images_dir(context)
149
+ end
150
+
151
+ def update_package_json(context, bundler)
152
+ package_json_path = File.join(context.app_path, "package.json")
153
+ package = if File.exist?(package_json_path)
154
+ JSON.parse(File.read(package_json_path))
155
+ else
156
+ { "scripts" => {}, "dependencies" => {} }
157
+ end
158
+
159
+ package["dependencies"] ||= {}
160
+ package["dependencies"]["react"] = "18.2.0"
161
+ package["dependencies"]["react-dom"] = "18.2.0"
162
+ package["dependencies"]["react-router-dom"] = "6.23.1"
163
+
164
+ if bundler == :esbuild && package["scripts"]["build"].nil?
165
+ package["scripts"]["build"] = "esbuild app/javascript/*.* --bundle --sourcemap --outdir=app/assets/builds --public-path=assets"
166
+ end
167
+
168
+ File.write(package_json_path, JSON.pretty_generate(package))
169
+ end
170
+
171
+ def add_esbuild_alias(context)
172
+ package_json_path = File.join(context.app_path, "package.json")
173
+ package = JSON.parse(File.read(package_json_path))
174
+ package["scripts"] ||= {}
175
+ build = package["scripts"]["build"]
176
+
177
+ if build && !build.include?("--alias:@=app/javascript")
178
+ package["scripts"]["build"] = "#{build} --alias:@=app/javascript"
179
+ end
180
+
181
+ File.write(package_json_path, JSON.pretty_generate(package))
182
+ end
183
+
184
+ # Rails' webpack config only knows application.js and has no JSX transform; the React files
185
+ # are .jsx, so the build would fail. Add Babel, the .jsx entry, and the @ alias.
186
+ def configure_webpack(context)
187
+ package_json_path = File.join(context.app_path, "package.json")
188
+ package = JSON.parse(File.read(package_json_path))
189
+ package["devDependencies"] ||= {}
190
+ package["devDependencies"]["@babel/core"] = "8.0.7"
191
+ package["devDependencies"]["@babel/preset-env"] = "8.0.7"
192
+ package["devDependencies"]["@babel/preset-react"] = "8.0.1"
193
+ package["devDependencies"]["babel-loader"] = "10.1.1"
194
+ File.write(package_json_path, JSON.pretty_generate(package))
195
+
196
+ File.write(File.join(context.app_path, "babel.config.json"), BABEL_CONFIG)
197
+ File.write(File.join(context.app_path, "webpack.config.js"), WEBPACK_CONFIG)
198
+ end
199
+
200
+ # Rails' bun.config.js bundles app/javascript/application.js; the React files are .jsx, which Bun
201
+ # builds natively once it is pointed at the right entry.
202
+ BUN_ENTRY = 'entrypoints: ["app/javascript/application.js"]'
203
+ BUN_ENTRY_JSX = 'entrypoints: ["app/javascript/application.jsx"]'
204
+
205
+ def configure_bun(context)
206
+ path = File.join(context.app_path, "bun.config.js")
207
+ content = File.exist?(path) ? File.read(path) : ""
208
+ return if content.include?(BUN_ENTRY_JSX)
209
+
210
+ unless content.include?(BUN_ENTRY)
211
+ context.shell.say(
212
+ "Warning: could not find `#{BUN_ENTRY}` in bun.config.js; set the entry point to " \
213
+ "app/javascript/application.jsx by hand.",
214
+ :yellow
215
+ )
216
+ return
217
+ end
218
+
219
+ File.write(path, content.sub(BUN_ENTRY, BUN_ENTRY_JSX))
220
+ end
221
+
222
+ def warn_non_esbuild_alias(context, bundler)
223
+ context.shell.say(
224
+ "Warning: the @ import alias is configured for editor/TypeScript tooling only and won't resolve " \
225
+ "at build time until a bundler-specific config is added (current bundler: #{bundler}).",
226
+ :yellow
227
+ )
228
+ end
229
+
230
+ def write_js_files(context)
231
+ application_js = File.join(context.app_path, "app/javascript/application.js")
232
+ File.delete(application_js) if File.exist?(application_js)
233
+
234
+ FileUtils.mkdir_p(File.join(context.app_path, "app/javascript"))
235
+
236
+ File.write(
237
+ File.join(context.app_path, "app/javascript/application.jsx"),
238
+ <<~'JS'
239
+ import React from "react";
240
+ import { createRoot } from "react-dom/client";
241
+ import { RouterProvider } from "react-router-dom";
242
+ import router from "./router";
243
+
244
+ const root = createRoot(document.getElementById("root"));
245
+ root.render(<RouterProvider router={router} />);
246
+ JS
247
+ )
248
+
249
+ File.write(
250
+ File.join(context.app_path, "app/javascript/router.jsx"),
251
+ <<~'JS'
252
+ import React from "react";
253
+ import { createBrowserRouter } from "react-router-dom";
254
+
255
+ const router = createBrowserRouter([
256
+ { path: "/", element: <h1>Welcome</h1> }
257
+ ]);
258
+
259
+ export default router;
260
+ JS
261
+ )
262
+ end
263
+
264
+ def write_jsconfig(context)
265
+ jsconfig = {
266
+ "compilerOptions" => {
267
+ "baseUrl" => ".",
268
+ "paths" => {
269
+ "@/*" => [ "app/javascript/*" ]
270
+ }
271
+ }
272
+ }
273
+ File.write(File.join(context.app_path, "jsconfig.json"), JSON.pretty_generate(jsconfig))
274
+ end
275
+
276
+ def create_images_dir(context)
277
+ FileUtils.mkdir_p(File.join(context.app_path, "app/javascript/images"))
278
+ end
279
+ end
280
+ end
281
+ end
282
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpinionatedRails
4
+ module Steps
5
+ module Core
6
+ class TestingStep < Step
7
+ def self.id = :testing
8
+ def self.summary = "Choose the test framework"
9
+ def self.description = "Prompts for RSpec or Minitest as the test framework."
10
+ def self.tier = :core
11
+ def self.prompts = "Test framework: RSpec or Minitest (default: Minitest)"
12
+
13
+ RSPEC_VERSION = "8.0.4".freeze
14
+
15
+ def prompt(context)
16
+ shell = context.shell
17
+ shell.say("Choose a test framework:")
18
+ shell.say(" 1. Minitest")
19
+ shell.say(" 2. RSpec")
20
+
21
+ choice = shell.ask("Test framework (default: Minitest):").to_s.strip
22
+ choice = "minitest" if choice.empty?
23
+
24
+ loop do
25
+ case choice.downcase
26
+ when "minitest", "1"
27
+ context.answer(:testing, :minitest)
28
+ break
29
+ when "rspec", "2"
30
+ context.answer(:testing, :rspec)
31
+ break
32
+ else
33
+ shell.say("Invalid. Enter 'Minitest', 'RSpec', 1, or 2.", :red)
34
+ choice = shell.ask("Test framework (default: Minitest):").to_s.strip
35
+ choice = "minitest" if choice.empty?
36
+ end
37
+ end
38
+ end
39
+
40
+ def generate(context)
41
+ framework = context.get(:testing)
42
+ raise ArgumentError, "No test framework answer" unless framework
43
+
44
+ context.set_output(:testing, { test_framework: framework })
45
+ install_rspec(context) if framework == :rspec
46
+ end
47
+
48
+ def outputs(context)
49
+ context.output(:testing)
50
+ end
51
+
52
+ private
53
+
54
+ def install_rspec(context)
55
+ gemfile = File.join(context.app_path, "Gemfile")
56
+ unless File.read(gemfile).include?("gem \"rspec-rails\"")
57
+ File.open(gemfile, "a") do |f|
58
+ f.puts("\ngroup :development, :test do\n gem \"rspec-rails\", \"#{RSPEC_VERSION}\"\nend")
59
+ end
60
+ end
61
+
62
+ return if context.skip_bundle_install
63
+
64
+ run_in_app(context, "bundle", "install", "--quiet") ||
65
+ raise(OpinionatedRails::Error, "bundle install failed")
66
+ run_in_app(context, "bundle", "exec", "rails", "generate", "rspec:install") ||
67
+ raise(OpinionatedRails::Error, "rspec:install failed")
68
+ end
69
+
70
+ def run_in_app(context, *command)
71
+ Dir.chdir(context.app_path) do
72
+ if defined?(Bundler)
73
+ Bundler.with_unbundled_env { system(*command) }
74
+ else
75
+ system(*command)
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,39 @@
1
+ # Project Style Guide
2
+
3
+ Conventions for this codebase. Follow the shape of existing code before inventing something new.
4
+
5
+ ## General
6
+
7
+ - Ruby style is enforced by RuboCop. Run `bin/rubocop` (add `-a` to autocorrect) before committing. Do not disable cops inline without a comment explaining why.
8
+ - Keep controllers thin: parse params, call one object, render or redirect. Put business rules in models or services.
9
+ - Prefer small, named methods over long ones. Comment the *why*, not the *what*.
10
+
11
+ ## Services (if `app/services` exists)
12
+
13
+ - Services inherit from `ApplicationService`, expose a single public `call`, and return a `Result`.
14
+ - Call them as `MyService.call(...)`. Check `result.success?` / `result.failure?`; read `result.data` and `result.errors`.
15
+ - Wrap multi-record writes in `transaction { ... }`. Add an error to the result to roll back.
16
+ - Services must not render, redirect or touch `params`. Pass plain arguments in.
17
+
18
+ ## Models and database
19
+
20
+ - Add database constraints (null, unique, foreign keys) and indexes in the migration, not only validations in the model.
21
+ - Never edit a migration that has been merged. Add a new one.
22
+ - Avoid N+1 queries: use `includes`/`preload` when iterating associations.
23
+
24
+ ## Testing
25
+
26
+ - Every behaviour change ships with a test. Fix a bug by writing the failing test first.
27
+ - Keep tests independent and deterministic. Do not hit real external services; stub or record them.
28
+ - Run the full suite and `bin/rubocop` before opening a pull request.
29
+
30
+ ## Security
31
+
32
+ - Never commit secrets. Use Rails credentials (`bin/rails credentials:edit`) or environment variables, and keep `.env*` files out of git.
33
+ - Always use strong parameters. Never build SQL with string interpolation.
34
+ - Run `bin/brakeman` and `bin/bundler-audit` regularly. CI should fail on new warnings.
35
+
36
+ ## Git and pull requests
37
+
38
+ - Small, focused commits with imperative-mood messages ("Add invoice export").
39
+ - Pull requests describe the problem, the approach and how it was verified.
@@ -0,0 +1,25 @@
1
+ ---
2
+ name: Bug report
3
+ about: Report a problem with the application
4
+ title: "[Bug] "
5
+ labels: bug
6
+ ---
7
+
8
+ ## Description
9
+
10
+ A clear description of the bug.
11
+
12
+ ## Steps to reproduce
13
+
14
+ 1.
15
+ 2.
16
+ 3.
17
+
18
+ ## Expected behavior
19
+
20
+ ## Actual behavior
21
+
22
+ ## Environment
23
+
24
+ - Version:
25
+ - OS:
@@ -0,0 +1,17 @@
1
+ ---
2
+ name: Feature request
3
+ about: Suggest an idea for this project
4
+ title: "[Feature] "
5
+ labels: enhancement
6
+ ---
7
+
8
+ ## Problem
9
+
10
+ What problem does this feature solve?
11
+
12
+ ## Proposed solution
13
+
14
+ Describe the solution you'd like.
15
+
16
+ ## Alternatives considered
17
+
@@ -0,0 +1,9 @@
1
+ ## Summary
2
+
3
+ Describe what this PR changes and why.
4
+
5
+ ## Checklist
6
+
7
+ - [ ] Tests added or updated
8
+ - [ ] Documentation updated if needed
9
+ - [ ] No unrelated changes included
@@ -0,0 +1,5 @@
1
+ # Example overrides — edit to taste after generation.
2
+ #
3
+ # Use `[a, [b, c]]` not `[ a, [ b, c ] ]`
4
+ # Layout/SpaceInsideArrayLiteralBrackets:
5
+ # Enabled: false
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ApplicationService
4
+ def self.call(params: {}, context: {}, **args)
5
+ new(params: params, context: context).call(**args)
6
+ end
7
+
8
+ attr_reader :result, :params, :context
9
+
10
+ def initialize(params: {}, context: {})
11
+ @params = params
12
+ @context = context
13
+ @result = Result.new
14
+ end
15
+
16
+ delegate :assign_data, to: :result
17
+ delegate :add_error, to: :result
18
+ delegate :success?, to: :result
19
+ delegate :failure?, to: :result
20
+ alias add_errors add_error
21
+
22
+ # Steps are usually private methods, so this deliberately uses send; never pass user input.
23
+ def step(method)
24
+ return unless success?
25
+
26
+ send(method)
27
+ end
28
+
29
+ def preload(*methods)
30
+ methods.map { |method| send(method) }
31
+ end
32
+
33
+ def call
34
+ raise NotImplementedError, "#call method must be implemented"
35
+ end
36
+
37
+ # Rolls back when the result has errors. ActiveRecord::Base.transaction swallows
38
+ # ActiveRecord::Rollback itself, and the errors are already on the result.
39
+ def transaction
40
+ ActiveRecord::Base.transaction do
41
+ yield
42
+ raise ActiveRecord::Rollback unless success?
43
+ end
44
+ end
45
+
46
+ ## Setup solidqueue first
47
+ # def self.call_async(params: {}, context: {})
48
+ # AsyncJob.perform_later(name, params, context)
49
+ # end
50
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Result
4
+ attr_reader :errors, :data, :status
5
+
6
+ def self.failure(*errors, status: :unprocessable_entity)
7
+ new(errors: errors, status: status)
8
+ end
9
+
10
+ def self.success(data = nil, status: :ok)
11
+ new(data: data, status: status)
12
+ end
13
+
14
+ def initialize(errors: [], data: nil, status: :ok)
15
+ @errors = Array(errors)
16
+ @data = data
17
+ @status = status
18
+ end
19
+
20
+ def assign_data(object)
21
+ @data = object
22
+ end
23
+
24
+ def add_error(message, status_name = nil)
25
+ @status = status_name || :unprocessable_entity
26
+ if message.is_a?(Array)
27
+ @errors += message
28
+ else
29
+ @errors << message
30
+ end
31
+ end
32
+
33
+ def success?
34
+ !failure?
35
+ end
36
+
37
+ def failure?
38
+ errors.present? || status == :unprocessable_entity
39
+ end
40
+
41
+ def error_string
42
+ errors.compact.join(" ")
43
+ end
44
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpinionatedRails
4
+ VERSION = "0.1.4"
5
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "date"
5
+ require "json"
6
+ require "fileutils"
7
+ require "tempfile"
8
+ # Bundler.with_unbundled_env runs the generators outside this gem's own bundle; the constant is
9
+ # not loaded when the installed gem runs without `bundle exec`.
10
+ require "bundler"
11
+ require "thor"
12
+
13
+ # Templates and generated files contain non-ASCII (em dashes); a C/POSIX locale
14
+ # (CI, Docker) would otherwise make File.read raise on them.
15
+ Encoding.default_external = Encoding::UTF_8
16
+
17
+ require_relative "opinionated_rails/version"
18
+ require_relative "opinionated_rails/error"
19
+ require_relative "opinionated_rails/context"
20
+ require_relative "opinionated_rails/answer_shell"
21
+ require_relative "opinionated_rails/step"
22
+ require_relative "opinionated_rails/step_graph"
23
+ require_relative "opinionated_rails/manifest"
24
+ require_relative "opinionated_rails/rails_version_guard"
25
+ require_relative "opinionated_rails/prerequisites"
26
+ require_relative "opinionated_rails/kamal_config"
27
+ require_relative "opinionated_rails/cli"
28
+ require_relative "opinionated_rails/generators/rails_new_invoker"
29
+ require_relative "opinionated_rails/generators/file_writer"
30
+
31
+ Dir[File.expand_path("opinionated_rails/steps/**/*.rb", __dir__)].sort.each do |file|
32
+ require file
33
+ end
34
+
35
+ module OpinionatedRails
36
+ def self.step_classes
37
+ [ Steps::Core, Steps::Addons ].flat_map do |mod|
38
+ mod.constants.map { |name| mod.const_get(name) }
39
+ end
40
+ end
41
+ end