railstart 0.5.0 → 0.6.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 +14 -0
- data/README.md +34 -3
- data/config/rails8_defaults.yaml +23 -0
- data/config/templates/AGENTS.md +30 -0
- data/config/templates/bin/ci +16 -0
- data/lib/railstart/cli.rb +12 -5
- data/lib/railstart/config.rb +28 -1
- data/lib/railstart/file_writer.rb +110 -0
- data/lib/railstart/generator.rb +39 -169
- data/lib/railstart/post_action_runner.rb +130 -0
- data/lib/railstart/question_asker.rb +113 -0
- data/lib/railstart/version.rb +1 -1
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 43c88184e8a592416bb521ca50fcccecbe331caf46851218cd1edd31e6134827
|
|
4
|
+
data.tar.gz: 3f6084258ec5d559ef064c75ebb4937208a0e56373427a6983c883bbfae1ff74
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f4fa31d2a8ab8f838a8c269f0c952b997c857b296567c291c70b7647f92780ec1cae26ecbfd8a04121d882e565e479761a829b4ba6e6f076dafe3d4551e352ad
|
|
7
|
+
data.tar.gz: 2485f48632b2a43d415ef717d111ee87cc48da6e3b9a23c7b6c04ea9c9e5b08cc7bbdfd1bf3915b8427c56b239ddf3d9b4849cbc00efbe548d8564f491b0c888
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.6.0] - 2026-10-03
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- `railstart new --yes` (`-y`) fully headless mode that implies defaults, never prompts, requires `APP_NAME`, and uses post-action `default` values for prompted actions.
|
|
14
|
+
- **File post-actions**: New `type: file` post-action that writes a file into the generated app from inline `content` or a `source` template (relative sources resolve against `config/templates/`), with `%{app_name}` interpolation, path traversal protection, and skip-on-existing `overwrite` semantics.
|
|
15
|
+
- **Shipped templates**: New `config/templates/AGENTS.md` (opinionated Rails 8 guide for coding agents, written by the enabled `write_agents_md` action) and `config/templates/bin/ci` (curated CI script behind the opt-in `setup_bin_ci` action).
|
|
16
|
+
|
|
17
|
+
## [0.5.1] - 2026-07-26
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
- Updated GitHub Actions `actions/checkout` from v5 to v7.
|
|
21
|
+
- Updated YARD from 0.9.44 to 0.9.45.
|
|
22
|
+
- Updated RDoc from 6.17.0 to 8.0.0.
|
|
23
|
+
|
|
10
24
|
## [0.5.0] - 2026-07-11
|
|
11
25
|
|
|
12
26
|
### Added
|
data/README.md
CHANGED
|
@@ -94,7 +94,8 @@ Presets are configuration overlays that let you define different defaults and ev
|
|
|
94
94
|
**Modes:**
|
|
95
95
|
- **Interactive** (default): prompts for each question from the config schema
|
|
96
96
|
- **With --default**: skips questions, loads "default" preset, shows summary and confirms
|
|
97
|
-
- **With --
|
|
97
|
+
- **With --yes / -y**: fully headless, implies `--default`, requires `APP_NAME`, skips all confirmations (post-action prompts follow each action's `default`)
|
|
98
|
+
- **With --preset**: loads specified preset as config overlay (can be interactive or with --default/--yes)
|
|
98
99
|
|
|
99
100
|
```bash
|
|
100
101
|
# Interactive mode (builtin defaults)
|
|
@@ -104,11 +105,14 @@ railstart new my_app
|
|
|
104
105
|
# Note: --default automatically loads the "default" preset (user or gem)
|
|
105
106
|
railstart new my_app --default
|
|
106
107
|
|
|
108
|
+
# Fully headless mode (implies --default, no prompts at all)
|
|
109
|
+
railstart new my_app --yes
|
|
110
|
+
|
|
107
111
|
# Interactive with custom preset
|
|
108
112
|
railstart new my_app --preset api-only
|
|
109
113
|
|
|
110
|
-
#
|
|
111
|
-
railstart new my_app --preset api-only --
|
|
114
|
+
# Fully headless with custom preset
|
|
115
|
+
railstart new my_app --preset api-only --yes
|
|
112
116
|
```
|
|
113
117
|
|
|
114
118
|
**Create custom presets** at `~/.config/railstart/presets/my-preset.yaml`:
|
|
@@ -348,6 +352,33 @@ Key differences from `command` actions:
|
|
|
348
352
|
- `variables` is optional; when present, its keys become instance variables accessible from the template (e.g., `@app_label`). Railstart always exposes `@app_name` and `@answers` for convenience.
|
|
349
353
|
- Template actions still honor `prompt`, `default`, and `if` just like command actions. Keep remote templates disabled by default unless you explicitly trust them.
|
|
350
354
|
|
|
355
|
+
#### File Post-Actions
|
|
356
|
+
|
|
357
|
+
For lightweight files (AGENTS.md, scripts, dotfiles) you can skip Rails templates entirely and write a file directly into the generated app. File actions never load Rails in-process.
|
|
358
|
+
|
|
359
|
+
```yaml
|
|
360
|
+
post_actions:
|
|
361
|
+
- id: write_agents_md
|
|
362
|
+
name: "Create AGENTS.md for coding agents"
|
|
363
|
+
type: file
|
|
364
|
+
enabled: true
|
|
365
|
+
prompt: "Create AGENTS.md for coding agents?"
|
|
366
|
+
default: true
|
|
367
|
+
path: AGENTS.md # required, relative to the app root
|
|
368
|
+
source: AGENTS.md # template file (or use `content:` for an inline string)
|
|
369
|
+
overwrite: false # optional; skip instead of overwriting existing files
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
Key differences from `template` actions:
|
|
373
|
+
|
|
374
|
+
- `path` is required and must stay inside the app (absolute, `~`-prefixed, and `..` paths are rejected).
|
|
375
|
+
- Provide exactly one of `content` (inline string) or `source` (file path). Relative `source` paths resolve against the gem's `config/templates/` directory; absolute or `~/` paths are used as-is.
|
|
376
|
+
- Content supports `%{app_name}` interpolation, like `%{value}` in `rails_flag`. Other `%` sequences pass through unchanged.
|
|
377
|
+
- `overwrite` defaults to `false`: existing files are skipped with a warning. Source-based writes keep the source file's executable bit.
|
|
378
|
+
- `prompt`, `default`, and `if` behave exactly like command actions.
|
|
379
|
+
|
|
380
|
+
The built-in config ships two file actions: `write_agents_md` (enabled by default, writes an opinionated AGENTS.md for coding agents) and `setup_bin_ci` (opt-in; Rails 8.1+ already generates `bin/ci`).
|
|
381
|
+
|
|
351
382
|
## Development
|
|
352
383
|
|
|
353
384
|
### Setup
|
data/config/rails8_defaults.yaml
CHANGED
|
@@ -126,6 +126,29 @@ questions:
|
|
|
126
126
|
rails_flag: "--skip-test"
|
|
127
127
|
|
|
128
128
|
post_actions:
|
|
129
|
+
- id: write_agents_md
|
|
130
|
+
name: "Create AGENTS.md for coding agents"
|
|
131
|
+
type: file
|
|
132
|
+
enabled: true
|
|
133
|
+
prompt: "Create AGENTS.md for coding agents?"
|
|
134
|
+
default: true
|
|
135
|
+
path: AGENTS.md
|
|
136
|
+
source: AGENTS.md
|
|
137
|
+
overwrite: false
|
|
138
|
+
|
|
139
|
+
# Opt-in: Rails 8.1+ already generates bin/ci, so this action stays
|
|
140
|
+
# disabled by default and never overwrites an existing script. Enable it
|
|
141
|
+
# to have Railstart write a curated CI script (keeps its executable bit).
|
|
142
|
+
- id: setup_bin_ci
|
|
143
|
+
name: "Create bin/ci script"
|
|
144
|
+
type: file
|
|
145
|
+
enabled: false
|
|
146
|
+
prompt: "Create bin/ci script?"
|
|
147
|
+
default: false
|
|
148
|
+
path: bin/ci
|
|
149
|
+
source: bin/ci
|
|
150
|
+
overwrite: false
|
|
151
|
+
|
|
129
152
|
- id: init_git
|
|
130
153
|
name: "Initialize git repository"
|
|
131
154
|
enabled: true
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# %{app_name}: Agent Guide
|
|
2
|
+
|
|
3
|
+
Guidance for AI coding agents working in this Rails 8 application.
|
|
4
|
+
|
|
5
|
+
## Setup
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
bin/setup
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Everyday Commands
|
|
12
|
+
|
|
13
|
+
- Run the test suite: `bin/rails test` (system tests: `bin/rails test:system`)
|
|
14
|
+
- Run a single test file: `bin/rails test test/models/user_test.rb`
|
|
15
|
+
- Lint: `bundle exec rubocop`
|
|
16
|
+
- Security scan: `bin/brakeman` (when the gem is installed)
|
|
17
|
+
- Console: `bin/rails console`
|
|
18
|
+
|
|
19
|
+
## Conventions
|
|
20
|
+
|
|
21
|
+
- Follow standard Ruby and Rails style; keep the existing code style consistent.
|
|
22
|
+
- Use `bin/rails generate` for new models, controllers, and migrations instead of hand-writing boilerplate.
|
|
23
|
+
- Keep controllers thin; put business logic in models or plain service objects.
|
|
24
|
+
- Write tests for new behavior; prefer fixtures and plain assertions over heavy mocking.
|
|
25
|
+
|
|
26
|
+
## Before Committing
|
|
27
|
+
|
|
28
|
+
1. `bin/rails test` passes.
|
|
29
|
+
2. `bundle exec rubocop` reports no offenses.
|
|
30
|
+
3. New or changed behavior is covered by tests.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# bin/ci — curated CI script written by Railstart.
|
|
3
|
+
# Rails 8.1+ generates its own bin/ci; enable the setup_bin_ci post-action
|
|
4
|
+
# only when the generated app does not already ship one.
|
|
5
|
+
set -euo pipefail
|
|
6
|
+
|
|
7
|
+
echo "== Running RuboCop =="
|
|
8
|
+
bundle exec rubocop
|
|
9
|
+
|
|
10
|
+
if bundle exec brakeman --version >/dev/null 2>&1; then
|
|
11
|
+
echo "== Running Brakeman =="
|
|
12
|
+
bundle exec brakeman --quiet --no-pager
|
|
13
|
+
fi
|
|
14
|
+
|
|
15
|
+
echo "== Running tests =="
|
|
16
|
+
bin/rails test
|
data/lib/railstart/cli.rb
CHANGED
|
@@ -32,6 +32,7 @@ module Railstart
|
|
|
32
32
|
puts " railstart init # Create config files (optional)"
|
|
33
33
|
puts " railstart new my_app # Interactive mode"
|
|
34
34
|
puts " railstart new my_app --default # Use defaults"
|
|
35
|
+
puts " railstart new my_app --yes # Fully headless"
|
|
35
36
|
puts " railstart new my_app --preset api-only # Use preset"
|
|
36
37
|
puts ""
|
|
37
38
|
puts "Run 'railstart help init' or 'railstart help new' for details"
|
|
@@ -122,17 +123,20 @@ module Railstart
|
|
|
122
123
|
|
|
123
124
|
Modes:
|
|
124
125
|
- Interactive (default): prompts for each question
|
|
126
|
+
- With default mode (--default or --yes): uses config defaults
|
|
125
127
|
- With preset: uses preset config (different questions/defaults), interactive or non-interactive
|
|
126
128
|
|
|
127
129
|
Examples:
|
|
128
130
|
railstart new my_app # Interactive mode
|
|
129
|
-
railstart new my_app --default # Non-interactive with default preset (if exists)
|
|
131
|
+
railstart new my_app --default # Non-interactive with default preset (if exists), still confirms
|
|
132
|
+
railstart new my_app --yes # Fully headless (no prompts, requires APP_NAME)
|
|
130
133
|
railstart new my_app --preset api-only # Interactive with api-only preset config
|
|
131
|
-
railstart new my_app --preset api-only --
|
|
134
|
+
railstart new my_app --preset api-only --yes # Fully headless with api-only preset
|
|
132
135
|
|
|
133
136
|
Presets are stored in: ~/.config/railstart/presets/*.yaml
|
|
134
137
|
DESC
|
|
135
138
|
option :default, type: :boolean, default: false, desc: "Use defaults non-interactively"
|
|
139
|
+
option :yes, type: :boolean, aliases: "-y", default: false, desc: "Headless mode: implies --default; no prompts"
|
|
136
140
|
option :preset, type: :string, desc: "Preset name from ~/.config/railstart/presets/", banner: "NAME"
|
|
137
141
|
#
|
|
138
142
|
# @param app_name [String, nil] desired Rails app name, prompted if omitted
|
|
@@ -144,6 +148,8 @@ module Railstart
|
|
|
144
148
|
# Railstart::CLI.start(%w[new my_app --preset api-only])
|
|
145
149
|
# @example Use default preset non-interactively
|
|
146
150
|
# Railstart::CLI.start(%w[new my_app --default])
|
|
151
|
+
# @example Run fully headless
|
|
152
|
+
# Railstart::CLI.start(%w[new my_app --yes])
|
|
147
153
|
def new(app_name = nil)
|
|
148
154
|
preset_name = determine_preset_name
|
|
149
155
|
preset_path = preset_name ? preset_file_for(preset_name) : nil
|
|
@@ -153,7 +159,8 @@ module Railstart
|
|
|
153
159
|
generator = Generator.new(
|
|
154
160
|
app_name,
|
|
155
161
|
config: config,
|
|
156
|
-
use_defaults: options[:default]
|
|
162
|
+
use_defaults: options[:default] || options[:yes],
|
|
163
|
+
assume_yes: options[:yes]
|
|
157
164
|
)
|
|
158
165
|
|
|
159
166
|
generator.run
|
|
@@ -230,8 +237,8 @@ module Railstart
|
|
|
230
237
|
# Explicit --preset flag takes priority
|
|
231
238
|
return options[:preset] if options[:preset]
|
|
232
239
|
|
|
233
|
-
# --default
|
|
234
|
-
return "default" if options[:default]
|
|
240
|
+
# --default and --yes map to "default" preset
|
|
241
|
+
return "default" if options[:default] || options[:yes]
|
|
235
242
|
|
|
236
243
|
nil
|
|
237
244
|
end
|
data/lib/railstart/config.rb
CHANGED
|
@@ -157,7 +157,7 @@ module Railstart
|
|
|
157
157
|
end
|
|
158
158
|
|
|
159
159
|
def fetch_id(entry)
|
|
160
|
-
return unless entry.
|
|
160
|
+
return unless entry.is_a?(Hash)
|
|
161
161
|
|
|
162
162
|
entry["id"] || entry[:id]
|
|
163
163
|
end
|
|
@@ -406,11 +406,38 @@ module Railstart
|
|
|
406
406
|
end
|
|
407
407
|
end
|
|
408
408
|
issues
|
|
409
|
+
when "file"
|
|
410
|
+
validate_file_action(entry, identifier, enabled)
|
|
409
411
|
else
|
|
410
412
|
["Post-action #{identifier} has unsupported type '#{action_type}'"]
|
|
411
413
|
end
|
|
412
414
|
end
|
|
413
415
|
|
|
416
|
+
def validate_file_action(entry, identifier, enabled)
|
|
417
|
+
issues = []
|
|
418
|
+
path = value_for(entry, "path")
|
|
419
|
+
if enabled && (path.nil? || path.to_s.strip.empty?)
|
|
420
|
+
issues << "Post-action #{identifier} is a file but missing a path"
|
|
421
|
+
elsif path && !path.to_s.strip.empty? && unsafe_file_path?(path.to_s)
|
|
422
|
+
issues << "Post-action #{identifier} has unsafe file path #{path.inspect}"
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
if enabled
|
|
426
|
+
has_content = key_present?(entry, "content") && !value_for(entry, "content").nil?
|
|
427
|
+
has_source = !value_for(entry, "source").to_s.strip.empty?
|
|
428
|
+
if has_content && has_source
|
|
429
|
+
issues << "Post-action #{identifier} is a file but defines both content and source"
|
|
430
|
+
elsif !has_content && !has_source
|
|
431
|
+
issues << "Post-action #{identifier} is a file but missing content or source"
|
|
432
|
+
end
|
|
433
|
+
end
|
|
434
|
+
issues
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
def unsafe_file_path?(path)
|
|
438
|
+
path.start_with?("/", "~") || path.match?(%r{\A[A-Za-z]:[\\/]}) || path.split(%r{[/\\]+}).include?("..")
|
|
439
|
+
end
|
|
440
|
+
|
|
414
441
|
def validate_post_action_condition(entry, identifier, question_ids)
|
|
415
442
|
return [] unless key_present?(entry, "if")
|
|
416
443
|
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "pathname"
|
|
5
|
+
require_relative "errors"
|
|
6
|
+
|
|
7
|
+
module Railstart
|
|
8
|
+
# Writes lightweight files into a generated application directory.
|
|
9
|
+
#
|
|
10
|
+
# Backs `type: file` post-actions, providing a way to ship small files
|
|
11
|
+
# (AGENTS.md, scripts, dotfiles) without loading Rails in-process like
|
|
12
|
+
# template post-actions do.
|
|
13
|
+
#
|
|
14
|
+
# @example Write a file from inline content
|
|
15
|
+
# writer = Railstart::FileWriter.new(app_path: Dir.pwd, app_name: "blog")
|
|
16
|
+
# writer.write(path: "AGENTS.md", content: "# %{app_name}")
|
|
17
|
+
class FileWriter
|
|
18
|
+
# Directory inside the gem where relative `source` paths resolve.
|
|
19
|
+
TEMPLATES_DIR = File.expand_path("../../config/templates", __dir__)
|
|
20
|
+
|
|
21
|
+
#
|
|
22
|
+
# @param app_path [String] absolute path to the generated application
|
|
23
|
+
# @param app_name [String] application name used for `%{app_name}` interpolation
|
|
24
|
+
def initialize(app_path:, app_name:)
|
|
25
|
+
@app_path = app_path
|
|
26
|
+
@app_name = app_name
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
#
|
|
30
|
+
# Write a file into the application directory.
|
|
31
|
+
#
|
|
32
|
+
# @param path [String] target path relative to the application root
|
|
33
|
+
# @param content [String, nil] inline file content (mutually exclusive with `source`)
|
|
34
|
+
# @param source [String, nil] template file path; relative paths resolve
|
|
35
|
+
# against the gem's config/templates directory, absolute or ~/ paths are used as-is
|
|
36
|
+
# @param overwrite [Boolean] replace an existing file when true
|
|
37
|
+
# @return [Symbol] :written when the file was written, :skipped when it
|
|
38
|
+
# already existed and `overwrite` is false
|
|
39
|
+
# @raise [Railstart::Error] when the target path escapes the app, the
|
|
40
|
+
# source escapes config/templates, or the source file cannot be read
|
|
41
|
+
def write(path:, content: nil, source: nil, overwrite: false)
|
|
42
|
+
source = nil if source.to_s.strip.empty?
|
|
43
|
+
target = safe_target(path)
|
|
44
|
+
if File.symlink?(target)
|
|
45
|
+
raise Error, "Unsafe file post-action path #{path.inspect}: refusing to write through a symlink"
|
|
46
|
+
end
|
|
47
|
+
return :skipped if File.exist?(target) && !overwrite
|
|
48
|
+
|
|
49
|
+
source_path = resolve_source(source) if source
|
|
50
|
+
body = interpolate(source_path ? read_source(source_path) : content.to_s)
|
|
51
|
+
ensure_resolved_within_app!(target, path)
|
|
52
|
+
FileUtils.mkdir_p(File.dirname(target))
|
|
53
|
+
File.write(target, body)
|
|
54
|
+
FileUtils.chmod(File.stat(source_path).mode & 0o777, target) if source_path
|
|
55
|
+
:written
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def safe_target(path)
|
|
61
|
+
root = File.expand_path(@app_path)
|
|
62
|
+
target = File.expand_path(path.to_s, root)
|
|
63
|
+
return target if target.start_with?("#{root}#{File::SEPARATOR}")
|
|
64
|
+
|
|
65
|
+
raise Error, "Unsafe file post-action path #{path.inspect}: must stay inside the app directory"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Lexical checks cannot see directory symlinks inside the app. Resolve the
|
|
69
|
+
# nearest existing ancestor BEFORE mkdir_p so a symlinked directory can
|
|
70
|
+
# neither receive the write nor absorb the created directories outside
|
|
71
|
+
# the app.
|
|
72
|
+
def ensure_resolved_within_app!(target, path)
|
|
73
|
+
root = File.realpath(@app_path)
|
|
74
|
+
resolved = File.realpath(nearest_existing_ancestor(target))
|
|
75
|
+
return if resolved == root || resolved.start_with?("#{root}#{File::SEPARATOR}")
|
|
76
|
+
|
|
77
|
+
raise Error, "Unsafe file post-action path #{path.inspect}: resolves outside the app directory"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def nearest_existing_ancestor(target)
|
|
81
|
+
dir = File.dirname(target)
|
|
82
|
+
dir = File.dirname(dir) until File.exist?(dir) || File.dirname(dir) == dir
|
|
83
|
+
dir
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def resolve_source(source)
|
|
87
|
+
raw = source.to_s
|
|
88
|
+
expanded = raw.start_with?("~") ? File.expand_path(raw) : raw
|
|
89
|
+
return expanded if Pathname.new(expanded).absolute?
|
|
90
|
+
|
|
91
|
+
resolved = File.expand_path(expanded, TEMPLATES_DIR)
|
|
92
|
+
return resolved if resolved.start_with?("#{TEMPLATES_DIR}#{File::SEPARATOR}")
|
|
93
|
+
|
|
94
|
+
raise Error, "Unsafe file post-action source #{source.inspect}: must stay inside #{TEMPLATES_DIR}"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def read_source(source_path)
|
|
98
|
+
File.read(source_path)
|
|
99
|
+
rescue SystemCallError => e
|
|
100
|
+
raise Error, "Cannot read file post-action source #{source_path}: #{e.message}"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Replaces only the documented `%{app_name}` token so literal `%`
|
|
104
|
+
# characters in file content (printf, percentages) pass through unchanged.
|
|
105
|
+
def interpolate(body)
|
|
106
|
+
# rubocop:disable-next Style/FormatStringToken -- `%{token}` is the documented interpolation style
|
|
107
|
+
body.gsub("%{app_name}", @app_name.to_s)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
data/lib/railstart/generator.rb
CHANGED
|
@@ -2,13 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
require "tty-prompt"
|
|
4
4
|
require_relative "ui"
|
|
5
|
-
require_relative "
|
|
5
|
+
require_relative "question_asker"
|
|
6
|
+
require_relative "post_action_runner"
|
|
6
7
|
|
|
7
8
|
module Railstart
|
|
8
|
-
# Orchestrates the
|
|
9
|
+
# Orchestrates the Rails app generation flow: welcome screen, app name,
|
|
10
|
+
# questions, summary, `rails new`, and post-actions.
|
|
9
11
|
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
+
# Question answering lives in QuestionAsker, post-action execution in
|
|
13
|
+
# PostActionRunner; this class only sequences the steps.
|
|
12
14
|
#
|
|
13
15
|
# @example Run generator with provided config
|
|
14
16
|
# config = Railstart::Config.load
|
|
@@ -23,11 +25,13 @@ module Railstart
|
|
|
23
25
|
# @param app_name [String, nil] preset app name, prompted if nil
|
|
24
26
|
# @param config [Hash, nil] injected config for testing, defaults to Config.load
|
|
25
27
|
# @param use_defaults [Boolean] skip interactive questions, use config defaults
|
|
28
|
+
# @param assume_yes [Boolean] fully headless mode, implies defaults and bypasses confirmations
|
|
26
29
|
# @param prompt [TTY::Prompt] injectable prompt for testing
|
|
27
|
-
def initialize(app_name = nil, config: nil, use_defaults: false, prompt: nil)
|
|
30
|
+
def initialize(app_name = nil, config: nil, use_defaults: false, assume_yes: false, prompt: nil)
|
|
28
31
|
@app_name = app_name
|
|
29
32
|
@config = config || Config.load
|
|
30
|
-
@
|
|
33
|
+
@assume_yes = assume_yes
|
|
34
|
+
@use_defaults = use_defaults || @assume_yes
|
|
31
35
|
@prompt = prompt || TTY::Prompt.new
|
|
32
36
|
@answers = {}
|
|
33
37
|
end
|
|
@@ -48,14 +52,8 @@ module Railstart
|
|
|
48
52
|
def run
|
|
49
53
|
show_welcome_screen unless @use_defaults
|
|
50
54
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
if @use_defaults
|
|
55
|
-
collect_defaults
|
|
56
|
-
else
|
|
57
|
-
ask_interactive_questions
|
|
58
|
-
end
|
|
55
|
+
resolve_app_name
|
|
56
|
+
@answers = collect_answers
|
|
59
57
|
|
|
60
58
|
show_summary
|
|
61
59
|
return unless confirm_proceed?
|
|
@@ -71,6 +69,15 @@ module Railstart
|
|
|
71
69
|
UI.show_welcome
|
|
72
70
|
end
|
|
73
71
|
|
|
72
|
+
def resolve_app_name
|
|
73
|
+
if @app_name.nil?
|
|
74
|
+
raise Error, "APP_NAME is required when running in --yes mode" if @assume_yes
|
|
75
|
+
|
|
76
|
+
ask_app_name
|
|
77
|
+
end
|
|
78
|
+
validate_app_name!
|
|
79
|
+
end
|
|
80
|
+
|
|
74
81
|
def ask_app_name
|
|
75
82
|
@app_name = @prompt.ask("App name?", default: "my_app") do |q|
|
|
76
83
|
q.validate(APP_NAME_PATTERN, APP_NAME_ERROR)
|
|
@@ -83,91 +90,9 @@ module Railstart
|
|
|
83
90
|
raise Error, "Invalid app name '#{@app_name}': #{APP_NAME_ERROR}"
|
|
84
91
|
end
|
|
85
92
|
|
|
86
|
-
def
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
question_id = question["id"]
|
|
91
|
-
default_value = find_default(question)
|
|
92
|
-
@answers[question_id] = default_value unless default_value.nil?
|
|
93
|
-
end
|
|
94
|
-
end
|
|
95
|
-
|
|
96
|
-
def ask_interactive_questions
|
|
97
|
-
Array(@config["questions"]).each do |question|
|
|
98
|
-
handle_question(question)
|
|
99
|
-
end
|
|
100
|
-
end
|
|
101
|
-
|
|
102
|
-
def handle_question(question)
|
|
103
|
-
return if should_skip_question?(question)
|
|
104
|
-
|
|
105
|
-
@answers[question["id"]] = ask_question(question)
|
|
106
|
-
end
|
|
107
|
-
|
|
108
|
-
def should_skip_question?(question)
|
|
109
|
-
depends = question["depends_on"]
|
|
110
|
-
return false unless depends
|
|
111
|
-
|
|
112
|
-
dep_question_id = depends["question"]
|
|
113
|
-
dep_value = depends["value"]
|
|
114
|
-
|
|
115
|
-
actual_value = @answers[dep_question_id]
|
|
116
|
-
actual_value != dep_value
|
|
117
|
-
end
|
|
118
|
-
|
|
119
|
-
def ask_question(question)
|
|
120
|
-
case question["type"]
|
|
121
|
-
when "select"
|
|
122
|
-
ask_select(question)
|
|
123
|
-
when "multi_select"
|
|
124
|
-
ask_multi_select(question)
|
|
125
|
-
when "yes_no"
|
|
126
|
-
ask_yes_no?(question)
|
|
127
|
-
when "input"
|
|
128
|
-
ask_input(question)
|
|
129
|
-
end
|
|
130
|
-
end
|
|
131
|
-
|
|
132
|
-
def ask_select(question)
|
|
133
|
-
# Convert to hash format: { 'Display Name' => 'value' }
|
|
134
|
-
choices = question["choices"].to_h { |choice| [choice["name"], choice["value"]] }
|
|
135
|
-
default_val = find_default(question)
|
|
136
|
-
|
|
137
|
-
# TTY::Prompt expects 1-based index for default
|
|
138
|
-
default_index = (question["choices"].index { |c| c["value"] == default_val }&.+(1) if default_val)
|
|
139
|
-
|
|
140
|
-
@prompt.select(question["prompt"], choices, default: default_index)
|
|
141
|
-
end
|
|
142
|
-
|
|
143
|
-
def ask_multi_select(question)
|
|
144
|
-
# Convert to hash format: { 'Display Name' => 'value' }
|
|
145
|
-
choices = question["choices"].to_h { |choice| [choice["name"], choice["value"]] }
|
|
146
|
-
|
|
147
|
-
# Transform value-based defaults to name-based defaults for TTY::Prompt
|
|
148
|
-
# Config uses stable values (e.g., "action_mailer"), TTY::Prompt needs display names
|
|
149
|
-
value_defaults = question["default"] || []
|
|
150
|
-
name_defaults = value_defaults.map do |value|
|
|
151
|
-
choice = question["choices"].find { |c| c["value"] == value }
|
|
152
|
-
choice ? choice["name"] : nil
|
|
153
|
-
end.compact
|
|
154
|
-
|
|
155
|
-
@prompt.multi_select(question["prompt"], choices, default: name_defaults)
|
|
156
|
-
end
|
|
157
|
-
|
|
158
|
-
def ask_yes_no?(question)
|
|
159
|
-
@prompt.yes?(question["prompt"], default: question.fetch("default", false))
|
|
160
|
-
end
|
|
161
|
-
|
|
162
|
-
def ask_input(question)
|
|
163
|
-
@prompt.ask(question["prompt"], default: question["default"])
|
|
164
|
-
end
|
|
165
|
-
|
|
166
|
-
def find_default(question)
|
|
167
|
-
# Support both default at question level and default: true on choice
|
|
168
|
-
return question["default"] if question.key?("default")
|
|
169
|
-
|
|
170
|
-
Array(question["choices"]).find { |c| c["default"] }&.[]("value")
|
|
93
|
+
def collect_answers
|
|
94
|
+
asker = QuestionAsker.new(questions: @config["questions"], prompt: @prompt)
|
|
95
|
+
@use_defaults ? asker.collect_defaults : asker.ask_all
|
|
171
96
|
end
|
|
172
97
|
|
|
173
98
|
def show_summary
|
|
@@ -212,9 +137,17 @@ module Railstart
|
|
|
212
137
|
end
|
|
213
138
|
|
|
214
139
|
def confirm_proceed?
|
|
140
|
+
return true if @assume_yes
|
|
141
|
+
|
|
215
142
|
@prompt.yes?("Proceed with app generation?")
|
|
216
143
|
end
|
|
217
144
|
|
|
145
|
+
def confirm_action?(action)
|
|
146
|
+
return action.fetch("default", true) if @assume_yes
|
|
147
|
+
|
|
148
|
+
@prompt.yes?(action["prompt"], default: action.fetch("default", true))
|
|
149
|
+
end
|
|
150
|
+
|
|
218
151
|
def generate_app
|
|
219
152
|
arguments = CommandBuilder.arguments(@app_name, @config, @answers)
|
|
220
153
|
command = CommandBuilder.build(@app_name, @config, @answers)
|
|
@@ -237,12 +170,13 @@ module Railstart
|
|
|
237
170
|
|
|
238
171
|
def run_post_actions
|
|
239
172
|
Dir.chdir(@app_name) do
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
173
|
+
PostActionRunner.new(
|
|
174
|
+
actions: @config["post_actions"],
|
|
175
|
+
answers: @answers,
|
|
176
|
+
app_name: @app_name,
|
|
177
|
+
confirmer: ->(action) { confirm_action?(action) },
|
|
178
|
+
command_runner: ->(command) { system(command) }
|
|
179
|
+
).run
|
|
246
180
|
|
|
247
181
|
puts
|
|
248
182
|
UI.success("Rails app created successfully at ./#{@app_name}")
|
|
@@ -250,69 +184,5 @@ module Railstart
|
|
|
250
184
|
rescue Errno::ENOENT
|
|
251
185
|
UI.warning("Could not change to app directory. Post-actions skipped.")
|
|
252
186
|
end
|
|
253
|
-
|
|
254
|
-
def process_post_action(action, template_runner)
|
|
255
|
-
return unless should_run_action?(action)
|
|
256
|
-
return unless confirm_action?(action)
|
|
257
|
-
|
|
258
|
-
if template_action?(action)
|
|
259
|
-
run_template_action(action, template_runner)
|
|
260
|
-
else
|
|
261
|
-
run_command_action(action)
|
|
262
|
-
end
|
|
263
|
-
end
|
|
264
|
-
|
|
265
|
-
def confirm_action?(action)
|
|
266
|
-
return true unless action["prompt"]
|
|
267
|
-
|
|
268
|
-
@prompt.yes?(action["prompt"], default: action.fetch("default", true))
|
|
269
|
-
end
|
|
270
|
-
|
|
271
|
-
def run_command_action(action)
|
|
272
|
-
UI.info(action["name"].to_s)
|
|
273
|
-
success = system(action["command"])
|
|
274
|
-
UI.warning("Post-action '#{action["name"]}' failed. Continuing anyway.") unless success
|
|
275
|
-
end
|
|
276
|
-
|
|
277
|
-
def run_template_action(action, template_runner)
|
|
278
|
-
return unless template_runner
|
|
279
|
-
|
|
280
|
-
UI.info(action["name"].to_s)
|
|
281
|
-
source = action["source"]
|
|
282
|
-
variables = template_variables(action)
|
|
283
|
-
template_runner.apply(source, variables: variables)
|
|
284
|
-
rescue TemplateError => e
|
|
285
|
-
UI.warning("Post-action '#{action["name"]}' failed. #{e.message}")
|
|
286
|
-
end
|
|
287
|
-
|
|
288
|
-
def template_variables(action)
|
|
289
|
-
base = { app_name: @app_name, answers: @answers }
|
|
290
|
-
extras = action["variables"].is_a?(Hash) ? action["variables"].transform_keys(&:to_sym) : {}
|
|
291
|
-
base.merge(extras)
|
|
292
|
-
end
|
|
293
|
-
|
|
294
|
-
def template_action?(action)
|
|
295
|
-
action["type"].to_s == "template"
|
|
296
|
-
end
|
|
297
|
-
|
|
298
|
-
def should_run_action?(action)
|
|
299
|
-
return false unless action.fetch("enabled", true)
|
|
300
|
-
|
|
301
|
-
if_condition = action["if"]
|
|
302
|
-
return true unless if_condition
|
|
303
|
-
|
|
304
|
-
question_id = if_condition["question"]
|
|
305
|
-
answer = @answers[question_id]
|
|
306
|
-
|
|
307
|
-
if if_condition.key?("equals")
|
|
308
|
-
answer == if_condition["equals"]
|
|
309
|
-
elsif if_condition.key?("includes")
|
|
310
|
-
expected = Array(if_condition["includes"])
|
|
311
|
-
actual = Array(answer)
|
|
312
|
-
expected.intersect?(actual)
|
|
313
|
-
else
|
|
314
|
-
true
|
|
315
|
-
end
|
|
316
|
-
end
|
|
317
187
|
end
|
|
318
188
|
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "file_writer"
|
|
4
|
+
require_relative "template_runner"
|
|
5
|
+
|
|
6
|
+
module Railstart
|
|
7
|
+
# Executes post-generation actions (commands, Rails templates, file writes)
|
|
8
|
+
# inside a generated application directory.
|
|
9
|
+
#
|
|
10
|
+
# @example Run enabled actions for a generated app
|
|
11
|
+
# Railstart::PostActionRunner.new(
|
|
12
|
+
# actions: config["post_actions"], answers: answers,
|
|
13
|
+
# app_name: "blog", prompt: prompt, assume_yes: false
|
|
14
|
+
# ).run
|
|
15
|
+
class PostActionRunner
|
|
16
|
+
#
|
|
17
|
+
# @param actions [Array<Hash>] post_action entries from the merged config
|
|
18
|
+
# @param answers [Hash] question answers used by action conditions
|
|
19
|
+
# @param app_name [String] application name for file interpolation and template variables
|
|
20
|
+
# @param confirmer [#call] decides whether a prompted action runs, given the action
|
|
21
|
+
# @param command_runner [#call, nil] executes shell commands; injectable for testing
|
|
22
|
+
def initialize(actions:, answers:, app_name:, confirmer:, command_runner: nil)
|
|
23
|
+
@actions = Array(actions)
|
|
24
|
+
@answers = answers
|
|
25
|
+
@app_name = app_name
|
|
26
|
+
@confirmer = confirmer
|
|
27
|
+
@command_runner = command_runner || ->(command) { system(command) }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
#
|
|
31
|
+
# Run every enabled action whose condition passes. The current working
|
|
32
|
+
# directory must be the generated app root.
|
|
33
|
+
#
|
|
34
|
+
# @return [void]
|
|
35
|
+
def run
|
|
36
|
+
template_runner = nil
|
|
37
|
+
|
|
38
|
+
@actions.each do |action|
|
|
39
|
+
template_runner ||= TemplateRunner.new(app_path: Dir.pwd) if template_action?(action)
|
|
40
|
+
process(action, template_runner)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def process(action, template_runner)
|
|
47
|
+
return unless should_run?(action)
|
|
48
|
+
return unless confirm?(action)
|
|
49
|
+
|
|
50
|
+
if template_action?(action)
|
|
51
|
+
run_template(action, template_runner)
|
|
52
|
+
elsif file_action?(action)
|
|
53
|
+
run_file(action)
|
|
54
|
+
else
|
|
55
|
+
run_command(action)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def confirm?(action)
|
|
60
|
+
return true unless action["prompt"]
|
|
61
|
+
|
|
62
|
+
@confirmer.call(action)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def run_command(action)
|
|
66
|
+
UI.info(action["name"].to_s)
|
|
67
|
+
success = @command_runner.call(action["command"])
|
|
68
|
+
UI.warning("Post-action '#{action["name"]}' failed. Continuing anyway.") unless success
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def run_template(action, template_runner)
|
|
72
|
+
return unless template_runner
|
|
73
|
+
|
|
74
|
+
UI.info(action["name"].to_s)
|
|
75
|
+
template_runner.apply(action["source"], variables: template_variables(action))
|
|
76
|
+
rescue TemplateError => e
|
|
77
|
+
UI.warning("Post-action '#{action["name"]}' failed. #{e.message}")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def run_file(action)
|
|
81
|
+
UI.info(action["name"].to_s)
|
|
82
|
+
writer = FileWriter.new(app_path: Dir.pwd, app_name: @app_name)
|
|
83
|
+
result = writer.write(
|
|
84
|
+
path: action["path"],
|
|
85
|
+
content: action["content"],
|
|
86
|
+
source: action["source"],
|
|
87
|
+
overwrite: action.fetch("overwrite", false)
|
|
88
|
+
)
|
|
89
|
+
if result == :skipped
|
|
90
|
+
UI.warning("Skipped '#{action["path"]}' (already exists)")
|
|
91
|
+
else
|
|
92
|
+
UI.success("Created #{action["path"]}")
|
|
93
|
+
end
|
|
94
|
+
rescue Error, SystemCallError => e
|
|
95
|
+
UI.warning("Post-action '#{action["name"]}' failed. #{e.message}")
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def template_variables(action)
|
|
99
|
+
base = { app_name: @app_name, answers: @answers }
|
|
100
|
+
extras = action["variables"].is_a?(Hash) ? action["variables"].transform_keys(&:to_sym) : {}
|
|
101
|
+
base.merge(extras)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def template_action?(action)
|
|
105
|
+
action["type"].to_s == "template"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def file_action?(action)
|
|
109
|
+
action["type"].to_s == "file"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def should_run?(action)
|
|
113
|
+
return false unless action.fetch("enabled", true)
|
|
114
|
+
|
|
115
|
+
if_condition = action["if"]
|
|
116
|
+
return true unless if_condition
|
|
117
|
+
|
|
118
|
+
answer = @answers[if_condition["question"]]
|
|
119
|
+
|
|
120
|
+
if if_condition.key?("equals")
|
|
121
|
+
answer == if_condition["equals"]
|
|
122
|
+
elsif if_condition.key?("includes")
|
|
123
|
+
expected = Array(if_condition["includes"])
|
|
124
|
+
expected.intersect?(Array(answer))
|
|
125
|
+
else
|
|
126
|
+
true
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railstart
|
|
4
|
+
# Answers configuration questions, either by collecting their defaults
|
|
5
|
+
# (non-interactive) or by prompting the user via TTY::Prompt.
|
|
6
|
+
#
|
|
7
|
+
# @example Collect defaults without prompting
|
|
8
|
+
# Railstart::QuestionAsker.new(questions: config["questions"], prompt: prompt).collect_defaults
|
|
9
|
+
# @example Ask every question interactively
|
|
10
|
+
# Railstart::QuestionAsker.new(questions: config["questions"], prompt: prompt).ask_all
|
|
11
|
+
class QuestionAsker
|
|
12
|
+
#
|
|
13
|
+
# @param questions [Array<Hash>] question entries from the merged config
|
|
14
|
+
# @param prompt [TTY::Prompt] prompt used for interactive questions
|
|
15
|
+
def initialize(questions:, prompt:)
|
|
16
|
+
@questions = Array(questions)
|
|
17
|
+
@prompt = prompt
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
#
|
|
21
|
+
# Build an answers hash from question defaults, skipping conditional
|
|
22
|
+
# questions whose dependency is not met.
|
|
23
|
+
#
|
|
24
|
+
# @return [Hash] question id => default value
|
|
25
|
+
def collect_defaults
|
|
26
|
+
answers = {}
|
|
27
|
+
@questions.each do |question|
|
|
28
|
+
next if skip_question?(question, answers)
|
|
29
|
+
|
|
30
|
+
default_value = find_default(question)
|
|
31
|
+
answers[question["id"]] = default_value unless default_value.nil?
|
|
32
|
+
end
|
|
33
|
+
answers
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
#
|
|
37
|
+
# Prompt for every question, respecting depends_on conditions.
|
|
38
|
+
#
|
|
39
|
+
# @return [Hash] question id => answer
|
|
40
|
+
def ask_all
|
|
41
|
+
answers = {}
|
|
42
|
+
@questions.each do |question|
|
|
43
|
+
next if skip_question?(question, answers)
|
|
44
|
+
|
|
45
|
+
answers[question["id"]] = ask_question(question)
|
|
46
|
+
end
|
|
47
|
+
answers
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def skip_question?(question, answers)
|
|
53
|
+
depends = question["depends_on"]
|
|
54
|
+
return false unless depends
|
|
55
|
+
|
|
56
|
+
answers[depends["question"]] != depends["value"]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def ask_question(question)
|
|
60
|
+
case question["type"]
|
|
61
|
+
when "select"
|
|
62
|
+
ask_select(question)
|
|
63
|
+
when "multi_select"
|
|
64
|
+
ask_multi_select(question)
|
|
65
|
+
when "yes_no"
|
|
66
|
+
ask_yes_no?(question)
|
|
67
|
+
when "input"
|
|
68
|
+
ask_input(question)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def ask_select(question)
|
|
73
|
+
# Convert to hash format: { 'Display Name' => 'value' }
|
|
74
|
+
choices = question["choices"].to_h { |choice| [choice["name"], choice["value"]] }
|
|
75
|
+
default_val = find_default(question)
|
|
76
|
+
|
|
77
|
+
# TTY::Prompt expects 1-based index for default
|
|
78
|
+
default_index = (question["choices"].index { |c| c["value"] == default_val }&.+(1) if default_val)
|
|
79
|
+
|
|
80
|
+
@prompt.select(question["prompt"], choices, default: default_index)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def ask_multi_select(question)
|
|
84
|
+
# Convert to hash format: { 'Display Name' => 'value' }
|
|
85
|
+
choices = question["choices"].to_h { |choice| [choice["name"], choice["value"]] }
|
|
86
|
+
|
|
87
|
+
# Transform value-based defaults to name-based defaults for TTY::Prompt
|
|
88
|
+
# Config uses stable values (e.g., "action_mailer"), TTY::Prompt needs display names
|
|
89
|
+
value_defaults = question["default"] || []
|
|
90
|
+
name_defaults = value_defaults.map do |value|
|
|
91
|
+
choice = question["choices"].find { |c| c["value"] == value }
|
|
92
|
+
choice ? choice["name"] : nil
|
|
93
|
+
end.compact
|
|
94
|
+
|
|
95
|
+
@prompt.multi_select(question["prompt"], choices, default: name_defaults)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def ask_yes_no?(question)
|
|
99
|
+
@prompt.yes?(question["prompt"], default: question.fetch("default", false))
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def ask_input(question)
|
|
103
|
+
@prompt.ask(question["prompt"], default: question["default"])
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def find_default(question)
|
|
107
|
+
# Support both default at question level and default: true on choice
|
|
108
|
+
return question["default"] if question.key?("default")
|
|
109
|
+
|
|
110
|
+
Array(question["choices"]).find { |c| c["default"] }&.[]("value")
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
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.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- dpaluy
|
|
@@ -71,13 +71,18 @@ files:
|
|
|
71
71
|
- config/presets/default.yaml
|
|
72
72
|
- config/presets/vite-bun.yaml
|
|
73
73
|
- config/rails8_defaults.yaml
|
|
74
|
+
- config/templates/AGENTS.md
|
|
75
|
+
- config/templates/bin/ci
|
|
74
76
|
- exe/railstart
|
|
75
77
|
- lib/railstart.rb
|
|
76
78
|
- lib/railstart/cli.rb
|
|
77
79
|
- lib/railstart/command_builder.rb
|
|
78
80
|
- lib/railstart/config.rb
|
|
79
81
|
- lib/railstart/errors.rb
|
|
82
|
+
- lib/railstart/file_writer.rb
|
|
80
83
|
- lib/railstart/generator.rb
|
|
84
|
+
- lib/railstart/post_action_runner.rb
|
|
85
|
+
- lib/railstart/question_asker.rb
|
|
81
86
|
- lib/railstart/template_runner.rb
|
|
82
87
|
- lib/railstart/ui.rb
|
|
83
88
|
- lib/railstart/version.rb
|