Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,43 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).


## [0.2.0] - 2025-11-22

### Added
- Three-layer preset system (builtin β†’ user β†’ preset config merging)
- CLI flags: `--preset NAME` and `--default` for preset selection
- Preset resolution: user presets (`~/.config/railstart/presets/`) override built-in gem presets
- Built-in presets: `default.yaml` (PostgreSQL + Tailwind + Importmap) and `api-only.yaml`
- Config overlay schema with id-based merging for questions and post_actions
- New `Railstart::UI` module for enhanced CLI presentation
- ASCII art logo displayed at startup
- Styled welcome box with dynamic Rails version detection
- Boxed configuration summary with syntax highlighting
- Icon-based status messages (success βœ“, info β„Ή, warning ⚠, error βœ—)
- Section headers with visual separators
- `tty-box` dependency for frame rendering

### Changed
- `Config.load` now accepts optional `preset_path` parameter
- Generator modes respect preset overlays for both interactive and non-interactive flows
- Generator always confirms before generation, even in `--default` mode
- Improved TTY::Prompt integration to use hash format for select/multi_select choices
- Welcome message now displays detected Rails version instead of hardcoded "Rails 8"
- Summary display redesigned with bordered box and colored output
- Status messages now use consistent icons and colors throughout
- Generator runs Rails commands outside bundler context using `Bundler.with_unbundled_env`
- Bundle install post-action now disabled by default

### Fixed
- Proper deep merging with id-based array merging for questions and post_actions
- TTY::Prompt select/multi_select displaying duplicate options (switched from array pairs to hash format)
- Default value selection not working correctly (now uses 1-based index as expected by TTY::Prompt)
- Bundle install post-action incorrectly prompting when user explicitly skips bundle install
- CLI error handling improved (Thor::UndefinedCommandError)

### Removed
- Plain text summary formatting replaced with styled box display

## [0.1.0] - 2025-11-21

### Added
Expand Down
15 changes: 12 additions & 3 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ PATH
specs:
railstart (0.2.0)
thor
tty-box
tty-prompt

GEM
Expand Down Expand Up @@ -39,7 +40,16 @@ GEM
parser (>= 3.3.7.2)
prism (~> 1.4)
ruby-progressbar (1.13.0)
strings (0.2.1)
strings-ansi (~> 0.2)
unicode-display_width (>= 1.5, < 3.0)
unicode_utils (~> 1.4)
strings-ansi (0.2.0)
thor (1.4.0)
tty-box (0.7.0)
pastel (~> 0.8)
strings (~> 0.2.0)
tty-cursor (~> 0.7)
tty-color (0.6.0)
tty-cursor (0.7.1)
tty-prompt (0.23.1)
Expand All @@ -50,9 +60,8 @@ GEM
tty-screen (~> 0.8)
wisper (~> 2.0)
tty-screen (0.8.2)
unicode-display_width (3.2.0)
unicode-emoji (~> 4.1)
unicode-emoji (4.1.0)
unicode-display_width (2.6.0)
unicode_utils (1.4.0)
wisper (2.0.1)
yard (0.9.37)

Expand Down
2 changes: 1 addition & 1 deletion config/rails8_defaults.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ post_actions:

- id: bundle_install
name: "Install gems"
enabled: true
enabled: false
prompt: "Run bundle install?"
default: true
if:
Expand Down
69 changes: 51 additions & 18 deletions lib/railstart/generator.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# frozen_string_literal: true

require "tty-prompt"
require_relative "ui"

module Railstart
# Orchestrates the interactive Rails app generation flow.
Expand Down Expand Up @@ -41,6 +42,8 @@ def initialize(app_name = nil, config: nil, use_defaults: false, prompt: nil)
# @example Run with defaults (noninteractive questions)
# Railstart::Generator.new("blog", use_defaults: true).run
def run
show_welcome_screen unless @use_defaults

ask_app_name unless @app_name

if @use_defaults
Expand All @@ -58,6 +61,11 @@ def run

private

def show_welcome_screen
UI.show_logo
UI.show_welcome
end

def ask_app_name
@app_name = @prompt.ask("App name?", default: "my_app") do |q|
q.validate(/\A[a-z0-9_-]+\z/, "Must be lowercase letters, numbers, underscores, or hyphens")
Expand Down Expand Up @@ -111,19 +119,23 @@ def ask_question(question)
end

def ask_select(question)
choices = question["choices"].map { |c| [c["name"], c["value"]] }
# Convert to hash format: { 'Display Name' => 'value' }
choices = question["choices"].each_with_object({}) do |choice, hash|
hash[choice["name"]] = choice["value"]
end
default_val = find_default(question)

# TTY::Prompt expects 1-based index for default, not the value
default_index = if default_val
question["choices"].index { |c| c["value"] == default_val }&.+(1)
end
# TTY::Prompt expects 1-based index for default
default_index = (question["choices"].index { |c| c["value"] == default_val }&.+(1) if default_val)

@prompt.select(question["prompt"], choices, default: default_index)
end

def ask_multi_select(question)
choices = question["choices"].map { |c| [c["name"], c["value"]] }
# Convert to hash format: { 'Display Name' => 'value' }
choices = question["choices"].each_with_object({}) do |choice, hash|
hash[choice["name"]] = choice["value"]
end
defaults = question["default"] || []

@prompt.multi_select(question["prompt"], choices, default: defaults)
Expand All @@ -145,10 +157,11 @@ def find_default(question)
end

def show_summary
puts "\n════════════════════════════════════════"
puts "Summary"
puts "════════════════════════════════════════"
puts "App name: #{@app_name}"
puts
UI.section("Configuration Summary")
puts

summary_lines = ["App name: #{UI.pastel.bright_cyan(@app_name)}"]

