railstart 0.4.5 → 0.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 335bb58fb0448ef183a5dab96d9d866e212d75a09310db0ed4c017d9e27cf327
4
- data.tar.gz: 42fea9cfc2d16dc6b167eb8580f108d43a940f6809de97c308cc64d467bb5056
3
+ metadata.gz: 074af527c055fff27ba43418de8c9c1085cdce74ac7e6bccac0509c32fa7536d
4
+ data.tar.gz: 8aa05e748770810612c88e8f0ac3b48fe9efdcdedd95278aa499e7387acf0410
5
5
  SHA512:
6
- metadata.gz: a7b8a26a4060fc6e1c449ab759d5dbbf8911ceba0166a8ce5b094fe81f069ee70b38c9ee88671f38e91eb4f1a6cb4f334815c8457efccc570a9fc57d94f7bf12
7
- data.tar.gz: 29d65e728974f716e6d514135f574b48a7b335842c9330e6457553462bf943dcc5b9beead5e5c5bcfb4212a8c6b30301f689df342e861e2e7f83bfce345f6729
6
+ metadata.gz: '01941cbb9ee8f2e281b70c5abfb2b22088b1b305909b225d3b7ed2a7c8e277deaf5c5e019f8397d3846148f73f3213625668492878db326ff800945357c1f7c8'
7
+ data.tar.gz: 7d194a1c4372ba55671974b54ed710d19333f6af0626797a743a05d30da982e3bdc08a792c5baca8956680f6bb2e0bd10c0acb76220ae48c89fbd298007b2eef
data/CHANGELOG.md CHANGED
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.0] - 2026-07-11
11
+
12
+ ### Added
13
+ - **Ruby 4.0.5 coverage**: Pinned development and release workflows to Ruby 4.0.5 and added exact Ruby 4.0.5 coverage to the existing CI matrix
14
+ - **Rails contract coverage**: Added opt-in `rails new --pretend` checks for the built-in configuration and every shipped preset
15
+ - **Config validation**: Added fail-fast validation for duplicate IDs, invalid defaults, malformed flags, dependency conditions, post-action conditions, and template variable names
16
+
17
+ ### Changed
18
+ - **Development dependencies**: Updated Minitest to 6.0.6, Rake to 13.4.2, RuboCop to 1.88.2, and YARD to 0.9.44
19
+ - **Safe command execution**: Rails generation now executes an argument vector instead of a shell-interpreted command string
20
+ - **Init command**: `railstart init` now generates minimal user overrides so future built-in choices and flags are not shadowed by a copied full configuration
21
+ - **CI quality gate**: CI now runs tests, RuboCop, YARD, gem packaging, executable smoke checks, and Rails CLI contract checks
22
+
23
+ ### Fixed
24
+ - **API-only preset**: The CSS `none` choice now emits `--skip-css` instead of the invalid `--css=none`
25
+ - **Documentation build**: Declared the Markdown documentation dependencies and made YARD fail on warnings
26
+
10
27
  ## [0.4.5] - 2026-04-30
11
28
 
12
29
  ### Added
data/README.md CHANGED
@@ -8,6 +8,8 @@ Interactive CLI wizard for generating Rails 8 applications with customizable con
8
8
 
9
9
  Think of it as `rails new` with an opinion and a friendly interactive experience.
10
10
 
11
+ Requires Ruby 3.2 or newer. Development and release tooling use Ruby 4.0.5.
12
+
11
13
  ## Installation
12
14
 
13
15
  ```bash
@@ -231,11 +233,11 @@ railstart init
231
233
  ```
232
234
 
233
235
  This creates:
234
- - `~/.config/railstart/config.yaml` - Complete configuration template (copy of rails8_defaults.yaml with all available options)
236
+ - `~/.config/railstart/config.yaml` - Minimal example containing only a few global overrides
235
237
  - `~/.config/railstart/presets/` - Directory for your presets
236
238
  - `~/.config/railstart/presets/example.yaml` - Example preset to get started
237
239
 
238
- The generated `config.yaml` shows all available questions, choices, flags, and post-actions. You can delete or comment out sections you don't want to customize, and modify the defaults for sections you do want to change.
240
+ Keep `config.yaml` minimal so future built-in choices and flags continue to reach your installation. The full shipped schema remains available in `config/rails8_defaults.yaml`, while `examples/config.yml` provides a larger copyable example.
239
241
 
