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,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpinionatedRails
4
+ module Steps
5
+ module Addons
6
+ class TailwindStep < Step
7
+ def self.id = :tailwind
8
+ def self.tier = :addon
9
+ def self.needs = [ :frontend ]
10
+ def self.summary = "Configure Tailwind CSS"
11
+ def self.description = "Installs Tailwind v3 with a path appropriate for the chosen frontend."
12
+ def self.prompts = "None"
13
+
14
+ TAILWIND_VERSION = "3.4.1".freeze
15
+
16
+ # The standalone CLI only auto-detects ./tailwind.config.js, but the config lives in config/.
17
+ REACT_CLI = "npx tailwindcss -c ./config/tailwind.config.js " \
18
+ "-i ./app/assets/stylesheets/application.tailwind.css -o ./app/assets/builds/tailwind.css".freeze
19
+ TAILWIND_RAILS_VERSION = "3.3.0".freeze
20
+
21
+ DIRECTIVES = <<~CSS
22
+ @tailwind base;
23
+ @tailwind components;
24
+ @tailwind utilities;
25
+ CSS
26
+
27
+ def if_condition(context)
28
+ context.output(:frontend, :frontend) != :none
29
+ end
30
+
31
+ def prompt(_context); end
32
+
33
+ def generate(context)
34
+ unless if_condition(context)
35
+ context.set_output(:tailwind, nil)
36
+ return
37
+ end
38
+
39
+ if context.output(:frontend, :frontend) == :react
40
+ configure_for_react(context)
41
+ else
42
+ configure_for_stimulus(context)
43
+ end
44
+
45
+ write_stylesheet(context)
46
+ update_layout(context)
47
+
48
+ context.set_output(:tailwind, { tailwind: true })
49
+ end
50
+
51
+ def outputs(context)
52
+ context.output(:tailwind)
53
+ end
54
+
55
+ private
56
+
57
+ def configure_for_stimulus(context)
58
+ gemfile = File.join(context.app_path, "Gemfile")
59
+ if File.exist?(gemfile) && !File.read(gemfile).include?("tailwindcss-rails")
60
+ File.open(gemfile, "a") { |f| f.puts("gem \"tailwindcss-rails\", \"#{TAILWIND_RAILS_VERSION}\"") }
61
+ end
62
+
63
+ write_tailwind_config(context, stimulus_content)
64
+ append_procfile(context, "css: bin/rails tailwindcss:watch")
65
+ end
66
+
67
+ def configure_for_react(context)
68
+ package_json_path = File.join(context.app_path, "package.json")
69
+ package = File.exist?(package_json_path) ? JSON.parse(File.read(package_json_path)) : {}
70
+
71
+ package["devDependencies"] ||= {}
72
+ package["devDependencies"]["tailwindcss"] = TAILWIND_VERSION
73
+
74
+ package["scripts"] ||= {}
75
+ package["scripts"]["build:css"] = "#{REACT_CLI} --minify"
76
+
77
+ File.write(package_json_path, JSON.pretty_generate(package))
78
+
79
+ write_tailwind_config(context, react_content)
80
+ append_procfile(context, "css: #{REACT_CLI} --watch")
81
+ end
82
+
83
+ def write_stylesheet(context)
84
+ css_path = File.join(context.app_path, "app/assets/stylesheets/application.tailwind.css")
85
+ FileUtils.mkdir_p(File.dirname(css_path))
86
+ File.write(css_path, DIRECTIVES)
87
+ end
88
+
89
+ def write_tailwind_config(context, content)
90
+ config_path = File.join(context.app_path, "config/tailwind.config.js")
91
+ FileUtils.mkdir_p(File.dirname(config_path))
92
+ File.write(config_path, content)
93
+ end
94
+
95
+ def stimulus_content
96
+ <<~JS
97
+ module.exports = {
98
+ content: [
99
+ "./app/views/**/*.html.erb",
100
+ "./app/helpers/**/*.rb",
101
+ "./app/javascript/**/*.js",
102
+ "./app/assets/stylesheets/**/*.css"
103
+ ],
104
+ theme: {},
105
+ plugins: []
106
+ }
107
+ JS
108
+ end
109
+
110
+ def react_content
111
+ <<~JS
112
+ module.exports = {
113
+ content: ["./app/views/**/*.html.erb", "./app/javascript/**/*.{js,jsx}"],
114
+ theme: {},
115
+ plugins: []
116
+ }
117
+ JS
118
+ end
119
+
120
+ def append_procfile(context, line)
121
+ procfile = File.join(context.app_path, "Procfile.dev")
122
+
123
+ if File.exist?(procfile)
124
+ content = File.read(procfile)
125
+ return if content.include?(line)
126
+
127
+ File.open(procfile, "a") { |f| f.puts(line) }
128
+ else
129
+ File.write(procfile, "web: bin/rails server\n#{line}\n")
130
+ end
131
+ end
132
+
133
+ def update_layout(context)
134
+ layout = File.join(context.app_path, "app/views/layouts/application.html.erb")
135
+ return unless File.exist?(layout)
136
+
137
+ content = File.read(layout)
138
+ return if content.include?('stylesheet_link_tag "tailwind"')
139
+
140
+ tag = ' <%= stylesheet_link_tag "tailwind", "data-turbo-track": "reload" %>'
141
+ content = content.sub(/\s*<\/head>/, "\n#{tag}\n </head>")
142
+
143
+ File.write(layout, content)
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpinionatedRails
4
+ module Steps
5
+ module Addons
6
+ class VcrStep < Step
7
+ def self.id = :vcr
8
+ def self.tier = :addon
9
+ def self.needs = [ :testing ]
10
+ def self.summary = "Configure VCR"
11
+ def self.description = "Installs VCR for recording and replaying HTTP interactions in tests."
12
+ def self.prompts = "Enable VCR for HTTP interaction recording? (default: no)"
13
+
14
+ VCR_VERSION = "6.2.0".freeze
15
+ WEBMOCK_VERSION = "3.23.1".freeze
16
+
17
+ MINIMAL_RAILS_HELPER = <<~RUBY
18
+ require 'spec_helper'
19
+ ENV['RAILS_ENV'] ||= 'test'
20
+ require_relative '../config/environment'
21
+ require 'rspec/rails'
22
+
23
+ RSpec.configure do |config|
24
+ config.fixture_paths = [
25
+ Rails.root.join('spec/fixtures')
26
+ ]
27
+ config.use_transactional_fixtures = true
28
+ end
29
+ RUBY
30
+
31
+ def if_condition(_context) = true
32
+
33
+ def prompt(context)
34
+ shell = context.shell
35
+ choice = shell.ask("Enable VCR for HTTP interaction recording? (default: no):").to_s.strip
36
+ choice = "no" if choice.empty?
37
+
38
+ loop do
39
+ case choice.downcase
40
+ when "yes", "y"
41
+ context.answer(:vcr, true)
42
+ break
43
+ when "no", "n"
44
+ context.answer(:vcr, false)
45
+ break
46
+ else
47
+ shell.say("Invalid. Enter 'yes' or 'no'.", :red)
48
+ choice = shell.ask("Enable VCR for HTTP interaction recording? (default: no):").to_s.strip
49
+ choice = "no" if choice.empty?
50
+ end
51
+ end
52
+ end
53
+
54
+ def generate(context)
55
+ enabled = context.get(:vcr) == true
56
+
57
+ unless enabled
58
+ context.set_output(:vcr, { vcr: false })
59
+ return
60
+ end
61
+
62
+ add_test_group_gems(context)
63
+
64
+ if context.output(:testing, :test_framework) == :minitest
65
+ install_for_minitest(context)
66
+ else
67
+ install_for_rspec(context)
68
+ end
69
+
70
+ context.set_output(:vcr, { vcr: true })
71
+ end
72
+
73
+ def outputs(context)
74
+ context.output(:vcr)
75
+ end
76
+
77
+ private
78
+
79
+ def add_test_group_gems(context)
80
+ add_test_group_gem(context, "gem \"vcr\", \"#{VCR_VERSION}\"")
81
+ add_test_group_gem(context, "gem \"webmock\", \"#{WEBMOCK_VERSION}\"")
82
+ end
83
+
84
+ def add_test_group_gem(context, gem_line)
85
+ gemfile_path = File.join(context.app_path, "Gemfile")
86
+ return unless File.exist?(gemfile_path)
87
+
88
+ content = File.read(gemfile_path)
89
+ return if content.include?(gem_line)
90
+
91
+ lines = content.lines
92
+ group_start = lines.index { |line| line.strip == "group :test do" }
93
+
94
+ if group_start
95
+ end_index = (group_start + 1...lines.size).find { |i| lines[i].strip == "end" }
96
+ lines.insert(end_index, " #{gem_line}\n")
97
+ else
98
+ lines << "\n" unless lines.last.nil? || lines.last.end_with?("\n")
99
+ lines << "\ngroup :test do\n #{gem_line}\nend\n"
100
+ end
101
+
102
+ File.write(gemfile_path, lines.join)
103
+ end
104
+
105
+ def vcr_config(cassette_dir)
106
+ <<~RUBY
107
+ require "vcr"
108
+ require "webmock"
109
+
110
+ VCR.configure do |config|
111
+ config.cassette_library_dir = "#{cassette_dir}"
112
+ config.hook_into :webmock
113
+
114
+ # Redact secrets from recorded cassettes before they're written to disk.
115
+ # Example: strip a bearer token from the Authorization header.
116
+ config.filter_sensitive_data("<API_KEY>") { ENV["API_KEY"] }
117
+ end
118
+ RUBY
119
+ end
120
+
121
+ def install_for_rspec(context)
122
+ support_path = File.join(context.app_path, "spec/support/vcr.rb")
123
+ FileUtils.mkdir_p(File.dirname(support_path))
124
+ File.write(support_path, vcr_config("spec/cassettes"))
125
+
126
+ ensure_rails_helper(context)
127
+ require_support_files(context)
128
+ end
129
+
130
+ def install_for_minitest(context)
131
+ support_path = File.join(context.app_path, "test/support/vcr.rb")
132
+ FileUtils.mkdir_p(File.dirname(support_path))
133
+ File.write(support_path, vcr_config("test/cassettes"))
134
+
135
+ require_line = "require_relative \"support/vcr\"\n"
136
+ test_helper_path = File.join(context.app_path, "test/test_helper.rb")
137
+ return unless File.exist?(test_helper_path)
138
+
139
+ content = File.read(test_helper_path)
140
+ return if content.include?(require_line)
141
+
142
+ File.write(test_helper_path, "#{require_line}#{content}")
143
+ end
144
+
145
+ def ensure_rails_helper(context)
146
+ rails_helper_path = File.join(context.app_path, "spec/rails_helper.rb")
147
+ return if File.exist?(rails_helper_path)
148
+
149
+ FileUtils.mkdir_p(File.dirname(rails_helper_path))
150
+ File.write(rails_helper_path, MINIMAL_RAILS_HELPER)
151
+ end
152
+
153
+ def require_support_files(context)
154
+ rails_helper_path = File.join(context.app_path, "spec/rails_helper.rb")
155
+ require_line = "require \"support/vcr\"\n"
156
+
157
+ content = File.read(rails_helper_path)
158
+ return if content.include?(require_line) || content.include?("support/**/*.rb")
159
+
160
+ lines = content.lines
161
+ require_index = lines.rindex { |line| line.start_with?("require") }
162
+ insert_at = require_index ? require_index + 1 : lines.size
163
+
164
+ lines.insert(insert_at, require_line)
165
+ File.write(rails_helper_path, lines.join)
166
+ end
167
+ end
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpinionatedRails
4
+ module Steps
5
+ module Core
6
+ class AppNameStep < Step
7
+ def self.id = :app_name
8
+ def self.summary = "Set the application name"
9
+ def self.description = "Records the Rails application name used for the generated app."
10
+ def self.tier = :core
11
+ def self.prompts = "App name (free text, valid Ruby constant once camelized, no leading digit)"
12
+
13
+ RESERVED_NAMES = %w[ test application ].freeze
14
+
15
+ def prompt(context)
16
+ return if preset_name?(context)
17
+
18
+ loop do
19
+ raw = context.shell.ask("App name:")
20
+ raise OpinionatedRails::Error, "No app name given (input ended)" if raw.nil?
21
+
22
+ normalized = normalize(raw)
23
+ errors = validate(normalized)
24
+
25
+ if errors.empty?
26
+ context.answer(:app_name, normalized)
27
+ break
28
+ end
29
+
30
+ context.shell.say("Invalid: #{errors.join(', ')}", :red)
31
+ end
32
+ end
33
+
34
+ # `opinionated_rails new my_app` supplies the name up front; fall back to asking if it's invalid.
35
+ def preset_name?(context)
36
+ preset = context.get(:app_name_input)
37
+ return false if preset.to_s.strip.empty?
38
+
39
+ normalized = normalize(preset)
40
+ errors = validate(normalized)
41
+ if errors.empty?
42
+ context.answer(:app_name, normalized)
43
+ return true
44
+ end
45
+
46
+ context.shell.say("Invalid app name #{preset.inspect}: #{errors.join(', ')}", :red)
47
+ false
48
+ end
49
+
50
+ def generate(context)
51
+ name = context.get(:app_name)
52
+ errors = validate(name)
53
+
54
+ raise ArgumentError, "Invalid app name: #{errors.join(', ')}" unless errors.empty?
55
+
56
+ context.set_output(:app_name, { app_name: name })
57
+ end
58
+
59
+ def outputs(context)
60
+ context.output(:app_name)
61
+ end
62
+
63
+ private
64
+
65
+ def normalize(raw)
66
+ raw.to_s.strip.downcase.gsub(/[-\s]+/, "_").gsub(/[^a-z0-9_]/, "")
67
+ end
68
+
69
+ def validate(name)
70
+ errors = []
71
+ errors << "cannot be blank" if name.empty?
72
+ errors << "must start with a letter" if name.match?(/\A\d/)
73
+ errors << "must contain only letters, digits, underscores and hyphens" unless name.match?(/\A[a-z0-9_-]*\z/)
74
+ errors << "is a reserved name" if RESERVED_NAMES.include?(name)
75
+ errors << "collides with an existing constant" if name.match?(/\A[a-z]/) && Object.const_defined?(camelize(name))
76
+ errors
77
+ end
78
+
79
+ def camelize(name)
80
+ name.split("_").map(&:capitalize).join
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpinionatedRails
4
+ module Steps
5
+ module Core
6
+ class AppTypeStep < Step
7
+ def self.id = :app_type
8
+ def self.summary = "Choose API-only or full-stack"
9
+ def self.description = "Decides whether the app is API-only or a full-stack Rails app."
10
+ def self.tier = :core
11
+ def self.prompts = "App type: API-only or Full-stack (default: Full-stack)"
12
+
13
+ def prompt(context)
14
+ shell = context.shell
15
+ shell.say("Choose an app type:")
16
+ shell.say(" 1. Full-stack")
17
+ shell.say(" 2. API-only")
18
+
19
+ choice = shell.ask("App type (default: Full-stack):").to_s.strip
20
+ choice = "full-stack" if choice.empty?
21
+
22
+ loop do
23
+ case choice.downcase
24
+ when "full-stack", "1"
25
+ context.answer(:app_type, :full_stack)
26
+ break
27
+ when "api-only", "2"
28
+ context.answer(:app_type, :api_only)
29
+ break
30
+ else
31
+ shell.say("Invalid. Enter 'Full-stack', 'API-only', 1, or 2.", :red)
32
+ choice = shell.ask("App type (default: Full-stack):").to_s.strip
33
+ choice = "full-stack" if choice.empty?
34
+ end
35
+ end
36
+ end
37
+
38
+ def generate(context)
39
+ type = context.get(:app_type)
40
+ raise ArgumentError, "No app type answer" unless type
41
+
42
+ context.set_output(:app_type, { app_type: type })
43
+ end
44
+
45
+ def outputs(context)
46
+ context.output(:app_type)
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpinionatedRails
4
+ module Steps
5
+ module Core
6
+ class DatabaseStep < Step
7
+ def self.id = :database
8
+ def self.summary = "Choose the database"
9
+ def self.description = "Prompts for the database adapter (SQLite, PostgreSQL, MySQL)."
10
+ def self.tier = :core
11
+ def self.prompts = "Database adapter: SQLite, PostgreSQL, or MySQL (default: SQLite)"
12
+
13
+ ADAPTERS = {
14
+ "sqlite" => :sqlite3,
15
+ "postgresql" => :postgresql,
16
+ "mysql" => :mysql2
17
+ }.freeze
18
+
19
+ DISPLAY_NAMES = %w[ SQLite PostgreSQL MySQL ].freeze
20
+
21
+ def prompt(context)
22
+ shell = context.shell
23
+ shell.say("Choose a database:")
24
+ DISPLAY_NAMES.each_with_index { |name, index| shell.say(" #{index + 1}. #{name}") }
25
+
26
+ loop do
27
+ choice = shell.ask("Database (default: SQLite):").to_s.strip.downcase
28
+ choice = "sqlite" if choice.empty?
29
+ choice = ADAPTERS.keys[choice.to_i - 1] if %w[1 2 3].include?(choice)
30
+
31
+ if ADAPTERS.key?(choice)
32
+ context.answer(:database, ADAPTERS[choice])
33
+ break
34
+ end
35
+
36
+ shell.say("Invalid choice. Enter #{DISPLAY_NAMES.join(', ')}.", :red)
37
+ end
38
+ end
39
+
40
+ def generate(context)
41
+ adapter = context.get(:database)
42
+ raise ArgumentError, "No database answer" unless adapter
43
+
44
+ context.set_output(:database, { database: adapter })
45
+ end
46
+
47
+ def outputs(context)
48
+ context.output(:database)
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end