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
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,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,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
|