240
242
  ### Built-in Defaults
241
243
 
@@ -243,20 +245,15 @@ Railstart ships with sensible Rails 8 defaults defined in `config/rails8_default
243
245
 
244
246
  ### Customize for Your Team
245
247
 
246
- You can create `~/.config/railstart/config.yaml` manually or use `railstart init` to generate a complete template file. The template includes all available options, so you can simply modify the defaults you want to change:
248
+ You can create `~/.config/railstart/config.yaml` manually or use `railstart init` to generate a minimal starting point. Add only the defaults you want to change:
247
249
 
248
250
  ```yaml
249
- # After running `railstart init`, your config.yaml will contain all options.
250
- # Simply modify the defaults you want to change:
251
+ # After running `railstart init`, config.yaml contains a few example overrides.
252
+ # Keep only the overrides you want to apply to every run:
251
253
 
252
254
  questions:
253
255
  - id: database
254
- choices:
255
- - name: PostgreSQL (recommended)
256
- value: postgresql
257
- default: true # Changed from SQLite to PostgreSQL
258
-
259
- # ... other questions with their full configuration ...
256
+ default: postgresql
260
257
 
261
258
  post_actions:
262
259
  - id: bundle_install
@@ -356,6 +353,9 @@ Key differences from `command` actions:
356
353
  ### Setup
357
354
 
358
355
  ```bash
356
+ # Install the required Ruby version
357
+ mise install ruby@4.0.5
358
+
359
359
  # Install dependencies
360
360
  bundle install
361
361
 
@@ -394,7 +394,7 @@ bundle exec rubocop
394
394
  bundle exec rubocop -a
395
395
 
396
396
  # Full check
397
- bundle exec rake test && bundle exec rubocop
397
+ bundle exec rake test && bundle exec rubocop && bundle exec rake yard && gem build railstart.gemspec
398
398
  ```
399
399
 
400
400
  ## Architecture
data/Rakefile CHANGED
@@ -13,7 +13,7 @@ begin
13
13
  require "yard"
14
14
  YARD::Rake::YardocTask.new(:yard) do |t|
15
15
  t.files = ["lib/**/*.rb"]
16
- t.options = ["--output-dir", "doc", "--markup", "markdown"]
16
+ t.options = ["--output-dir", "doc", "--markup", "markdown", "--fail-on-warning"]
17
17
  end
18
18
  rescue LoadError
19
19
  # YARD not available
@@ -13,6 +13,7 @@ questions:
13
13
  - name: None
14
14
  value: none
15
15
  default: true
16
+ rails_flag: "--skip-css"
16
17
 
17
18
  - id: javascript
18
19
  choices:
@@ -34,6 +34,7 @@ questions:
34
34
  value: sass
35
35
  - name: None (skip CSS setup)
36
36
  value: none
37
+ rails_flag: "--skip-css"
37
38
  rails_flag: "--css=%{value}"
38
39
 
39
40
  - id: javascript
data/lib/railstart/cli.rb CHANGED
@@ -257,9 +257,26 @@ module Railstart
257
257
  end
258
258
 
259
259
  def example_user_config
260
- # Copy the full rails8_defaults.yaml as the user config template
261
- defaults_path = File.expand_path("../../config/rails8_defaults.yaml", __dir__)
262
- File.read(defaults_path)
260
+ <<~YAML
261
+ ---
262
+ # User overrides for every Railstart run.
263
+ #
264
+ # Keep this file minimal. Entries merge by id with Railstart's built-in
265
+ # configuration. Overriding a choices array replaces the entire array.
266
+ # See config/rails8_defaults.yaml in the Railstart repository for the
267
+ # full schema and every available question, choice, and post-action.
268
+
269
+ questions:
270
+ - id: database
271
+ default: postgresql
272
+
273
+ - id: skip_docker
274
+ default: true
275
+
276
+ post_actions:
277
+ - id: bundle_install
278
+ enabled: false
279
+ YAML
263
280
  end
264
281
 
265
282
  def example_preset_config
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "shellwords"
4
+
3
5
  module Railstart
4
6
  # Translates configuration and user answers into a `rails new` command string.
5
7
  #
