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,586 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'optparse'
|
|
4
|
+
|
|
5
|
+
module CreateRailsApp
|
|
6
|
+
# Main entry point that orchestrates the entire create-rails-app flow.
|
|
7
|
+
#
|
|
8
|
+
# Parses CLI flags, detects installed Rails versions, runs the interactive
|
|
9
|
+
# wizard (or loads a preset), builds the +rails new+ command, and executes it.
|
|
10
|
+
# All collaborators are injected via constructor for testability.
|
|
11
|
+
#
|
|
12
|
+
# @example Run from the command line
|
|
13
|
+
# CreateRailsApp::CLI.start(ARGV)
|
|
14
|
+
class CLI
|
|
15
|
+
# Holds the resolved Rails version info and whether installation is needed.
|
|
16
|
+
VersionChoice = Struct.new(:version, :series, :needs_install)
|
|
17
|
+
|
|
18
|
+
# Constructs and runs a CLI instance with the given arguments.
|
|
19
|
+
#
|
|
20
|
+
# @param argv [Array<String>] command-line arguments
|
|
21
|
+
# @param out [IO] standard output stream
|
|
22
|
+
# @param err [IO] standard error stream
|
|
23
|
+
# @param store [Config::Store] configuration persistence
|
|
24
|
+
# @param detector [Detection::Runtime] runtime version detector
|
|
25
|
+
# @param rails_detector [Detection::RailsVersions] Rails version detector
|
|
26
|
+
# @param runner [Runner, nil] command runner (built from +out+ if nil)
|
|
27
|
+
# @param prompter [UI::Prompter, nil] user interaction handler
|
|
28
|
+
# @param palette [UI::Palette, nil] color palette for terminal output
|
|
29
|
+
# @return [Integer] exit code (0 on success, non-zero on failure)
|
|
30
|
+
def self.start(
|
|
31
|
+
argv = ARGV,
|
|
32
|
+
out: $stdout,
|
|
33
|
+
err: $stderr,
|
|
34
|
+
store: Config::Store.new,
|
|
35
|
+
detector: Detection::Runtime.new,
|
|
36
|
+
rails_detector: Detection::RailsVersions.new,
|
|
37
|
+
runner: nil,
|
|
38
|
+
prompter: nil,
|
|
39
|
+
palette: nil
|
|
40
|
+
)
|
|
41
|
+
instance = new(
|
|
42
|
+
argv: argv,
|
|
43
|
+
out: out,
|
|
44
|
+
err: err,
|
|
45
|
+
store: store,
|
|
46
|
+
detector: detector,
|
|
47
|
+
rails_detector: rails_detector,
|
|
48
|
+
runner: runner || Runner.new(out: out),
|
|
49
|
+
prompter: prompter,
|
|
50
|
+
palette: palette
|
|
51
|
+
)
|
|
52
|
+
instance.run
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @param argv [Array<String>] command-line arguments
|
|
56
|
+
# @param out [IO] standard output stream
|
|
57
|
+
# @param err [IO] standard error stream
|
|
58
|
+
# @param store [Config::Store] configuration persistence
|
|
59
|
+
# @param detector [Detection::Runtime] runtime version detector
|
|
60
|
+
# @param rails_detector [Detection::RailsVersions] Rails version detector
|
|
61
|
+
# @param runner [Runner] command runner
|
|
62
|
+
# @param prompter [UI::Prompter, nil] user interaction handler
|
|
63
|
+
# @param palette [UI::Palette, nil] color palette for terminal output
|
|
64
|
+
def initialize(argv:, out:, err:, store:, detector:, rails_detector:, runner:, prompter:, palette:)
|
|
65
|
+
@argv = argv.dup
|
|
66
|
+
@out = out
|
|
67
|
+
@err = err
|
|
68
|
+
@store = store
|
|
69
|
+
@detector = detector
|
|
70
|
+
@rails_detector = rails_detector
|
|
71
|
+
@runner = runner
|
|
72
|
+
@prompter = prompter
|
|
73
|
+
@palette = palette || UI::Palette.new
|
|
74
|
+
@options = {}
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Runs the CLI: parses flags, dispatches to the appropriate action,
|
|
78
|
+
# and returns an exit code.
|
|
79
|
+
#
|
|
80
|
+
# @return [Integer] exit code (0 success, 1 error, 130 interrupt)
|
|
81
|
+
def run
|
|
82
|
+
parse_options!
|
|
83
|
+
|
|
84
|
+
validate_top_level_flags!
|
|
85
|
+
|
|
86
|
+
return print_version if @options[:version]
|
|
87
|
+
return print_help if @options[:help]
|
|
88
|
+
return doctor if @options[:doctor]
|
|
89
|
+
return list_presets if @options[:list_presets]
|
|
90
|
+
return show_preset if @options[:show_preset]
|
|
91
|
+
return delete_preset if @options[:delete_preset]
|
|
92
|
+
|
|
93
|
+
validate_preset_name!(@options[:save_preset]) if @options[:save_preset]
|
|
94
|
+
|
|
95
|
+
runtime_versions = @detector.detect
|
|
96
|
+
installed_rails = @rails_detector.detect
|
|
97
|
+
version_choice = resolve_rails_version!(installed_rails)
|
|
98
|
+
compatibility_entry = Compatibility::Matrix.for(version_choice.version || "#{version_choice.series}.0")
|
|
99
|
+
builder = CommandBuilder.new
|
|
100
|
+
|
|
101
|
+
app_name = resolve_app_name!
|
|
102
|
+
|
|
103
|
+
if @options[:minimal]
|
|
104
|
+
Options::Validator.new(compatibility_entry).validate!(app_name: app_name, options: {})
|
|
105
|
+
|
|
106
|
+
version_choice = install_and_update_version!(version_choice) if version_choice.needs_install
|
|
107
|
+
|
|
108
|
+
command = builder.build(app_name: app_name, rails_version: version_choice.version, options: {}, minimal: true)
|
|
109
|
+
@runner.run!(command, dry_run: @options[:dry_run])
|
|
110
|
+
return 0
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
selected_options =
|
|
114
|
+
if @options[:preset]
|
|
115
|
+
load_preset_options!
|
|
116
|
+
else
|
|
117
|
+
run_interactive_wizard!(
|
|
118
|
+
app_name: app_name,
|
|
119
|
+
builder: builder,
|
|
120
|
+
compatibility_entry: compatibility_entry,
|
|
121
|
+
last_used: @store.last_used,
|
|
122
|
+
runtime_versions: runtime_versions,
|
|
123
|
+
version_choice: version_choice
|
|
124
|
+
)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
Options::Validator.new(compatibility_entry).validate!(app_name: app_name, options: selected_options)
|
|
128
|
+
|
|
129
|
+
version_choice = install_and_update_version!(version_choice) if version_choice.needs_install
|
|
130
|
+
|
|
131
|
+
command = builder.build(
|
|
132
|
+
app_name: app_name,
|
|
133
|
+
rails_version: version_choice.version,
|
|
134
|
+
options: selected_options,
|
|
135
|
+
minimal: false
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
@runner.run!(command, dry_run: @options[:dry_run])
|
|
139
|
+
unless @options[:dry_run]
|
|
140
|
+
begin
|
|
141
|
+
@store.save_last_used(selected_options)
|
|
142
|
+
save_preset_if_requested(selected_options)
|
|
143
|
+
rescue ValidationError, ConfigError => e
|
|
144
|
+
@err.puts("Warning: #{e.message}")
|
|
145
|
+
rescue Interrupt
|
|
146
|
+
@err.puts('Skipped preset save.')
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
0
|
|
150
|
+
rescue OptionParser::ParseError, ArgumentError, Error => e
|
|
151
|
+
@err.puts(e.message)
|
|
152
|
+
1
|
|
153
|
+
rescue Interrupt
|
|
154
|
+
@err.puts(palette.color(:exit_message, 'See ya!'))
|
|
155
|
+
130
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
private
|
|
159
|
+
|
|
160
|
+
attr_reader :palette
|
|
161
|
+
|
|
162
|
+
# @return [void]
|
|
163
|
+
def parse_options!
|
|
164
|
+
@option_parser = OptionParser.new do |parser|
|
|
165
|
+
parser.banner = 'Usage: create-rails-app [APP_NAME] [options]'
|
|
166
|
+
parser.on('-h', '--help', 'Show this help message') { @options[:help] = true }
|
|
167
|
+
parser.on('--preset NAME', 'Use a saved preset') { |value| @options[:preset] = value }
|
|
168
|
+
parser.on('--save-preset NAME', 'Save selected options as a preset') { |value| @options[:save_preset] = value }
|
|
169
|
+
parser.on('--list-presets', 'List saved presets') { @options[:list_presets] = true }
|
|
170
|
+
parser.on('--show-preset NAME', 'Show preset details') { |value| @options[:show_preset] = value }
|
|
171
|
+
parser.on('--delete-preset NAME', 'Delete a preset') { |value| @options[:delete_preset] = value }
|
|
172
|
+
parser.on('--doctor', 'Print runtime and version info') { @options[:doctor] = true }
|
|
173
|
+
parser.on('--version', 'Print version') { @options[:version] = true }
|
|
174
|
+
parser.on('--dry-run', 'Print the command without executing') { @options[:dry_run] = true }
|
|
175
|
+
parser.on('--rails-version VERSION', 'Use a specific Rails version') do |value|
|
|
176
|
+
@options[:rails_version] = value
|
|
177
|
+
end
|
|
178
|
+
parser.on('--minimal', 'Pass --minimal to rails new') { @options[:minimal] = true }
|
|
179
|
+
end
|
|
180
|
+
@option_parser.parse!(@argv)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# @raise [ValidationError] if conflicting flags are combined
|
|
184
|
+
# @return [void]
|
|
185
|
+
def validate_top_level_flags!
|
|
186
|
+
conflicting_create_action = @options[:preset] || @options[:save_preset] || !@argv.empty?
|
|
187
|
+
query_action = @options[:list_presets] || @options[:show_preset]
|
|
188
|
+
if @options[:list_presets] && @options[:show_preset]
|
|
189
|
+
raise ValidationError, '--list-presets and --show-preset cannot be combined'
|
|
190
|
+
end
|
|
191
|
+
if query_action && (@options[:delete_preset] || conflicting_create_action)
|
|
192
|
+
raise ValidationError, 'Preset query options cannot be combined with other actions'
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
if @options[:delete_preset] && conflicting_create_action
|
|
196
|
+
raise ValidationError, '--delete-preset cannot be combined with create actions'
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
if @options[:dry_run] && (@options[:delete_preset] || query_action)
|
|
200
|
+
raise ValidationError, '--dry-run cannot be combined with query or delete actions'
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
if @options[:doctor] &&
|
|
204
|
+
(query_action || @options[:delete_preset] || @options[:dry_run] || conflicting_create_action)
|
|
205
|
+
raise ValidationError, '--doctor cannot be combined with other actions'
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
raise ValidationError, '--minimal cannot be combined with --preset' if @options[:minimal] && @options[:preset]
|
|
209
|
+
if @options[:minimal] && @options[:save_preset]
|
|
210
|
+
raise ValidationError, '--minimal cannot be combined with --save-preset'
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
conflicting_action = @options[:doctor] || query_action
|
|
214
|
+
conflicting_action ||= @options[:delete_preset]
|
|
215
|
+
conflicting_action ||= @options[:dry_run]
|
|
216
|
+
conflicting_action ||= conflicting_create_action
|
|
217
|
+
return unless @options[:version] && conflicting_action
|
|
218
|
+
|
|
219
|
+
raise ValidationError, '--version cannot be combined with other actions'
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# @return [Integer] exit code
|
|
223
|
+
def print_version
|
|
224
|
+
@out.puts(VERSION)
|
|
225
|
+
0
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# @return [Integer] exit code
|
|
229
|
+
def print_help
|
|
230
|
+
@out.puts(@option_parser)
|
|
231
|
+
0
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Prints runtime version info and supported options.
|
|
235
|
+
#
|
|
236
|
+
# @return [Integer] exit code
|
|
237
|
+
def doctor
|
|
238
|
+
runtime_versions = @detector.detect
|
|
239
|
+
@out.puts("ruby: #{runtime_versions.ruby}")
|
|
240
|
+
@out.puts("rubygems: #{runtime_versions.rubygems}")
|
|
241
|
+
installed_rails = @rails_detector.detect
|
|
242
|
+
if installed_rails.empty?
|
|
243
|
+
@out.puts('rails: not installed')
|
|
244
|
+
else
|
|
245
|
+
installed_rails.each { |series, version| @out.puts("rails #{series}: #{version}") }
|
|
246
|
+
end
|
|
247
|
+
Compatibility::Matrix::TABLE.each do |entry|
|
|
248
|
+
range = entry.requirement.requirements.map(&:join).join(', ')
|
|
249
|
+
options = Options::Catalog::ORDER.select { |key| entry.supports_option?(key) }
|
|
250
|
+
@out.puts("options (#{range}): #{options.join(', ')}")
|
|
251
|
+
end
|
|
252
|
+
0
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# @return [Integer] exit code
|
|
256
|
+
def list_presets
|
|
257
|
+
@store.preset_names.each { |name| @out.puts(name) }
|
|
258
|
+
0
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# @return [Integer] exit code
|
|
262
|
+
# @raise [ValidationError] if the preset does not exist
|
|
263
|
+
def show_preset
|
|
264
|
+
preset = @store.preset(@options[:show_preset])
|
|
265
|
+
raise ValidationError, "Preset not found: #{@options[:show_preset]}" unless preset
|
|
266
|
+
|
|
267
|
+
@out.puts(@options[:show_preset])
|
|
268
|
+
preset.sort.each { |key, value| @out.puts(" #{key}: #{value.inspect}") }
|
|
269
|
+
0
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# @return [Integer] exit code
|
|
273
|
+
def delete_preset
|
|
274
|
+
@store.delete_preset(@options[:delete_preset])
|
|
275
|
+
0
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# Resolves which Rails version to use without installing.
|
|
279
|
+
#
|
|
280
|
+
# Returns a {VersionChoice} indicating the selected version/series
|
|
281
|
+
# and whether installation is needed (deferred until after the wizard).
|
|
282
|
+
#
|
|
283
|
+
# @param installed_rails [Hash{String => String}]
|
|
284
|
+
# @return [VersionChoice]
|
|
285
|
+
def resolve_rails_version!(installed_rails)
|
|
286
|
+
if @options[:rails_version]
|
|
287
|
+
version = @options[:rails_version]
|
|
288
|
+
v = Gem::Version.new(version)
|
|
289
|
+
if v.segments.length < 2
|
|
290
|
+
raise ValidationError, "Rails version must have at least major.minor (e.g. 8.1), got: #{version}"
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
series = "#{v.segments[0]}.#{v.segments[1]}"
|
|
294
|
+
Compatibility::Matrix.for(version)
|
|
295
|
+
installed_version = installed_rails[series]
|
|
296
|
+
if installed_version && Gem::Version.new(installed_version) >= v
|
|
297
|
+
return VersionChoice.new(version: installed_version, series: series, needs_install: false)
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
return VersionChoice.new(version: version, series: series, needs_install: true)
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
choices = build_version_choices(installed_rails)
|
|
304
|
+
raise UnsupportedRailsVersionError, 'No supported Rails series available' if choices.empty?
|
|
305
|
+
|
|
306
|
+
answer = prompter.choose(
|
|
307
|
+
'{{bold:Select Rails version}}',
|
|
308
|
+
options: choices.map { |c| c[:label] },
|
|
309
|
+
default: choices.first[:label]
|
|
310
|
+
)
|
|
311
|
+
selected = choices.find { |c| c[:label] == answer }
|
|
312
|
+
raise ValidationError, "Unknown version selection: #{answer}" unless selected
|
|
313
|
+
|
|
314
|
+
VersionChoice.new(
|
|
315
|
+
version: selected[:version],
|
|
316
|
+
series: selected[:series],
|
|
317
|
+
needs_install: !selected[:installed]
|
|
318
|
+
)
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# Builds choice labels for version selection prompt.
|
|
322
|
+
#
|
|
323
|
+
# @param installed_rails [Hash{String => String}]
|
|
324
|
+
# @return [Array<Hash>]
|
|
325
|
+
def build_version_choices(installed_rails)
|
|
326
|
+
Compatibility::Matrix::SUPPORTED_SERIES.reverse_each.map do |series|
|
|
327
|
+
version = installed_rails[series]
|
|
328
|
+
label = "Rails #{series}"
|
|
329
|
+
label += ' (not installed)' unless version
|
|
330
|
+
{ label: label, version: version, series: series, installed: !version.nil? }
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# Installs Rails if needed, with a spinner in normal mode or print in dry-run.
|
|
335
|
+
#
|
|
336
|
+
# @param version_choice [VersionChoice]
|
|
337
|
+
# @return [String, nil] exact installed version, or nil in dry-run without exact version
|
|
338
|
+
def install_rails!(version_choice)
|
|
339
|
+
version_constraint = version_choice.version || "~> #{version_choice.series}.0"
|
|
340
|
+
install_command = ['gem', 'install', 'rails', '-v', version_constraint]
|
|
341
|
+
|
|
342
|
+
if @options[:dry_run]
|
|
343
|
+
@runner.run!(install_command, dry_run: true)
|
|
344
|
+
return version_choice.version
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
::CLI::UI::Spinner.spin("Installing Rails #{version_choice.version || version_choice.series}") do
|
|
348
|
+
@runner.run!(install_command)
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
return version_choice.version if version_choice.version
|
|
352
|
+
|
|
353
|
+
refreshed = @rails_detector.detect
|
|
354
|
+
version = refreshed[version_choice.series]
|
|
355
|
+
unless version
|
|
356
|
+
raise UnsupportedRailsVersionError,
|
|
357
|
+
"Failed to detect Rails #{version_choice.series} after installation"
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
version
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
# Installs Rails and returns an updated VersionChoice.
|
|
364
|
+
#
|
|
365
|
+
# @param version_choice [VersionChoice]
|
|
366
|
+
# @return [VersionChoice]
|
|
367
|
+
def install_and_update_version!(version_choice)
|
|
368
|
+
installed_version = install_rails!(version_choice)
|
|
369
|
+
VersionChoice.new(
|
|
370
|
+
version: installed_version || version_choice.version,
|
|
371
|
+
series: version_choice.series,
|
|
372
|
+
needs_install: false
|
|
373
|
+
)
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
# @return [String] app name from argv or interactive prompt
|
|
377
|
+
# @raise [ValidationError] if app name is required but missing
|
|
378
|
+
def resolve_app_name!
|
|
379
|
+
app_name = @argv.shift
|
|
380
|
+
return app_name if app_name && !app_name.empty?
|
|
381
|
+
|
|
382
|
+
raise ValidationError, 'App name is required when --preset is provided' if @options[:preset]
|
|
383
|
+
|
|
384
|
+
loop do
|
|
385
|
+
answer = prompter.text('App name:', allow_empty: false)
|
|
386
|
+
if answer == Wizard::BACK
|
|
387
|
+
@err.puts('Nothing to go back to.')
|
|
388
|
+
next
|
|
389
|
+
end
|
|
390
|
+
return answer
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
# @return [Hash{Symbol => Object}] preset options
|
|
395
|
+
# @raise [ValidationError] if the preset does not exist
|
|
396
|
+
def load_preset_options!
|
|
397
|
+
preset = @store.preset(@options[:preset])
|
|
398
|
+
raise ValidationError, "Preset not found: #{@options[:preset]}" unless preset
|
|
399
|
+
|
|
400
|
+
preset.transform_keys(&:to_sym)
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
# Runs the interactive wizard loop with summary + edit-again flow.
|
|
404
|
+
#
|
|
405
|
+
# @param app_name [String]
|
|
406
|
+
# @param builder [CommandBuilder]
|
|
407
|
+
# @param compatibility_entry [Compatibility::Matrix::Entry]
|
|
408
|
+
# @param last_used [Hash{Symbol => Object}]
|
|
409
|
+
# @param runtime_versions [Detection::RuntimeInfo]
|
|
410
|
+
# @param version_choice [VersionChoice]
|
|
411
|
+
# @return [Hash{Symbol => Object}] selected options
|
|
412
|
+
def run_interactive_wizard!(
|
|
413
|
+
app_name:,
|
|
414
|
+
builder:,
|
|
415
|
+
compatibility_entry:,
|
|
416
|
+
last_used:,
|
|
417
|
+
runtime_versions:,
|
|
418
|
+
version_choice:
|
|
419
|
+
)
|
|
420
|
+
defaults = last_used
|
|
421
|
+
start_index = 0
|
|
422
|
+
prompter.frame('Controls') do
|
|
423
|
+
prompter.say("Press #{palette.color(:control_back, 'Ctrl+B')} to go back one step.")
|
|
424
|
+
prompter.say("Press #{palette.color(:control_exit, 'Ctrl+C')} to exit.")
|
|
425
|
+
end
|
|
426
|
+
loop do
|
|
427
|
+
wizard = Wizard.new(
|
|
428
|
+
compatibility_entry: compatibility_entry,
|
|
429
|
+
defaults: defaults,
|
|
430
|
+
prompter: prompter
|
|
431
|
+
)
|
|
432
|
+
selected_options = wizard.run(start_index: start_index)
|
|
433
|
+
|
|
434
|
+
command = builder.build(
|
|
435
|
+
app_name: app_name,
|
|
436
|
+
rails_version: version_choice.version,
|
|
437
|
+
options: selected_options,
|
|
438
|
+
minimal: false
|
|
439
|
+
)
|
|
440
|
+
show_summary(
|
|
441
|
+
app_name: app_name,
|
|
442
|
+
command: command,
|
|
443
|
+
runtime_versions: runtime_versions,
|
|
444
|
+
version_choice: version_choice
|
|
445
|
+
)
|
|
446
|
+
action = prompter.choose('Next step', options: ['create', 'edit again'], default: 'create')
|
|
447
|
+
if action == Wizard::BACK
|
|
448
|
+
defaults = selected_options
|
|
449
|
+
start_index = wizard.last_presented_index
|
|
450
|
+
next
|
|
451
|
+
end
|
|
452
|
+
return selected_options if action == 'create'
|
|
453
|
+
|
|
454
|
+
defaults = selected_options
|
|
455
|
+
start_index = 0
|
|
456
|
+
end
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
def show_summary(app_name:, command:, runtime_versions:, version_choice:)
|
|
460
|
+
new_idx = if command[1] == 'new' then 1
|
|
461
|
+
elsif command[2] == 'new' then 2
|
|
462
|
+
end
|
|
463
|
+
return unless new_idx
|
|
464
|
+
|
|
465
|
+
args = command[(new_idx + 2)..] || []
|
|
466
|
+
|
|
467
|
+
prompter.frame('create-rails-app summary') do
|
|
468
|
+
rails_display = version_choice.version || "~> #{version_choice.series}"
|
|
469
|
+
runtime_line = "#{palette.color(:summary_label, 'Runtime:')} "
|
|
470
|
+
runtime_line += [
|
|
471
|
+
['ruby', runtime_versions.ruby],
|
|
472
|
+
['rubygems', runtime_versions.rubygems],
|
|
473
|
+
['rails', rails_display]
|
|
474
|
+
].map { |name, ver| "#{palette.color(:runtime_name, name)} #{palette.color(:runtime_value, ver.to_s)}" }
|
|
475
|
+
.join(', ')
|
|
476
|
+
prompter.say(runtime_line)
|
|
477
|
+
|
|
478
|
+
if version_choice.needs_install
|
|
479
|
+
constraint = version_choice.version || "~> #{version_choice.series}.0"
|
|
480
|
+
install_line = "gem install rails -v '#{constraint}'"
|
|
481
|
+
prompter.say("#{palette.color(:summary_label, 'Install:')} #{palette.color(:install_cmd, install_line)}")
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
base = command[0..new_idx].join(' ')
|
|
485
|
+
prefix = "#{palette.color(:command_base, base)} #{palette.color(:command_app, app_name)}"
|
|
486
|
+
formatted = args.map do |arg|
|
|
487
|
+
next palette.color(:arg_value, arg) unless arg.start_with?('--')
|
|
488
|
+
next palette.color(:arg_name, arg) unless arg.include?('=')
|
|
489
|
+
|
|
490
|
+
name, value = arg.split('=', 2)
|
|
491
|
+
"#{palette.color(:arg_name, name)}#{palette.color(:arg_eq, '=')}#{palette.color(:arg_value, value)}"
|
|
492
|
+
end
|
|
493
|
+
wrap_command_lines("#{palette.color(:summary_label, 'Command:')} #{prefix}", formatted).each do |line|
|
|
494
|
+
prompter.say(line)
|
|
495
|
+
end
|
|
496
|
+
end
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
# @param str [String]
|
|
500
|
+
# @return [String] text with ANSI escape codes and cli-ui markup removed
|
|
501
|
+
def strip_formatting(str)
|
|
502
|
+
str.gsub(/\e\[[0-9;]*m/, '').gsub(/\{\{[^}]*:([^}]*)\}\}/, '\1')
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
# @return [Integer]
|
|
506
|
+
def terminal_width
|
|
507
|
+
::CLI::UI::Terminal.width
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
# Wraps a command across multiple lines if it exceeds terminal width.
|
|
511
|
+
#
|
|
512
|
+
# @param prefix [String] colored prefix (e.g. "Command: rails new APP")
|
|
513
|
+
# @param formatted_args [Array<String>] colored argument strings
|
|
514
|
+
# @return [Array<String>] lines to emit
|
|
515
|
+
def wrap_command_lines(prefix, formatted_args)
|
|
516
|
+
return [prefix] if formatted_args.empty?
|
|
517
|
+
|
|
518
|
+
full = "#{prefix} #{formatted_args.join(' ')}"
|
|
519
|
+
# Frame borders consume ~4 chars
|
|
520
|
+
max_width = terminal_width - 4
|
|
521
|
+
return [full] if strip_formatting(full).length <= max_width
|
|
522
|
+
|
|
523
|
+
lines = ["#{prefix} \\"]
|
|
524
|
+
current = ' '
|
|
525
|
+
formatted_args.each_with_index do |arg, i|
|
|
526
|
+
candidate = current == ' ' ? "#{current}#{arg}" : "#{current} #{arg}"
|
|
527
|
+
if strip_formatting(candidate).length > max_width && current != ' '
|
|
528
|
+
lines << "#{current} \\"
|
|
529
|
+
current = " #{arg}"
|
|
530
|
+
else
|
|
531
|
+
current = candidate
|
|
532
|
+
end
|
|
533
|
+
lines << current if i == formatted_args.length - 1
|
|
534
|
+
end
|
|
535
|
+
lines
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
# @param selected_options [Hash{Symbol => Object}]
|
|
539
|
+
# @return [void]
|
|
540
|
+
def save_preset_if_requested(selected_options)
|
|
541
|
+
if @options[:save_preset]
|
|
542
|
+
save_preset_with_overwrite_check(@options[:save_preset], selected_options)
|
|
543
|
+
return
|
|
544
|
+
end
|
|
545
|
+
|
|
546
|
+
return if @options[:preset]
|
|
547
|
+
return unless prompter.confirm('Save these options as a preset?', default: false)
|
|
548
|
+
|
|
549
|
+
preset_name = prompter.text('Preset name:', allow_empty: false)
|
|
550
|
+
save_preset_with_overwrite_check(preset_name, selected_options)
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
# @param name [String]
|
|
554
|
+
# @param options [Hash{Symbol => Object}]
|
|
555
|
+
# @return [void]
|
|
556
|
+
def save_preset_with_overwrite_check(name, options)
|
|
557
|
+
validate_preset_name!(name)
|
|
558
|
+
return if @store.preset(name) && !prompter.confirm("Preset '#{name}' already exists. Overwrite?", default: false)
|
|
559
|
+
|
|
560
|
+
@store.save_preset(name, options)
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
# @return [Regexp] pattern for valid preset names
|
|
564
|
+
PRESET_NAME_PATTERN = /\A[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}\z/
|
|
565
|
+
private_constant :PRESET_NAME_PATTERN
|
|
566
|
+
|
|
567
|
+
# @param name [String]
|
|
568
|
+
# @raise [ValidationError] if the name is invalid
|
|
569
|
+
# @return [void]
|
|
570
|
+
def validate_preset_name!(name)
|
|
571
|
+
return if name.is_a?(String) && name.match?(PRESET_NAME_PATTERN)
|
|
572
|
+
|
|
573
|
+
raise ValidationError,
|
|
574
|
+
"Invalid preset name: #{name.inspect}. " \
|
|
575
|
+
'Must start with alphanumeric, then alphanumeric/dashes/underscores (max 64 chars).'
|
|
576
|
+
end
|
|
577
|
+
|
|
578
|
+
# @return [UI::Prompter]
|
|
579
|
+
def prompter
|
|
580
|
+
@prompter ||= begin
|
|
581
|
+
UI::Prompter.setup!
|
|
582
|
+
UI::Prompter.new(out: @out)
|
|
583
|
+
end
|
|
584
|
+
end
|
|
585
|
+
end
|
|
586
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CreateRailsApp
|
|
4
|
+
# Converts an app name, Rails version, and option hash into a
|
|
5
|
+
# +rails new+ command array.
|
|
6
|
+
#
|
|
7
|
+
# @example
|
|
8
|
+
# CommandBuilder.new.build(
|
|
9
|
+
# app_name: 'myapp', rails_version: '8.1.2',
|
|
10
|
+
# options: { api: true, database: 'postgresql' }
|
|
11
|
+
# )
|
|
12
|
+
# #=> ['rails', '_8.1.2_', 'new', 'myapp', '--api', '--database=postgresql']
|
|
13
|
+
class CommandBuilder
|
|
14
|
+
# Builds the +rails new+ command array.
|
|
15
|
+
#
|
|
16
|
+
# @param app_name [String]
|
|
17
|
+
# @param rails_version [String, nil] exact version for version pinning (nil omits pin)
|
|
18
|
+
# @param options [Hash{Symbol => Object}]
|
|
19
|
+
# @param minimal [Boolean] pass +--minimal+ flag
|
|
20
|
+
# @return [Array<String>]
|
|
21
|
+
def build(app_name:, rails_version: nil, options: {}, minimal: false)
|
|
22
|
+
command = ['rails']
|
|
23
|
+
command << "_#{rails_version}_" if rails_version
|
|
24
|
+
command.push('new', app_name)
|
|
25
|
+
command << '--minimal' if minimal
|
|
26
|
+
|
|
27
|
+
Options::Catalog::ORDER.each do |key|
|
|
28
|
+
next unless options.key?(key)
|
|
29
|
+
|
|
30
|
+
append_option!(command, key, options[key])
|
|
31
|
+
end
|
|
32
|
+
command
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
# @param command [Array<String>]
|
|
38
|
+
# @param key [Symbol]
|
|
39
|
+
# @param value [Object]
|
|
40
|
+
# @return [void]
|
|
41
|
+
def append_option!(command, key, value)
|
|
42
|
+
definition = Options::Catalog.fetch(key)
|
|
43
|
+
case definition[:type]
|
|
44
|
+
when :skip
|
|
45
|
+
command << definition[:skip_flag] if value == false
|
|
46
|
+
when :flag
|
|
47
|
+
command << definition[:on] if value == true
|
|
48
|
+
when :enum
|
|
49
|
+
command << "#{definition[:flag]}=#{value}" if value.is_a?(String) && definition[:flag]
|
|
50
|
+
command << definition[:none] if value == false && definition[:none].is_a?(String)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|