railstart 0.4.4 → 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 +25 -0
- data/README.md +46 -24
- 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,31 @@ 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
|
+
|
|
27
|
+
## [0.4.5] - 2026-04-30
|
|
28
|
+
|
|
29
|
+
### Added
|
|
30
|
+
- **Example configs**: Added copyable `examples/` YAML files for global defaults, API apps, standard PostgreSQL apps, minimal SQLite apps, Vite/Bun apps, and template post-actions
|
|
31
|
+
|
|
32
|
+
### Changed
|
|
33
|
+
- **Agent guidance**: Tracked `AGENTS.md` in git while excluding it from gem builds
|
|
34
|
+
|
|
10
35
|
## [0.4.4] - 2026-04-30
|
|
11
36
|
|
|
12
37
|
### Changed
|
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
|
|
@@ -147,11 +149,11 @@ Presets are powerful tools for defining opinionated Rails configurations for spe
|
|
|
147
149
|
|
|
148
150
|
### Quick Preset Creation
|
|
149
151
|
|
|
150
|
-
Create a new preset file in
|
|
152
|
+
Create a new preset file in `~/.config/railstart/presets/{name}.yaml`:
|
|
151
153
|
|
|
152
154
|
```yaml
|
|
153
155
|
---
|
|
154
|
-
# My Team Preset - PostgreSQL +
|
|
156
|
+
# My Team Preset - PostgreSQL + Tailwind + Importmap
|
|
155
157
|
|
|
156
158
|
questions:
|
|
157
159
|
- id: database
|
|
@@ -160,23 +162,21 @@ questions:
|
|
|
160
162
|
value: postgresql
|
|
161
163
|
default: true
|
|
162
164
|
|
|
163
|
-
- id:
|
|
165
|
+
- id: css
|
|
164
166
|
choices:
|
|
165
|
-
- name:
|
|
166
|
-
value:
|
|
167
|
+
- name: Tailwind
|
|
168
|
+
value: tailwind
|
|
167
169
|
default: true
|
|
168
170
|
|
|
169
|
-
- id:
|
|
171
|
+
- id: javascript
|
|
170
172
|
choices:
|
|
171
|
-
- name:
|
|
172
|
-
value:
|
|
173
|
+
- name: Importmap
|
|
174
|
+
value: importmap
|
|
173
175
|
default: true
|
|
176
|
+
rails_flag: "--javascript=importmap"
|
|
174
177
|
|
|
175
178
|
post_actions:
|
|
176
|
-
- id:
|
|
177
|
-
enabled: true
|
|
178
|
-
|
|
179
|
-
- id: setup_rspec
|
|
179
|
+
- id: init_git
|
|
180
180
|
enabled: true
|
|
181
181
|
```
|
|
182
182
|
|
|
@@ -198,6 +198,30 @@ Railstart includes several ready-to-use presets:
|
|
|
198
198
|
- **`api-only`** - Minimal Rails for JSON APIs (no views, no frontend)
|
|
199
199
|
- **`vite-bun`** - Modern SPA with Vite + Bundlebun
|
|
200
200
|
|
|
201
|
+
### Example YAML Files
|
|
202
|
+
|
|
203
|
+
This repository also includes copyable YAML examples in `examples/`:
|
|
204
|
+
|
|
205
|
+
- `examples/config.yml` - global defaults for `~/.config/railstart/config.yaml`
|
|
206
|
+
- `examples/presets/api-postgresql.yml` - JSON API service with PostgreSQL
|
|
207
|
+
- `examples/presets/standard-postgresql.yml` - full-stack PostgreSQL app
|
|
208
|
+
- `examples/presets/minimal-sqlite.yml` - lightweight local prototype app
|
|
209
|
+
- `examples/presets/vite-bun.yml` - Vite, Bun, Tailwind, and PostgreSQL
|
|
210
|
+
- `examples/presets/template-action.yml` - template post-action reference
|
|
211
|
+
|
|
212
|
+
Use a preset example directly by passing its path:
|
|
213
|
+
|
|
214
|
+
```bash
|
|
215
|
+
railstart new my_app --preset ./examples/presets/standard-postgresql.yml --default
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
Or copy one into your user presets directory:
|
|
219
|
+
|
|
220
|
+
```bash
|
|
221
|
+
cp examples/presets/standard-postgresql.yml ~/.config/railstart/presets/standard-postgresql.yml
|
|
222
|
+
railstart new my_app --preset standard-postgresql --default
|
|
223
|
+
```
|
|
224
|
+
|
|
201
225
|
## Configuration
|
|
202
226
|
|
|
203
227
|
### Initialize Configuration Files
|
|
@@ -209,11 +233,11 @@ railstart init
|
|
|
209
233
|
```
|
|
210
234
|
|
|
211
235
|
This creates:
|
|
212
|
-
- `~/.config/railstart/config.yaml` -
|
|
236
|
+
- `~/.config/railstart/config.yaml` - Minimal example containing only a few global overrides
|
|
213
237
|
- `~/.config/railstart/presets/` - Directory for your presets
|
|
214
238
|
- `~/.config/railstart/presets/example.yaml` - Example preset to get started
|
|
215
239
|
|
|
216
|
-
|
|
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.
|
|
217
241
|
|
|
218
242
|
### Built-in Defaults
|
|
219
243
|
|
|
@@ -221,20 +245,15 @@ Railstart ships with sensible Rails 8 defaults defined in `config/rails8_default
|
|
|
221
245
|
|
|
222
246
|
### Customize for Your Team
|
|
223
247
|
|
|
224
|
-
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:
|
|
225
249
|
|
|
226
250
|
```yaml
|
|
227
|
-
# After running `railstart init`,
|
|
228
|
-
#
|
|
251
|
+
# After running `railstart init`, config.yaml contains a few example overrides.
|
|
252
|
+
# Keep only the overrides you want to apply to every run:
|
|
229
253
|
|
|
230
254
|
questions:
|
|
231
255
|
- id: database
|
|
232
|
-
|
|
233
|
-
- name: PostgreSQL (recommended)
|
|
234
|
-
value: postgresql
|
|
235
|
-
default: true # Changed from SQLite to PostgreSQL
|
|
236
|
-
|
|
237
|
-
# ... other questions with their full configuration ...
|
|
256
|
+
default: postgresql
|
|
238
257
|
|
|
239
258
|
post_actions:
|
|
240
259
|
- id: bundle_install
|
|
@@ -334,6 +353,9 @@ Key differences from `command` actions:
|
|
|
334
353
|
### Setup
|
|
335
354
|
|
|
336
355
|
```bash
|
|
356
|
+
# Install the required Ruby version
|
|
357
|
+
mise install ruby@4.0.5
|
|
358
|
+
|
|
337
359
|
# Install dependencies
|
|
338
360
|
bundle install
|
|
339
361
|
|
|
@@ -372,7 +394,7 @@ bundle exec rubocop
|
|
|
372
394
|
bundle exec rubocop -a
|
|
373
395
|
|
|
374
396
|
# Full check
|
|
375
|
-
bundle exec rake test && bundle exec rubocop
|
|
397
|
+
bundle exec rake test && bundle exec rubocop && bundle exec rake yard && gem build railstart.gemspec
|
|
376
398
|
```
|
|
377
399
|
|
|
378
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: []
|