@@ -23,12 +25,28 @@ module Railstart
23
25
  # @example
24
26
  # Railstart::CommandBuilder.build("todo", config, answers)
25
27
  def build(app_name, config, answers)
26
- flags = collect_flags(config["questions"], answers)
27
- "rails new #{app_name} #{flags.join(" ")}".strip
28
+ arguments(app_name, config, answers).map { |argument| display_argument(argument) }.join(" ")
29
+ end
30
+
31
+ # Build process arguments for `rails new` without shell interpretation.
32
+ #
33
+ # @param app_name [String] target Rails app name
34
+ # @param config [Hash] merged configuration from {Railstart::Config.load}
35
+ # @param answers [Hash] user answers keyed by question id
36
+ # @return [Array<String>] executable and arguments suitable for `system(*arguments)`
37
+ def arguments(app_name, config, answers)
38
+ ["rails", "new", app_name.to_s, *collect_flags(config["questions"], answers)]
28
39
  end
29
40
 
30
41
  private
31
42
 
43
+ def display_argument(argument)
44
+ value = argument.to_s
45
+ return value if value.match?(%r{\A[\w@%+=:,./-]+\z})
46
+
47
+ Shellwords.escape(value)
48
+ end
49
+
32
50
  def collect_flags(questions, answers)
33
51
  flags = []
34
52
  Array(questions).each do |question|
@@ -34,6 +34,8 @@ module Railstart
34
34
  user = read_yaml(user_path, required: false)
35
35
  preset = preset_path ? read_yaml(preset_path, required: false) : {}
36
36
 
37
+ validate_layer_ids!(builtin, user, preset)
38
+
37
39
  merged = merge_config(builtin, user)
38
40
  merged = merge_config(merged, preset) unless preset.empty?
39
41
  validate!(merged)
@@ -54,7 +56,7 @@ module Railstart
54
56
  return template if template.nil? || (!template.include?("%{") && !template.include?("%<"))
55
57
 
56
58
  format(template, value: value)
57
- rescue KeyError => e
59
+ rescue KeyError, ArgumentError => e
58
60
  raise ConfigError, "Invalid interpolation token in rails_flag \"#{template}\": #{e.message}"
59
61
  end
60
62
 
@@ -161,64 +163,104 @@ module Railstart
161
163
  end
162
164
 
163
165
  def validate!(config)
164
- issues = []
165
- question_ids = Array(config["questions"]).map { |e| fetch_id(e) }.compact
166
+ questions = Array(config["questions"])
167
+ post_actions = Array(config["post_actions"])
168
+ issues = validate_questions(questions)
169
+ issues.concat(validate_post_actions(post_actions, questions))
170
+ raise ConfigValidationError.new("Invalid configuration", issues: issues) unless issues.empty?
171
+ end
166
172
 
167
- MERGEABLE_COLLECTIONS.each do |collection|
168
- entries = Array(config[collection])
169
- issues.concat(validate_collection(collection, entries, question_ids))
173
+ def validate_layer_ids!(*layers)
174
+ issues = layers.flat_map do |layer|
175
+ MERGEABLE_COLLECTIONS.flat_map do |collection|
176
+ duplicate_id_issues(collection, Array(layer[collection]))
177
+ end
170
178
  end
171
179
  raise ConfigValidationError.new("Invalid configuration", issues: issues) unless issues.empty?
172
180
  end
173
181
 
174
- def validate_collection(name, entries, question_ids = [])
182
+ def duplicate_id_issues(collection, entries)
183
+ counts = entries.filter_map { |entry| fetch_id(entry) }.tally
184
+ counts.filter_map do |id, count|
185
+ "#{collection} entry id #{id} is defined #{count} times" if count > 1
186
+ end
187
+ end
188
+
189
+ def validate_questions(entries)
175
190
  issues = []
176
191
  id_counts = Hash.new(0)
192
+ question_positions = entries.each_with_index.filter_map do |entry, index|
193
+ id = fetch_id(entry)
194
+ [id, index] if id
195
+ end.to_h
177
196
 
178
197
  entries.each_with_index do |entry, index|
179
198
  unless entry.is_a?(Hash)
180
- issues << "#{name} entry at index #{index} must be a Hash"
199
+ issues << "questions entry at index #{index} must be a Hash"
181
200
  next
