railstart 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 06f6eda8bd62deeef7ba87448cd471a783f36a31c8a0026a6f4813883dc4ea7e
4
- data.tar.gz: d493a6c6f85c095e45f0fbc8f5c380488af1fc57fbdd07802f7fe8481e528f4d
3
+ metadata.gz: a11ff1fa7939d71ae25db7c053cb82f0d20babc2ea36f80d5b455ef909ac9a5b
4
+ data.tar.gz: 81dbc554f5bb0f4b77ddf1786d10c5b70018a49e319bbdec40397586338b6f58
5
5
  SHA512:
6
- metadata.gz: 0a45f5201a19322362da2029b79b5a96439c4c68d0f1201f605bc88008adfc602a28bba0423f3db3a7b43acc5d4ded66a42ad11f3ed281483b417ec31ab32c16
7
- data.tar.gz: 95f319acae2e68a8ed747cb98fc20f6194e9d85947c5b63a345bf4b0d95b72c21f3a08a9638347f75ca7a619f278e10ef29ea26d52b620c2d18f7ffc34006c5a
6
+ metadata.gz: 75b456127f40e5636561bb7fdedf260eafadc8a32a89f4ba8088807735a0746a52cafa6975beef669f869fe47e8ea870cc7a30f471ef525c9d8e492a6d472185
7
+ data.tar.gz: 1a02ea13c31ed012b01a7ec83ca6388f09407b74d7fa72c4134bd46f860e71435ccd586931e6569e7809cd109c55504112f6d7b747a6ede87561af6d75513106
data/CHANGELOG.md CHANGED
@@ -6,6 +6,43 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
8
 
9
+ ## [0.2.0] - 2025-11-22
10
+
11
+ ### Added
12
+ - Three-layer preset system (builtin → user → preset config merging)
13
+ - CLI flags: `--preset NAME` and `--default` for preset selection
14
+ - Preset resolution: user presets (`~/.config/railstart/presets/`) override built-in gem presets
15
+ - Built-in presets: `default.yaml` (PostgreSQL + Tailwind + Importmap) and `api-only.yaml`
16
+ - Config overlay schema with id-based merging for questions and post_actions
17
+ - New `Railstart::UI` module for enhanced CLI presentation
18
+ - ASCII art logo displayed at startup
19
+ - Styled welcome box with dynamic Rails version detection
20
+ - Boxed configuration summary with syntax highlighting
21
+ - Icon-based status messages (success ✓, info ℹ, warning ⚠, error ✗)
22
+ - Section headers with visual separators
23
+ - `tty-box` dependency for frame rendering
24
+
25
+ ### Changed
26
+ - `Config.load` now accepts optional `preset_path` parameter
27
+ - Generator modes respect preset overlays for both interactive and non-interactive flows
28
+ - Generator always confirms before generation, even in `--default` mode
29
+ - Improved TTY::Prompt integration to use hash format for select/multi_select choices
30
+ - Welcome message now displays detected Rails version instead of hardcoded "Rails 8"
31
+ - Summary display redesigned with bordered box and colored output
32
+ - Status messages now use consistent icons and colors throughout
33
+ - Generator runs Rails commands outside bundler context using `Bundler.with_unbundled_env`
34
+ - Bundle install post-action now disabled by default
35
+
36
+ ### Fixed
37
+ - Proper deep merging with id-based array merging for questions and post_actions
38
+ - TTY::Prompt select/multi_select displaying duplicate options (switched from array pairs to hash format)
39
+ - Default value selection not working correctly (now uses 1-based index as expected by TTY::Prompt)
40
+ - Bundle install post-action incorrectly prompting when user explicitly skips bundle install
41
+ - CLI error handling improved (Thor::UndefinedCommandError)
42
+
43
+ ### Removed
44
+ - Plain text summary formatting replaced with styled box display
45
+
9
46
  ## [0.1.0] - 2025-11-21
10
47
 
11
48
  ### Added
data/README.md CHANGED
@@ -16,9 +16,13 @@ gem install railstart
16
16
 
17
17
  ## Usage
18
18
 
19
- ### Generate a new Rails app
19
+ ### Quick Start
20
20
 
21
21
  ```bash
22
+ # Generate config files (optional, for customization)
23
+ railstart init
24
+
25
+ # Generate a new Rails app
22
26
  railstart new my_app
23
27
 
24
28
  # Or run without arguments for help
@@ -79,25 +83,88 @@ Creating Rails app...
79
83
  ✨ Rails app created successfully at ./my_app
80
84
  ```
81
85
 
