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