182
201
  end
183
202
 
184
203
  id = fetch_id(entry)
185
204
  if id.nil? || id.to_s.strip.empty?
186
- issues << "#{name} entry at index #{index} is missing an id"
205
+ issues << "questions entry at index #{index} is missing an id"
187
206
  else
188
207
  id_counts[id] += 1
189
208
  end
190
209
 
191
- if name == "questions"
192
- type = entry["type"] || entry[:type]
193
- unless QUESTION_TYPES.include?(type)
194
- issues << "Question #{id || index} has invalid type #{type.inspect}"
195
- next
196
- end
210
+ identifier = id || index
211
+ type = value_for(entry, "type")
212
+ unless QUESTION_TYPES.include?(type)
213
+ issues << "Question #{identifier} has invalid type #{type.inspect}"
214
+ next
215
+ end
197
216
 
198
- issues.concat(validate_question_choices(entry, id || index)) if CHOICE_REQUIRED_TYPES.include?(type)
199
- elsif name == "post_actions"
200
- issues.concat(validate_post_action_entry(entry, id || index)) if entry.fetch("enabled", true)
217
+ prompt = value_for(entry, "prompt")
218
+ issues << "Question #{identifier} is missing a prompt" if prompt.nil? || prompt.to_s.strip.empty?
219
+ issues.concat(validate_question_choices(entry, identifier)) if CHOICE_REQUIRED_TYPES.include?(type)
220
+ issues.concat(validate_question_default(entry, identifier, type))
221
+ issues.concat(validate_flag_source(entry, "Question #{identifier}"))
222
+ issues.concat(validate_depends_on(entry, identifier, index, question_positions))
223
+ end
201
224
 
202
- if_condition = entry["if"] || entry[:if]
203
- if if_condition.is_a?(Hash)
204
- ref_question_id = if_condition["question"] || if_condition[:question]
205
- if ref_question_id && !question_ids.include?(ref_question_id)
206
- issues << "Post-action #{id || index} references unknown question '#{ref_question_id}'"
207
- end
208
- end
225
+ id_counts.each do |id, count|
226
+ issues << "questions entry id #{id} is defined #{count} times" if count > 1
227
+ end
228
+
229
+ issues
230
+ end
231
+
232
+ def validate_post_actions(entries, questions)
233
+ issues = []
234
+ id_counts = Hash.new(0)
235
+ question_ids = questions.filter_map { |entry| fetch_id(entry) }
236
+
237
+ entries.each_with_index do |entry, index|
238
+ unless entry.is_a?(Hash)
239
+ issues << "post_actions entry at index #{index} must be a Hash"
240
+ next
209
241
  end
242
+
243
+ id = fetch_id(entry)
244
+ if id.nil? || id.to_s.strip.empty?
245
+ issues << "post_actions entry at index #{index} is missing an id"
246
+ else
247
+ id_counts[id] += 1
248
+ end
249
+
250
+ identifier = id || index
251
+ issues.concat(validate_post_action_entry(entry, identifier))
252
+ issues.concat(validate_post_action_condition(entry, identifier, question_ids))
210
253
  end
211
254
 
212
255
  id_counts.each do |id, count|
213
- issues << "#{name} entry id #{id} is defined #{count} times" if count > 1
256
+ issues << "post_actions entry id #{id} is defined #{count} times" if count > 1
214
257
  end
215
-
216
258
  issues
217
259
  end
218
260
 
219
261
  def validate_question_choices(entry, question_id)
220
262
  issues = []
221
- choices = entry["choices"] || entry[:choices]
263
+ choices = value_for(entry, "choices")
222
264
 
223
265
  if !choices.is_a?(Array) || choices.empty?
224
266
  issues << "Question #{question_id} (#{entry["type"]}) must define at least one choice"
@@ -230,43 +272,183 @@ module Railstart
230
272
  issues << "Question #{question_id} choice at index #{cidx} must be a Hash"
231
273
  next
232
274
  end
233
- unless choice["name"] || choice[:name]
275
+ unless key_present?(choice, "name") && !value_for(choice, "name").to_s.strip.empty?
234
276
  issues << "Question #{question_id} choice at index #{cidx} missing 'name'"
235
277
  end
