diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f79463..6b900e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Gemfile.lock b/Gemfile.lock index cb1a450..7801fb3 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -3,6 +3,7 @@ PATH specs: railstart (0.2.0) thor + tty-box tty-prompt GEM @@ -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) @@ -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) diff --git a/config/rails8_defaults.yaml b/config/rails8_defaults.yaml index 98cdb07..deb853a 100644 --- a/config/rails8_defaults.yaml +++ b/config/rails8_defaults.yaml @@ -115,7 +115,7 @@ post_actions: - id: bundle_install name: "Install gems" - enabled: true + enabled: false prompt: "Run bundle install?" default: true if: diff --git a/lib/railstart/generator.rb b/lib/railstart/generator.rb index 3ace515..c3bbad9 100644 --- a/lib/railstart/generator.rb +++ b/lib/railstart/generator.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "tty-prompt" +require_relative "ui" module Railstart # Orchestrates the interactive Rails app generation flow. @@ -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 @@ -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") @@ -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) @@ -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"] @@ -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? @@ -180,11 +204,19 @@ 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 @@ -192,9 +224,10 @@ 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) @@ -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) diff --git a/lib/railstart/ui.rb b/lib/railstart/ui.rb new file mode 100644 index 0000000..2b53a86 --- /dev/null +++ b/lib/railstart/ui.rb @@ -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 diff --git a/railstart.gemspec b/railstart.gemspec index 6ec3d76..31a765a 100644 --- a/railstart.gemspec +++ b/railstart.gemspec @@ -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"]