railstart 0.1.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 +7 -0
- data/.yardopts +9 -0
- data/CHANGELOG.md +12 -0
- data/LICENSE.txt +21 -0
- data/README.md +240 -0
- data/Rakefile +22 -0
- data/config/rails8_defaults.yaml +124 -0
- data/exe/railstart +6 -0
- data/lib/railstart/cli.rb +39 -0
- data/lib/railstart/command_builder.rb +70 -0
- data/lib/railstart/config.rb +259 -0
- data/lib/railstart/errors.rb +28 -0
- data/lib/railstart/generator.rb +226 -0
- data/lib/railstart/version.rb +5 -0
- data/lib/railstart.rb +22 -0
- metadata +92 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 06f6eda8bd62deeef7ba87448cd471a783f36a31c8a0026a6f4813883dc4ea7e
|
|
4
|
+
data.tar.gz: d493a6c6f85c095e45f0fbc8f5c380488af1fc57fbdd07802f7fe8481e528f4d
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 0a45f5201a19322362da2029b79b5a96439c4c68d0f1201f605bc88008adfc602a28bba0423f3db3a7b43acc5d4ded66a42ad11f3ed281483b417ec31ab32c16
|
|
7
|
+
data.tar.gz: 95f319acae2e68a8ed747cb98fc20f6194e9d85947c5b63a345bf4b0d95b72c21f3a08a9638347f75ca7a619f278e10ef29ea26d52b620c2d18f7ffc34006c5a
|
data/.yardopts
ADDED
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
## [0.1.0] - 2025-11-21
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- Initial release
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 dpaluy
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
# Railstart
|
|
2
|
+
|
|
3
|
+
[](https://badge.fury.io/rb/railstart)
|
|
4
|
+
[](https://rubydoc.info/gems/railstart)
|
|
5
|
+
[](https://github.com/dpaluy/railstart/actions/workflows/ci.yml)
|
|
6
|
+
|
|
7
|
+
Interactive CLI wizard for generating Rails 8 applications with customizable configuration and smart defaults.
|
|
8
|
+
|
|
9
|
+
Think of it as `rails new` with an opinion and a friendly interactive experience.
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
gem install railstart
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
### Generate a new Rails app
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
railstart new my_app
|
|
23
|
+
|
|
24
|
+
# Or run without arguments for help
|
|
25
|
+
railstart
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
This launches an interactive wizard that guides you through Rails app setup:
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
Which database?
|
|
32
|
+
1) SQLite (default)
|
|
33
|
+
2) PostgreSQL
|
|
34
|
+
3) MySQL
|
|
35
|
+
> 2
|
|
36
|
+
|
|
37
|
+
Which CSS framework?
|
|
38
|
+
1) Tailwind (default)
|
|
39
|
+
2) Bootstrap
|
|
40
|
+
3) Bulma
|
|
41
|
+
4) PostCSS
|
|
42
|
+
5) None
|
|
43
|
+
> 1
|
|
44
|
+
|
|
45
|
+
Which JavaScript bundler?
|
|
46
|
+
1) Importmap (default)
|
|
47
|
+
2) esbuild
|
|
48
|
+
3) Webpack
|
|
49
|
+
4) Rollup
|
|
50
|
+
> 1
|
|
51
|
+
|
|
52
|
+
Skip any features?
|
|
53
|
+
(space to select, enter when done)
|
|
54
|
+
‣ ⓞ Action Mailer
|
|
55
|
+
ⓞ Action Mailbox
|
|
56
|
+
ⓞ Action Text
|
|
57
|
+
...
|
|
58
|
+
|
|
59
|
+
Generate API-only app?
|
|
60
|
+
> No
|
|
61
|
+
|
|
62
|
+
... (more questions) ...
|
|
63
|
+
|
|
64
|
+
Summary
|
|
65
|
+
════════════════════════════════════════
|
|
66
|
+
App name: my_app
|
|
67
|
+
Database: postgresql
|
|
68
|
+
CSS: tailwind
|
|
69
|
+
JavaScript: importmap
|
|
70
|
+
Skipped: (none)
|
|
71
|
+
API: No
|
|
72
|
+
════════════════════════════════════════
|
|
73
|
+
|
|
74
|
+
Proceed with app generation? Yes
|
|
75
|
+
|
|
76
|
+
Running: rails new my_app --database=postgresql --css=tailwind ...
|
|
77
|
+
|
|
78
|
+
Creating Rails app...
|
|
79
|
+
✨ Rails app created successfully at ./my_app
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Skip interactive mode (use defaults)
|
|
83
|
+
|
|
84
|
+
Use the `--default` flag to skip all questions and apply built-in defaults:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
railstart new my_app --default
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
This creates a PostgreSQL + Tailwind + Importmap Rails app instantly.
|
|
91
|
+
|
|
92
|
+
## Configuration
|
|
93
|
+
|
|
94
|
+
### Built-in Defaults
|
|
95
|
+
|
|
96
|
+
Railstart ships with sensible Rails 8 defaults defined in `config/rails8_defaults.yaml`. These drive the interactive questions and their defaults.
|
|
97
|
+
|
|
98
|
+
### Customize for Your Team
|
|
99
|
+
|
|
100
|
+
Create a `~/.config/railstart/config.yaml` file to override defaults:
|
|
101
|
+
|
|
102
|
+
```yaml
|
|
103
|
+
questions:
|
|
104
|
+
- id: database
|
|
105
|
+
choices:
|
|
106
|
+
- name: PostgreSQL (recommended)
|
|
107
|
+
value: postgresql
|
|
108
|
+
default: true # Your team's preference
|
|
109
|
+
|
|
110
|
+
post_actions:
|
|
111
|
+
- id: bundle_install
|
|
112
|
+
enabled: false # Your team manages gems differently
|
|
113
|
+
|
|
114
|
+
- id: setup_auth
|
|
115
|
+
name: "Setup authentication"
|
|
116
|
+
enabled: true
|
|
117
|
+
command: "bundle exec rails generate devise:install"
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
**Merge behavior:**
|
|
121
|
+
|
|
122
|
+
- User config (at `~/.config/railstart/config.yaml`) overrides built-in config
|
|
123
|
+
- By `id`: questions and post-actions are merged by their unique `id`
|
|
124
|
+
- If you override a question's `choices`, the entire choice list is replaced
|
|
125
|
+
- New questions/actions are appended in order
|
|
126
|
+
|
|
127
|
+
### Configuration Schema
|
|
128
|
+
|
|
129
|
+
#### Questions
|
|
130
|
+
|
|
131
|
+
```yaml
|
|
132
|
+
questions:
|
|
133
|
+
- id: database # unique identifier
|
|
134
|
+
type: select|multi_select|yes_no|input
|
|
135
|
+
prompt: "User-facing question"
|
|
136
|
+
help: "Optional inline help text"
|
|
137
|
+
default: value_or_true_or_false
|
|
138
|
+
|
|
139
|
+
# For select/multi_select
|
|
140
|
+
choices:
|
|
141
|
+
- name: "Display name"
|
|
142
|
+
value: "internal_value"
|
|
143
|
+
default: true # at most one per select
|
|
144
|
+
rails_flag: "--flag=%{value}"
|
|
145
|
+
|
|
146
|
+
# For yes_no/input
|
|
147
|
+
rails_flag: "--flag" # or --flag=%{value}
|
|
148
|
+
|
|
149
|
+
# Optional: only ask if condition is met
|
|
150
|
+
depends_on:
|
|
151
|
+
question: other_question_id
|
|
152
|
+
value: expected_value
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
**Question types:**
|
|
156
|
+
|
|
157
|
+
- `select` - Single choice; returns scalar value
|
|
158
|
+
- `multi_select` - Multiple choices; returns array
|
|
159
|
+
- `yes_no` - Boolean; returns true/false
|
|
160
|
+
- `input` - Free text; returns string
|
|
161
|
+
|
|
162
|
+
#### Post-actions
|
|
163
|
+
|
|
164
|
+
```yaml
|
|
165
|
+
post_actions:
|
|
166
|
+
- id: my_action # unique identifier
|
|
167
|
+
name: "Human readable name"
|
|
168
|
+
enabled: true # can be disabled
|
|
169
|
+
command: "shell command to run"
|
|
170
|
+
|
|
171
|
+
# Optional: prompt user before running
|
|
172
|
+
prompt: "Run this action?"
|
|
173
|
+
default: true
|
|
174
|
+
|
|
175
|
+
# Optional: only run if condition is met
|
|
176
|
+
if:
|
|
177
|
+
question: question_id
|
|
178
|
+
equals: value # or includes: [array, values]
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
## Development
|
|
182
|
+
|
|
183
|
+
### Setup
|
|
184
|
+
|
|
185
|
+
```bash
|
|
186
|
+
# Install dependencies
|
|
187
|
+
bundle install
|
|
188
|
+
|
|
189
|
+
# Or use the setup script
|
|
190
|
+
bin/setup
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
### Testing the CLI
|
|
194
|
+
|
|
195
|
+
```bash
|
|
196
|
+
# Test the executable during development
|
|
197
|
+
bundle exec exe/railstart new my_app
|
|
198
|
+
bundle exec exe/railstart new my_app --default
|
|
199
|
+
|
|
200
|
+
# Interactive console for experimenting
|
|
201
|
+
bin/console
|
|
202
|
+
# Then in IRB:
|
|
203
|
+
# Railstart::CLI.start(["new", "my_app"])
|
|
204
|
+
|
|
205
|
+
# Install locally to test as a real gem
|
|
206
|
+
gem build railstart.gemspec
|
|
207
|
+
gem install railstart-0.1.0.gem
|
|
208
|
+
railstart new my_app
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
### Running Tests
|
|
212
|
+
|
|
213
|
+
```bash
|
|
214
|
+
# Run tests
|
|
215
|
+
bundle exec rake test
|
|
216
|
+
|
|
217
|
+
# Lint code
|
|
218
|
+
bundle exec rubocop
|
|
219
|
+
|
|
220
|
+
# Lint and auto-fix
|
|
221
|
+
bundle exec rubocop -a
|
|
222
|
+
|
|
223
|
+
# Full check
|
|
224
|
+
bundle exec rake test && bundle exec rubocop
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
## Architecture
|
|
228
|
+
|
|
229
|
+
- **Config System** (`lib/railstart/config.rb`) - Loads and merges YAML configurations
|
|
230
|
+
- **Generator** (`lib/railstart/generator.rb`) - Orchestrates interactive flow
|
|
231
|
+
- **Command Builder** (`lib/railstart/command_builder.rb`) - Translates answers to `rails new` flags
|
|
232
|
+
- **CLI** (`lib/railstart/cli.rb`) - Thor command interface
|
|
233
|
+
|
|
234
|
+
## Contributing
|
|
235
|
+
|
|
236
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/dpaluy/railstart.
|
|
237
|
+
|
|
238
|
+
## License
|
|
239
|
+
|
|
240
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bundler/gem_tasks"
|
|
4
|
+
require "rake/testtask"
|
|
5
|
+
|
|
6
|
+
Rake::TestTask.new(:test) do |t|
|
|
7
|
+
t.libs << "test"
|
|
8
|
+
t.libs << "lib"
|
|
9
|
+
t.test_files = FileList["test/**/*_test.rb"]
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
begin
|
|
13
|
+
require "yard"
|
|
14
|
+
YARD::Rake::YardocTask.new(:yard) do |t|
|
|
15
|
+
t.files = ["lib/**/*.rb"]
|
|
16
|
+
t.options = ["--output-dir", "doc", "--markup", "markdown"]
|
|
17
|
+
end
|
|
18
|
+
rescue LoadError
|
|
19
|
+
# YARD not available
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
task default: :test
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
---
|
|
2
|
+
# Railstart Rails 8 Default Configuration
|
|
3
|
+
# This configuration defines all available questions and post-generation actions
|
|
4
|
+
# Users can override this by creating ~/.config/railstart/config.yaml
|
|
5
|
+
|
|
6
|
+
questions:
|
|
7
|
+
- id: database
|
|
8
|
+
type: select
|
|
9
|
+
prompt: "Which database?"
|
|
10
|
+
choices:
|
|
11
|
+
- name: SQLite
|
|
12
|
+
value: sqlite3
|
|
13
|
+
default: true
|
|
14
|
+
- name: PostgreSQL
|
|
15
|
+
value: postgresql
|
|
16
|
+
- name: MySQL
|
|
17
|
+
value: mysql
|
|
18
|
+
rails_flag: "--database=%<value>s"
|
|
19
|
+
|
|
20
|
+
- id: css
|
|
21
|
+
type: select
|
|
22
|
+
prompt: "Which CSS framework?"
|
|
23
|
+
choices:
|
|
24
|
+
- name: Tailwind
|
|
25
|
+
value: tailwind
|
|
26
|
+
default: true
|
|
27
|
+
- name: Bootstrap
|
|
28
|
+
value: bootstrap
|
|
29
|
+
- name: Bulma
|
|
30
|
+
value: bulma
|
|
31
|
+
- name: PostCSS (no framework)
|
|
32
|
+
value: postcss
|
|
33
|
+
- name: None
|
|
34
|
+
value: none
|
|
35
|
+
rails_flag: "--css=%{value}"
|
|
36
|
+
|
|
37
|
+
- id: javascript
|
|
38
|
+
type: select
|
|
39
|
+
prompt: "Which JavaScript bundler?"
|
|
40
|
+
choices:
|
|
41
|
+
- name: Importmap (default)
|
|
42
|
+
value: importmap
|
|
43
|
+
default: true
|
|
44
|
+
- name: esbuild
|
|
45
|
+
value: esbuild
|
|
46
|
+
- name: Webpack
|
|
47
|
+
value: webpack
|
|
48
|
+
- name: Rollup
|
|
49
|
+
value: rollup
|
|
50
|
+
rails_flag: "--javascript=%{value}"
|
|
51
|
+
|
|
52
|
+
- id: skip_features
|
|
53
|
+
type: multi_select
|
|
54
|
+
prompt: "Skip any features?"
|
|
55
|
+
choices:
|
|
56
|
+
- name: Action Mailer
|
|
57
|
+
value: action_mailer
|
|
58
|
+
rails_flag: "--skip-action-mailer"
|
|
59
|
+
- name: Action Mailbox
|
|
60
|
+
value: action_mailbox
|
|
61
|
+
rails_flag: "--skip-action-mailbox"
|
|
62
|
+
- name: Action Text
|
|
63
|
+
value: action_text
|
|
64
|
+
rails_flag: "--skip-action-text"
|
|
65
|
+
- name: Active Record
|
|
66
|
+
value: active_record
|
|
67
|
+
rails_flag: "--skip-active-record"
|
|
68
|
+
- name: Active Job
|
|
69
|
+
value: active_job
|
|
70
|
+
rails_flag: "--skip-active-job"
|
|
71
|
+
- name: Active Storage
|
|
72
|
+
value: active_storage
|
|
73
|
+
rails_flag: "--skip-active-storage"
|
|
74
|
+
- name: Action Cable
|
|
75
|
+
value: action_cable
|
|
76
|
+
rails_flag: "--skip-action-cable"
|
|
77
|
+
- name: Hotwire (Turbo + Stimulus)
|
|
78
|
+
value: hotwire
|
|
79
|
+
rails_flag: "--skip-hotwire"
|
|
80
|
+
|
|
81
|
+
- id: api_only
|
|
82
|
+
type: yes_no
|
|
83
|
+
prompt: "Generate API-only app? (no views, minimal middleware)"
|
|
84
|
+
default: false
|
|
85
|
+
rails_flag: "--api"
|
|
86
|
+
|
|
87
|
+
- id: skip_git
|
|
88
|
+
type: yes_no
|
|
89
|
+
prompt: "Skip git initialization?"
|
|
90
|
+
default: false
|
|
91
|
+
rails_flag: "--skip-git"
|
|
92
|
+
|
|
93
|
+
- id: skip_docker
|
|
94
|
+
type: yes_no
|
|
95
|
+
prompt: "Skip Docker setup?"
|
|
96
|
+
default: false
|
|
97
|
+
rails_flag: "--skip-docker"
|
|
98
|
+
|
|
99
|
+
- id: skip_bundle
|
|
100
|
+
type: yes_no
|
|
101
|
+
prompt: "Skip bundle install? (run manually later)"
|
|
102
|
+
default: false
|
|
103
|
+
rails_flag: "--skip-bundle"
|
|
104
|
+
|
|
105
|
+
post_actions:
|
|
106
|
+
- id: init_git
|
|
107
|
+
name: "Initialize git repository"
|
|
108
|
+
enabled: true
|
|
109
|
+
prompt: "Initialize git and create first commit?"
|
|
110
|
+
default: true
|
|
111
|
+
if:
|
|
112
|
+
question: skip_git
|
|
113
|
+
equals: false
|
|
114
|
+
command: "git init && git add . && git commit -m 'Initial commit'"
|
|
115
|
+
|
|
116
|
+
- id: bundle_install
|
|
117
|
+
name: "Install gems"
|
|
118
|
+
enabled: true
|
|
119
|
+
prompt: "Run bundle install?"
|
|
120
|
+
default: true
|
|
121
|
+
if:
|
|
122
|
+
question: skip_bundle
|
|
123
|
+
equals: true
|
|
124
|
+
command: "bundle install"
|
data/exe/railstart
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "thor"
|
|
4
|
+
require_relative "generator"
|
|
5
|
+
|
|
6
|
+
module Railstart
|
|
7
|
+
# CLI commands for Railstart, exposing Thor tasks for interactive generation.
|
|
8
|
+
#
|
|
9
|
+
# @example Run the wizard with defaults
|
|
10
|
+
# Railstart::CLI.start(%w[new my_app --default])
|
|
11
|
+
# @example Print version
|
|
12
|
+
# Railstart::CLI.start(%w[version])
|
|
13
|
+
class CLI < Thor
|
|
14
|
+
desc "new [APP_NAME]", "Start a new interactive Rails app setup"
|
|
15
|
+
option :default, type: :boolean, default: false, desc: "Use default configuration without prompting"
|
|
16
|
+
#
|
|
17
|
+
# @param app_name [String, nil] desired Rails app name, prompted if omitted
|
|
18
|
+
# @return [void]
|
|
19
|
+
# @raise [Railstart::Error] when generation fails due to configuration or runtime errors
|
|
20
|
+
# @example Start wizard with prompts
|
|
21
|
+
# Railstart::CLI.start(%w[new my_app])
|
|
22
|
+
def new(app_name = nil)
|
|
23
|
+
generator = Generator.new(app_name, use_defaults: options[:default])
|
|
24
|
+
generator.run
|
|
25
|
+
rescue Railstart::Error => e
|
|
26
|
+
puts "Error: #{e.message}"
|
|
27
|
+
exit 1
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
desc "version", "Print Railstart version"
|
|
31
|
+
#
|
|
32
|
+
# @return [void]
|
|
33
|
+
# @example Display version string
|
|
34
|
+
# Railstart::CLI.start(%w[version])
|
|
35
|
+
def version
|
|
36
|
+
puts "Railstart v#{Railstart::VERSION}"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railstart
|
|
4
|
+
# Translates configuration and user answers into a `rails new` command string.
|
|
5
|
+
#
|
|
6
|
+
# This class provides deterministic, side-effect-free command construction.
|
|
7
|
+
#
|
|
8
|
+
# @example Build a command from answers
|
|
9
|
+
# config = { "questions" => [{ "id" => "database", "type" => "select", "rails_flag" => "--database=%{value}" }] }
|
|
10
|
+
# answers = { "database" => "postgresql" }
|
|
11
|
+
# Railstart::CommandBuilder.build("blog", config, answers)
|
|
12
|
+
# # => "rails new blog --database=postgresql"
|
|
13
|
+
class CommandBuilder
|
|
14
|
+
class << self
|
|
15
|
+
#
|
|
16
|
+
# Build a `rails new` command string using config metadata and answers.
|
|
17
|
+
#
|
|
18
|
+
# @param app_name [String] target Rails app name
|
|
19
|
+
# @param config [Hash] merged configuration from {Railstart::Config.load}
|
|
20
|
+
# @param answers [Hash] user answers keyed by question id
|
|
21
|
+
# @return [String] fully assembled CLI command
|
|
22
|
+
# @raise [Railstart::ConfigError] when flag interpolation fails
|
|
23
|
+
# @example
|
|
24
|
+
# Railstart::CommandBuilder.build("todo", config, answers)
|
|
25
|
+
def build(app_name, config, answers)
|
|
26
|
+
flags = collect_flags(config["questions"], answers)
|
|
27
|
+
"rails new #{app_name} #{flags.join(" ")}".strip
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def collect_flags(questions, answers)
|
|
33
|
+
flags = []
|
|
34
|
+
Array(questions).each do |question|
|
|
35
|
+
answer = answers[question["id"]]
|
|
36
|
+
next unless answer
|
|
37
|
+
|
|
38
|
+
process_question_flags(flags, question, answer)
|
|
39
|
+
end
|
|
40
|
+
flags
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def process_question_flags(flags, question, answer)
|
|
44
|
+
case question["type"]
|
|
45
|
+
when "select", "yes_no", "input"
|
|
46
|
+
add_flags(flags, question, answer)
|
|
47
|
+
when "multi_select"
|
|
48
|
+
process_multi_select(flags, question, answer)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def process_multi_select(flags, question, answer)
|
|
53
|
+
Array(question["choices"]).each do |choice|
|
|
54
|
+
next unless Array(answer).include?(choice["value"])
|
|
55
|
+
|
|
56
|
+
add_flags(flags, choice, choice["value"])
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def add_flags(flags, source, value)
|
|
61
|
+
flag_list = source["rails_flags"] || [source["rails_flag"]].compact
|
|
62
|
+
|
|
63
|
+
flag_list.each do |flag|
|
|
64
|
+
interpolated = Config.interpolate_flag(flag, value)
|
|
65
|
+
flags << interpolated
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require_relative "errors"
|
|
5
|
+
|
|
6
|
+
module Railstart
|
|
7
|
+
# Provides loading, merging, and validation of Railstart configuration data.
|
|
8
|
+
#
|
|
9
|
+
# Combines built-in defaults with optional user overrides and exposes helpers
|
|
10
|
+
# for downstream components.
|
|
11
|
+
class Config
|
|
12
|
+
BUILTIN_CONFIG_PATH = File.expand_path("../../config/rails8_defaults.yaml", __dir__)
|
|
13
|
+
USER_CONFIG_PATH = File.expand_path("~/.config/railstart/config.yaml")
|
|
14
|
+
QUESTION_TYPES = %w[select multi_select yes_no input].freeze
|
|
15
|
+
CHOICE_REQUIRED_TYPES = %w[select multi_select].freeze
|
|
16
|
+
MERGEABLE_COLLECTIONS = %w[questions post_actions].freeze
|
|
17
|
+
|
|
18
|
+
class << self
|
|
19
|
+
#
|
|
20
|
+
# Load, merge, and validate configuration from built-in and user sources.
|
|
21
|
+
#
|
|
22
|
+
# @param builtin_path [String] path to default config YAML shipped with the gem
|
|
23
|
+
# @param user_path [String] optional user override YAML path
|
|
24
|
+
# @return [Hash] deep-copied, merged, validated configuration hash
|
|
25
|
+
# @raise [Railstart::ConfigLoadError] when YAML files are missing or unreadable
|
|
26
|
+
# @raise [Railstart::ConfigValidationError] when validation fails
|
|
27
|
+
# @example
|
|
28
|
+
# config = Railstart::Config.load
|
|
29
|
+
def load(builtin_path: BUILTIN_CONFIG_PATH, user_path: USER_CONFIG_PATH)
|
|
30
|
+
builtin = read_yaml(builtin_path, required: true)
|
|
31
|
+
user = read_yaml(user_path, required: false)
|
|
32
|
+
merged = merge_config(builtin, user)
|
|
33
|
+
validate!(merged)
|
|
34
|
+
merged
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
#
|
|
38
|
+
# Interpolate `%{value}` placeholders within Rails flags.
|
|
39
|
+
#
|
|
40
|
+
# @param template [String] flag template
|
|
41
|
+
# @param value [Object] value to substitute into the template
|
|
42
|
+
# @return [String] interpolated flag string
|
|
43
|
+
# @raise [Railstart::ConfigError] when placeholder tokens are invalid
|
|
44
|
+
# @example
|
|
45
|
+
# Railstart::Config.interpolate_flag("--database=%{value}", "postgresql")
|
|
46
|
+
# # => "--database=postgresql"
|
|
47
|
+
def interpolate_flag(template, value)
|
|
48
|
+
return template if template.nil? || (!template.include?("%{") && !template.include?("%<"))
|
|
49
|
+
|
|
50
|
+
format(template, value: value)
|
|
51
|
+
rescue KeyError => e
|
|
52
|
+
raise ConfigError, "Invalid interpolation token in rails_flag \"#{template}\": #{e.message}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def read_yaml(path, required:)
|
|
58
|
+
return {} if path.nil? || path.to_s.empty?
|
|
59
|
+
|
|
60
|
+
unless File.exist?(path)
|
|
61
|
+
raise ConfigLoadError, "Missing required config file: #{path}" if required
|
|
62
|
+
|
|
63
|
+
return {}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
data = YAML.safe_load_file(path, aliases: true) || {}
|
|
67
|
+
raise ConfigLoadError, "Config file #{path} must define a Hash at the top level" unless data.is_a?(Hash)
|
|
68
|
+
|
|
69
|
+
deep_dup(data)
|
|
70
|
+
rescue Errno::EACCES => e
|
|
71
|
+
raise ConfigLoadError, "Cannot read #{path}: #{e.message}"
|
|
72
|
+
rescue Psych::Exception => e
|
|
73
|
+
raise ConfigLoadError, "Failed to parse #{path}: #{e.message}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def merge_config(base, override)
|
|
77
|
+
normalized_base = base || {}
|
|
78
|
+
return deep_dup(normalized_base) if override.nil? || override.empty?
|
|
79
|
+
|
|
80
|
+
deep_merge_hash(normalized_base, override)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def deep_merge_hash(base, override)
|
|
84
|
+
return deep_dup(base || {}) if override.nil? || override.empty?
|
|
85
|
+
|
|
86
|
+
result = deep_dup(base || {})
|
|
87
|
+
override.each do |key, override_value|
|
|
88
|
+
result[key] = deep_merge_value(key, result[key], override_value)
|
|
89
|
+
end
|
|
90
|
+
result
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def deep_merge_value(key, left, right)
|
|
94
|
+
return deep_dup(left) if right.nil?
|
|
95
|
+
return deep_dup(right) if left.nil?
|
|
96
|
+
|
|
97
|
+
if special_array_key?(key)
|
|
98
|
+
merge_id_array(left, right)
|
|
99
|
+
elsif left.is_a?(Hash) && right.is_a?(Hash)
|
|
100
|
+
deep_merge_hash(left, right)
|
|
101
|
+
else
|
|
102
|
+
deep_dup(right)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def merge_id_array(base, override)
|
|
107
|
+
base_entries = Array(base)
|
|
108
|
+
override_entries = Array(override)
|
|
109
|
+
|
|
110
|
+
map = {}
|
|
111
|
+
order = []
|
|
112
|
+
base_without_id = []
|
|
113
|
+
|
|
114
|
+
base_entries.each do |entry|
|
|
115
|
+
copy = deep_dup(entry)
|
|
116
|
+
id = fetch_id(copy)
|
|
117
|
+
if id
|
|
118
|
+
order << id unless order.include?(id)
|
|
119
|
+
map[id] = copy
|
|
120
|
+
else
|
|
121
|
+
base_without_id << copy
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
override_without_id = []
|
|
126
|
+
override_entries.each do |entry|
|
|
127
|
+
copy = deep_dup(entry)
|
|
128
|
+
id = fetch_id(copy)
|
|
129
|
+
if id
|
|
130
|
+
order << id unless order.include?(id)
|
|
131
|
+
map[id] = merge_entries(map[id], copy)
|
|
132
|
+
else
|
|
133
|
+
override_without_id << copy
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
order.map { |id| map[id] } + base_without_id + override_without_id
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def merge_entries(left, right)
|
|
141
|
+
return deep_dup(right) if left.nil?
|
|
142
|
+
return deep_dup(left) if right.nil?
|
|
143
|
+
|
|
144
|
+
if left.is_a?(Hash) && right.is_a?(Hash)
|
|
145
|
+
deep_merge_hash(left, right)
|
|
146
|
+
else
|
|
147
|
+
deep_dup(right)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def fetch_id(entry)
|
|
152
|
+
return unless entry.respond_to?(:[])
|
|
153
|
+
|
|
154
|
+
entry["id"] || entry[:id]
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def validate!(config)
|
|
158
|
+
issues = []
|
|
159
|
+
question_ids = Array(config["questions"]).map { |e| fetch_id(e) }.compact
|
|
160
|
+
|
|
161
|
+
MERGEABLE_COLLECTIONS.each do |collection|
|
|
162
|
+
entries = Array(config[collection])
|
|
163
|
+
issues.concat(validate_collection(collection, entries, question_ids))
|
|
164
|
+
end
|
|
165
|
+
raise ConfigValidationError.new("Invalid configuration", issues: issues) unless issues.empty?
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def validate_collection(name, entries, question_ids = [])
|
|
169
|
+
issues = []
|
|
170
|
+
id_counts = Hash.new(0)
|
|
171
|
+
|
|
172
|
+
entries.each_with_index do |entry, index|
|
|
173
|
+
unless entry.is_a?(Hash)
|
|
174
|
+
issues << "#{name} entry at index #{index} must be a Hash"
|
|
175
|
+
next
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
id = fetch_id(entry)
|
|
179
|
+
if id.nil? || id.to_s.strip.empty?
|
|
180
|
+
issues << "#{name} entry at index #{index} is missing an id"
|
|
181
|
+
else
|
|
182
|
+
id_counts[id] += 1
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
if name == "questions"
|
|
186
|
+
type = entry["type"] || entry[:type]
|
|
187
|
+
unless QUESTION_TYPES.include?(type)
|
|
188
|
+
issues << "Question #{id || index} has invalid type #{type.inspect}"
|
|
189
|
+
next
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
issues.concat(validate_question_choices(entry, id || index)) if CHOICE_REQUIRED_TYPES.include?(type)
|
|
193
|
+
elsif name == "post_actions"
|
|
194
|
+
if entry.fetch("enabled", true)
|
|
195
|
+
command = entry["command"] || entry[:command]
|
|
196
|
+
if command.nil? || command.to_s.strip.empty?
|
|
197
|
+
issues << "Post-action #{id || index} is enabled but missing a command"
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
if_condition = entry["if"] || entry[:if]
|
|
202
|
+
if if_condition.is_a?(Hash)
|
|
203
|
+
ref_question_id = if_condition["question"] || if_condition[:question]
|
|
204
|
+
if ref_question_id && !question_ids.include?(ref_question_id)
|
|
205
|
+
issues << "Post-action #{id || index} references unknown question '#{ref_question_id}'"
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
id_counts.each do |id, count|
|
|
212
|
+
issues << "#{name} entry id #{id} is defined #{count} times" if count > 1
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
issues
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def validate_question_choices(entry, question_id)
|
|
219
|
+
issues = []
|
|
220
|
+
choices = entry["choices"] || entry[:choices]
|
|
221
|
+
|
|
222
|
+
if !choices.is_a?(Array) || choices.empty?
|
|
223
|
+
issues << "Question #{question_id} (#{entry["type"]}) must define at least one choice"
|
|
224
|
+
return issues
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
choices.each_with_index do |choice, cidx|
|
|
228
|
+
unless choice.is_a?(Hash)
|
|
229
|
+
issues << "Question #{question_id} choice at index #{cidx} must be a Hash"
|
|
230
|
+
next
|
|
231
|
+
end
|
|
232
|
+
unless choice["name"] || choice[:name]
|
|
233
|
+
issues << "Question #{question_id} choice at index #{cidx} missing 'name'"
|
|
234
|
+
end
|
|
235
|
+
unless choice["value"] || choice[:value]
|
|
236
|
+
issues << "Question #{question_id} choice at index #{cidx} missing 'value'"
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
issues
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def deep_dup(value)
|
|
244
|
+
case value
|
|
245
|
+
when Hash
|
|
246
|
+
value.transform_values { |v| deep_dup(v) }
|
|
247
|
+
when Array
|
|
248
|
+
value.map { |v| deep_dup(v) }
|
|
249
|
+
else
|
|
250
|
+
value
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def special_array_key?(key)
|
|
255
|
+
key && MERGEABLE_COLLECTIONS.include?(key.to_s)
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Railstart
|
|
4
|
+
# Base error class for all Railstart-specific failures.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Raised for configuration-related issues within Railstart.
|
|
8
|
+
class ConfigError < Error; end
|
|
9
|
+
|
|
10
|
+
# Raised when configuration files cannot be read or parsed.
|
|
11
|
+
class ConfigLoadError < ConfigError; end
|
|
12
|
+
|
|
13
|
+
# Raised when configuration validation fails with one or more issues.
|
|
14
|
+
#
|
|
15
|
+
# @attr_reader issues [Array<String>] collection of validation error descriptions
|
|
16
|
+
class ConfigValidationError < ConfigError
|
|
17
|
+
attr_reader :issues
|
|
18
|
+
|
|
19
|
+
#
|
|
20
|
+
# @param message [String] base message explaining the failure
|
|
21
|
+
# @param issues [Array<String>] detailed validation error messages
|
|
22
|
+
def initialize(message = "Invalid configuration", issues: [])
|
|
23
|
+
@issues = Array(issues)
|
|
24
|
+
detail = @issues.empty? ? message : "#{message}:\n- #{@issues.join("\n- ")}"
|
|
25
|
+
super(detail)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tty-prompt"
|
|
4
|
+
|
|
5
|
+
module Railstart
|
|
6
|
+
# Orchestrates the interactive Rails app generation flow.
|
|
7
|
+
#
|
|
8
|
+
# Handles configuration loading, prompting, summary display, command execution,
|
|
9
|
+
# and optional post-generation actions while remaining easy to test.
|
|
10
|
+
#
|
|
11
|
+
# @example Run generator with provided config
|
|
12
|
+
# config = Railstart::Config.load
|
|
13
|
+
# Railstart::Generator.new("blog", config: config).run
|
|
14
|
+
class Generator
|
|
15
|
+
#
|
|
16
|
+
# @param app_name [String, nil] preset app name, prompted if nil
|
|
17
|
+
# @param config [Hash, nil] injected config for testing, defaults to Config.load
|
|
18
|
+
# @param use_defaults [Boolean] skip interactive mode and use all defaults
|
|
19
|
+
# @param prompt [TTY::Prompt] injectable prompt for testing
|
|
20
|
+
def initialize(app_name = nil, config: nil, use_defaults: false, prompt: nil)
|
|
21
|
+
@app_name = app_name
|
|
22
|
+
@config = config || Config.load
|
|
23
|
+
@use_defaults = use_defaults
|
|
24
|
+
@prompt = prompt || TTY::Prompt.new
|
|
25
|
+
@answers = {}
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
#
|
|
29
|
+
# Run the complete generation flow, prompting the user and invoking Rails.
|
|
30
|
+
#
|
|
31
|
+
# @return [void]
|
|
32
|
+
# @raise [Railstart::ConfigError, Railstart::ConfigValidationError] when configuration is invalid
|
|
33
|
+
# @example Run interactively using defaults or custom answers
|
|
34
|
+
# Railstart::Generator.new.run
|
|
35
|
+
def run
|
|
36
|
+
ask_app_name unless @app_name
|
|
37
|
+
ask_questions
|
|
38
|
+
show_summary
|
|
39
|
+
return unless confirm_proceed?
|
|
40
|
+
|
|
41
|
+
generate_app
|
|
42
|
+
run_post_actions
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def ask_app_name
|
|
48
|
+
@app_name = @prompt.ask("App name?", default: "my_app") do |q|
|
|
49
|
+
q.validate(/\A[a-z0-9_-]+\z/, "Must be lowercase letters, numbers, underscores, or hyphens")
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def ask_questions
|
|
54
|
+
if @use_defaults
|
|
55
|
+
collect_defaults
|
|
56
|
+
else
|
|
57
|
+
ask_interactive_questions
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def collect_defaults
|
|
62
|
+
Array(@config["questions"]).each do |question|
|
|
63
|
+
default_value = find_default(question)
|
|
64
|
+
@answers[question["id"]] = default_value unless default_value.nil?
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def ask_interactive_questions
|
|
69
|
+
Array(@config["questions"]).each do |question|
|
|
70
|
+
handle_question(question)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def handle_question(question)
|
|
75
|
+
return if should_skip_question?(question)
|
|
76
|
+
|
|
77
|
+
@answers[question["id"]] = ask_question(question)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def should_skip_question?(question)
|
|
81
|
+
depends = question["depends_on"]
|
|
82
|
+
return false unless depends
|
|
83
|
+
|
|
84
|
+
dep_question_id = depends["question"]
|
|
85
|
+
dep_value = depends["value"]
|
|
86
|
+
|
|
87
|
+
actual_value = @answers[dep_question_id]
|
|
88
|
+
actual_value != dep_value
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def ask_question(question)
|
|
92
|
+
case question["type"]
|
|
93
|
+
when "select"
|
|
94
|
+
ask_select(question)
|
|
95
|
+
when "multi_select"
|
|
96
|
+
ask_multi_select(question)
|
|
97
|
+
when "yes_no"
|
|
98
|
+
ask_yes_no?(question)
|
|
99
|
+
when "input"
|
|
100
|
+
ask_input(question)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def ask_select(question)
|
|
105
|
+
choices = question["choices"].map { |c| [c["name"], c["value"]] }
|
|
106
|
+
default_val = find_default(question)
|
|
107
|
+
|
|
108
|
+
@prompt.select(question["prompt"], choices, default: default_val)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def ask_multi_select(question)
|
|
112
|
+
choices = question["choices"].map { |c| [c["name"], c["value"]] }
|
|
113
|
+
defaults = question["default"] || []
|
|
114
|
+
|
|
115
|
+
@prompt.multi_select(question["prompt"], choices, default: defaults)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def ask_yes_no?(question)
|
|
119
|
+
@prompt.yes?(question["prompt"], default: question.fetch("default", false))
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def ask_input(question)
|
|
123
|
+
@prompt.ask(question["prompt"], default: question["default"])
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def find_default(question)
|
|
127
|
+
# Support both default at question level and default: true on choice
|
|
128
|
+
return question["default"] if question.key?("default")
|
|
129
|
+
|
|
130
|
+
Array(question["choices"]).find { |c| c["default"] }&.[]("value")
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def show_summary
|
|
134
|
+
puts "\n════════════════════════════════════════"
|
|
135
|
+
puts "Summary"
|
|
136
|
+
puts "════════════════════════════════════════"
|
|
137
|
+
puts "App name: #{@app_name}"
|
|
138
|
+
|
|
139
|
+
Array(@config["questions"]).each do |question|
|
|
140
|
+
question_id = question["id"]
|
|
141
|
+
next unless @answers.key?(question_id)
|
|
142
|
+
|
|
143
|
+
answer = @answers[question_id]
|
|
144
|
+
label = question["prompt"].delete_suffix("?").delete_suffix(":").strip
|
|
145
|
+
|
|
146
|
+
value_str = case answer
|
|
147
|
+
when Array
|
|
148
|
+
answer.empty? ? "none" : answer.join(", ")
|
|
149
|
+
when false
|
|
150
|
+
"No"
|
|
151
|
+
when true
|
|
152
|
+
"Yes"
|
|
153
|
+
else
|
|
154
|
+
answer.to_s
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
puts "#{label}: #{value_str}"
|
|
158
|
+
end
|
|
159
|
+
puts "════════════════════════════════════════\n"
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def confirm_proceed?
|
|
163
|
+
return true if @use_defaults
|
|
164
|
+
|
|
165
|
+
@prompt.yes?("Proceed with app generation?")
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def generate_app
|
|
169
|
+
command = CommandBuilder.build(@app_name, @config, @answers)
|
|
170
|
+
|
|
171
|
+
puts "Running: #{command}\n\n"
|
|
172
|
+
success = system(command)
|
|
173
|
+
|
|
174
|
+
return if success
|
|
175
|
+
|
|
176
|
+
raise Error, "Failed to generate Rails app. Check the output above for details."
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def run_post_actions
|
|
180
|
+
Dir.chdir(@app_name)
|
|
181
|
+
Array(@config["post_actions"]).each { |action| process_post_action(action) }
|
|
182
|
+
puts "\n✨ Rails app created successfully at ./#{@app_name}"
|
|
183
|
+
rescue Errno::ENOENT
|
|
184
|
+
warn "Could not change to app directory. Post-actions skipped."
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def process_post_action(action)
|
|
188
|
+
return unless should_run_action?(action)
|
|
189
|
+
return unless confirm_action?(action)
|
|
190
|
+
|
|
191
|
+
execute_action(action)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def confirm_action?(action)
|
|
195
|
+
return true unless action["prompt"]
|
|
196
|
+
|
|
197
|
+
@prompt.yes?(action["prompt"], default: action.fetch("default", true))
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def execute_action(action)
|
|
201
|
+
puts "→ #{action["name"]}"
|
|
202
|
+
success = system(action["command"])
|
|
203
|
+
warn "Warning: Post-action '#{action["name"]}' failed. Continuing anyway." unless success
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def should_run_action?(action)
|
|
207
|
+
return false unless action.fetch("enabled", true)
|
|
208
|
+
|
|
209
|
+
if_condition = action["if"]
|
|
210
|
+
return true unless if_condition
|
|
211
|
+
|
|
212
|
+
question_id = if_condition["question"]
|
|
213
|
+
answer = @answers[question_id]
|
|
214
|
+
|
|
215
|
+
if if_condition.key?("equals")
|
|
216
|
+
answer == if_condition["equals"]
|
|
217
|
+
elsif if_condition.key?("includes")
|
|
218
|
+
expected = Array(if_condition["includes"])
|
|
219
|
+
actual = Array(answer)
|
|
220
|
+
expected.intersect?(actual)
|
|
221
|
+
else
|
|
222
|
+
true
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
data/lib/railstart.rb
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "railstart/version"
|
|
4
|
+
require_relative "railstart/errors"
|
|
5
|
+
require_relative "railstart/config"
|
|
6
|
+
require_relative "railstart/command_builder"
|
|
7
|
+
require_relative "railstart/generator"
|
|
8
|
+
require_relative "railstart/cli"
|
|
9
|
+
|
|
10
|
+
# Main namespace for the Railstart gem.
|
|
11
|
+
#
|
|
12
|
+
# Provides an interactive CLI wizard for generating Rails 8 applications
|
|
13
|
+
# with customizable configuration and post-generation hooks.
|
|
14
|
+
#
|
|
15
|
+
# @example Generate a new Rails app
|
|
16
|
+
# $ railstart new my_app
|
|
17
|
+
#
|
|
18
|
+
# @see CLI
|
|
19
|
+
# @see Generator
|
|
20
|
+
# @see Config
|
|
21
|
+
module Railstart
|
|
22
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: railstart
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- dpaluy
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: thor
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: tty-prompt
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '0'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '0'
|
|
40
|
+
description: Interactive CLI wizard for Rails app generation with customizable config
|
|
41
|
+
email:
|
|
42
|
+
- dpaluy@users.noreply.github.com
|
|
43
|
+
executables:
|
|
44
|
+
- railstart
|
|
45
|
+
extensions: []
|
|
46
|
+
extra_rdoc_files:
|
|
47
|
+
- CHANGELOG.md
|
|
48
|
+
- LICENSE.txt
|
|
49
|
+
- README.md
|
|
50
|
+
files:
|
|
51
|
+
- ".yardopts"
|
|
52
|
+
- CHANGELOG.md
|
|
53
|
+
- LICENSE.txt
|
|
54
|
+
- README.md
|
|
55
|
+
- Rakefile
|
|
56
|
+
- config/rails8_defaults.yaml
|
|
57
|
+
- exe/railstart
|
|
58
|
+
- lib/railstart.rb
|
|
59
|
+
- lib/railstart/cli.rb
|
|
60
|
+
- lib/railstart/command_builder.rb
|
|
61
|
+
- lib/railstart/config.rb
|
|
62
|
+
- lib/railstart/errors.rb
|
|
63
|
+
- lib/railstart/generator.rb
|
|
64
|
+
- lib/railstart/version.rb
|
|
65
|
+
homepage: https://github.com/dpaluy/railstart
|
|
66
|
+
licenses:
|
|
67
|
+
- MIT
|
|
68
|
+
metadata:
|
|
69
|
+
rubygems_mfa_required: 'true'
|
|
70
|
+
homepage_uri: https://github.com/dpaluy/railstart
|
|
71
|
+
documentation_uri: https://rubydoc.info/gems/railstart
|
|
72
|
+
source_code_uri: https://github.com/dpaluy/railstart
|
|
73
|
+
changelog_uri: https://github.com/dpaluy/railstart/blob/main/CHANGELOG.md
|
|
74
|
+
bug_tracker_uri: https://github.com/dpaluy/railstart/issues
|
|
75
|
+
rdoc_options: []
|
|
76
|
+
require_paths:
|
|
77
|
+
- lib
|
|
78
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
79
|
+
requirements:
|
|
80
|
+
- - ">="
|
|
81
|
+
- !ruby/object:Gem::Version
|
|
82
|
+
version: 3.2.0
|
|
83
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
84
|
+
requirements:
|
|
85
|
+
- - ">="
|
|
86
|
+
- !ruby/object:Gem::Version
|
|
87
|
+
version: '0'
|
|
88
|
+
requirements: []
|
|
89
|
+
rubygems_version: 3.7.2
|
|
90
|
+
specification_version: 4
|
|
91
|
+
summary: Rails application starter and development utilities
|
|
92
|
+
test_files: []
|