236
- unless choice["value"] || choice[:value]
278
+ unless key_present?(choice, "value") && !value_for(choice, "value").nil?
237
279
  issues << "Question #{question_id} choice at index #{cidx} missing 'value'"
238
280
  end
281
+ if key_present?(choice, "default") && ![true, false].include?(value_for(choice, "default"))
282
+ issues << "Question #{question_id} choice at index #{cidx} default must be true or false"
283
+ end
284
+ issues.concat(validate_flag_source(choice, "Question #{question_id} choice at index #{cidx}"))
239
285
  end
240
286
 
287
+ issues.concat(duplicate_choice_issues(choices, question_id, "name"))
288
+ issues.concat(duplicate_choice_issues(choices, question_id, "value"))
289
+ default_count = choices.count do |choice|
290
+ choice.is_a?(Hash) && value_for(choice, "default") == true
291
+ end
292
+ if value_for(entry, "type") == "select" && default_count > 1
293
+ issues << "Question #{question_id} must define at most one default choice"
294
+ end
295
+ issues
296
+ end
297
+
298
+ def duplicate_choice_issues(choices, question_id, field)
299
+ values = choices.filter_map do |choice|
300
+ value_for(choice, field) if choice.is_a?(Hash) && key_present?(choice, field)
301
+ end
302
+ values.tally.filter_map do |value, count|
303
+ "Question #{question_id} has duplicate choice #{field} #{value.inspect}" if count > 1
304
+ end
305
+ end
306
+
307
+ def validate_question_default(entry, identifier, type)
308
+ return [] unless key_present?(entry, "default")
309
+
310
+ default = value_for(entry, "default")
311
+ choices = Array(value_for(entry, "choices"))
312
+ choice_values = choices.filter_map do |choice|
313
+ value_for(choice, "value") if choice.is_a?(Hash) && key_present?(choice, "value")
314
+ end
315
+
316
+ case type
317
+ when "select"
318
+ return [] if choice_values.include?(default)
319
+
320
+ ["Question #{identifier} default #{default.inspect} is not a defined choice"]
321
+ when "multi_select"
322
+ return ["Question #{identifier} default must be an Array"] unless default.is_a?(Array)
323
+
324
+ unknown = default - choice_values
325
+ unknown.empty? ? [] : ["Question #{identifier} has unknown default choice #{unknown.first.inspect}"]
326
+ when "yes_no"
327
+ [true, false].include?(default) ? [] : ["Question #{identifier} default must be true or false"]
328
+ else
329
+ []
330
+ end
331
+ end
332
+
333
+ def validate_flag_source(source, label)
334
+ issues = []
335
+ if key_present?(source, "rails_flag")
336
+ flag = value_for(source, "rails_flag")
337
+ if flag.is_a?(String)
338
+ issues.concat(interpolation_issues(flag, label, "rails_flag"))
339
+ else
340
+ issues << "#{label} rails_flag must be a String"
341
+ end
342
+ end
343
+
344
+ if key_present?(source, "rails_flags")
345
+ flags = value_for(source, "rails_flags")
346
+ if !flags.is_a?(Array) || flags.any? { |flag| !flag.is_a?(String) }
347
+ issues << "#{label} rails_flags must be an Array of Strings"
348
+ else
349
+ flags.each { |flag| issues.concat(interpolation_issues(flag, label, "rails_flags")) }
350
+ end
351
+ end
352
+ issues
353
+ end
354
+
355
+ def interpolation_issues(flag, label, field)
356
+ interpolate_flag(flag, "value")
357
+ []
358
+ rescue ConfigError => e
359
+ ["#{label} has invalid #{field} interpolation: #{e.message}"]
360
+ end
361
+
362
+ def validate_depends_on(entry, identifier, index, question_positions)
363
+ return [] unless key_present?(entry, "depends_on")
364
+
365
+ condition = value_for(entry, "depends_on")
366
+ return ["Question #{identifier} depends_on must be a Hash"] unless condition.is_a?(Hash)
367
+
368
+ issues = unsupported_key_issues(condition, %w[question value], "Question #{identifier} depends_on")
369
+ reference = value_for(condition, "question")
370
+ issues << "Question #{identifier} depends_on is missing question" if reference.to_s.strip.empty?
371
+ issues << "Question #{identifier} depends_on is missing value" unless key_present?(condition, "value")
372
+ if !reference.to_s.empty? && !question_positions.key?(reference)
373
+ issues << "Question #{identifier} references unknown question '#{reference}'"
374
+ elsif question_positions[reference] && question_positions[reference] >= index
375
+ issues << "Question #{identifier} depends_on must reference an earlier question"
376
+ end
241
377
  issues