Array(@config["questions"]).each do |question|
question_id = question["id"]
Expand All @@ -168,9 +181,20 @@ def show_summary
answer.to_s
end

puts "#{label}: #{value_str}"
summary_lines << "#{label}: #{UI.pastel.bright_white(value_str)}"
end
puts "════════════════════════════════════════\n"

box = TTY::Box.frame(
width: 60,
padding: [0, 2],
border: :light,
style: {
border: { fg: :bright_black }
}
) { summary_lines.join("\n") }

puts box
puts
end

def confirm_proceed?
Expand All @@ -180,21 +204,30 @@ def confirm_proceed?
def generate_app
command = CommandBuilder.build(@app_name, @config, @answers)

puts "Running: #{command}\n\n"
success = system(command)
UI.info("Running: #{command}")
puts

# Run rails command outside of bundler context to use system Rails
success = if defined?(Bundler)
Bundler.with_unbundled_env { system(command) }
else
system(command)
end

return if success

UI.error("Failed to generate Rails app. Check the output above for details.")
raise Error, "Failed to generate Rails app. Check the output above for details."
end

def run_post_actions
Dir.chdir(@app_name)

Array(@config["post_actions"]).each { |action| process_post_action(action) }
puts "\n✨ Rails app created successfully at ./#{@app_name}"
puts
UI.success("Rails app created successfully at ./#{@app_name}")
rescue Errno::ENOENT
warn "Could not change to app directory. Post-actions skipped."
UI.warning("Could not change to app directory. Post-actions skipped.")
end

def process_post_action(action)
Expand All @@ -211,9 +244,9 @@ def confirm_action?(action)
end

def execute_action(action)
puts "β†’ #{action["name"]}"
UI.info(action["name"].to_s)
success = system(action["command"])
warn "Warning: Post-action '#{action["name"]}' failed. Continuing anyway." unless success
UI.warning("Post-action '#{action["name"]}' failed. Continuing anyway.") unless success
end

def should_run_action?(action)
Expand Down
143 changes: 143 additions & 0 deletions lib/railstart/ui.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
# frozen_string_literal: true

require "tty-box"

module Railstart
# Provides UI enhancement utilities for the Railstart CLI.
#
# Handles ASCII art headers, styled boxes, and visual polish
# to create a Laravel-like installer experience.
module UI
# ASCII art logo for Railstart
LOGO = <<~LOGO
β•”β•β•—β”Œβ”€β”β”¬β”¬ β”Œβ”€β”β”Œβ”¬β”β”Œβ”€β”β”¬β”€β”β”Œβ”¬β”
β• β•¦β•β”œβ”€β”€β”‚β”‚ └─┐ β”‚ β”œβ”€β”€β”œβ”¬β”˜ β”‚
β•©β•šβ•β”΄ β”΄β”΄β”΄β”€β”˜β””β”€β”˜ β”΄ β”΄ ┴┴└─ β”΄
LOGO

module_function

#
# Display the Railstart ASCII art logo with version and optional color.
#
# @param color [Symbol] color name (e.g., :cyan, :green, :magenta)
# @return [void]
def show_logo(color: :cyan)
require_relative "version"
puts pastel.send(color, LOGO)
puts pastel.dim(" v#{Railstart::VERSION}")
puts
end

#
# Display a styled welcome message in a box.
#
# @param message [String] the welcome text to display (defaults to Rails version message)
# @return [void]
def show_welcome(message = nil)
message ||= "Interactive Rails #{rails_version} Application Generator"

box = TTY::Box.frame(
width: 60,
height: 3,
align: :center,
padding: [0, 1],
border: :thick,
style: {
fg: :bright_white,
border: { fg: :cyan }
}
) { message }

puts box
puts # blank line
end

#
# Detect the installed Rails version.
#
# @return [String] Rails version or "Unknown" if not found
def rails_version
require "bundler"
Bundler.with_unbundled_env do
version_output = `rails --version 2>/dev/null`.strip
version_output[/Rails (\d+\.\d+\.\d+)/, 1] || "Unknown"
end
rescue StandardError
"Unknown"
end

#
# Display a section header with optional separator line.
#
# @param title [String] section title
# @param separator [Boolean] whether to show a line underneath
# @return [void]
def section(title, separator: true)
puts pastel.cyan.bold(title.to_s)
puts pastel.dim("─" * 60) if separator
end

#
# Display a success message with checkmark.
#
# @param message [String] the success message
# @return [void]
def success(message)
puts pastel.green("βœ“ #{message}")
end

#
# Display a warning message with icon.
#
# @param message [String] the warning message
# @return [void]
def warning(message)
puts pastel.yellow("⚠ #{message}")
end

#
# Display an error message with icon.
#
# @param message [String] the error message
# @return [void]
def error(message)
puts pastel.red("βœ— #{message}")
end

#
# Display an info message with icon.
#
# @param message [String] the info message
# @return [void]
def info(message)
puts pastel.blue("β„Ή #{message}")
end

#
# Lazy-load Pastel for color formatting.
#
# @return [Pastel] pastel instance
def pastel
@pastel ||= begin
require "pastel"
Pastel.new
rescue LoadError
# Fallback to no-op if pastel is not available
# (tty-prompt depends on pastel, so this should never happen)
NullPastel.new
end
end

# Null object pattern for when Pastel is unavailable
class NullPastel
def method_missing(_method, *args)
args.first.to_s
end

def respond_to_missing?(*)
true
end
end
end
end
1 change: 1 addition & 0 deletions railstart.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ Gem::Specification.new do |spec|
spec.bindir = "exe"
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
spec.add_dependency "thor"
spec.add_dependency "tty-box"
spec.add_dependency "tty-prompt"
spec.require_paths = ["lib"]

Expand Down