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,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CreateRailsApp
|
|
4
|
+
module UI
|
|
5
|
+
# Color constants for terminal output.
|
|
6
|
+
#
|
|
7
|
+
# Maps semantic roles (e.g. +:summary_label+, +:arg_name+) to ANSI 256-color
|
|
8
|
+
# codes or +cli-ui+ basic color names, depending on terminal capabilities.
|
|
9
|
+
class Palette
|
|
10
|
+
# ANSI reset sequence.
|
|
11
|
+
RESET = "\e[0m"
|
|
12
|
+
|
|
13
|
+
# Role-to-color mappings for 256-color terminals.
|
|
14
|
+
#
|
|
15
|
+
# @return [Hash{Symbol => Integer}]
|
|
16
|
+
ROLE_COLORS_256 = {
|
|
17
|
+
control_back: 45,
|
|
18
|
+
control_exit: 203,
|
|
19
|
+
summary_label: 213,
|
|
20
|
+
runtime_name: 111,
|
|
21
|
+
runtime_value: 190,
|
|
22
|
+
command_base: 39,
|
|
23
|
+
command_app: 82,
|
|
24
|
+
arg_name: 117,
|
|
25
|
+
arg_eq: 250,
|
|
26
|
+
arg_value: 214,
|
|
27
|
+
install_cmd: 178,
|
|
28
|
+
exit_message: 82
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
# Role-to-color mappings for basic (8/16-color) terminals.
|
|
32
|
+
#
|
|
33
|
+
# @return [Hash{Symbol => String}]
|
|
34
|
+
ROLE_COLORS_BASIC = {
|
|
35
|
+
control_back: 'cyan',
|
|
36
|
+
control_exit: 'red',
|
|
37
|
+
summary_label: 'magenta',
|
|
38
|
+
runtime_name: 'blue',
|
|
39
|
+
runtime_value: 'green',
|
|
40
|
+
command_base: 'blue',
|
|
41
|
+
command_app: 'green',
|
|
42
|
+
arg_name: 'blue',
|
|
43
|
+
arg_eq: 'white',
|
|
44
|
+
arg_value: 'yellow',
|
|
45
|
+
install_cmd: 'yellow',
|
|
46
|
+
exit_message: 'green'
|
|
47
|
+
}.freeze
|
|
48
|
+
|
|
49
|
+
# @param env [Hash] environment variables (defaults to +ENV+)
|
|
50
|
+
def initialize(env: ENV)
|
|
51
|
+
@env = env
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Wraps text in the appropriate ANSI color for the given role.
|
|
55
|
+
#
|
|
56
|
+
# @param role [Symbol] a key from {ROLE_COLORS_256}/{ROLE_COLORS_BASIC}
|
|
57
|
+
# @param text [String] the text to colorize
|
|
58
|
+
# @return [String]
|
|
59
|
+
def color(role, text)
|
|
60
|
+
if no_color?
|
|
61
|
+
ROLE_COLORS_256.fetch(role) # validate role key exists
|
|
62
|
+
text
|
|
63
|
+
elsif supports_256_colors?
|
|
64
|
+
"\e[38;5;#{ROLE_COLORS_256.fetch(role)}m#{text}#{RESET}"
|
|
65
|
+
else
|
|
66
|
+
"{{#{ROLE_COLORS_BASIC.fetch(role)}:#{text}}}"
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
# @return [Boolean]
|
|
73
|
+
def no_color?
|
|
74
|
+
@env.key?('NO_COLOR') || @env['TERM'] == 'dumb'
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# @return [Boolean]
|
|
78
|
+
def supports_256_colors?
|
|
79
|
+
return false if no_color?
|
|
80
|
+
|
|
81
|
+
term = @env.fetch('TERM', '')
|
|
82
|
+
colorterm = @env.fetch('COLORTERM', '')
|
|
83
|
+
term.include?('256color') || colorterm.match?(/truecolor|24bit/i)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'cli/ui'
|
|
4
|
+
|
|
5
|
+
module CreateRailsApp
|
|
6
|
+
module UI
|
|
7
|
+
# Raised when the user presses Ctrl+B during a prompt.
|
|
8
|
+
class BackKeyPressed < StandardError; end
|
|
9
|
+
|
|
10
|
+
# Thin wrapper around +cli-ui+ for all user interaction.
|
|
11
|
+
#
|
|
12
|
+
# Every prompt the wizard issues goes through this class, making it
|
|
13
|
+
# easy to inject a test double.
|
|
14
|
+
class Prompter
|
|
15
|
+
CTRL_B = "\u0002"
|
|
16
|
+
|
|
17
|
+
module ReadCharPatch
|
|
18
|
+
def read_char
|
|
19
|
+
char = super
|
|
20
|
+
raise BackKeyPressed if char == CTRL_B
|
|
21
|
+
|
|
22
|
+
char
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Enables the +cli-ui+ stdout router and applies the Ctrl+B patch.
|
|
27
|
+
#
|
|
28
|
+
# Call once before creating a Prompter instance. Idempotent.
|
|
29
|
+
#
|
|
30
|
+
# @return [void]
|
|
31
|
+
def self.setup!
|
|
32
|
+
::CLI::UI::StdoutRouter.enable
|
|
33
|
+
|
|
34
|
+
unless ::CLI::UI::Prompt.respond_to?(:read_char)
|
|
35
|
+
raise Error, 'CLI::UI::Prompt does not respond to read_char; back-navigation patch cannot be applied'
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
singleton = ::CLI::UI::Prompt.singleton_class
|
|
39
|
+
return if singleton.ancestors.include?(ReadCharPatch)
|
|
40
|
+
|
|
41
|
+
singleton.prepend(ReadCharPatch)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @param out [IO] output stream
|
|
45
|
+
def initialize(out: $stdout)
|
|
46
|
+
@out = out
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Opens a visual frame with a title.
|
|
50
|
+
#
|
|
51
|
+
# @param title [String]
|
|
52
|
+
# @yield block executed inside the frame
|
|
53
|
+
# @return [void]
|
|
54
|
+
def frame(title, &)
|
|
55
|
+
::CLI::UI::Frame.open(title, &)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Presents a single-choice list.
|
|
59
|
+
#
|
|
60
|
+
# @param question [String]
|
|
61
|
+
# @param options [Array<String>]
|
|
62
|
+
# @param default [String, nil]
|
|
63
|
+
# @return [String] selected option, or {Wizard::BACK} on Ctrl+B
|
|
64
|
+
def choose(question, options:, default: nil)
|
|
65
|
+
::CLI::UI.ask(question, options: options, default: default, filter_ui: false)
|
|
66
|
+
rescue BackKeyPressed
|
|
67
|
+
Wizard::BACK
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Prompts for free-text input.
|
|
71
|
+
#
|
|
72
|
+
# @param question [String]
|
|
73
|
+
# @param default [String, nil]
|
|
74
|
+
# @param allow_empty [Boolean]
|
|
75
|
+
# @return [String]
|
|
76
|
+
def text(question, default: nil, allow_empty: true)
|
|
77
|
+
::CLI::UI.ask(question, default: default, allow_empty: allow_empty)
|
|
78
|
+
rescue BackKeyPressed
|
|
79
|
+
Wizard::BACK
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Prompts for yes/no confirmation.
|
|
83
|
+
#
|
|
84
|
+
# @param question [String]
|
|
85
|
+
# @param default [Boolean]
|
|
86
|
+
# @return [Boolean]
|
|
87
|
+
def confirm(question, default: true)
|
|
88
|
+
::CLI::UI.confirm(question, default: default)
|
|
89
|
+
rescue BackKeyPressed
|
|
90
|
+
# Confirm callers (preset save, overwrite prompts) don't handle BACK;
|
|
91
|
+
# swallowing the key press and returning the default is intentional.
|
|
92
|
+
default
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Prints a formatted message to the output stream.
|
|
96
|
+
#
|
|
97
|
+
# @param message [String] message with optional +cli-ui+ formatting tags
|
|
98
|
+
# @return [void]
|
|
99
|
+
def say(message)
|
|
100
|
+
@out.puts(::CLI::UI.fmt(message))
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CreateRailsApp
|
|
4
|
+
# Step-by-step interactive prompt loop for choosing +rails new+ options.
|
|
5
|
+
#
|
|
6
|
+
# Walks through each supported option in {Options::Catalog::ORDER},
|
|
7
|
+
# presenting only the options supported by the detected Rails version.
|
|
8
|
+
# Supports back-navigation via +Ctrl+B+ and smart filtering via {SKIP_RULES}.
|
|
9
|
+
#
|
|
10
|
+
# @see CLI#run_interactive_wizard!
|
|
11
|
+
class Wizard
|
|
12
|
+
# Sentinel returned by the prompter when the user presses Ctrl+B.
|
|
13
|
+
BACK = Object.new.tap { |o| o.define_singleton_method(:inspect) { '#<BACK>' } }.freeze
|
|
14
|
+
|
|
15
|
+
# Human-readable labels for each option key.
|
|
16
|
+
#
|
|
17
|
+
# @return [Hash{Symbol => String}]
|
|
18
|
+
LABELS = {
|
|
19
|
+
api: 'API-only mode',
|
|
20
|
+
active_record: 'Active Record (ORM)',
|
|
21
|
+
database: 'Database',
|
|
22
|
+
javascript: 'JavaScript approach',
|
|
23
|
+
css: 'CSS framework',
|
|
24
|
+
asset_pipeline: 'Asset pipeline',
|
|
25
|
+
hotwire: 'Hotwire (Turbo + Stimulus)',
|
|
26
|
+
jbuilder: 'Jbuilder (JSON templates)',
|
|
27
|
+
action_mailer: 'Action Mailer',
|
|
28
|
+
action_mailbox: 'Action Mailbox',
|
|
29
|
+
action_text: 'Action Text (rich text)',
|
|
30
|
+
active_job: 'Active Job',
|
|
31
|
+
active_storage: 'Active Storage (file uploads)',
|
|
32
|
+
action_cable: 'Action Cable (WebSockets)',
|
|
33
|
+
test: 'Tests',
|
|
34
|
+
system_test: 'System tests',
|
|
35
|
+
brakeman: 'Brakeman (security scanner)',
|
|
36
|
+
bundler_audit: 'Bundler Audit (dependency checker)',
|
|
37
|
+
rubocop: 'RuboCop (linter)',
|
|
38
|
+
ci: 'CI files',
|
|
39
|
+
docker: 'Dockerfile',
|
|
40
|
+
kamal: 'Kamal (deployment)',
|
|
41
|
+
thruster: 'Thruster (HTTP/2 proxy)',
|
|
42
|
+
solid: 'Solid (Cache/Queue/Cable)',
|
|
43
|
+
devcontainer: 'Dev Container',
|
|
44
|
+
bootsnap: 'Bootsnap (boot speedup)',
|
|
45
|
+
dev_gems: 'Dev gems',
|
|
46
|
+
keeps: 'Source control .keep files',
|
|
47
|
+
decrypted_diffs: 'Decrypted diffs',
|
|
48
|
+
git: 'Initialize git',
|
|
49
|
+
bundle: 'Run bundle install'
|
|
50
|
+
}.freeze
|
|
51
|
+
|
|
52
|
+
# Short explanations shown below each wizard step.
|
|
53
|
+
#
|
|
54
|
+
# @return [Hash{Symbol => String}]
|
|
55
|
+
HELP_TEXT = {
|
|
56
|
+
api: 'Generates a slimmed-down app optimized for API backends.',
|
|
57
|
+
active_record: 'Database ORM layer. Skipping also skips the database choice.',
|
|
58
|
+
database: 'Which database adapter to configure.',
|
|
59
|
+
javascript: 'How JavaScript is managed in the asset pipeline.',
|
|
60
|
+
css: 'Which CSS framework to pre-install.',
|
|
61
|
+
asset_pipeline: 'Which asset pipeline to use for JS/CSS bundling.',
|
|
62
|
+
hotwire: 'Turbo + Stimulus for SPA-like behavior over HTML.',
|
|
63
|
+
jbuilder: 'DSL for building JSON views.',
|
|
64
|
+
action_mailer: 'Framework for sending emails.',
|
|
65
|
+
action_mailbox: 'Routes inbound emails to controller-like mailboxes.',
|
|
66
|
+
action_text: 'Rich text content and editing with Trix.',
|
|
67
|
+
active_job: 'Framework for declaring and running background jobs.',
|
|
68
|
+
active_storage: 'Upload files to cloud services like S3 or GCS.',
|
|
69
|
+
action_cable: 'WebSocket framework for real-time features.',
|
|
70
|
+
test: 'Generates test directory and helpers.',
|
|
71
|
+
system_test: 'Browser-based integration tests via Capybara.',
|
|
72
|
+
brakeman: 'Static analysis for security vulnerabilities.',
|
|
73
|
+
bundler_audit: 'Checks dependencies for known vulnerabilities.',
|
|
74
|
+
rubocop: 'Ruby style and lint checking.',
|
|
75
|
+
ci: 'Generates CI workflow configuration.',
|
|
76
|
+
docker: 'Generates Dockerfile for containerized deployment.',
|
|
77
|
+
kamal: 'Generates Kamal deploy configuration.',
|
|
78
|
+
thruster: 'HTTP/2 proxy with asset caching and X-Sendfile.',
|
|
79
|
+
solid: 'Solid Cache, Solid Queue, and Solid Cable adapters.',
|
|
80
|
+
devcontainer: 'Generates VS Code dev container configuration.',
|
|
81
|
+
bootsnap: 'Speeds up boot times with caching.',
|
|
82
|
+
dev_gems: 'Development gems like web-console.',
|
|
83
|
+
keeps: 'Empty directories preserved via .keep files.',
|
|
84
|
+
decrypted_diffs: 'Show decrypted diffs of encrypted credentials in git.',
|
|
85
|
+
git: 'Initializes a git repository for the new app.',
|
|
86
|
+
bundle: 'Runs bundle install after generating the app.'
|
|
87
|
+
}.freeze
|
|
88
|
+
|
|
89
|
+
# Per-choice hints displayed next to enum choices.
|
|
90
|
+
#
|
|
91
|
+
# @return [Hash{Symbol => Hash{String => String}}]
|
|
92
|
+
CHOICE_HELP = {
|
|
93
|
+
database: {
|
|
94
|
+
'sqlite3' => 'simple file-based, great for development',
|
|
95
|
+
'postgresql' => 'full-featured, most popular for production',
|
|
96
|
+
'mysql' => 'widely used relational database',
|
|
97
|
+
'trilogy' => 'modern MySQL-compatible client',
|
|
98
|
+
'mariadb-mysql' => 'MariaDB with mysql2 adapter',
|
|
99
|
+
'mariadb-trilogy' => 'MariaDB with Trilogy adapter'
|
|
100
|
+
},
|
|
101
|
+
javascript: {
|
|
102
|
+
'importmap' => 'no bundler, uses browser-native import maps',
|
|
103
|
+
'bun' => 'fast all-in-one JS runtime and bundler',
|
|
104
|
+
'webpack' => 'established full-featured bundler',
|
|
105
|
+
'esbuild' => 'extremely fast JS bundler',
|
|
106
|
+
'rollup' => 'ES module-focused bundler',
|
|
107
|
+
'none' => 'no JavaScript setup'
|
|
108
|
+
},
|
|
109
|
+
asset_pipeline: {
|
|
110
|
+
'propshaft' => 'modern, lightweight asset pipeline',
|
|
111
|
+
'sprockets' => 'classic asset pipeline with preprocessing',
|
|
112
|
+
'none' => 'no asset pipeline'
|
|
113
|
+
},
|
|
114
|
+
css: {
|
|
115
|
+
'tailwind' => 'utility-first CSS framework',
|
|
116
|
+
'bootstrap' => 'popular component-based framework',
|
|
117
|
+
'bulma' => 'modern CSS-only framework',
|
|
118
|
+
'postcss' => 'CSS transformations via plugins',
|
|
119
|
+
'sass' => 'CSS with variables, nesting, and mixins',
|
|
120
|
+
'none' => 'no CSS framework'
|
|
121
|
+
}
|
|
122
|
+
}.freeze
|
|
123
|
+
|
|
124
|
+
# Rules that determine when a wizard step should be silently skipped.
|
|
125
|
+
# Each lambda receives the current values hash and returns true to skip.
|
|
126
|
+
#
|
|
127
|
+
# @return [Hash{Symbol => Proc}]
|
|
128
|
+
SKIP_RULES = {
|
|
129
|
+
database: ->(values) { values[:active_record] == false },
|
|
130
|
+
javascript: ->(values) { values[:api] == true },
|
|
131
|
+
css: ->(values) { values[:api] == true },
|
|
132
|
+
asset_pipeline: ->(values) { values[:api] == true },
|
|
133
|
+
hotwire: ->(values) { values[:api] == true },
|
|
134
|
+
jbuilder: ->(values) { values[:api] == true },
|
|
135
|
+
action_mailbox: ->(values) { values[:active_record] == false },
|
|
136
|
+
action_text: ->(values) { values[:api] == true || values[:active_record] == false },
|
|
137
|
+
active_storage: ->(values) { values[:active_record] == false },
|
|
138
|
+
system_test: ->(values) { values[:test] == false || values[:api] == true }
|
|
139
|
+
}.freeze
|
|
140
|
+
|
|
141
|
+
attr_reader :last_presented_index
|
|
142
|
+
|
|
143
|
+
# @param compatibility_entry [Compatibility::Matrix::Entry]
|
|
144
|
+
# @param defaults [Hash{Symbol => Object}] initial default values (e.g. last-used)
|
|
145
|
+
# @param prompter [UI::Prompter]
|
|
146
|
+
def initialize(compatibility_entry:, defaults:, prompter:)
|
|
147
|
+
@compatibility_entry = compatibility_entry
|
|
148
|
+
@prompter = prompter
|
|
149
|
+
@values = sanitize_defaults(defaults)
|
|
150
|
+
@stashed = {}
|
|
151
|
+
@last_presented_index = 0
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Runs the wizard and returns the selected options.
|
|
155
|
+
#
|
|
156
|
+
# @param start_index [Integer] step index to resume from
|
|
157
|
+
# @return [Hash{Symbol => Object}]
|
|
158
|
+
def run(start_index: 0)
|
|
159
|
+
keys = Options::Catalog::ORDER.select { |key| @compatibility_entry.supports_option?(key) }
|
|
160
|
+
index = [start_index, keys.length - 1].min
|
|
161
|
+
while index < keys.length
|
|
162
|
+
key = keys[index]
|
|
163
|
+
|
|
164
|
+
if skip_step?(key)
|
|
165
|
+
@stashed[key] = @values.delete(key) if @values.key?(key)
|
|
166
|
+
index += 1
|
|
167
|
+
next
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
@values[key] = @stashed.delete(key) if @stashed.key?(key) && !@values.key?(key)
|
|
171
|
+
|
|
172
|
+
@last_presented_index = index
|
|
173
|
+
answer = ask_for(key, index:, total: keys.length)
|
|
174
|
+
case answer
|
|
175
|
+
when BACK
|
|
176
|
+
index = find_previous_unskipped(keys, index)
|
|
177
|
+
else
|
|
178
|
+
assign_value(key, answer)
|
|
179
|
+
index += 1
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
@values.dup
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
private
|
|
186
|
+
|
|
187
|
+
# @param key [Symbol]
|
|
188
|
+
# @return [Boolean]
|
|
189
|
+
def skip_step?(key)
|
|
190
|
+
rule = SKIP_RULES[key]
|
|
191
|
+
rule&.call(@values)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Finds the previous unskipped step index.
|
|
195
|
+
#
|
|
196
|
+
# @param keys [Array<Symbol>]
|
|
197
|
+
# @param current_index [Integer]
|
|
198
|
+
# @return [Integer]
|
|
199
|
+
def find_previous_unskipped(keys, current_index)
|
|
200
|
+
i = current_index - 1
|
|
201
|
+
# i.positive? (not i >= 0) stops the loop at i==0 so we can check
|
|
202
|
+
# the first step explicitly below. If every preceding step is
|
|
203
|
+
# skipped, we stay at current_index (nowhere to go back to).
|
|
204
|
+
i -= 1 while i.positive? && skip_step?(keys[i])
|
|
205
|
+
return current_index if i >= 0 && skip_step?(keys[i])
|
|
206
|
+
|
|
207
|
+
[i, 0].max
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# @param key [Symbol]
|
|
211
|
+
# @param index [Integer]
|
|
212
|
+
# @param total [Integer]
|
|
213
|
+
# @return [Object] user answer or {BACK}
|
|
214
|
+
def ask_for(key, index:, total:)
|
|
215
|
+
definition = Options::Catalog.fetch(key)
|
|
216
|
+
label = LABELS.fetch(key)
|
|
217
|
+
skip_question = definition[:type] == :skip ||
|
|
218
|
+
(definition[:type] == :enum && !@compatibility_entry.allowed_values(key))
|
|
219
|
+
question = render_question(index: index, total: total, key: key, label: label, skip: skip_question)
|
|
220
|
+
case definition[:type]
|
|
221
|
+
when :skip
|
|
222
|
+
ask_skip(question, key)
|
|
223
|
+
when :flag
|
|
224
|
+
ask_flag(question, key)
|
|
225
|
+
when :enum
|
|
226
|
+
# When the Matrix provides nil (no enum values), the option is a
|
|
227
|
+
# simple include/skip for this Rails version (e.g. asset_pipeline in 8.0+).
|
|
228
|
+
if @compatibility_entry.allowed_values(key)
|
|
229
|
+
ask_enum(question, key, definition)
|
|
230
|
+
else
|
|
231
|
+
ask_skip(question, key)
|
|
232
|
+
end
|
|
233
|
+
else
|
|
234
|
+
raise Error, "Unknown option type for #{key}"
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# @param question [String]
|
|
239
|
+
# @param key [Symbol]
|
|
240
|
+
# @return [true, false, BACK]
|
|
241
|
+
def ask_skip(question, key)
|
|
242
|
+
choices = %w[no yes]
|
|
243
|
+
current = @values[key]
|
|
244
|
+
selected = current == false ? 'yes' : 'no'
|
|
245
|
+
answer = choose_with_default_marker(question, key:, choices:, rails_default: 'no', selected:)
|
|
246
|
+
return BACK if answer == BACK
|
|
247
|
+
return false if answer == 'yes'
|
|
248
|
+
|
|
249
|
+
true
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# @param question [String]
|
|
253
|
+
# @param key [Symbol]
|
|
254
|
+
# @return [true, nil, BACK]
|
|
255
|
+
def ask_flag(question, key)
|
|
256
|
+
choices = %w[yes no]
|
|
257
|
+
current = @values[key]
|
|
258
|
+
selected = current == true ? 'yes' : 'no'
|
|
259
|
+
answer = choose_with_default_marker(question, key:, choices:, rails_default: 'no', selected:)
|
|
260
|
+
return BACK if answer == BACK
|
|
261
|
+
return true if answer == 'yes'
|
|
262
|
+
|
|
263
|
+
nil
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# @param question [String]
|
|
267
|
+
# @param key [Symbol]
|
|
268
|
+
# @param definition [Hash]
|
|
269
|
+
# @return [String, false, BACK]
|
|
270
|
+
def ask_enum(question, key, definition)
|
|
271
|
+
choices = @compatibility_entry.allowed_values(key).dup
|
|
272
|
+
choices << 'none' if definition[:none]
|
|
273
|
+
current = @values[key]
|
|
274
|
+
selected = enum_selected_choice(current, choices)
|
|
275
|
+
rails_default = definition[:rails_default] || choices.first
|
|
276
|
+
answer = choose_with_default_marker(question, key:, choices:, rails_default:, selected:)
|
|
277
|
+
return BACK if answer == BACK
|
|
278
|
+
return false if answer == 'none'
|
|
279
|
+
|
|
280
|
+
answer
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# @param key [Symbol]
|
|
284
|
+
# @param value [Object]
|
|
285
|
+
# @return [void]
|
|
286
|
+
def assign_value(key, value)
|
|
287
|
+
if value.nil?
|
|
288
|
+
@values.delete(key)
|
|
289
|
+
else
|
|
290
|
+
@values[key] = value
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# Filters defaults to only supported options.
|
|
295
|
+
#
|
|
296
|
+
# @param hash [Hash]
|
|
297
|
+
# @return [Hash{Symbol => Object}]
|
|
298
|
+
def sanitize_defaults(hash)
|
|
299
|
+
hash.transform_keys(&:to_sym)
|
|
300
|
+
.select { |key, _| @compatibility_entry.supports_option?(key) }
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# Resolves which enum choice to pre-select based on the user's last pick.
|
|
304
|
+
#
|
|
305
|
+
# @param current [Object]
|
|
306
|
+
# @param choices [Array<String>]
|
|
307
|
+
# @return [String]
|
|
308
|
+
def enum_selected_choice(current, choices)
|
|
309
|
+
return 'none' if current == false && choices.include?('none')
|
|
310
|
+
return choices.first if current == true
|
|
311
|
+
return current if current.is_a?(String) && choices.include?(current)
|
|
312
|
+
|
|
313
|
+
choices.first
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# Presents a choice list with the Rails default labeled and the user's
|
|
317
|
+
# last pick reordered first (pre-selected).
|
|
318
|
+
#
|
|
319
|
+
# @param question [String]
|
|
320
|
+
# @param key [Symbol]
|
|
321
|
+
# @param choices [Array<String>]
|
|
322
|
+
# @param rails_default [String] the true Rails default (labeled "(default)")
|
|
323
|
+
# @param selected [String] the user's last pick (reordered first)
|
|
324
|
+
# @return [String] the raw choice value, or {BACK}
|
|
325
|
+
def choose_with_default_marker(question, key:, choices:, rails_default:, selected:)
|
|
326
|
+
actual_selected = choices.include?(selected) ? selected : choices.first
|
|
327
|
+
ordered = choices.include?(rails_default) ? [rails_default] + (choices - [rails_default]) : choices
|
|
328
|
+
rendered_pairs = ordered.map do |choice|
|
|
329
|
+
[render_choice_label(key, choice, rails_default: rails_default), choice]
|
|
330
|
+
end
|
|
331
|
+
rendered = rendered_pairs.map(&:first)
|
|
332
|
+
selected_label = rendered_pairs.find { |_, raw| raw == actual_selected }&.first || rendered.first
|
|
333
|
+
answer = @prompter.choose(question, options: rendered, default: selected_label)
|
|
334
|
+
return BACK if answer == BACK
|
|
335
|
+
|
|
336
|
+
rendered_index = rendered.index(answer)
|
|
337
|
+
return ordered[rendered_index] if rendered_index
|
|
338
|
+
|
|
339
|
+
actual_selected
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# @param index [Integer]
|
|
343
|
+
# @param total [Integer]
|
|
344
|
+
# @param key [Symbol]
|
|
345
|
+
# @param label [String]
|
|
346
|
+
# @return [String]
|
|
347
|
+
def render_question(index:, total:, key:, label:, skip: false)
|
|
348
|
+
step = format('%<current>02d/%<total>02d', current: index + 1, total: total)
|
|
349
|
+
if skip
|
|
350
|
+
"{{cyan:#{step}}} {{bold:Skip #{label}?}} - #{HELP_TEXT.fetch(key)}"
|
|
351
|
+
else
|
|
352
|
+
"{{cyan:#{step}}} {{bold:#{label}}} - #{HELP_TEXT.fetch(key)}"
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# @param key [Symbol]
|
|
357
|
+
# @param choice [String]
|
|
358
|
+
# @param default_choice [String]
|
|
359
|
+
# @return [String]
|
|
360
|
+
def render_choice_label(key, choice, rails_default:)
|
|
361
|
+
label = choice
|
|
362
|
+
hint = CHOICE_HELP.dig(key, choice)
|
|
363
|
+
label = "#{label} - #{hint}" if hint
|
|
364
|
+
label = "#{label} (default)" if choice == rails_default
|
|
365
|
+
label
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
end
|
data/lib/create_rails_app.rb
CHANGED
|
@@ -1,7 +1,28 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative 'create_rails_app/version'
|
|
4
|
+
require_relative 'create_rails_app/error'
|
|
5
|
+
require_relative 'create_rails_app/detection/runtime'
|
|
6
|
+
require_relative 'create_rails_app/detection/rails_versions'
|
|
7
|
+
require_relative 'create_rails_app/options/catalog'
|
|
8
|
+
require_relative 'create_rails_app/compatibility/matrix'
|
|
9
|
+
require_relative 'create_rails_app/options/validator'
|
|
10
|
+
require_relative 'create_rails_app/command_builder'
|
|
11
|
+
require_relative 'create_rails_app/config/store'
|
|
12
|
+
require_relative 'create_rails_app/runner'
|
|
13
|
+
require_relative 'create_rails_app/ui/palette'
|
|
14
|
+
require_relative 'create_rails_app/ui/prompter'
|
|
15
|
+
require_relative 'create_rails_app/wizard'
|
|
16
|
+
require_relative 'create_rails_app/cli'
|
|
4
17
|
|
|
18
|
+
# Interactive TUI wizard for +rails new+.
|
|
19
|
+
#
|
|
20
|
+
# Detects installed Rails versions, shows version-aware options
|
|
21
|
+
# via a static compatibility matrix, and builds the correct +rails new+
|
|
22
|
+
# command. Config (presets, last-used options) is stored in
|
|
23
|
+
# +~/.config/create-rails-app/config.yml+.
|
|
24
|
+
#
|
|
25
|
+
# @see CreateRailsApp::CLI Entry point
|
|
26
|
+
# @see CreateRailsApp::Compatibility::Matrix Rails version compatibility
|
|
5
27
|
module CreateRailsApp
|
|
6
|
-
class Error < StandardError; end
|
|
7
28
|
end
|
metadata
CHANGED
|
@@ -1,16 +1,45 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: create-rails-app
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Leonid Svyatov
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
-
dependencies:
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: cli-kit
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '5.0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '5.0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: cli-ui
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '2.7'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '2.7'
|
|
40
|
+
description: Stop memorizing rails new flags. This interactive CLI wizard walks you
|
|
41
|
+
through every option, remembers your last choices, and saves reusable presets. Supports
|
|
42
|
+
Rails 7.2+ with version-aware option filtering, back navigation, and dry-run mode.
|
|
14
43
|
email:
|
|
15
44
|
- leonid@svyatov.com
|
|
16
45
|
executables:
|
|
@@ -23,7 +52,20 @@ files:
|
|
|
23
52
|
- README.md
|
|
24
53
|
- exe/create-rails-app
|
|
25
54
|
- lib/create_rails_app.rb
|
|
55
|
+
- lib/create_rails_app/cli.rb
|
|
56
|
+
- lib/create_rails_app/command_builder.rb
|
|
57
|
+
- lib/create_rails_app/compatibility/matrix.rb
|
|
58
|
+
- lib/create_rails_app/config/store.rb
|
|
59
|
+
- lib/create_rails_app/detection/rails_versions.rb
|
|
60
|
+
- lib/create_rails_app/detection/runtime.rb
|
|
61
|
+
- lib/create_rails_app/error.rb
|
|
62
|
+
- lib/create_rails_app/options/catalog.rb
|
|
63
|
+
- lib/create_rails_app/options/validator.rb
|
|
64
|
+
- lib/create_rails_app/runner.rb
|
|
65
|
+
- lib/create_rails_app/ui/palette.rb
|
|
66
|
+
- lib/create_rails_app/ui/prompter.rb
|
|
26
67
|
- lib/create_rails_app/version.rb
|
|
68
|
+
- lib/create_rails_app/wizard.rb
|
|
27
69
|
homepage: https://github.com/svyatov/create-rails-app
|
|
28
70
|
licenses:
|
|
29
71
|
- MIT
|
|
@@ -51,5 +93,6 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
51
93
|
requirements: []
|
|
52
94
|
rubygems_version: 4.0.6
|
|
53
95
|
specification_version: 4
|
|
54
|
-
summary: Interactive CLI wizard for rails new
|
|
96
|
+
summary: Interactive CLI wizard for rails new that remembers your choices and saves
|
|
97
|
+
presets.
|
|
55
98
|
test_files: []
|