242
378
  end
243
379
 
244
380
  def validate_post_action_entry(entry, identifier)
245
- action_type = (entry["type"] || entry[:type] || "command").to_s
381
+ action_type = (value_for(entry, "type") || "command").to_s
382
+ enabled = entry.fetch("enabled", entry.fetch(:enabled, true))
246
383
 
247
384
  case action_type
248
385
  when "command"
249
- command = entry["command"] || entry[:command]
250
- if command.nil? || command.to_s.strip.empty?
386
+ command = value_for(entry, "command")
387
+ if enabled && (command.nil? || command.to_s.strip.empty?)
251
388
  ["Post-action #{identifier} is enabled but missing a command"]
252
389
  else
253
390
  []
254
391
  end
255
392
  when "template"
256
393
  issues = []
257
- source = entry["source"] || entry[:source]
258
- if source.nil? || source.to_s.strip.empty?
394
+ source = value_for(entry, "source")
395
+ if enabled && (source.nil? || source.to_s.strip.empty?)
259
396
  issues << "Post-action #{identifier} is a template but missing a source"
260
397
  end
261
398
 
262
- variables = entry["variables"] || entry[:variables]
399
+ variables = value_for(entry, "variables")
263
400
  issues << "Post-action #{identifier} template variables must be a Hash" if variables && !variables.is_a?(Hash)
401
+ if variables.is_a?(Hash)
402
+ variables.each_key do |key|
403
+ unless /\A[a-zA-Z_]\w*\z/.match?(key.to_s)
404
+ issues << "Post-action #{identifier} has invalid template variable name #{key.inspect}"
405
+ end
406
+ end
407
+ end
264
408
  issues
265
409
  else
266
410
  ["Post-action #{identifier} has unsupported type '#{action_type}'"]
267
411
  end
268
412
  end
269
413
 
414
+ def validate_post_action_condition(entry, identifier, question_ids)
415
+ return [] unless key_present?(entry, "if")
416
+
417
+ condition = value_for(entry, "if")
418
+ return ["Post-action #{identifier} condition must be a Hash"] unless condition.is_a?(Hash)
419
+
420
+ issues = unsupported_key_issues(condition, %w[question equals includes], "Post-action #{identifier} condition")
421
+ reference = value_for(condition, "question")
422
+ issues << "Post-action #{identifier} condition is missing question" if reference.to_s.strip.empty?
423
+ if !reference.to_s.empty? && !question_ids.include?(reference)
424
+ issues << "Post-action #{identifier} references unknown question '#{reference}'"
425
+ end
426
+
427
+ operators = %w[equals includes].select { |key| key_present?(condition, key) }
428
+ unless operators.one?
429
+ issues << "Post-action #{identifier} condition must define exactly one of equals or includes"
430
+ end
431
+ if key_present?(condition, "includes") && !value_for(condition, "includes").is_a?(Array)
432
+ issues << "Post-action #{identifier} condition includes must be an Array"
433
+ end
434
+ issues
435
+ end
436
+
437
+ def unsupported_key_issues(hash, allowed, label)
438
+ unsupported = hash.keys.map(&:to_s) - allowed
439
+ unsupported.map { |key| "#{label} has unsupported key '#{key}'" }
440
+ end
441
+
442
+ def key_present?(hash, key)
443
+ hash.key?(key) || hash.key?(key.to_sym)
444
+ end
445
+
446
+ def value_for(hash, key)
447
+ return hash[key] if hash.key?(key)
448
+
449
+ hash[key.to_sym]
450
+ end
451
+
270
452
  def deep_dup(value)
271
453
  case value
272
454
  when Hash
@@ -16,6 +16,9 @@ module Railstart
16
16
  # @example Run generator non-interactively
17
17
  # Railstart::Generator.new("blog", use_defaults: true).run
18
18
  class Generator