82
- ### Skip interactive mode (use defaults)
86
+ ### Use Presets
87
+
88
+ Presets are configuration overlays that let you define different defaults and even different questions/post-actions for specific use cases.
89
+
90
+ **Important:** When you use `--default` without `--preset`, Railstart automatically applies the `default` preset (from `~/.config/railstart/presets/default.yaml` or the gem's built-in version). This means defaults may differ from the base `rails8_defaults.yaml` config.
83
91
 
84
- Use the `--default` flag to skip all questions and apply built-in defaults:
92
+ **Modes:**
93
+ - **Interactive** (default): prompts for each question from the config schema
94
+ - **With --default**: skips questions, loads "default" preset, shows summary and confirms
95
+ - **With --preset**: loads specified preset as config overlay (can be interactive or with --default)
85
96
 
86
97
  ```bash
98
+ # Interactive mode (builtin defaults)
99
+ railstart new my_app
100
+
101
+ # Non-interactive with "default" preset (asks no questions, shows summary + confirms)
102
+ # Note: --default automatically loads the "default" preset (user or gem)
87
103
  railstart new my_app --default
104
+
105
+ # Interactive with custom preset
106
+ railstart new my_app --preset api-only
107
+
108
+ # Non-interactive with custom preset
109
+ railstart new my_app --preset api-only --default
110
+ ```
111
+
112
+ **Create custom presets** at `~/.config/railstart/presets/my-preset.yaml`:
113
+
114
+ Presets use the same YAML schema as config files - they can override question defaults, change choices, add new questions, or modify post-actions:
115
+
116
+ ```yaml
117
+ # ~/.config/railstart/presets/api-only.yaml
118
+ # Presets merge on top of user config (and built-in config)
119
+ questions:
120
+ - id: database
121
+ choices:
122
+ - name: PostgreSQL
123
+ value: postgresql
124
+ default: true # Different default for this preset
125
+
126
+ - id: api_only
127
+ default: true # Override default to true for API preset
128
+
129
+ post_actions:
130
+ - id: init_git
131
+ enabled: false # Disable git init for this preset
88
132
  ```
89
133
 
90
- This creates a PostgreSQL + Tailwind + Importmap Rails app instantly.
134
+ Then use it:
135
+
136
+ ```bash
137
+ # Interactive with api-only config
138
+ railstart new my_app --preset api-only
139
+
140
+ # Non-interactive with api-only config
141
+ railstart new my_app --preset api-only --default
142
+ ```
91
143
 
92
144
  ## Configuration
93
145
 
146
+ ### Initialize Configuration Files
147
+
148
+ The easiest way to get started with custom configuration is to generate example files:
149
+
150
+ ```bash
151
+ railstart init
152
+ ```
153
+
154
+ This creates:
155
+ - `~/.config/railstart/config.yaml` - Example user config with common customizations
156
+ - `~/.config/railstart/presets/` - Directory for your presets
157
+ - `~/.config/railstart/presets/example.yaml` - Example preset to get started
158
+
159
+ You can then edit these files to match your preferences.
160
+
94
161
  ### Built-in Defaults
95
162
 
96
163
  Railstart ships with sensible Rails 8 defaults defined in `config/rails8_defaults.yaml`. These drive the interactive questions and their defaults.
97
164
 
98
165
  ### Customize for Your Team
99
166
 
100
- Create a `~/.config/railstart/config.yaml` file to override defaults:
167
+ You can create `~/.config/railstart/config.yaml` manually or use `railstart init` to generate an example file:
101
168
 
102
169
  ```yaml
103
170
  questions:
@@ -204,7 +271,7 @@ bin/console
204
271
 
205
272
  # Install locally to test as a real gem
206
273
  gem build railstart.gemspec
207
- gem install railstart-0.1.0.gem
274
+ gem install railstart-[version].gem
208
275
  railstart new my_app
209
276
  ```
210
277
 
@@ -226,10 +293,28 @@ bundle exec rake test && bundle exec rubocop
226
293
 
227
294
  ## Architecture
228
295
 
229
- - **Config System** (`lib/railstart/config.rb`) - Loads and merges YAML configurations
296
+ ### Three-Layer Configuration System
297
+
298
+ Railstart merges configuration from three sources (in order):
299
+
300
+ 1. **Built-in config**: `config/rails8_defaults.yaml` (shipped with gem)
301
+ 2. **User config**: `~/.config/railstart/config.yaml` (optional global overrides)
302
+ 3. **Preset** (optional): `~/.config/railstart/presets/NAME.yaml` (per-run overlay)
303
+
304
+ Each layer can:
305
+ - Override question defaults
306
+ - Replace choice lists entirely (by question ID)
307
+ - Add new questions
308
+ - Add/modify post-actions
309
+ - Enable/disable post-actions
310
+
311
+ Merging is by `id` for both `questions` and `post_actions`, allowing surgical overrides without duplicating entire configs.
312
+
313
+ ### Core Components
314
+
230
315
  - **Generator** (`lib/railstart/generator.rb`) - Orchestrates interactive flow
231
316
  - **Command Builder** (`lib/railstart/command_builder.rb`) - Translates answers to `rails new` flags
232
- - **CLI** (`lib/railstart/cli.rb`) - Thor command interface
317
+ - **CLI** (`lib/railstart/cli.rb`) - Thor command interface with `--preset` option
233
318
 
234
319
  ## Contributing
235
320
 
@@ -0,0 +1,46 @@
1
+ ---
2
+ # API-Only Preset - Minimal Rails app for JSON APIs
3
+
4
+ questions:
5
+ - id: database
6
+ choices:
7
+ - name: PostgreSQL
8
+ value: postgresql
9
+ default: true
10
+
11
+ - id: css
12
+ choices:
13
+ - name: None
14
+ value: none
15
+ default: true
16
+
17
+ - id: javascript
18
+ choices:
19
+ - name: Importmap (default)
20
+ value: importmap
21
+ default: true
22
+
23
+ - id: skip_features
24
+ default:
25
+ - action_mailer
26
+ - action_text
27
+ - hotwire
28
+
29
+ - id: api_only
30
+ default: true
31
+
32
+ - id: skip_git
33
+ default: false
34
+
35
+ - id: skip_docker
36
+ default: false
37
+
38
+ - id: skip_bundle
39
+ default: false
40
+
41
+ post_actions:
42
+ - id: init_git
43
+ enabled: true
44
+
45
+ - id: bundle_install
46
+ enabled: true
@@ -0,0 +1,45 @@
1
+ ---
2
+ # Default Preset - Sensible defaults for quick start
3
+ # This preset is automatically loaded when using --default flag without --preset
4
+ # Can be overridden by user preset at ~/.config/railstart/presets/default.yaml
5
+
6
+ questions:
7
+ - id: database
8
+ choices:
9
+ - name: PostgreSQL
10
+ value: postgresql
11
+ default: true
12
+
13
+ - id: css
14
+ choices:
15
+ - name: Tailwind
16
+ value: tailwind
17
+ default: true
18
+
19
+ - id: javascript
20
+ choices:
21
+ - name: Importmap (default)
22
+ value: importmap
23
+ default: true
24
+
25
+ - id: skip_features
26
+ default: []
27
+
28
+ - id: api_only
29
+ default: false
30
+
31
+ - id: skip_git
32
+ default: false
33
+
34
+ - id: skip_docker
35
+ default: false
36
+
37
+ - id: skip_bundle
38
+ default: false
39
+
40
+ post_actions:
41
+ - id: init_git
42
+ enabled: true
43
+
44
+ - id: bundle_install
45
+ enabled: true
@@ -115,7 +115,7 @@ post_actions:
115
115
 
116
116
  - id: bundle_install
117
117
  name: "Install gems"
118
- enabled: true
118
+ enabled: false
119
119
  prompt: "Run bundle install?"
120
120
  default: true
121
121
  if:
data/lib/railstart/cli.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "thor"
4
+ require "fileutils"
4
5
  require_relative "generator"
5
6
 
6
7
  module Railstart
@@ -11,22 +12,206 @@ module Railstart
11
12
  # @example Print version
12
13
  # Railstart::CLI.start(%w[version])
13
14
  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"
15
+ def self.exit_on_failure?
16
+ true
17
+ end
18
+
19
+ # Show help by default when no command is given
20
+ def self.start(given_args = ARGV, config = {})
21
+ if given_args.empty?
22
+ # Show command list instead of requiring a command argument
23
+ puts "Railstart - Interactive Rails 8 application generator"
24
+ puts ""
25
+ puts "Usage:"
26
+ puts " railstart init # Generate config files"
27
+ puts " railstart new [APP_NAME] [OPTIONS] # Generate a new Rails app"
28
+ puts " railstart version # Show version"
29
+ puts " railstart help [COMMAND] # Show help for a command"
30
+ puts ""
31
+ puts "Quick Start:"
32
+ puts " railstart init # Create config files (optional)"
33
+ puts " railstart new my_app # Interactive mode"
34
+ puts " railstart new my_app --default # Use defaults"
35
+ puts " railstart new my_app --preset api-only # Use preset"
36
+ puts ""
37
+ puts "Run 'railstart help init' or 'railstart help new' for details"
38
+ return
39
+ end
40
+ super
41
+ end
42
+
43
+ # Custom banner to show available options
44
+ class << self
45
+ # rubocop:disable Style/OptionalBooleanParameter
46
+ def banner(command, _namespace = nil, _subcommand = false)
47
+ "#{basename} #{command.usage}"
48
+ end
49
+
50
+ # Override to show only positive form of boolean options
51
+ def help(shell, subcommand = false)
52
+ # rubocop:enable Style/OptionalBooleanParameter
53
+ list = printable_commands(true, subcommand)
54
+ Thor::Util.thor_classes_in(self).each do |klass|
55
+ list += klass.printable_commands(false)
56
+ end
57
+
58
+ shell.say "Commands:"
59
+ shell.print_table(list, indent: 2, truncate: true)
60
+ shell.say
61
+ class_options_help(shell)
62
+ end
63
+ end
64
+
65
+ # Override to customize option display
66
+ # rubocop:disable Style/OptionalBooleanParameter
67
+ def help(command = nil, subcommand = false)
68
+ # rubocop:enable Style/OptionalBooleanParameter
69
+ if command
70
+ if self.class.subcommands.include?(command)
71
+ self.class.subcommand_classes[command].help(shell, subcommand)
72
+ else
73
+ cmd = self.class.all_commands[command]
74
+ raise Thor::UndefinedCommandError, "Could not find command '#{command}'." unless cmd
75
+
76
+ shell.say "Usage:"
77
+ shell.say " #{self.class.banner(cmd)}"
78
+ shell.say
79
+ if cmd.long_description
80
+ shell.say "Description:"
81
+ shell.print_wrapped(cmd.long_description, indent: 2)
82
+ else
83
+ shell.say cmd.description
84
+ end
85
+
86
+ print_custom_options(cmd)
87
+ end
88
+ else
89
+ super
90
+ end
91
+ end
92
+
93
+ no_commands do
94
+ def print_custom_options(cmd)
95
+ return unless cmd.options.any?
96
+
97
+ shell.say
98
+ shell.say "Options:"
99
+ cmd.options.each do |name, option|
100
+ print_option(name, option)
101
+ end
102
+ end
103
+
104
+ def print_option(name, option)
105
+ # For boolean options, only show the positive form
106
+ if option.type == :boolean
107
+ shell.say " [--#{name}]#{" " * [20 - name.length, 0].max}# #{option.description}"
108
+ else
109
+ print_non_boolean_option(name, option)
110
+ end
111
+ end
112
+
113
+ def print_non_boolean_option(name, option)
114
+ banner_text = option.banner || name.to_s.upcase
115
+ padding = [20 - (name.length + banner_text.length + 3), 0].max
116
+ shell.say " [--#{name}=#{banner_text}]#{" " * padding}# #{option.description}"
117
+ end
118
+ end
119
+ desc "new [APP_NAME]", "Generate a new Rails 8 application"
120
+ long_desc <<~DESC
121
+ Generate a new Rails 8 application with an interactive wizard.
122
+
123
+ Modes:
124
+ - Interactive (default): prompts for each question
125
+ - With preset: uses preset config (different questions/defaults), interactive or non-interactive
126
+
127
+ Examples:
128
+ railstart new my_app # Interactive mode
129
+ railstart new my_app --default # Non-interactive with default preset (if exists)
130
+ railstart new my_app --preset api-only # Interactive with api-only preset config
131
+ railstart new my_app --preset api-only --default # Non-interactive with api-only preset
132
+
133
+ Presets are stored in: ~/.config/railstart/presets/*.yaml
134
+ DESC
135
+ option :default, type: :boolean, default: false, desc: "Use defaults non-interactively"
136
+ option :preset, type: :string, desc: "Preset name from ~/.config/railstart/presets/", banner: "NAME"
16
137
  #
17
138
  # @param app_name [String, nil] desired Rails app name, prompted if omitted
18
139
  # @return [void]
19
140
  # @raise [Railstart::Error] when generation fails due to configuration or runtime errors
20
141
  # @example Start wizard with prompts
21
142
  # Railstart::CLI.start(%w[new my_app])
143
+ # @example Use preset
144
+ # Railstart::CLI.start(%w[new my_app --preset api-only])
145
+ # @example Use default preset non-interactively
146
+ # Railstart::CLI.start(%w[new my_app --default])
22
147
  def new(app_name = nil)
23
- generator = Generator.new(app_name, use_defaults: options[:default])
148
+ preset_name = determine_preset_name
149
+ preset_path = preset_name ? preset_file_for(preset_name) : nil
150
+
151
+ config = Config.load(preset_path: preset_path)
152
+
153
+ generator = Generator.new(
154
+ app_name,
155
+ config: config,
156
+ use_defaults: options[:default]
157
+ )
158
+
24
159
  generator.run
25
160
  rescue Railstart::Error => e
26
161
  puts "Error: #{e.message}"
27
162
  exit 1
28
163
  end
29
164
 
165
+ desc "init", "Generate config directory and starter files"
166
+ long_desc <<~DESC
167
+ Creates ~/.config/railstart directory structure with example configuration files.
168
+
169
+ This generates:
170
+ - ~/.config/railstart/config.yaml (example user config)
171
+ - ~/.config/railstart/presets/ directory
172
+ - ~/.config/railstart/presets/example.yaml (example preset)
173
+
174
+ You can then customize these files for your preferences.
175
+ DESC
176
+ option :force, type: :boolean, default: false, desc: "Overwrite existing files"
177
+ #
178
+ # @return [void]
179
+ # @example Generate config files
180
+ # Railstart::CLI.start(%w[init])
181
+ def init
182
+ config_dir = File.expand_path("~/.config/railstart")
183
+ presets_dir = File.join(config_dir, "presets")
184
+
185
+ # Create directories
186
+ FileUtils.mkdir_p(presets_dir)
187
+ puts "✓ Created #{config_dir}"
188
+ puts "✓ Created #{presets_dir}"
189
+
190
+ # Generate example user config
191
+ user_config_path = File.join(config_dir, "config.yaml")
192
+ if File.exist?(user_config_path) && !options[:force]
193
+ puts "⊗ Skipped #{user_config_path} (already exists, use --force to overwrite)"
194
+ else
195
+ File.write(user_config_path, example_user_config)
196
+ puts "✓ Created #{user_config_path}"
197
+ end
198
+
199
+ # Generate example preset
200
+ example_preset_path = File.join(presets_dir, "example.yaml")
201
+ if File.exist?(example_preset_path) && !options[:force]
202
+ puts "⊗ Skipped #{example_preset_path} (already exists, use --force to overwrite)"
203
+ else
204
+ File.write(example_preset_path, example_preset_config)
205
+ puts "✓ Created #{example_preset_path}"
206
+ end
207
+
208
+ puts "\n✨ Configuration files initialized!"
209
+ puts "\nNext steps:"
210
+ puts " 1. Edit ~/.config/railstart/config.yaml to customize defaults"
211
+ puts " 2. Create custom presets in ~/.config/railstart/presets/"
212
+ puts " 3. Use with: railstart new my_app --preset example"
213
+ end
214
+
30
215
  desc "version", "Print Railstart version"
31
216
  #
32
217
  # @return [void]
@@ -35,5 +220,104 @@ module Railstart
35
220
  def version
36
221
  puts "Railstart v#{Railstart::VERSION}"
37
222
  end
223
+
224
+ PRESET_DIR = File.expand_path("~/.config/railstart/presets")
225
+ GEM_PRESET_DIR = File.expand_path("../../config/presets", __dir__)
226
+
227
+ private
228
+
229
+ def determine_preset_name
230
+ # Explicit --preset flag takes priority
231
+ return options[:preset] if options[:preset]
232
+
233
+ # --default maps to "default" preset
234
+ return "default" if options[:default]
235
+
236
+ nil
237
+ end
238
+
239
+ def preset_file_for(name)
240
+ # Check user presets first
241
+ user_path = File.join(PRESET_DIR, "#{name}.yaml")
242
+ return user_path if File.exist?(user_path)
243
+
244
+ # Fall back to built-in gem presets
245
+ gem_path = File.join(GEM_PRESET_DIR, "#{name}.yaml")
246
+ return gem_path if File.exist?(gem_path)
247
+
248
+ # If explicit --preset was used, raise error
249
+ raise Railstart::ConfigLoadError, "Preset '#{name}' not found in #{PRESET_DIR} or gem presets" if options[:preset]
250
+
251
+ # For --default with missing preset, return nil (fall back to builtin config)
252
+ nil
253
+ end
254
+
255
+ def example_user_config
256
+ <<~YAML
257
+ ---
258
+ # Railstart User Configuration
259
+ # This file overrides built-in defaults for all your Rails projects.
260
+ #
261
+ # Merge behavior: questions and post_actions are merged by 'id'.
262
+ # Override individual fields or add new entries.
263
+
264
+ questions:
265
+ # Example: Change database default to PostgreSQL
266
+ - id: database
267
+ choices:
268
+ - name: PostgreSQL
269
+ value: postgresql
270
+ default: true
271
+
272
+ # Example: Change CSS default to Tailwind
273
+ - id: css
274
+ choices:
275
+ - name: Tailwind
276
+ value: tailwind
277
+ default: true
278
+
279
+ post_actions:
280
+ # Example: Disable bundle install (manage gems manually)
281
+ # - id: bundle_install
282
+ # enabled: false
283
+
284
+ # Example: Add custom post-action
285
+ # - id: setup_linting
286
+ # name: "Setup RuboCop and StandardRB"
287
+ # enabled: true
288
+ # command: "bundle add rubocop rubocop-rails standard --group development"
289
+ YAML
290
+ end
291
+
292
+ def example_preset_config
293
+ <<~YAML
294
+ ---
295
+ # Example Preset - Customize this for your use case
296
+ # Use with: railstart new my_app --preset example
297
+
298
+ questions:
299
+ - id: database
300
+ choices:
301
+ - name: PostgreSQL
302
+ value: postgresql
303
+ default: true
304
+
305
+ - id: css
306
+ choices:
307
+ - name: Tailwind
308
+ value: tailwind
309
+ default: true
310
+
311
+ - id: api_only
312
+ default: false
313
+
314
+ post_actions:
315
+ - id: init_git
316
+ enabled: true
317
+
318
+ - id: bundle_install
319
+ enabled: true
320
+ YAML
321
+ end
38
322
  end
39
323
  end
@@ -17,19 +17,25 @@ module Railstart
17
17
 
18
18
  class << self
19
19
  #
20
- # Load, merge, and validate configuration from built-in and user sources.
20
+ # Load, merge, and validate configuration from built-in, user, and preset sources.
21
21
  #
22
22
  # @param builtin_path [String] path to default config YAML shipped with the gem
23
23
  # @param user_path [String] optional user override YAML path
24
+ # @param preset_path [String] optional preset YAML path (third overlay)
24
25
  # @return [Hash] deep-copied, merged, validated configuration hash
25
26
  # @raise [Railstart::ConfigLoadError] when YAML files are missing or unreadable
26
27
  # @raise [Railstart::ConfigValidationError] when validation fails
27
28
  # @example
28
29
  # config = Railstart::Config.load
29
- def load(builtin_path: BUILTIN_CONFIG_PATH, user_path: USER_CONFIG_PATH)
30
+ # @example With preset
31
+ # config = Railstart::Config.load(preset_path: "~/.config/railstart/presets/api-only.yaml")
32
+ def load(builtin_path: BUILTIN_CONFIG_PATH, user_path: USER_CONFIG_PATH, preset_path: nil)
30
33
  builtin = read_yaml(builtin_path, required: true)
31
34
  user = read_yaml(user_path, required: false)
35
+ preset = preset_path ? read_yaml(preset_path, required: false) : {}
36
+
32
37
  merged = merge_config(builtin, user)
38
+ merged = merge_config(merged, preset) unless preset.empty?
33
39
  validate!(merged)
34
40
  merged
35
41
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "tty-prompt"
4
+ require_relative "ui"
4
5
 
5
6
  module Railstart
6
7
  # Orchestrates the interactive Rails app generation flow.
@@ -11,11 +12,13 @@ module Railstart
11
12
  # @example Run generator with provided config
12
13
  # config = Railstart::Config.load
13
14
  # Railstart::Generator.new("blog", config: config).run
15
+ # @example Run generator non-interactively
16
+ # Railstart::Generator.new("blog", use_defaults: true).run
14
17
  class Generator
15
18
  #
16
19
  # @param app_name [String, nil] preset app name, prompted if nil
17
20
  # @param config [Hash, nil] injected config for testing, defaults to Config.load
18
- # @param use_defaults [Boolean] skip interactive mode and use all defaults
21
+ # @param use_defaults [Boolean] skip interactive questions, use config defaults
19
22
  # @param prompt [TTY::Prompt] injectable prompt for testing
20
23
  def initialize(app_name = nil, config: nil, use_defaults: false, prompt: nil)
21
24
  @app_name = app_name
@@ -28,13 +31,27 @@ module Railstart
28
31
  #
29
32
  # Run the complete generation flow, prompting the user and invoking Rails.
30
33
  #
34
+ # Mode selection:
35
+ # - use_defaults: false (default) → interactive wizard
36
+ # - use_defaults: true → collect config defaults, show summary, confirm, run
37
+ #
31
38
  # @return [void]
32
39
  # @raise [Railstart::ConfigError, Railstart::ConfigValidationError] when configuration is invalid
33
- # @example Run interactively using defaults or custom answers
34
- # Railstart::Generator.new.run
40
+ # @example Run interactively
41
+ # Railstart::Generator.new("blog").run
42
+ # @example Run with defaults (noninteractive questions)
43
+ # Railstart::Generator.new("blog", use_defaults: true).run
35
44
  def run
45
+ show_welcome_screen unless @use_defaults
46
+
36
47
  ask_app_name unless @app_name
37
- ask_questions
48
+
49
+ if @use_defaults
50
+ collect_defaults
51
+ else
52
+ ask_interactive_questions
53
+ end
54
+
38
55
  show_summary
39
56
  return unless confirm_proceed?
40
57
 
@@ -44,24 +61,24 @@ module Railstart
44
61
 
45
62
  private
46
63
 
64
+ def show_welcome_screen
65
+ UI.show_logo
66
+ UI.show_welcome
67
+ end
68
+
47
69
  def ask_app_name
48
70
  @app_name = @prompt.ask("App name?", default: "my_app") do |q|
49
71
  q.validate(/\A[a-z0-9_-]+\z/, "Must be lowercase letters, numbers, underscores, or hyphens")
50
72
  end
51
73
  end
52
74
 
53
- def ask_questions
54
- if @use_defaults
55
- collect_defaults
56
- else
57
- ask_interactive_questions
58
- end
59
- end
60
-
61
75
  def collect_defaults
62
76
  Array(@config["questions"]).each do |question|
77
+ next if should_skip_question?(question)
78
+
79
+ question_id = question["id"]
63
80
  default_value = find_default(question)
64
- @answers[question["id"]] = default_value unless default_value.nil?
81
+ @answers[question_id] = default_value unless default_value.nil?
65
82
  end
66
83
  end
67
84
 
@@ -102,14 +119,23 @@ module Railstart
102
119
  end
103
120
 
104
121
  def ask_select(question)
105
- choices = question["choices"].map { |c| [c["name"], c["value"]] }
122
+ # Convert to hash format: { 'Display Name' => 'value' }
123
+ choices = question["choices"].each_with_object({}) do |choice, hash|
124
+ hash[choice["name"]] = choice["value"]
125
+ end
106
126
  default_val = find_default(question)
107
127
 
108
- @prompt.select(question["prompt"], choices, default: default_val)
128
+ # TTY::Prompt expects 1-based index for default
129
+ default_index = (question["choices"].index { |c| c["value"] == default_val }&.+(1) if default_val)
130
+
131
+ @prompt.select(question["prompt"], choices, default: default_index)
109
132
  end
110
133
 
111
134
  def ask_multi_select(question)
112
- choices = question["choices"].map { |c| [c["name"], c["value"]] }
135
+ # Convert to hash format: { 'Display Name' => 'value' }
136
+ choices = question["choices"].each_with_object({}) do |choice, hash|
137
+ hash[choice["name"]] = choice["value"]
138
+ end
113
139
  defaults = question["default"] || []
114
140
 
115
141
  @prompt.multi_select(question["prompt"], choices, default: defaults)
@@ -131,10 +157,11 @@ module Railstart
131
157
  end
132
158
 
133
159
  def show_summary
134
- puts "\n════════════════════════════════════════"
135
- puts "Summary"
136
- puts "════════════════════════════════════════"
137
- puts "App name: #{@app_name}"
160
+ puts
161
+ UI.section("Configuration Summary")
162
+ puts
163
+
164
+ summary_lines = ["App name: #{UI.pastel.bright_cyan(@app_name)}"]
138
165
 
139
166
  Array(@config["questions"]).each do |question|
140
167
  question_id = question["id"]
@@ -154,34 +181,53 @@ module Railstart
154
181
  answer.to_s
155
182
  end
156
183
 
157
- puts "#{label}: #{value_str}"
184
+ summary_lines << "#{label}: #{UI.pastel.bright_white(value_str)}"
158
185
  end
159
- puts "════════════════════════════════════════\n"
186
+
187
+ box = TTY::Box.frame(
188
+ width: 60,
189
+ padding: [0, 2],
190
+ border: :light,
191
+ style: {
192
+ border: { fg: :bright_black }
193
+ }
194
+ ) { summary_lines.join("\n") }
195
+
196
+ puts box
197
+ puts
160
198
  end
161
199
 
162
200
  def confirm_proceed?
163
- return true if @use_defaults
164
-
165
201
  @prompt.yes?("Proceed with app generation?")
166
202
  end
167
203
 
168
204
  def generate_app
169
205
  command = CommandBuilder.build(@app_name, @config, @answers)
170
206
 
171
- puts "Running: #{command}\n\n"
172
- success = system(command)
207
+ UI.info("Running: #{command}")
208
+ puts
209
+
210
+ # Run rails command outside of bundler context to use system Rails
211
+ success = if defined?(Bundler)
212
+ Bundler.with_unbundled_env { system(command) }
213
+ else
214
+ system(command)
215
+ end
173
216
 
174
217
  return if success
175
218
 
219
+ UI.error("Failed to generate Rails app. Check the output above for details.")
176
220
  raise Error, "Failed to generate Rails app. Check the output above for details."
177
221
  end
178
222
 
179
223
  def run_post_actions
180
224
  Dir.chdir(@app_name)
225
+
181
226
  Array(@config["post_actions"]).each { |action| process_post_action(action) }
182
- puts "\n✨ Rails app created successfully at ./#{@app_name}"
227
+ puts
228
+ UI.success("Rails app created successfully at ./#{@app_name}")
183
229
  rescue Errno::ENOENT
184
- warn "Could not change to app directory. Post-actions skipped."
230
+ UI.warning("Could not change to app directory. Post-actions skipped.")
185
231
  end
186
232
 
187
233
  def process_post_action(action)
@@ -198,9 +244,9 @@ module Railstart
198
244
  end
199
245
 
200
246
  def execute_action(action)
201
- puts "→ #{action["name"]}"
247
+ UI.info(action["name"].to_s)
202
248
  success = system(action["command"])
203
- warn "Warning: Post-action '#{action["name"]}' failed. Continuing anyway." unless success
249
+ UI.warning("Post-action '#{action["name"]}' failed. Continuing anyway.") unless success
204
250
  end
205
251
 
206
252
  def should_run_action?(action)
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tty-box"
4
+
5
+ module Railstart
6
+ # Provides UI enhancement utilities for the Railstart CLI.
7
+ #
8
+ # Handles ASCII art headers, styled boxes, and visual polish
9
+ # to create a Laravel-like installer experience.
10
+ module UI
11
+ # ASCII art logo for Railstart
12
+ LOGO = <<~LOGO
13
+ ╔═╗┌─┐┬┬ ┌─┐┌┬┐┌─┐┬─┐┌┬┐
14
+ ╠╦╝├─┤││ └─┐ │ ├─┤├┬┘ │
15
+ ╩╚═┴ ┴┴┴─┘└─┘ ┴ ┴ ┴┴└─ ┴
16
+ LOGO
17
+
18
+ module_function
19
+
20
+ #
21
+ # Display the Railstart ASCII art logo with version and optional color.
22
+ #
23
+ # @param color [Symbol] color name (e.g., :cyan, :green, :magenta)
24
+ # @return [void]
25
+ def show_logo(color: :cyan)
26
+ require_relative "version"
27
+ puts pastel.send(color, LOGO)
28
+ puts pastel.dim(" v#{Railstart::VERSION}")
29
+ puts
30
+ end
31
+
32
+ #
33
+ # Display a styled welcome message in a box.
34
+ #
35
+ # @param message [String] the welcome text to display (defaults to Rails version message)
36
+ # @return [void]
37
+ def show_welcome(message = nil)
38
+ message ||= "Interactive Rails #{rails_version} Application Generator"
39
+
40
+ box = TTY::Box.frame(
41
+ width: 60,
42
+ height: 3,
43
+ align: :center,
44
+ padding: [0, 1],
45
+ border: :thick,
46
+ style: {
47
+ fg: :bright_white,
48
+ border: { fg: :cyan }
49
+ }
50
+ ) { message }
51
+
52
+ puts box
53
+ puts # blank line
54
+ end
55
+
56
+ #
57
+ # Detect the installed Rails version.
58
+ #
59
+ # @return [String] Rails version or "Unknown" if not found
60
+ def rails_version
61
+ require "bundler"
62
+ Bundler.with_unbundled_env do
63
+ version_output = `rails --version 2>/dev/null`.strip
64
+ version_output[/Rails (\d+\.\d+\.\d+)/, 1] || "Unknown"
65
+ end
66
+ rescue StandardError
67
+ "Unknown"
68
+ end
69
+
70
+ #
71
+ # Display a section header with optional separator line.
72
+ #
73
+ # @param title [String] section title
74
+ # @param separator [Boolean] whether to show a line underneath
75
+ # @return [void]
76
+ def section(title, separator: true)
77
+ puts pastel.cyan.bold(title.to_s)
78
+ puts pastel.dim("─" * 60) if separator
79
+ end
80
+
81
+ #
82
+ # Display a success message with checkmark.
83
+ #
84
+ # @param message [String] the success message
85
+ # @return [void]
86
+ def success(message)
87
+ puts pastel.green("✓ #{message}")
88
+ end
89
+
90
+ #
91
+ # Display a warning message with icon.
92
+ #
93
+ # @param message [String] the warning message
94
+ # @return [void]
95
+ def warning(message)
96
+ puts pastel.yellow("⚠ #{message}")
97
+ end
98
+
99
+ #
100
+ # Display an error message with icon.
101
+ #
102
+ # @param message [String] the error message
103
+ # @return [void]
104
+ def error(message)
105
+ puts pastel.red("✗ #{message}")
106
+ end
107
+
108
+ #
109
+ # Display an info message with icon.
110
+ #
111
+ # @param message [String] the info message
112
+ # @return [void]
113
+ def info(message)
114
+ puts pastel.blue("ℹ #{message}")
115
+ end
116
+
117
+ #
118
+ # Lazy-load Pastel for color formatting.
119
+ #
120
+ # @return [Pastel] pastel instance
121
+ def pastel
122
+ @pastel ||= begin
123
+ require "pastel"
124
+ Pastel.new
125
+ rescue LoadError
126
+ # Fallback to no-op if pastel is not available
127
+ # (tty-prompt depends on pastel, so this should never happen)
128
+ NullPastel.new
129
+ end
130
+ end
131
+
132
+ # Null object pattern for when Pastel is unavailable
133
+ class NullPastel
134
+ def method_missing(_method, *args)
135
+ args.first.to_s
136
+ end
137
+
138
+ def respond_to_missing?(*)
139
+ true
140
+ end
141
+ end
142
+ end
143
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Railstart
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
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.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - dpaluy
@@ -23,6 +23,20 @@ dependencies:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
25
  version: '0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: tty-box
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'
26
40
  - !ruby/object:Gem::Dependency
27
41
  name: tty-prompt
28
42
  requirement: !ruby/object:Gem::Requirement
@@ -53,6 +67,8 @@ files:
53
67
  - LICENSE.txt
54
68
  - README.md
55
69
  - Rakefile
70
+ - config/presets/api-only.yaml
71
+ - config/presets/default.yaml
56
72
  - config/rails8_defaults.yaml
57
73
  - exe/railstart
58
74
  - lib/railstart.rb
@@ -61,6 +77,7 @@ files:
61
77
  - lib/railstart/config.rb
62
78
  - lib/railstart/errors.rb
63
79
  - lib/railstart/generator.rb
80
+ - lib/railstart/ui.rb
64
81
  - lib/railstart/version.rb
65
82
  homepage: https://github.com/dpaluy/railstart
66
83
  licenses:
@@ -86,7 +103,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
86
103
  - !ruby/object:Gem::Version
87
104
  version: '0'
88
105
  requirements: []
89
- rubygems_version: 3.7.2
106
+ rubygems_version: 3.6.9
90
107
  specification_version: 4
91
108
  summary: Rails application starter and development utilities
92
109
  test_files: []