From 87bd677aead9c42c89ab12a4784f36e2dd2a45f2 Mon Sep 17 00:00:00 2001 From: dpaluy Date: Sat, 22 Nov 2025 16:41:21 -0600 Subject: [PATCH] feat(ui): add Laravel-inspired CLI interface with styled boxes and dynamic version Add comprehensive UI enhancements including ASCII art logo, styled welcome box, colored status messages, and boxed configuration summary. Welcome message now displays detected Rails version dynamically. Key improvements: - New Railstart::UI module with logo, welcome box, section headers, and status messages - TTY::Box integration for styled frames and borders - Dynamic Rails version detection in welcome message - Improved select/multi_select choices using hash format instead of array pairs - Fixed TTY::Prompt default value handling (1-based index) - Bundler context isolation for Rails command execution - Bundle install post-action disabled by default Also includes CHANGELOG update documenting preset system and UI improvements. --- CHANGELOG.md | 37 ++++++++++ Gemfile.lock | 15 +++- config/rails8_defaults.yaml | 2 +- lib/railstart/generator.rb | 69 ++++++++++++----- lib/railstart/ui.rb | 143 ++++++++++++++++++++++++++++++++++++ railstart.gemspec | 1 + 6 files changed, 245 insertions(+), 22 deletions(-) create mode 100644 lib/railstart/ui.rb 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"]