19
+ APP_NAME_PATTERN = /\A[a-z0-9_-]+\z/
20
+ APP_NAME_ERROR = "Must be lowercase letters, numbers, underscores, or hyphens"
21
+
19
22
  #
20
23
  # @param app_name [String, nil] preset app name, prompted if nil
21
24
  # @param config [Hash, nil] injected config for testing, defaults to Config.load
@@ -46,6 +49,7 @@ module Railstart
46
49
  show_welcome_screen unless @use_defaults
47
50
 
48
51
  ask_app_name unless @app_name
52
+ validate_app_name!
49
53
 
50
54
  if @use_defaults
51
55
  collect_defaults
@@ -69,10 +73,16 @@ module Railstart
69
73
 
70
74
  def ask_app_name
71
75
  @app_name = @prompt.ask("App name?", default: "my_app") do |q|
72
- q.validate(/\A[a-z0-9_-]+\z/, "Must be lowercase letters, numbers, underscores, or hyphens")
76
+ q.validate(APP_NAME_PATTERN, APP_NAME_ERROR)
73
77
  end
74
78
  end
75
79
 
80
+ def validate_app_name!
81
+ return if @app_name.to_s.match?(APP_NAME_PATTERN)
82
+
83
+ raise Error, "Invalid app name '#{@app_name}': #{APP_NAME_ERROR}"
84
+ end
85
+
76
86
  def collect_defaults
77
87
  Array(@config["questions"]).each do |question|
78
88
  next if should_skip_question?(question)
@@ -121,9 +131,7 @@ module Railstart
121
131
 
122
132
  def ask_select(question)
123
133
  # Convert to hash format: { 'Display Name' => 'value' }
124
- choices = question["choices"].each_with_object({}) do |choice, hash|
125
- hash[choice["name"]] = choice["value"]
126
- end
134
+ choices = question["choices"].to_h { |choice| [choice["name"], choice["value"]] }
127
135
  default_val = find_default(question)
128
136
 
129
137
  # TTY::Prompt expects 1-based index for default
@@ -134,9 +142,7 @@ module Railstart
134
142
 
135
143
  def ask_multi_select(question)
136
144
  # Convert to hash format: { 'Display Name' => 'value' }
137
- choices = question["choices"].each_with_object({}) do |choice, hash|
138
- hash[choice["name"]] = choice["value"]
139
- end
145
+ choices = question["choices"].to_h { |choice| [choice["name"], choice["value"]] }
140
146
 
141
147
  # Transform value-based defaults to name-based defaults for TTY::Prompt
142
148
  # Config uses stable values (e.g., "action_mailer"), TTY::Prompt needs display names
@@ -210,6 +216,7 @@ module Railstart
210
216
  end
211
217
 
212
218
  def generate_app
219
+ arguments = CommandBuilder.arguments(@app_name, @config, @answers)
213
220
  command = CommandBuilder.build(@app_name, @config, @answers)
214
221
 
215
222
  UI.info("Running: #{command}")
@@ -217,9 +224,9 @@ module Railstart
217
224
 
218
225
  # Run rails command outside of bundler context to use system Rails
219
226
  success = if defined?(Bundler)
220
- Bundler.with_unbundled_env { system(command) }
227
+ Bundler.with_unbundled_env { system(*arguments) }
221
228
  else
222
- system(command)
229
+ system(*arguments)
223
230
  end
224
231
 
225
232
  return if success
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Railstart
4
- VERSION = "0.4.5"
4
+ VERSION = "0.5.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: railstart
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.5
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - dpaluy
@@ -86,7 +86,6 @@ licenses:
86
86
  - MIT
87
87
  metadata:
88
88
  rubygems_mfa_required: 'true'
89
- homepage_uri: https://github.com/dpaluy/railstart
90
89
  documentation_uri: https://rubydoc.info/gems/railstart
91
90
  source_code_uri: https://github.com/dpaluy/railstart
92
91
  changelog_uri: https://github.com/dpaluy/railstart/blob/master/CHANGELOG.md
@@ -105,7 +104,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
105
104
  - !ruby/object:Gem::Version
106
105
  version: '0'
107
106
  requirements: []
108
- rubygems_version: 3.6.9
107
+ rubygems_version: 4.0.10
109
108
  specification_version: 4
110
109
  summary: Rails application starter and development utilities
111
110
  test_files: []