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 +4 -4
- data/CHANGELOG.md +17 -0
- data/README.md +12 -12
- data/Rakefile +1 -1
- data/config/presets/api-only.yaml +1 -0
- data/config/rails8_defaults.yaml +1 -0
- data/lib/railstart/cli.rb +20 -3
- data/lib/railstart/command_builder.rb +20 -2
- data/lib/railstart/config.rb +218 -36
- data/lib/railstart/generator.rb +16 -9
- data/lib/railstart/version.rb +1 -1
- metadata +2 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 074af527c055fff27ba43418de8c9c1085cdce74ac7e6bccac0509c32fa7536d
|
|
4
|
+
data.tar.gz: 8aa05e748770810612c88e8f0ac3b48fe9efdcdedd95278aa499e7387acf0410
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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` -
|
|
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
|
-
|
|
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
|
|
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`,
|
|
250
|
-
#
|
|
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
|
-
|
|
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
|
data/config/rails8_defaults.yaml
CHANGED
data/lib/railstart/cli.rb
CHANGED
|
@@ -257,9 +257,26 @@ module Railstart
|
|
|
257
257
|
end
|
|
258
258
|
|
|
259
259
|
def example_user_config
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
27
|
-
|
|
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|
|
data/lib/railstart/config.rb
CHANGED
|
@@ -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
|
-
|
|
165
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
|
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 << "
|
|
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 << "
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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 << "
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
data/lib/railstart/generator.rb
CHANGED
|
@@ -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(
|
|
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"].
|
|
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"].
|
|
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(
|
|
227
|
+
Bundler.with_unbundled_env { system(*arguments) }
|
|
221
228
|
else
|
|
222
|
-
system(
|
|
229
|
+
system(*arguments)
|
|
223
230
|
end
|
|
224
231
|
|
|
225
232
|
return if success
|
data/lib/railstart/version.rb
CHANGED
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
|
+
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:
|
|
107
|
+
rubygems_version: 4.0.10
|
|
109
108
|
specification_version: 4
|
|
110
109
|
summary: Rails application starter and development utilities
|
|
111
110
|
test_files: []
|