create-rails-app 0.1.0 → 0.2.0
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 +4 -4
- data/CHANGELOG.md +4 -0
- data/README.md +95 -6
- data/exe/create-rails-app +8 -4
- data/lib/create_rails_app/cli.rb +586 -0
- data/lib/create_rails_app/command_builder.rb +54 -0
- data/lib/create_rails_app/compatibility/matrix.rb +132 -0
- data/lib/create_rails_app/config/store.rb +149 -0
- data/lib/create_rails_app/detection/rails_versions.rb +67 -0
- data/lib/create_rails_app/detection/runtime.rb +24 -0
- data/lib/create_rails_app/error.rb +15 -0
- data/lib/create_rails_app/options/catalog.rb +120 -0
- data/lib/create_rails_app/options/validator.rb +99 -0
- data/lib/create_rails_app/runner.rb +48 -0
- data/lib/create_rails_app/ui/palette.rb +87 -0
- data/lib/create_rails_app/ui/prompter.rb +104 -0
- data/lib/create_rails_app/version.rb +1 -1
- data/lib/create_rails_app/wizard.rb +368 -0
- data/lib/create_rails_app.rb +22 -1
- metadata +48 -5
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CreateRailsApp
|
|
4
|
+
module Compatibility
|
|
5
|
+
# Static lookup table mapping Rails version ranges to the +rails new+
|
|
6
|
+
# options each range supports.
|
|
7
|
+
#
|
|
8
|
+
# This is the single source of truth for what each Rails version
|
|
9
|
+
# can do. The wizard, validator, and builder all consult it.
|
|
10
|
+
#
|
|
11
|
+
# @example Look up the entry for a specific Rails version
|
|
12
|
+
# entry = Matrix.for('8.0.1')
|
|
13
|
+
# entry.supports_option?(:kamal) #=> true
|
|
14
|
+
class Matrix
|
|
15
|
+
# A single row in the compatibility table.
|
|
16
|
+
#
|
|
17
|
+
# @!attribute [r] requirement
|
|
18
|
+
# @return [Gem::Requirement] Rails version range this entry covers
|
|
19
|
+
# @!attribute [r] supported_options
|
|
20
|
+
# @return [Hash{Symbol => Array<String>, nil}] option keys to allowed
|
|
21
|
+
# values (+nil+ means any boolean/skip value is accepted)
|
|
22
|
+
Entry = Struct.new(:requirement, :supported_options) do
|
|
23
|
+
# @param rails_version [Gem::Version] version to test
|
|
24
|
+
# @return [Boolean]
|
|
25
|
+
def match?(rails_version)
|
|
26
|
+
requirement.satisfied_by?(rails_version)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @param option_key [Symbol, String]
|
|
30
|
+
# @return [Boolean]
|
|
31
|
+
def supports_option?(option_key)
|
|
32
|
+
supported_options.key?(option_key.to_sym)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @param option_key [Symbol, String]
|
|
36
|
+
# @return [Array<String>, nil] allowed values, or nil for skips/flags
|
|
37
|
+
def allowed_values(option_key)
|
|
38
|
+
supported_options[option_key.to_sym]
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Supported Rails series for version detection and installation.
|
|
43
|
+
#
|
|
44
|
+
# @return [Array<String>]
|
|
45
|
+
SUPPORTED_SERIES = %w[7.2 8.0 8.1].freeze
|
|
46
|
+
|
|
47
|
+
# Options shared across all supported Rails versions.
|
|
48
|
+
# Enum values are derived from Catalog to prevent drift.
|
|
49
|
+
COMMON_OPTIONS = {
|
|
50
|
+
api: nil,
|
|
51
|
+
database: Options::Catalog::BASE_DATABASE_VALUES,
|
|
52
|
+
javascript: Options::Catalog::DEFINITIONS[:javascript][:values],
|
|
53
|
+
css: Options::Catalog::DEFINITIONS[:css][:values],
|
|
54
|
+
asset_pipeline: Options::Catalog::DEFINITIONS[:asset_pipeline][:values],
|
|
55
|
+
active_record: nil,
|
|
56
|
+
action_mailer: nil,
|
|
57
|
+
action_mailbox: nil,
|
|
58
|
+
action_text: nil,
|
|
59
|
+
active_job: nil,
|
|
60
|
+
active_storage: nil,
|
|
61
|
+
action_cable: nil,
|
|
62
|
+
hotwire: nil,
|
|
63
|
+
jbuilder: nil,
|
|
64
|
+
test: nil,
|
|
65
|
+
system_test: nil,
|
|
66
|
+
brakeman: nil,
|
|
67
|
+
rubocop: nil,
|
|
68
|
+
ci: nil,
|
|
69
|
+
docker: nil,
|
|
70
|
+
devcontainer: nil,
|
|
71
|
+
bootsnap: nil,
|
|
72
|
+
dev_gems: nil,
|
|
73
|
+
keeps: nil,
|
|
74
|
+
decrypted_diffs: nil,
|
|
75
|
+
git: nil,
|
|
76
|
+
bundle: nil
|
|
77
|
+
}.freeze
|
|
78
|
+
|
|
79
|
+
# Options added or changed in Rails 8.0+.
|
|
80
|
+
RAILS_8_OPTIONS = {
|
|
81
|
+
kamal: nil,
|
|
82
|
+
thruster: nil,
|
|
83
|
+
solid: nil,
|
|
84
|
+
database: Options::Catalog::DEFINITIONS[:database][:values],
|
|
85
|
+
asset_pipeline: nil
|
|
86
|
+
}.freeze
|
|
87
|
+
|
|
88
|
+
# Options added in Rails 8.1+.
|
|
89
|
+
RAILS_81_OPTIONS = {
|
|
90
|
+
bundler_audit: nil
|
|
91
|
+
}.freeze
|
|
92
|
+
|
|
93
|
+
# @return [Array<Entry>] all known Rails compatibility entries
|
|
94
|
+
TABLE = [
|
|
95
|
+
Entry.new(
|
|
96
|
+
requirement: Gem::Requirement.new('~> 7.2.0'),
|
|
97
|
+
supported_options: COMMON_OPTIONS.dup.freeze
|
|
98
|
+
),
|
|
99
|
+
Entry.new(
|
|
100
|
+
requirement: Gem::Requirement.new('~> 8.0.0'),
|
|
101
|
+
supported_options: COMMON_OPTIONS.merge(RAILS_8_OPTIONS).freeze
|
|
102
|
+
),
|
|
103
|
+
Entry.new(
|
|
104
|
+
requirement: Gem::Requirement.new('~> 8.1.0'),
|
|
105
|
+
supported_options: COMMON_OPTIONS.merge(RAILS_8_OPTIONS).merge(RAILS_81_OPTIONS).freeze
|
|
106
|
+
)
|
|
107
|
+
].freeze
|
|
108
|
+
|
|
109
|
+
# Returns human-readable version range strings for all entries.
|
|
110
|
+
#
|
|
111
|
+
# @return [Array<String>]
|
|
112
|
+
def self.supported_ranges
|
|
113
|
+
TABLE.map { |entry| entry.requirement.requirements.map(&:join).join(', ') }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Finds the compatibility entry for the given Rails version.
|
|
117
|
+
#
|
|
118
|
+
# @param rails_version [Gem::Version, String] the Rails version
|
|
119
|
+
# @return [Entry]
|
|
120
|
+
# @raise [UnsupportedRailsVersionError] if no entry matches
|
|
121
|
+
def self.for(rails_version)
|
|
122
|
+
version = Gem::Version.new(rails_version.to_s)
|
|
123
|
+
entry = TABLE.find { |candidate| candidate.match?(version) }
|
|
124
|
+
return entry if entry
|
|
125
|
+
|
|
126
|
+
message = "Unsupported Rails version: #{version}. "
|
|
127
|
+
message += "Supported ranges: #{supported_ranges.join(' | ')}"
|
|
128
|
+
raise UnsupportedRailsVersionError, message
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'tempfile'
|
|
5
|
+
require 'yaml'
|
|
6
|
+
|
|
7
|
+
module CreateRailsApp
|
|
8
|
+
module Config
|
|
9
|
+
# YAML persistence for presets and last-used options.
|
|
10
|
+
#
|
|
11
|
+
# Stores configuration at +~/.config/create-rails-app/config.yml+ (or
|
|
12
|
+
# +$XDG_CONFIG_HOME/create-rails-app/config.yml+). Writes are atomic
|
|
13
|
+
# via +Tempfile+ + rename.
|
|
14
|
+
class Store
|
|
15
|
+
# @return [Integer] current config file schema version
|
|
16
|
+
SCHEMA_VERSION = 1
|
|
17
|
+
|
|
18
|
+
# @param path [String, nil] override the default config file path
|
|
19
|
+
def initialize(path: nil)
|
|
20
|
+
@path = path || default_path
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @return [String] absolute path to the config file
|
|
24
|
+
attr_reader :path
|
|
25
|
+
|
|
26
|
+
# Returns the last-used option hash (empty hash if none saved).
|
|
27
|
+
#
|
|
28
|
+
# @return [Hash{String => Object}]
|
|
29
|
+
def last_used
|
|
30
|
+
data.fetch('last_used')
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Persists the given options as last-used.
|
|
34
|
+
#
|
|
35
|
+
# @param options [Hash{Symbol => Object}]
|
|
36
|
+
# @return [void]
|
|
37
|
+
def save_last_used(options)
|
|
38
|
+
payload = data
|
|
39
|
+
payload['last_used'] = stringify_keys(options)
|
|
40
|
+
write(payload)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Returns a preset by name, or +nil+ if it does not exist.
|
|
44
|
+
#
|
|
45
|
+
# @param name [String]
|
|
46
|
+
# @return [Hash{String => Object}, nil]
|
|
47
|
+
def preset(name)
|
|
48
|
+
data.fetch('presets').fetch(name.to_s, nil)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Returns all preset names sorted alphabetically.
|
|
52
|
+
#
|
|
53
|
+
# @return [Array<String>]
|
|
54
|
+
def preset_names
|
|
55
|
+
data.fetch('presets').keys.sort
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Saves a named preset.
|
|
59
|
+
#
|
|
60
|
+
# @param name [String]
|
|
61
|
+
# @param options [Hash{Symbol => Object}]
|
|
62
|
+
# @return [void]
|
|
63
|
+
def save_preset(name, options)
|
|
64
|
+
payload = data
|
|
65
|
+
payload.fetch('presets')[name.to_s] = stringify_keys(options)
|
|
66
|
+
write(payload)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Deletes a named preset (no-op if it does not exist).
|
|
70
|
+
#
|
|
71
|
+
# @param name [String]
|
|
72
|
+
# @return [void]
|
|
73
|
+
def delete_preset(name)
|
|
74
|
+
payload = data
|
|
75
|
+
payload.fetch('presets').delete(name.to_s)
|
|
76
|
+
write(payload)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
# @return [Hash{String => Object}]
|
|
82
|
+
def data
|
|
83
|
+
raw = load
|
|
84
|
+
{
|
|
85
|
+
'version' => raw.fetch('version', SCHEMA_VERSION),
|
|
86
|
+
'last_used' => (raw['last_used'].is_a?(Hash) ? raw['last_used'] : {}),
|
|
87
|
+
'presets' => (raw['presets'].is_a?(Hash) ? raw['presets'] : {})
|
|
88
|
+
}
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# @return [Hash]
|
|
92
|
+
# @raise [ConfigError] if YAML is malformed
|
|
93
|
+
def load
|
|
94
|
+
return {} unless File.file?(path)
|
|
95
|
+
|
|
96
|
+
result = YAML.safe_load_file(path, aliases: false)
|
|
97
|
+
unless result.nil? || result.is_a?(Hash)
|
|
98
|
+
raise ConfigError, "Invalid config file at #{path}: expected a YAML mapping"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
version = result&.fetch('version', SCHEMA_VERSION) || SCHEMA_VERSION
|
|
102
|
+
unless version.is_a?(Integer)
|
|
103
|
+
raise ConfigError, "Invalid config version at #{path}: expected integer, got #{version.inspect}"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
if version > SCHEMA_VERSION
|
|
107
|
+
raise ConfigError,
|
|
108
|
+
"Config file at #{path} has unsupported version #{version} (expected #{SCHEMA_VERSION}). " \
|
|
109
|
+
'Please upgrade create-rails-app or delete the config file.'
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
result || {}
|
|
113
|
+
rescue Psych::SyntaxError, Psych::DisallowedClass => e
|
|
114
|
+
raise ConfigError, "Invalid config file at #{path}: #{e.message}"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# @param payload [Hash]
|
|
118
|
+
# @return [void]
|
|
119
|
+
def write(payload)
|
|
120
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
121
|
+
tmp = Tempfile.create(['create-rails-app', '.yml'], File.dirname(path))
|
|
122
|
+
tmp.write(YAML.dump(payload))
|
|
123
|
+
tmp.close
|
|
124
|
+
File.rename(tmp.path, path)
|
|
125
|
+
rescue SystemCallError => e
|
|
126
|
+
begin
|
|
127
|
+
File.unlink(tmp.path) if tmp&.path && File.exist?(tmp.path)
|
|
128
|
+
rescue SystemCallError # :nocov:
|
|
129
|
+
nil
|
|
130
|
+
end
|
|
131
|
+
raise ConfigError, "Failed to write config to #{path}: #{e.message}"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# @return [String]
|
|
135
|
+
def default_path
|
|
136
|
+
config_home = ENV.fetch('XDG_CONFIG_HOME', File.join(Dir.home, '.config'))
|
|
137
|
+
File.join(config_home, 'create-rails-app', 'config.yml')
|
|
138
|
+
rescue ArgumentError
|
|
139
|
+
raise ConfigError, 'Cannot determine home directory. Set HOME or XDG_CONFIG_HOME.'
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# @param hash [Hash{Symbol => Object}]
|
|
143
|
+
# @return [Hash{String => Object}]
|
|
144
|
+
def stringify_keys(hash)
|
|
145
|
+
hash.transform_keys(&:to_s)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'open3'
|
|
4
|
+
require 'timeout'
|
|
5
|
+
|
|
6
|
+
module CreateRailsApp
|
|
7
|
+
module Detection
|
|
8
|
+
# Detects locally installed Rails versions using +gem list+.
|
|
9
|
+
#
|
|
10
|
+
# Groups installed versions by supported series (7.2, 8.0, 8.1)
|
|
11
|
+
# and returns the latest patch version for each installed series.
|
|
12
|
+
class RailsVersions
|
|
13
|
+
# @return [Regexp] pattern to extract version strings from gem list output
|
|
14
|
+
VERSION_PATTERN = /\d+\.\d+\.\d+(?:\.\w+)?/
|
|
15
|
+
|
|
16
|
+
# @param gem_command [String] path or name of the gem executable
|
|
17
|
+
def initialize(gem_command: 'gem')
|
|
18
|
+
@gem_command = gem_command
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Detects installed Rails versions grouped by supported series.
|
|
22
|
+
#
|
|
23
|
+
# @return [Hash{String => String}] series => latest patch version
|
|
24
|
+
# e.g. { "8.0" => "8.0.7", "8.1" => "8.1.2" }
|
|
25
|
+
def detect
|
|
26
|
+
versions = installed_versions
|
|
27
|
+
group_by_series(versions)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
# @return [Array<Gem::Version>] all installed Rails versions, sorted descending
|
|
33
|
+
def installed_versions
|
|
34
|
+
output, status = Timeout.timeout(10) do
|
|
35
|
+
if defined?(Bundler)
|
|
36
|
+
Bundler.with_unbundled_env do
|
|
37
|
+
Open3.capture2e(@gem_command, 'list', 'rails', '--local', '--exact')
|
|
38
|
+
end
|
|
39
|
+
else
|
|
40
|
+
Open3.capture2e(@gem_command, 'list', 'rails', '--local', '--exact')
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
return [] unless status.success?
|
|
44
|
+
|
|
45
|
+
rails_line = output.lines.find { |line| line.match?(/\Arails\s/) }
|
|
46
|
+
return [] unless rails_line
|
|
47
|
+
|
|
48
|
+
versions = rails_line.scan(VERSION_PATTERN).map { |v| Gem::Version.new(v) }
|
|
49
|
+
versions.sort.reverse
|
|
50
|
+
rescue SystemCallError, Timeout::Error
|
|
51
|
+
[]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @param versions [Array<Gem::Version>]
|
|
55
|
+
# @return [Hash{String => String}]
|
|
56
|
+
def group_by_series(versions)
|
|
57
|
+
result = {}
|
|
58
|
+
Compatibility::Matrix::SUPPORTED_SERIES.each do |series|
|
|
59
|
+
requirement = Gem::Requirement.new("~> #{series}.0")
|
|
60
|
+
match = versions.find { |v| requirement.satisfied_by?(v) }
|
|
61
|
+
result[series] = match.to_s if match
|
|
62
|
+
end
|
|
63
|
+
result
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CreateRailsApp
|
|
4
|
+
module Detection
|
|
5
|
+
# Holds detected Ruby and RubyGems versions.
|
|
6
|
+
#
|
|
7
|
+
# @!attribute [r] ruby
|
|
8
|
+
# @return [Gem::Version]
|
|
9
|
+
# @!attribute [r] rubygems
|
|
10
|
+
# @return [Gem::Version]
|
|
11
|
+
RuntimeInfo = Struct.new(:ruby, :rubygems)
|
|
12
|
+
|
|
13
|
+
# Detects the current Ruby and RubyGems versions.
|
|
14
|
+
class Runtime
|
|
15
|
+
# @return [RuntimeInfo]
|
|
16
|
+
def detect
|
|
17
|
+
RuntimeInfo.new(
|
|
18
|
+
ruby: Gem::Version.new(RUBY_VERSION),
|
|
19
|
+
rubygems: Gem::Version.new(Gem::VERSION)
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CreateRailsApp
|
|
4
|
+
# Base error for all create-rails-app failures.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Raised when the config file is corrupt or unreadable.
|
|
8
|
+
class ConfigError < Error; end
|
|
9
|
+
|
|
10
|
+
# Raised when CLI flags or option values are invalid.
|
|
11
|
+
class ValidationError < Error; end
|
|
12
|
+
|
|
13
|
+
# Raised when no supported Rails version can be found or installed.
|
|
14
|
+
class UnsupportedRailsVersionError < Error; end
|
|
15
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CreateRailsApp
|
|
4
|
+
module Options
|
|
5
|
+
# Registry of every +rails new+ option create-rails-app knows about.
|
|
6
|
+
#
|
|
7
|
+
# Each entry in {DEFINITIONS} describes the option's type and CLI flags.
|
|
8
|
+
# {ORDER} controls the sequence in which the wizard presents options.
|
|
9
|
+
#
|
|
10
|
+
# @see Options::Validator
|
|
11
|
+
# @see Wizard
|
|
12
|
+
module Catalog
|
|
13
|
+
# Option definitions keyed by symbolic name.
|
|
14
|
+
#
|
|
15
|
+
# Types:
|
|
16
|
+
# - +:flag+ — opt-in; emits +--flag+ when true, nothing when false
|
|
17
|
+
# - +:enum+ — emits +--flag=value+; +:none+ flag emits +--skip-flag+
|
|
18
|
+
# - +:skip+ — opt-out; emits nothing when true (include), +--skip-X+ when false (exclude)
|
|
19
|
+
#
|
|
20
|
+
# @return [Hash{Symbol => Hash}]
|
|
21
|
+
# Database adapters shared across all Rails versions.
|
|
22
|
+
BASE_DATABASE_VALUES = %w[sqlite3 postgresql mysql trilogy].freeze
|
|
23
|
+
|
|
24
|
+
# Database adapters added in Rails 8.0+.
|
|
25
|
+
MARIADB_DATABASE_VALUES = %w[mariadb-mysql mariadb-trilogy].freeze
|
|
26
|
+
|
|
27
|
+
DEFINITIONS = {
|
|
28
|
+
# Flags (opt-in)
|
|
29
|
+
api: { type: :flag, on: '--api' }.freeze,
|
|
30
|
+
# Enums
|
|
31
|
+
database: { type: :enum, flag: '--database',
|
|
32
|
+
values: (BASE_DATABASE_VALUES + MARIADB_DATABASE_VALUES).freeze }.freeze,
|
|
33
|
+
javascript: { type: :enum, flag: '--javascript', none: '--skip-javascript',
|
|
34
|
+
values: %w[importmap bun webpack esbuild rollup].freeze }.freeze,
|
|
35
|
+
css: { type: :enum, flag: '--css', none: true,
|
|
36
|
+
values: %w[tailwind bootstrap bulma postcss sass].freeze,
|
|
37
|
+
rails_default: 'none' }.freeze,
|
|
38
|
+
asset_pipeline: { type: :enum, flag: '--asset-pipeline', none: '--skip-asset-pipeline',
|
|
39
|
+
values: %w[propshaft sprockets].freeze, rails_default: 'sprockets' }.freeze,
|
|
40
|
+
# Skip (included by default, --skip-X to exclude)
|
|
41
|
+
active_record: { type: :skip, skip_flag: '--skip-active-record' }.freeze,
|
|
42
|
+
action_mailer: { type: :skip, skip_flag: '--skip-action-mailer' }.freeze,
|
|
43
|
+
action_mailbox: { type: :skip, skip_flag: '--skip-action-mailbox' }.freeze,
|
|
44
|
+
action_text: { type: :skip, skip_flag: '--skip-action-text' }.freeze,
|
|
45
|
+
active_job: { type: :skip, skip_flag: '--skip-active-job' }.freeze,
|
|
46
|
+
active_storage: { type: :skip, skip_flag: '--skip-active-storage' }.freeze,
|
|
47
|
+
action_cable: { type: :skip, skip_flag: '--skip-action-cable' }.freeze,
|
|
48
|
+
hotwire: { type: :skip, skip_flag: '--skip-hotwire' }.freeze,
|
|
49
|
+
jbuilder: { type: :skip, skip_flag: '--skip-jbuilder' }.freeze,
|
|
50
|
+
test: { type: :skip, skip_flag: '--skip-test' }.freeze,
|
|
51
|
+
system_test: { type: :skip, skip_flag: '--skip-system-test' }.freeze,
|
|
52
|
+
brakeman: { type: :skip, skip_flag: '--skip-brakeman' }.freeze,
|
|
53
|
+
bundler_audit: { type: :skip, skip_flag: '--skip-bundler-audit' }.freeze,
|
|
54
|
+
rubocop: { type: :skip, skip_flag: '--skip-rubocop' }.freeze,
|
|
55
|
+
ci: { type: :skip, skip_flag: '--skip-ci' }.freeze,
|
|
56
|
+
docker: { type: :skip, skip_flag: '--skip-docker' }.freeze,
|
|
57
|
+
kamal: { type: :skip, skip_flag: '--skip-kamal' }.freeze,
|
|
58
|
+
thruster: { type: :skip, skip_flag: '--skip-thruster' }.freeze,
|
|
59
|
+
solid: { type: :skip, skip_flag: '--skip-solid' }.freeze,
|
|
60
|
+
devcontainer: { type: :flag, on: '--devcontainer' }.freeze,
|
|
61
|
+
bootsnap: { type: :skip, skip_flag: '--skip-bootsnap' }.freeze,
|
|
62
|
+
dev_gems: { type: :skip, skip_flag: '--skip-dev-gems' }.freeze,
|
|
63
|
+
keeps: { type: :skip, skip_flag: '--skip-keeps' }.freeze,
|
|
64
|
+
decrypted_diffs: { type: :skip, skip_flag: '--skip-decrypted-diffs' }.freeze,
|
|
65
|
+
git: { type: :skip, skip_flag: '--skip-git' }.freeze,
|
|
66
|
+
bundle: { type: :skip, skip_flag: '--skip-bundle' }.freeze
|
|
67
|
+
}.freeze
|
|
68
|
+
|
|
69
|
+
# Wizard step order — matches the sequence users see.
|
|
70
|
+
#
|
|
71
|
+
# Phase 1: Core architecture (flags + enums + active_record)
|
|
72
|
+
# Phase 2: Skip options with auto-skip consequences + dependents
|
|
73
|
+
# Phase 3: Independent skips + devcontainer flag
|
|
74
|
+
#
|
|
75
|
+
# @return [Array<Symbol>]
|
|
76
|
+
ORDER = %i[
|
|
77
|
+
api
|
|
78
|
+
active_record
|
|
79
|
+
database
|
|
80
|
+
javascript
|
|
81
|
+
css
|
|
82
|
+
asset_pipeline
|
|
83
|
+
test
|
|
84
|
+
system_test
|
|
85
|
+
hotwire
|
|
86
|
+
jbuilder
|
|
87
|
+
action_mailbox
|
|
88
|
+
action_text
|
|
89
|
+
active_storage
|
|
90
|
+
devcontainer
|
|
91
|
+
action_mailer
|
|
92
|
+
active_job
|
|
93
|
+
action_cable
|
|
94
|
+
brakeman
|
|
95
|
+
bundler_audit
|
|
96
|
+
rubocop
|
|
97
|
+
ci
|
|
98
|
+
docker
|
|
99
|
+
kamal
|
|
100
|
+
thruster
|
|
101
|
+
solid
|
|
102
|
+
bootsnap
|
|
103
|
+
dev_gems
|
|
104
|
+
keeps
|
|
105
|
+
decrypted_diffs
|
|
106
|
+
git
|
|
107
|
+
bundle
|
|
108
|
+
].freeze
|
|
109
|
+
|
|
110
|
+
# Fetches the definition for a given option key.
|
|
111
|
+
#
|
|
112
|
+
# @param key [Symbol, String]
|
|
113
|
+
# @return [Hash] the option definition
|
|
114
|
+
# @raise [KeyError] if the key is unknown
|
|
115
|
+
def self.fetch(key)
|
|
116
|
+
DEFINITIONS.fetch(key.to_sym)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CreateRailsApp
|
|
4
|
+
module Options
|
|
5
|
+
# Validates user-selected options against the compatibility entry
|
|
6
|
+
# for the detected Rails version.
|
|
7
|
+
#
|
|
8
|
+
# Checks that: the app name is valid, every option key is known,
|
|
9
|
+
# every option is supported by this Rails version, and every
|
|
10
|
+
# value is valid for the option's type and allowed values.
|
|
11
|
+
class Validator
|
|
12
|
+
# @return [Regexp] pattern for valid Rails app names
|
|
13
|
+
APP_NAME_PATTERN = /\A[a-zA-Z][a-zA-Z0-9_-]*\z/
|
|
14
|
+
|
|
15
|
+
# @param compatibility_entry [Compatibility::Matrix::Entry]
|
|
16
|
+
def initialize(compatibility_entry)
|
|
17
|
+
@compatibility_entry = compatibility_entry
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Validates the app name and all options.
|
|
21
|
+
#
|
|
22
|
+
# @param app_name [String]
|
|
23
|
+
# @param options [Hash{Symbol => Object}]
|
|
24
|
+
# @return [true]
|
|
25
|
+
# @raise [ValidationError] if any validation fails
|
|
26
|
+
def validate!(app_name:, options:) # rubocop:disable Naming/PredicateMethod
|
|
27
|
+
validate_app_name!(app_name)
|
|
28
|
+
|
|
29
|
+
options.each do |key, value|
|
|
30
|
+
validate_option_key!(key)
|
|
31
|
+
validate_supported_option!(key)
|
|
32
|
+
validate_value!(key, value)
|
|
33
|
+
validate_supported_value!(key, value)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
true
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
# @param app_name [String]
|
|
42
|
+
# @raise [ValidationError]
|
|
43
|
+
# @return [void]
|
|
44
|
+
def validate_app_name!(app_name)
|
|
45
|
+
return if app_name.is_a?(String) && app_name.match?(APP_NAME_PATTERN)
|
|
46
|
+
|
|
47
|
+
raise ValidationError, "Invalid app name: #{app_name.inspect}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @param key [Symbol]
|
|
51
|
+
# @raise [ValidationError]
|
|
52
|
+
# @return [void]
|
|
53
|
+
def validate_option_key!(key)
|
|
54
|
+
return if Catalog::DEFINITIONS.key?(key.to_sym)
|
|
55
|
+
|
|
56
|
+
raise ValidationError, "Unknown option: #{key}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# @param key [Symbol]
|
|
60
|
+
# @raise [ValidationError]
|
|
61
|
+
# @return [void]
|
|
62
|
+
def validate_supported_option!(key)
|
|
63
|
+
return if @compatibility_entry.supports_option?(key)
|
|
64
|
+
|
|
65
|
+
raise ValidationError, "Option #{key} is not supported by this Rails version"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# @param key [Symbol]
|
|
69
|
+
# @param value [Object]
|
|
70
|
+
# @raise [ValidationError]
|
|
71
|
+
# @return [void]
|
|
72
|
+
def validate_value!(key, value)
|
|
73
|
+
definition = Catalog.fetch(key)
|
|
74
|
+
case definition[:type]
|
|
75
|
+
when :skip, :flag
|
|
76
|
+
return if value.nil? || value == true || value == false
|
|
77
|
+
when :enum
|
|
78
|
+
return if value.nil? || definition[:values].include?(value)
|
|
79
|
+
return if definition.key?(:none) && [true, false].include?(value)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
raise ValidationError, "Invalid value for #{key}: #{value.inspect}"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# @param key [Symbol]
|
|
86
|
+
# @param value [Object]
|
|
87
|
+
# @raise [ValidationError]
|
|
88
|
+
# @return [void]
|
|
89
|
+
def validate_supported_value!(key, value)
|
|
90
|
+
return if value.nil? || value == true || value == false
|
|
91
|
+
|
|
92
|
+
supported_values = @compatibility_entry.allowed_values(key)
|
|
93
|
+
return if supported_values&.include?(value)
|
|
94
|
+
|
|
95
|
+
raise ValidationError, "Value #{value.inspect} for #{key} is not supported by this Rails version"
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'cli/kit'
|
|
4
|
+
require 'shellwords'
|
|
5
|
+
|
|
6
|
+
module CreateRailsApp
|
|
7
|
+
# Executes commands via the shell.
|
|
8
|
+
#
|
|
9
|
+
# Supports +--dry-run+ mode, which prints commands instead of running them.
|
|
10
|
+
# Can run multiple commands in sequence (e.g. gem install + rails new).
|
|
11
|
+
#
|
|
12
|
+
# @example
|
|
13
|
+
# Runner.new.run!(['rails', '_8.1.2_', 'new', 'myapp', '--api'])
|
|
14
|
+
class Runner
|
|
15
|
+
# @param out [IO] output stream for dry-run printing
|
|
16
|
+
# @param system_runner [#call, nil] callable that executes a shell command
|
|
17
|
+
def initialize(out: $stdout, system_runner: nil)
|
|
18
|
+
@out = out
|
|
19
|
+
@system_runner = system_runner || ->(*command) { ::CLI::Kit::System.system(*command) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Executes the command or prints it in dry-run mode.
|
|
23
|
+
#
|
|
24
|
+
# @param command [Array<String>] the command to execute
|
|
25
|
+
# @param dry_run [Boolean] when true, prints instead of executing
|
|
26
|
+
# @return [true] on success
|
|
27
|
+
# @raise [Error] if the command exits with a non-zero status
|
|
28
|
+
def run!(command, dry_run: false)
|
|
29
|
+
if dry_run
|
|
30
|
+
@out.puts(command.shelljoin)
|
|
31
|
+
return true
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
status = if defined?(Bundler)
|
|
35
|
+
Bundler.with_unbundled_env { @system_runner.call(*command) }
|
|
36
|
+
else
|
|
37
|
+
@system_runner.call(*command)
|
|
38
|
+
end
|
|
39
|
+
return true if status.respond_to?(:success?) && status.success?
|
|
40
|
+
|
|
41
|
+
code = status.respond_to?(:exitstatus) ? status.exitstatus : nil
|
|
42
|
+
message = 'Command failed'
|
|
43
|
+
message += " (exit #{code})" if code
|
|
44
|
+
message += ": #{command.shelljoin}"
|
|
45
|
+
raise Error, message
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|