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,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpinionatedRails
4
+ # `kamal init` writes a fully commented-out deploy.yml, which is not deployable and loses data:
5
+ # no RAILS_MASTER_KEY, no volume for SQLite, no Redis for Sidekiq. This builds a file that is
6
+ # complete for the chosen stack; only the CHANGE_ME values (server, registry, host) are left to fill in.
7
+ module KamalConfig
8
+ PLACEHOLDER = "CHANGE_ME"
9
+
10
+ def self.service_name(app_name) = app_name.to_s.tr("_", "-")
11
+
12
+ def self.deploy_yml(app_name:, database:, stack:)
13
+ service = service_name(app_name)
14
+ volume = app_name.to_s
15
+ sqlite = database == :sqlite3
16
+ redis = stack == :redis
17
+
18
+ lines = []
19
+ lines << "# Kamal deployment for #{service}, generated by opinionated_rails."
20
+ lines << "# Replace every #{PLACEHOLDER}_* value, then run `bin/kamal setup`. Docs: https://kamal-deploy.org"
21
+ lines << "service: #{service}"
22
+ lines << "image: #{PLACEHOLDER}_registry_username/#{service}"
23
+ lines << ""
24
+ lines << "servers:"
25
+ lines << " web:"
26
+ lines << " - #{PLACEHOLDER}_server_ip"
27
+ if redis
28
+ lines << " job:"
29
+ lines << " hosts:"
30
+ lines << " - #{PLACEHOLDER}_server_ip"
31
+ lines << " cmd: bundle exec sidekiq"
32
+ end
33
+ lines << ""
34
+ lines << "proxy:"
35
+ lines << " ssl: true"
36
+ lines << " host: #{PLACEHOLDER}.example.com"
37
+ lines << ""
38
+ lines << "registry:"
39
+ lines << " username: #{PLACEHOLDER}_registry_username"
40
+ lines << " password:"
41
+ lines << " - KAMAL_REGISTRY_PASSWORD"
42
+ lines << ""
43
+ lines << "builder:"
44
+ lines << " arch: amd64"
45
+ lines << ""
46
+ lines << "env:"
47
+ lines << " secret:"
48
+ lines << " - RAILS_MASTER_KEY"
49
+ lines << " - DATABASE_URL" unless sqlite
50
+ lines << " clear:"
51
+ lines << (redis ? " REDIS_URL: redis://#{service}-redis:6379/1" : " SOLID_QUEUE_IN_PUMA: true")
52
+ lines << ""
53
+ lines << "# Persistent data. Without these, every deploy starts from an empty database/uploads."
54
+ lines << "volumes:"
55
+ lines << " - \"#{volume}_storage:/rails/storage\""
56
+ lines << " - \"#{volume}_data:/data\"" if sqlite
57
+ lines << ""
58
+ lines << "# Keep old fingerprinted assets available to in-flight requests during a deploy."
59
+ lines << "asset_path: /rails/public/assets"
60
+ lines.concat(redis_accessory(service)) if redis
61
+ lines << ""
62
+ lines << "aliases:"
63
+ lines << " console: app exec --interactive --reuse \"bin/rails console\""
64
+ lines << " shell: app exec --interactive --reuse \"bash\""
65
+ lines << " logs: app logs -f"
66
+ lines << " dbc: app exec --interactive --reuse \"bin/rails dbconsole --include-password\""
67
+ "#{lines.join("\n")}\n"
68
+ end
69
+
70
+ def self.redis_accessory(service)
71
+ [
72
+ "",
73
+ "accessories:",
74
+ " redis:",
75
+ " image: redis:7",
76
+ " host: #{PLACEHOLDER}_server_ip",
77
+ " directories:",
78
+ " - data:/data",
79
+ " cmd: redis-server --appendonly yes"
80
+ ]
81
+ end
82
+
83
+ # Safe to commit: values are read from the environment or from config/master.key, never written here.
84
+ def self.secrets(database:)
85
+ lines = [
86
+ "# Read from your environment or a file; never write raw credentials here (this file is committed).",
87
+ "KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD",
88
+ "RAILS_MASTER_KEY=$(cat config/master.key)"
89
+ ]
90
+ lines << "DATABASE_URL=$DATABASE_URL" unless database == :sqlite3
91
+ "#{lines.join("\n")}\n"
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "date"
5
+ require "fileutils"
6
+
7
+ module OpinionatedRails
8
+ class Manifest
9
+ FILENAME = ".opinionated_rails.yml"
10
+ VERSION = "0.1.0"
11
+
12
+ def self.load(app_path)
13
+ path = File.join(app_path, FILENAME)
14
+ data = File.exist?(path) ? YAML.safe_load_file(path, permitted_classes: [ Date, DateTime, Time ], aliases: true) || {} : {}
15
+ new(app_path, data)
16
+ end
17
+
18
+ def initialize(app_path, data)
19
+ @app_path = app_path
20
+ @data = data
21
+ end
22
+
23
+ def [](key)
24
+ @data[key]
25
+ end
26
+
27
+ def []=(key, value)
28
+ @data[key] = value
29
+ end
30
+
31
+ def to_h
32
+ @data.dup
33
+ end
34
+
35
+ def write
36
+ path = File.join(@app_path, FILENAME)
37
+ tmp = "#{path}.tmp.#{Process.pid}"
38
+ File.write(tmp, YAML.dump(@data))
39
+ File.rename(tmp, path)
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+
6
+ module OpinionatedRails
7
+ # Every gem and npm package the steps pin. `rake pins:check` confirms each pinned version still
8
+ # exists on its registry and reports newer ones, so pins cannot rot unnoticed.
9
+ module Pins
10
+ Pin = Struct.new(:registry, :name, :version, :source)
11
+ Result = Struct.new(:pin, :status, :latest)
12
+
13
+ # *_VERSION constants in the steps => gem name (the constant name alone is ambiguous)
14
+ GEM_NAMES = {
15
+ "RSPEC" => "rspec-rails", "REDIS" => "redis", "SIDEKIQ" => "sidekiq", "FACTORY_BOT" => "factory_bot_rails",
16
+ "DEVISE" => "devise", "GEARED_PAGINATION" => "geared_pagination", "PAGY" => "pagy",
17
+ "RUBOCOP_OMAKASE" => "rubocop-rails-omakase", "DOTENV_RAILS" => "dotenv-rails", "VCR" => "vcr",
18
+ "WEBMOCK" => "webmock", "DOCKERFILE_RAILS" => "dockerfile-rails", "KAMAL" => "kamal",
19
+ "LETTER_OPENER" => "letter_opener", "POSTMARK_RAILS" => "postmark-rails", "FAKER" => "faker",
20
+ "TAILWIND_RAILS" => "tailwindcss-rails"
21
+ }.freeze
22
+
23
+ # constants that pin an npm package rather than a gem
24
+ NPM_CONSTANTS = { "TAILWIND" => "tailwindcss" }.freeze
25
+
26
+ CONSTANT_PATTERN = /^\s+([A-Z][A-Z_]*)_VERSION = "(\d[\w.]*)"/
27
+ NPM_ASSIGNMENT_PATTERN = /package\["(?:dependencies|devDependencies)"\]\["([^"]+)"\] = "(\d[\w.]*)"/
28
+
29
+ def self.collect(root = File.expand_path("..", __dir__))
30
+ Dir.glob(File.join(root, "opinionated_rails/steps/**/*.rb")).sort.flat_map do |file|
31
+ source = File.read(file, encoding: "UTF-8")
32
+ relative = file.sub("#{root}/", "")
33
+ constants = source.scan(CONSTANT_PATTERN).map { |const, version| pin_for_constant(const, version, relative) }
34
+ npm = source.scan(NPM_ASSIGNMENT_PATTERN).map { |name, version| Pin.new(:npm, name, version, relative) }
35
+ constants + npm
36
+ end.uniq { |pin| [ pin.registry, pin.name, pin.version ] }
37
+ end
38
+
39
+ def self.pin_for_constant(const, version, source)
40
+ return Pin.new(:npm, NPM_CONSTANTS.fetch(const), version, source) if NPM_CONSTANTS.key?(const)
41
+
42
+ Pin.new(:gem, GEM_NAMES.fetch(const) { raise ArgumentError, "No gem name for #{const}_VERSION in #{source}; add it to Pins::GEM_NAMES" },
43
+ version, source)
44
+ end
45
+
46
+ # fetcher: ->(registry, name) { { versions: [...], latest: "x" } }
47
+ def self.check(pins, fetcher: method(:fetch))
48
+ pins.map do |pin|
49
+ info = fetcher.call(pin.registry, pin.name)
50
+ Result.new(pin, status_for(pin, info), info[:latest])
51
+ end
52
+ end
53
+
54
+ def self.status_for(pin, info)
55
+ return :missing unless info[:versions].include?(pin.version)
56
+
57
+ newer?(info[:latest], pin.version) ? :outdated : :current
58
+ end
59
+
60
+ def self.newer?(latest, pinned)
61
+ Gem::Version.correct?(latest) && Gem::Version.correct?(pinned) && Gem::Version.new(latest) > Gem::Version.new(pinned)
62
+ end
63
+
64
+ def self.fetch(registry, name)
65
+ case registry
66
+ when :gem
67
+ list = JSON.parse(get("https://rubygems.org/api/v1/versions/#{name}.json"))
68
+ stable = list.reject { |v| v["prerelease"] }.map { |v| v["number"] }
69
+ { versions: list.map { |v| v["number"] }, latest: stable.max_by { |v| Gem::Version.new(v) } }
70
+ when :npm
71
+ data = JSON.parse(get("https://registry.npmjs.org/#{name}"))
72
+ { versions: data.fetch("versions").keys, latest: data.dig("dist-tags", "latest") }
73
+ end
74
+ end
75
+
76
+ def self.get(url)
77
+ response = Net::HTTP.get_response(URI(url))
78
+ raise "GET #{url} returned #{response.code}" unless response.is_a?(Net::HTTPSuccess)
79
+
80
+ response.body
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpinionatedRails
4
+ # Tools the chosen options shell out to. Checked before `rails new` so a missing tool fails
5
+ # fast with instructions, rather than leaving a half-built app (jsbundling-rails, for example,
6
+ # silently skips `yarn add esbuild` when yarn is absent).
7
+ module Prerequisites
8
+ INSTALL_HINTS = {
9
+ "node" => "install Node.js (https://nodejs.org)",
10
+ "yarn" => "enable it with `corepack enable` or install it with `npm install -g yarn`",
11
+ "bun" => "install it from https://bun.sh"
12
+ }.freeze
13
+
14
+ def self.required_tools(context)
15
+ return [] unless context.get(:frontend) == :react
16
+
17
+ context.get(:bundler) == :bun ? %w[ bun ] : %w[ node yarn ]
18
+ end
19
+
20
+ def self.available?(tool, path: ENV.fetch("PATH", ""))
21
+ path.split(File::PATH_SEPARATOR).any? do |dir|
22
+ candidate = File.join(dir, tool)
23
+ File.file?(candidate) && File.executable?(candidate)
24
+ end
25
+ end
26
+
27
+ def self.check!(context, path: ENV.fetch("PATH", ""))
28
+ return if context.skip_bundle_install
29
+
30
+ missing = required_tools(context).reject { |tool| available?(tool, path: path) }
31
+ return if missing.empty?
32
+
33
+ details = missing.map { |tool| " - #{tool}: #{INSTALL_HINTS.fetch(tool)}" }.join("\n")
34
+ raise OpinionatedRails::Error,
35
+ "The React frontend needs tools that are not on your PATH:\n#{details}\n" \
36
+ "Install them and re-run, or pass --skip-bundle-install to only scaffold files."
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module OpinionatedRails
6
+ # The steps patch text and config that Rails 8.1 generates (Solid defaults, Kamal, authentication),
7
+ # so only Rails 8.1+ within 8.x is supported. Other versions are rejected before anything is generated.
8
+ class RailsVersionGuard
9
+ class UnsupportedError < OpinionatedRails::Error; end
10
+
11
+ MINIMUM = Gem::Version.new("8.1")
12
+ SUPPORTED_MAJOR = 8
13
+
14
+ def self.from_string(version)
15
+ new(version)
16
+ end
17
+
18
+ # Version of the `rails` executable `rails new` will use, or an error if there is none.
19
+ def self.installed
20
+ output, status = Bundler.with_unbundled_env { Open3.capture2e("rails", "--version") }
21
+ version = output[/Rails (\d[\w.]*)/, 1]
22
+ return version if status.success? && version
23
+
24
+ raise UnsupportedError, "Rails is not installed or `rails --version` failed. Install it with `gem install rails`."
25
+ rescue Errno::ENOENT
26
+ raise UnsupportedError, "Rails is not installed. Install it with `gem install rails`."
27
+ end
28
+
29
+ def self.check_installed!
30
+ from_string(installed).assert!
31
+ end
32
+
33
+ def initialize(version)
34
+ @version = version
35
+ end
36
+
37
+ def assert!
38
+ parsed = Gem::Version.correct?(@version) && Gem::Version.new(@version)
39
+
40
+ unless parsed && parsed.segments.first == SUPPORTED_MAJOR && parsed >= MINIMUM
41
+ raise UnsupportedError,
42
+ "Rails #{@version} is not supported. opinionated_rails needs Rails #{MINIMUM} or newer in the " \
43
+ "#{SUPPORTED_MAJOR}.x series. Update with `gem update rails`."
44
+ end
45
+
46
+ true
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpinionatedRails
4
+ class Step
5
+ def self.id
6
+ raise NotImplementedError, "#{name}#id not implemented"
7
+ end
8
+
9
+ def self.summary
10
+ raise NotImplementedError, "#{name}#summary not implemented"
11
+ end
12
+
13
+ def self.description
14
+ raise NotImplementedError, "#{name}#description not implemented"
15
+ end
16
+
17
+ def self.tier
18
+ :addon
19
+ end
20
+
21
+ def self.needs
22
+ []
23
+ end
24
+
25
+ def if_condition(_context)
26
+ true
27
+ end
28
+
29
+ def prompt(_context)
30
+ raise NotImplementedError, "#{self.class}#prompt not yet implemented"
31
+ end
32
+
33
+ def generate(_context)
34
+ raise NotImplementedError, "#{self.class}#generate not yet implemented"
35
+ end
36
+
37
+ def outputs(_context)
38
+ raise NotImplementedError, "#{self.class}#outputs not yet implemented"
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpinionatedRails
4
+ class StepGraph
5
+ class Error < OpinionatedRails::Error; end
6
+ class CycleError < Error; end
7
+ class MissingDependencyError < Error; end
8
+
9
+ def initialize(steps)
10
+ @steps = steps
11
+ @by_id = steps.to_h { |step| [ step.class.id, step ] }
12
+ end
13
+
14
+ def sorted
15
+ @by_id.each_value do |step|
16
+ step.class.needs.each do |needed|
17
+ raise MissingDependencyError, "Step #{step.class.id.inspect} needs #{needed.inspect}, which is not registered" unless @by_id.key?(needed)
18
+ end
19
+ end
20
+
21
+ in_degree = Hash.new(0)
22
+ @by_id.each_key { |id| in_degree[id] = 0 }
23
+ dependents = Hash.new { |h, k| h[k] = [] }
24
+
25
+ @by_id.each_value do |step|
26
+ step.class.needs.each do |needed|
27
+ dependents[needed] << step.class.id
28
+ in_degree[step.class.id] += 1
29
+ end
30
+ end
31
+
32
+ queue = in_degree.select { |_, v| v.zero? }.keys
33
+ result = []
34
+
35
+ until queue.empty?
36
+ id = queue.shift
37
+ result << @by_id[id]
38
+
39
+ dependents[id].each do |dependent|
40
+ in_degree[dependent] -= 1
41
+ queue << dependent if in_degree[dependent].zero?
42
+ end
43
+ end
44
+
45
+ if result.size != @by_id.size
46
+ remaining = in_degree.reject { |_, v| v.zero? }.keys
47
+ raise CycleError, "Cyclic step dependencies detected among: #{remaining.map(&:inspect).join(', ')}"
48
+ end
49
+
50
+ result
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpinionatedRails
4
+ module Steps
5
+ module Addons
6
+ class AuthStep < Step
7
+ def self.id = :auth
8
+ def self.tier = :addon
9
+ def self.needs = []
10
+ def self.summary = "Configure authentication"
11
+ def self.description = "Prompts for a user model name and chooses Rails 8 built-in, Devise, or none."
12
+ def self.prompts = "User model name (default: User), then Rails 8 built-in / Devise / None (default: None)"
13
+
14
+ MODEL_NAME_FORMAT = /\A[A-Z][A-Za-z0-9]*\z/
15
+ DEVISE_VERSION = "5.0.4".freeze
16
+
17
+ def if_condition(_context) = true
18
+
19
+ API_ONLY_NOTICE = "Authentication is skipped for API-only apps: Rails' built-in generator and Devise " \
20
+ "both generate browser-session code that does not work without cookies and views."
21
+
22
+ def prompt(context)
23
+ shell = context.shell
24
+
25
+ if api_only?(context)
26
+ shell.say(API_ONLY_NOTICE, :yellow)
27
+ context.answer(:auth, :none)
28
+ return
29
+ end
30
+
31
+ model_name = shell.ask("User model name (default: User):").to_s.strip
32
+ model_name = "User" if model_name.empty?
33
+ until MODEL_NAME_FORMAT.match?(model_name)
34
+ shell.say("Invalid model name. Use a CamelCase constant such as User or Account.", :red)
35
+ model_name = shell.ask("User model name (default: User):").to_s.strip
36
+ model_name = "User" if model_name.empty?
37
+ end
38
+ context.answer(:auth_model_name, model_name)
39
+
40
+ shell.say("Choose an authentication strategy:")
41
+ shell.say(" 1. None (default)")
42
+ shell.say(" 2. Rails 8 built-in")
43
+ shell.say(" 3. Devise")
44
+
45
+ choice = shell.ask("Authentication (default: None):").to_s.strip
46
+ choice = "1" if choice.empty?
47
+
48
+ loop do
49
+ case choice.downcase
50
+ when "none", "1"
51
+ context.answer(:auth, :none)
52
+ break
53
+ when "rails8", "rails 8", "2"
54
+ context.answer(:auth, :rails8)
55
+ break
56
+ when "devise", "3"
57
+ context.answer(:auth, :devise)
58
+ break
59
+ else
60
+ shell.say("Invalid. Enter 'None', 'Rails8', 'Devise', 1, 2, or 3.", :red)
61
+ choice = shell.ask("Authentication (default: None):").to_s.strip
62
+ choice = "1" if choice.empty?
63
+ end
64
+ end
65
+ end
66
+
67
+ def generate(context)
68
+ strategy = api_only?(context) ? :none : (context.get(:auth) || :none)
69
+
70
+ case strategy
71
+ when :rails8
72
+ install_rails8_auth(context)
73
+ when :devise
74
+ install_devise(context)
75
+ else
76
+ context.set_output(:auth, { auth: :none })
77
+ end
78
+ end
79
+
80
+ def outputs(context)
81
+ context.output(:auth)
82
+ end
83
+
84
+ private
85
+
86
+ def api_only?(context) = context.output(:app_type, :app_type) == :api_only
87
+
88
+ def install_rails8_auth(context)
89
+ unless context.skip_bundle_install
90
+ command = [ "bundle", "exec", "rails", "generate", "authentication" ]
91
+
92
+ run_in_app(context, "bundle", "install", "--quiet") ||
93
+ raise(OpinionatedRails::Error, "bundle install failed")
94
+ run_in_app(context, *command) ||
95
+ raise(OpinionatedRails::Error, "rails generate authentication failed")
96
+ end
97
+
98
+ add_home_page(context) unless context.output(:frontend, :frontend) == :react
99
+
100
+ context.set_output(:auth, { auth: :rails8, user_model: "User" })
101
+ end
102
+
103
+ # Rails 8's generated SessionsController redirects to root_url after sign-in, but a new app has no
104
+ # root route, so signing in would raise NameError. (React apps get their root from the SPA step.)
105
+ def add_home_page(context)
106
+ routes_path = File.join(context.app_path, "config/routes.rb")
107
+ return unless File.exist?(routes_path)
108
+
109
+ routes = File.readlines(routes_path)
110
+ return if routes.any? { |line| line.match?(/^\s*root\b/) }
111
+
112
+ end_index = routes.rindex { |line| line.strip == "end" }
113
+ return unless end_index
114
+
115
+ routes.insert(end_index, " root \"home#index\"\n")
116
+ File.write(routes_path, routes.join)
117
+
118
+ write_file(context, "app/controllers/home_controller.rb", "class HomeController < ApplicationController\n def index\n end\nend\n")
119
+ write_file(context, "app/views/home/index.html.erb", "<h1>Home</h1>\n")
120
+ end
121
+
122
+ def write_file(context, relative, content)
123
+ path = File.join(context.app_path, relative)
124
+ FileUtils.mkdir_p(File.dirname(path))
125
+ File.write(path, content) unless File.exist?(path)
126
+ end
127
+
128
+ def install_devise(context)
129
+ model_name = context.get(:auth_model_name) || "User"
130
+
131
+ add_top_level_gem(context, "gem \"devise\", \"#{DEVISE_VERSION}\"")
132
+
133
+ unless context.skip_bundle_install
134
+ run_in_app(context, "bundle", "install", "--quiet") ||
135
+ raise(OpinionatedRails::Error, "bundle install failed")
136
+ run_in_app(context, "bundle", "exec", "rails", "generate", "devise:install") ||
137
+ raise(OpinionatedRails::Error, "devise:install failed")
138
+ run_in_app(context, "bundle", "exec", "rails", "generate", "devise", model_name) ||
139
+ raise(OpinionatedRails::Error, "devise generator failed")
140
+
141
+ run_in_app(context, "bundle", "exec", "rails", "generate", "devise:views") ||
142
+ raise(OpinionatedRails::Error, "devise:views failed")
143
+ end
144
+
145
+ context.set_output(:auth, { auth: :devise, user_model: model_name })
146
+ end
147
+
148
+ def run_in_app(context, *command)
149
+ Dir.chdir(context.app_path) do
150
+ if defined?(Bundler)
151
+ Bundler.with_unbundled_env { system(*command) }
152
+ else
153
+ system(*command)
154
+ end
155
+ end
156
+ end
157
+
158
+ def add_top_level_gem(context, gem_line)
159
+ gemfile_path = File.join(context.app_path, "Gemfile")
160
+ return unless File.exist?(gemfile_path)
161
+
162
+ content = File.read(gemfile_path)
163
+ return if content.include?(gem_line)
164
+
165
+ File.open(gemfile_path, "a") { |f| f.puts(gem_line) }
166
+ end
167
+ end
168
+ end
169
+ end
170
+ end