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 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
@@ -0,0 +1,9 @@
1
+ --markup markdown
2
+ --output-dir doc
3
+ --protected
4
+ --private
5
+ lib/**/*.rb
6
+ -
7
+ README.md
8
+ CHANGELOG.md
9
+ LICENSE.txt
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
+ [![Gem Version](https://badge.fury.io/rb/railstart.svg)](https://badge.fury.io/rb/railstart)
4
+ [![Documentation](https://img.shields.io/badge/docs-rubydoc.info-blue.svg)](https://rubydoc.info/gems/railstart)
5
+ [![CI](https://github.com/dpaluy/railstart/actions/workflows/ci.yml/badge.svg)](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,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "railstart"
5
+
6
+ Railstart::CLI.start(ARGV)
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Railstart
4
+ VERSION = "0.1.0"
5
+ 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: []