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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: f673ffcd0b2ce9c01e2528eaaff216a26160aec73b40fa535c1eac444914cf7a
4
+ data.tar.gz: 45bab309fd8f83547bc56f930203c035ef9969b9bdc2eebcc05ff661004fb4a2
5
+ SHA512:
6
+ metadata.gz: 94af0313d580ce866f84628f371832cfb46c1d2fe0e9a14b20ebadac61e7b006e9f1e663cfa32b6c7c39381bcfe0a60fd5fe521d80663887dd6638b6fa363c62
7
+ data.tar.gz: c4cfd132d350b83bda4ff6d5ad24f0cb1f9d4a2ad098d70267e992e61060dc363e1bd67618dc33868f887ef686a0136ee1423b09213200a532942088ec4183f5
data/CHANGELOG.md ADDED
@@ -0,0 +1,24 @@
1
+ # Changelog
2
+
3
+ ## 0.1.4 (2026-10-10)
4
+
5
+ The first published release. Versions 0.1.0 to 0.1.3 were tagged but never published: 0.1.0 and 0.1.1 were missing the MySQL and bun fixes below, the release of 0.1.2 was refused because its tag did not match the gem version, and the release of 0.1.3 failed because the publish job did not install Ruby.
6
+
7
+ - `opinionated_rails new [APP_NAME]` generates a Rails 8 app by running `rails new` and layering independent steps on top: database, app type, test framework, frontend (Stimulus or React with esbuild, bun or webpack), RuboCop, background jobs/cache/cable (Solid or Redis + Sidekiq), pagination, staging environment, application services, secrets handling, authentication (Rails 8 built-in or Devise), VCR, JS linting, Docker and Kamal, GitHub Actions CI, and mailers.
8
+ - Non-interactive use: `--database/-d`, `--javascript/-j`, `--config FILE` (YAML answers) and `--yes`.
9
+ - `opinionated_rails list` and `opinionated_rails info STEP_ID`.
10
+ - Checks for required tools (yarn or bun for React) before generating, and refuses to overwrite an existing directory.
11
+ - Final `bundle install`, JavaScript install and RuboCop autocorrect so a generated app boots and passes its own `bin/rubocop`, Brakeman and bundler-audit.
12
+ - Complete Kamal config (`config/deploy.yml`, `.kamal/secrets`), staging that cannot send production email, ESLint 9 flat config, Redis fail-fast in production, and warnings when an expected Rails edit cannot be made.
13
+ - Requires Rails 8.1 or newer in the 8.x series.
14
+ - Authentication is skipped for API-only apps (Rails' generator and Devise both assume browser sessions).
15
+
16
+ ### Fixed since the 0.1.0 tag
17
+
18
+ - `--database mysql` failed in `rails new`, because Rails 8.1 accepts `mysql` and the tool passed `mysql2`.
19
+ - React with bun failed to build: Rails' `bun.config.js` pointed at `application.js`, but the React step writes `application.jsx`. The entry point is now set to `application.jsx`, with a warning if Rails' file has changed.
20
+ - Added a RubyGems release workflow (trusted publishing, triggered by a version tag).
21
+
22
+ ## 0.1.0 to 0.1.3 (tagged, not published)
23
+
24
+ Superseded by 0.1.4.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Brolin Remunyanga
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,59 @@
1
+ # opinionated_rails
2
+
3
+ Generate a Rails 8 app by choosing from independent, opinionated options. The tool runs `rails new`, then layers on exactly the pieces you picked (database, tests, frontend, background jobs, authentication, CI, deployment and more) and checks that the result boots and passes its own RuboCop, Brakeman and bundler-audit.
4
+
5
+ **Documentation: <https://brolinr.github.io/opinionated_rails/>**
6
+
7
+ ## Quick start
8
+
9
+ ```bash
10
+ gem install opinionated_rails # needs Rails 8.1+ (gem install rails)
11
+
12
+ opinionated_rails new my_app # answer the prompts; enter accepts a default
13
+
14
+ # or script it
15
+ opinionated_rails new my_app --database postgresql --javascript esbuild --yes
16
+ opinionated_rails new my_app --config answers.yml --yes
17
+ ```
18
+
19
+ For React you also need `yarn` (esbuild, webpack) or `bun`. The tool checks before generating anything.
20
+
21
+ ## What it can do
22
+
23
+ | Area | Options |
24
+ | --- | --- |
25
+ | Database | SQLite, PostgreSQL, MySQL |
26
+ | App type | Full-stack, API-only |
27
+ | Tests | Minitest, RSpec (FactoryBot or fixtures), VCR |
28
+ | Frontend | Stimulus + Hotwire, or React with esbuild, bun or webpack; Tailwind CSS; ESLint 9 |
29
+ | Jobs, cache, cable | Solid Trifecta, or Redis + Sidekiq |
30
+ | Authentication | Rails 8 built-in or Devise (full-stack apps) |
31
+ | Also | Pagination (pagy or geared_pagination), staging environment, application services, dotenv or Rails credentials, mailers, GitHub Actions CI, Docker and Kamal deployment |
32
+
33
+ Every choice has a flag or a [config file](https://brolinr.github.io/opinionated_rails/config-file.html) key, and values are validated before anything is generated.
34
+
35
+ ## Documentation
36
+
37
+ - [Getting started](https://brolinr.github.io/opinionated_rails/getting-started.html)
38
+ - [Command line](https://brolinr.github.io/opinionated_rails/command-line.html) and [config file](https://brolinr.github.io/opinionated_rails/config-file.html)
39
+ - [Every option](https://brolinr.github.io/opinionated_rails/options.html) and [the generated app](https://brolinr.github.io/opinionated_rails/generated-app.html)
40
+ - [Deploying with Kamal](https://brolinr.github.io/opinionated_rails/deploying.html)
41
+ - [Troubleshooting](https://brolinr.github.io/opinionated_rails/troubleshooting.html)
42
+ - [Contributing](https://brolinr.github.io/opinionated_rails/contributing/): architecture, writing a step, testing, releasing
43
+
44
+ The sources are in [`docs/`](docs/). `opinionated_rails list` and `opinionated_rails info STEP_ID` show the steps in your terminal.
45
+
46
+ ## Development
47
+
48
+ ```bash
49
+ bin/setup
50
+ bundle exec rspec # fast suite
51
+ bundle exec rubocop
52
+ RUN_INTEGRATION_SPECS=1 bundle exec rspec # also generates real apps for every combo (slow, needs network)
53
+ ```
54
+
55
+ See [CONTRIBUTING.md](CONTRIBUTING.md), [STYLE.md](STYLE.md) and the [contributing guide](docs/contributing/).
56
+
57
+ ## License
58
+
59
+ Released under the [MIT License](LICENSE.txt).
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "opinionated_rails"
5
+ require "opinionated_rails/cli"
6
+
7
+ OpinionatedRails::CLI.start(ARGV)
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpinionatedRails
4
+ # Wraps the interactive shell so prompts can be answered from flags or a config file.
5
+ # Unanswered prompts fall through to the wrapped shell, or take their default with `yes: true`.
6
+ # A preset answer that a step rejects raises instead of re-prompting forever.
7
+ class AnswerShell
8
+ # config key => text found in the prompt label (the part before "(default")
9
+ LABELS = {
10
+ "app_name" => "App name",
11
+ "database" => "Database",
12
+ "app_type" => "App type",
13
+ "test_framework" => "Test framework",
14
+ "frontend" => "Frontend",
15
+ "bundler" => "Bundler",
16
+ "rubocop" => "RuboCop style",
17
+ "stack" => "Stack",
18
+ "pagination" => "Pagination",
19
+ "staging" => "staging environment",
20
+ "services" => "application service",
21
+ "secrets" => "Secrets handling",
22
+ "auth_model" => "User model name",
23
+ "auth" => "Authentication",
24
+ "factory" => "Factory strategy",
25
+ "vcr" => "VCR",
26
+ "js_lint" => "JS linting",
27
+ "kamal" => "Kamal",
28
+ "ci" => "CI workflow",
29
+ "mailer" => "mailer"
30
+ }.freeze
31
+
32
+ VALUE_ALIASES = {
33
+ "database" => { "sqlite3" => "sqlite", "postgres" => "postgresql", "pg" => "postgresql", "mysql2" => "mysql" },
34
+ "secrets" => { "credentials" => "rails_credentials" }
35
+ }.freeze
36
+
37
+ YES_NO = %w[ yes y no n ].freeze
38
+
39
+ # Accepted values per key, checked before anything is generated so a typo fails immediately
40
+ # instead of after `rails new` has already run. A spec keeps this in step with the steps' prompts.
41
+ CHOICES = {
42
+ "database" => %w[ sqlite postgresql mysql ],
43
+ "app_type" => %w[ full-stack api-only ],
44
+ "test_framework" => %w[ minitest rspec ],
45
+ "frontend" => %w[ stimulus stimulus+hotwire react ],
46
+ "bundler" => %w[ esbuild bun webpack ],
47
+ "rubocop" => %w[ omakase custom ],
48
+ "stack" => %w[ solid redis ],
49
+ "pagination" => %w[ pagy geared_pagination ],
50
+ "secrets" => %w[ rails_credentials rails\ credentials dotenv ],
51
+ "auth" => %w[ none rails8 devise ],
52
+ "factory" => %w[ factorybot factory_bot fixtures ],
53
+ "staging" => YES_NO, "services" => YES_NO, "vcr" => YES_NO, "js_lint" => YES_NO,
54
+ "kamal" => YES_NO, "ci" => YES_NO, "mailer" => YES_NO
55
+ }.freeze
56
+
57
+ MODEL_NAME = /\A[A-Z][A-Za-z0-9]*\z/
58
+
59
+ attr_reader :used
60
+
61
+ def initialize(base, answers: {}, yes: false)
62
+ @base = base
63
+ @yes = yes
64
+ @answers = normalize(answers)
65
+ @used = Hash.new(0)
66
+ end
67
+
68
+ def ask(prompt = nil, *args)
69
+ key = key_for(prompt)
70
+
71
+ if key && @answers.key?(key)
72
+ @used[key] += 1
73
+ raise Error, invalid_message(key, prompt) if @used[key] > 1
74
+
75
+ return @answers[key]
76
+ end
77
+
78
+ return "" if @yes
79
+
80
+ @base.ask(prompt, *args)
81
+ end
82
+
83
+ # Config keys that no prompt consumed (e.g. `bundler` for a Stimulus app).
84
+ def unused_keys
85
+ @answers.keys - @used.keys
86
+ end
87
+
88
+ def respond_to_missing?(name, include_private = false)
89
+ @base.respond_to?(name, include_private) || super
90
+ end
91
+
92
+ def method_missing(name, *args, &block)
93
+ return super unless @base.respond_to?(name)
94
+
95
+ @base.public_send(name, *args, &block)
96
+ end
97
+
98
+ private
99
+
100
+ def key_for(prompt)
101
+ label = prompt.to_s.split("(default").first.downcase
102
+ LABELS.find { |_, text| label.include?(text.downcase) }&.first
103
+ end
104
+
105
+ def normalize(answers)
106
+ unknown = answers.keys.map(&:to_s) - LABELS.keys
107
+ raise Error, "Unknown option(s): #{unknown.join(', ')}. Valid keys: #{LABELS.keys.join(', ')}" unless unknown.empty?
108
+
109
+ answers.each_with_object({}) do |(key, value), result|
110
+ key = key.to_s
111
+ value = value == true ? "yes" : value == false ? "no" : value.to_s.strip
112
+ result[key] = VALUE_ALIASES.fetch(key, {}).fetch(value.downcase, value)
113
+ validate_value!(key, result[key])
114
+ end
115
+ end
116
+
117
+ def validate_value!(key, value)
118
+ choices = CHOICES[key]
119
+ if choices && !choices.include?(value.downcase)
120
+ raise Error, "Invalid value #{value.inspect} for `#{key}`. Choose one of: #{choices.join(', ')}"
121
+ end
122
+ return unless key == "auth_model" && !MODEL_NAME.match?(value)
123
+
124
+ raise Error, "Invalid value #{value.inspect} for `auth_model`. Use a CamelCase constant such as User or Account."
125
+ end
126
+
127
+ def invalid_message(key, prompt)
128
+ "Invalid value #{@answers[key].inspect} for `#{key}` (#{prompt.to_s.strip})"
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,291 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+
5
+ module OpinionatedRails
6
+ class CLI < Thor
7
+ class UnknownStepError < Thor::Error; end
8
+
9
+ def self.exit_on_failure? = true
10
+
11
+ # The database steps use the adapter name (:mysql2), but `rails new --database` wants "mysql".
12
+ RAILS_DATABASE_FLAGS = { sqlite3: "sqlite3", postgresql: "postgresql", mysql2: "mysql" }.freeze
13
+
14
+ # Steps `new` runs after `rails new`, in declaration order (StepGraph sorts by dependency).
15
+ def self.generated_steps
16
+ [
17
+ Steps::Core::TestingStep,
18
+ Steps::Core::FrontendStep,
19
+ Steps::Core::DatabaseStep,
20
+ Steps::Addons::JsLintStep,
21
+ Steps::Addons::DocsStep,
22
+ Steps::Addons::SpaControllerStep,
23
+ Steps::Addons::TailwindStep,
24
+ Steps::Addons::FactoryOrFixturesStep,
25
+ Steps::Addons::VcrStep,
26
+ Steps::Addons::RubocopStep,
27
+ Steps::Addons::CiStep,
28
+ Steps::Addons::FakerStep,
29
+ Steps::Addons::PrIssueTemplatesStep,
30
+ Steps::Addons::SolidStackStep,
31
+ Steps::Addons::PaginationStep,
32
+ Steps::Addons::StagingEnvStep,
33
+ Steps::Addons::ServicesStep,
34
+ Steps::Addons::SecretsHandlingStep,
35
+ Steps::Addons::AuthStep,
36
+ Steps::Addons::MailerStep,
37
+ Steps::Addons::DockerKamalStep
38
+ ]
39
+ end
40
+
41
+ desc "new [APP_NAME]", "Generate a new Rails app"
42
+ method_option :skip_bundle_install, type: :boolean, default: false, desc: "Skip bundle install"
43
+ method_option :database, aliases: "-d", type: :string, desc: "sqlite3, postgresql or mysql (skips the prompt)"
44
+ method_option :javascript, aliases: "-j", type: :string,
45
+ desc: "Use React with this bundler: esbuild, bun or webpack (skips the prompts)"
46
+ method_option :config, aliases: "-c", type: :string, desc: "YAML file of answers; see the README for keys"
47
+ method_option :yes, aliases: "-y", type: :boolean, default: false, desc: "Accept the default for every unanswered prompt"
48
+ def new(app_name = nil)
49
+ RailsVersionGuard.check_installed!
50
+ answers = preset_answers(app_name)
51
+ shell = AnswerShell.new(Thor::Shell::Basic.new, answers: answers, yes: options[:yes])
52
+ context = OpinionatedRails::Context.new(
53
+ app_path: Dir.pwd,
54
+ shell: shell,
55
+ skip_bundle_install: options[:skip_bundle_install]
56
+ )
57
+
58
+ context.answer(:app_name_input, app_name) if app_name
59
+ prompt_and_generate(context, Steps::Core::AppNameStep)
60
+ chosen_name = context.output(:app_name, :app_name)
61
+ if chosen_name && File.exist?(File.join(Dir.pwd, chosen_name))
62
+ raise OpinionatedRails::Error, "Directory #{chosen_name} already exists"
63
+ end
64
+ prompt_and_generate(context, Steps::Core::DatabaseStep)
65
+ prompt_and_generate(context, Steps::Core::AppTypeStep)
66
+
67
+ testing_step = Steps::Core::TestingStep.new
68
+ testing_step.prompt(context) if testing_step.if_condition(context)
69
+
70
+ frontend_step = Steps::Core::FrontendStep.new
71
+ frontend_step.prompt(context) if frontend_step.if_condition(context)
72
+
73
+ app_name = context.output(:app_name, :app_name)
74
+ raise OpinionatedRails::Error, "No app name captured" unless app_name
75
+
76
+ Prerequisites.check!(context)
77
+
78
+ flags = build_rails_new_flags(context)
79
+ context.app_path = File.join(Dir.pwd, app_name)
80
+
81
+ OpinionatedRails::Generators::RailsNewInvoker.new(
82
+ app_name,
83
+ flags: flags,
84
+ skip_bundle_install: context.skip_bundle_install
85
+ ).invoke
86
+
87
+ prompted = [ :testing, :frontend, :database ]
88
+ selected_steps = self.class.generated_steps.map(&:new)
89
+
90
+ StepGraph.new(selected_steps).sorted.each do |step|
91
+ run_step(context, step, prompted)
92
+ end
93
+
94
+ finish_app(context)
95
+
96
+ unused = shell.unused_keys
97
+ shell.say("Ignored options (their prompt did not apply): #{unused.join(', ')}", :yellow) unless unused.empty?
98
+ end
99
+
100
+ desc "add STEP_ID", "Add a step to an existing app"
101
+ def add(_step_id)
102
+ raise NotImplementedError, "OpinionatedRails::CLI#add not yet implemented"
103
+ end
104
+
105
+ desc "list", "List all supported steps"
106
+ method_option :tier, type: :string, enum: %w[ core addon ], desc: "Filter by tier"
107
+ def list
108
+ steps = step_classes
109
+ steps = steps.select { |step_class| step_class.tier.to_s == options[:tier] } if options[:tier]
110
+
111
+ steps.each do |step_class|
112
+ puts "#{step_class.id} (#{step_class.tier}) — #{step_class.summary}"
113
+ end
114
+ end
115
+
116
+ desc "info STEP_ID", "Show details for a step"
117
+ def info(step_id)
118
+ step_id = step_id.to_sym
119
+ step_class = step_classes.find { |klass| klass.id == step_id }
120
+ raise UnknownStepError, "Unknown step: #{step_id.inspect}" unless step_class
121
+
122
+ puts "id: #{step_class.id}"
123
+ puts "tier: #{step_class.tier}"
124
+ puts "summary: #{step_class.summary}"
125
+ puts "description: #{step_class.description}"
126
+ puts "needs: #{format_needs(step_class)}"
127
+ puts "prompts for: #{format_prompts(step_class)}"
128
+ puts "outputs: #{safe_outputs(step_class)}"
129
+ end
130
+
131
+ private
132
+
133
+ def run_step(context, step, prompted)
134
+ unless prompted.include?(step.class.id)
135
+ step.prompt(context) if step.if_condition(context)
136
+ prompted << step.class.id
137
+ end
138
+
139
+ step.generate(context)
140
+ rescue StandardError => e
141
+ raise_with_recovery_hint(context, step.class.id, e)
142
+ end
143
+
144
+ def finish_app(context)
145
+ { "bundle install" => -> { install_bundle(context) },
146
+ "JavaScript install" => -> { install_javascript_packages(context) },
147
+ "Tailwind build" => -> { build_tailwind_css(context) },
148
+ "RuboCop autocorrect" => -> { autocorrect_style(context) } }.each do |name, action|
149
+ action.call
150
+ rescue StandardError => e
151
+ raise_with_recovery_hint(context, name, e)
152
+ end
153
+ end
154
+
155
+ # A failure after `rails new` leaves a half-built app. Say which step failed and where it is,
156
+ # rather than leaving the user with only the underlying error.
157
+ def raise_with_recovery_hint(context, step_name, error)
158
+ raise OpinionatedRails::Error,
159
+ "Step `#{step_name}` failed: #{error.message}\n" \
160
+ "The partially generated app is in #{context.app_path}. Fix the problem above, then delete " \
161
+ "that directory and run the command again (re-running over it is not supported)."
162
+ end
163
+
164
+ # Answers from --config, overridden by --database / --javascript. The APP_NAME argument wins
165
+ # over a config app_name.
166
+ def preset_answers(app_name)
167
+ answers = load_config(options[:config])
168
+ answers.delete("app_name") if app_name
169
+ answers["database"] = options[:database] if options[:database]
170
+
171
+ if options[:javascript]
172
+ answers["frontend"] = "react"
173
+ answers["bundler"] = options[:javascript]
174
+ end
175
+
176
+ answers
177
+ end
178
+
179
+ def load_config(path)
180
+ return {} unless path
181
+
182
+ raise OpinionatedRails::Error, "Config file not found: #{path}" unless File.file?(path)
183
+
184
+ data = YAML.safe_load_file(path) || {}
185
+ raise OpinionatedRails::Error, "Config file must be a YAML mapping of answers: #{path}" unless data.is_a?(Hash)
186
+
187
+ data.transform_keys(&:to_s)
188
+ rescue Psych::SyntaxError => e
189
+ raise OpinionatedRails::Error, "Config file is not valid YAML (#{path}): #{e.message}"
190
+ end
191
+
192
+ # Steps append gems to the Gemfile after the last mid-run `bundle install`,
193
+ # so resolve and install them once everything has been generated.
194
+ def install_bundle(context)
195
+ return if context.skip_bundle_install
196
+
197
+ success = Dir.chdir(context.app_path) do
198
+ Bundler.with_unbundled_env { system("bundle", "install", "--quiet") }
199
+ end
200
+ success || raise(OpinionatedRails::Error, "final bundle install failed")
201
+ end
202
+
203
+ # Steps add React, ESLint and Tailwind to package.json after jsbundling's own install ran, so
204
+ # install them, then run the build scripts: without the built assets the first page load fails
205
+ # with a missing-asset error, and a broken JS pipeline should surface here, not later.
206
+ def install_javascript_packages(context)
207
+ return if context.skip_bundle_install
208
+ return unless File.exist?(File.join(context.app_path, "package.json"))
209
+
210
+ runner = context.get(:bundler) == :bun ? "bun" : "yarn"
211
+ unless Prerequisites.available?(runner)
212
+ context.shell.say("Warning: #{runner} not found; run `#{runner} install` and its build scripts in the app.", :yellow)
213
+ return
214
+ end
215
+
216
+ scripts = JSON.parse(File.read(File.join(context.app_path, "package.json"))).fetch("scripts", {})
217
+ commands = [ [ runner, "install" ] ] + %w[ build build:css ].select { |name| scripts.key?(name) }.map { |name| [ runner, "run", name ] }
218
+
219
+ commands.each do |command|
220
+ success = Dir.chdir(context.app_path) { Bundler.with_unbundled_env { system(*command) } }
221
+ success || raise(OpinionatedRails::Error, "#{command.join(' ')} failed")
222
+ end
223
+ end
224
+
225
+ # tailwindcss-rails only builds the CSS through rake hooks, so a fresh app's first `bin/rails test`
226
+ # (which renders the layout) fails on the missing tailwind.css until it has been built once.
227
+ def build_tailwind_css(context)
228
+ return if context.skip_bundle_install
229
+
230
+ gemfile = File.join(context.app_path, "Gemfile")
231
+ return unless File.exist?(gemfile) && File.read(gemfile).include?("tailwindcss-rails")
232
+
233
+ success = Dir.chdir(context.app_path) { Bundler.with_unbundled_env { system("bin/rails", "tailwindcss:build") } }
234
+ success || raise(OpinionatedRails::Error, "bin/rails tailwindcss:build failed")
235
+ end
236
+
237
+ # Generators we don't own (Devise, dockerfile-rails, ...) emit code that fails the app's own
238
+ # RuboCop config, which would turn the generated CI red on the first push.
239
+ def autocorrect_style(context)
240
+ return if context.skip_bundle_install
241
+ return unless File.exist?(File.join(context.app_path, "bin/rubocop"))
242
+
243
+ clean = Dir.chdir(context.app_path) do
244
+ Bundler.with_unbundled_env { system("bin/rubocop", "-a", "--format", "quiet") }
245
+ end
246
+ return if clean
247
+
248
+ context.shell.say("Warning: bin/rubocop still reports offenses in the generated app; run it to review.", :yellow)
249
+ end
250
+
251
+ def prompt_and_generate(context, step_class)
252
+ step = step_class.new
253
+ step.prompt(context)
254
+ step.generate(context)
255
+ end
256
+
257
+ def build_rails_new_flags(context)
258
+ flags = []
259
+ database = context.output(:database, :database)
260
+ flags << "--database=#{RAILS_DATABASE_FLAGS.fetch(database, database)}"
261
+ flags << "--api" if context.output(:app_type, :app_type) == :api_only
262
+ flags << "--skip-test" if context.get(:testing) == :rspec
263
+ flags << "--javascript=#{context.get(:bundler)}" if context.get(:frontend) == :react && context.get(:bundler)
264
+ flags << "--skip-docker"
265
+ flags << "--skip-kamal"
266
+ flags << "--skip-ci"
267
+ flags
268
+ end
269
+
270
+ def step_classes
271
+ OpinionatedRails.step_classes
272
+ end
273
+
274
+ def format_needs(step_class)
275
+ return "none" if step_class.needs.empty?
276
+
277
+ step_class.needs.join(", ")
278
+ end
279
+
280
+ def format_prompts(step_class)
281
+ step_class.respond_to?(:prompts) ? step_class.prompts : "not yet implemented"
282
+ end
283
+
284
+ def safe_outputs(step_class)
285
+ step = step_class.new
286
+ step.outputs(OpinionatedRails::Context.new(app_path: Dir.pwd))
287
+ rescue NotImplementedError
288
+ "not yet implemented"
289
+ end
290
+ end
291
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+
5
+ module OpinionatedRails
6
+ class Context
7
+ attr_accessor :app_path, :shell, :skip_bundle_install
8
+ attr_reader :answers, :outputs
9
+
10
+ def initialize(app_path:, shell: Thor::Shell::Basic.new, skip_bundle_install: false)
11
+ @app_path = app_path
12
+ @answers = {}
13
+ @outputs = {}
14
+ @shell = shell
15
+ @skip_bundle_install = skip_bundle_install
16
+ end
17
+
18
+ def answer(key, value)
19
+ @answers[key] = value
20
+ end
21
+
22
+ def get(key)
23
+ @answers[key]
24
+ end
25
+
26
+ def selected?(step_id)
27
+ !!@answers[step_id]
28
+ end
29
+
30
+ def set_output(step_id, data)
31
+ @outputs[step_id] = data
32
+ end
33
+
34
+ def output(step_id, key = nil)
35
+ step = @outputs.fetch(step_id, {})
36
+ key ? step[key] : step
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpinionatedRails
4
+ # Thor::Error makes the CLI print the message and exit non-zero instead of dumping a backtrace.
5
+ class Error < Thor::Error; end
6
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module OpinionatedRails
6
+ module Generators
7
+ class FileWriter
8
+ def write(path, content)
9
+ FileUtils.mkdir_p(File.dirname(path))
10
+ File.write(path, content)
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpinionatedRails
4
+ module Generators
5
+ class RailsNewInvoker
6
+ def initialize(app_name, flags: [], skip_bundle_install: false)
7
+ @app_name = app_name
8
+ @flags = flags
9
+ @skip_bundle_install = skip_bundle_install
10
+ end
11
+
12
+ def invoke
13
+ command = [ "rails", "new", @app_name, *@flags ]
14
+ command << "--skip-bundle" if @skip_bundle_install
15
+
16
+ success = if defined?(Bundler)
17
+ Bundler.with_unbundled_env { system(*command) }
18
+ else
19
+ system(*command)
20
+ end
21
+
22
+ success || raise(OpinionatedRails::Error, "rails new failed")
23
+ end
24
+ end
25
+ end
26
+ end