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