From 40f6911d173077fcc0d039d3f584980f69d39578 Mon Sep 17 00:00:00 2001 From: David Paluy Date: Sat, 11 Jul 2026 13:24:37 -0500 Subject: [PATCH 1/6] feat: require Ruby 4.0.5 and harden generation --- .../references/cli-and-config.md | 5 +- .github/workflows/ci.yml | 31 ++- .github/workflows/release.yml | 2 +- .rubocop.yml | 2 +- .ruby-version | 1 + AGENTS.md | 4 +- CHANGELOG.md | 14 + Gemfile | 2 + Gemfile.lock | 14 + README.md | 24 +- Rakefile | 2 +- config/presets/api-only.yaml | 1 + config/rails8_defaults.yaml | 1 + lib/railstart/cli.rb | 23 +- lib/railstart/command_builder.rb | 22 +- lib/railstart/config.rb | 254 +++++++++++++++--- lib/railstart/generator.rb | 25 +- railstart.gemspec | 9 +- test/cli_test.rb | 31 ++- test/command_builder_test.rb | 14 + test/config_test.rb | 231 ++++++++++++++++ test/generator_test.rb | 36 +++ test/rails_cli_contract_test.rb | 45 ++++ test/shipped_config_test.rb | 38 +++ 24 files changed, 735 insertions(+), 96 deletions(-) create mode 100644 .ruby-version create mode 100644 test/rails_cli_contract_test.rb create mode 100644 test/shipped_config_test.rb diff --git a/.agents/skills/railstart-coder/references/cli-and-config.md b/.agents/skills/railstart-coder/references/cli-and-config.md index 42153c2..3c2db86 100644 --- a/.agents/skills/railstart-coder/references/cli-and-config.md +++ b/.agents/skills/railstart-coder/references/cli-and-config.md @@ -32,7 +32,8 @@ Important CLI behavior: - `--preset NAME` resolves user presets first from `~/.config/railstart/presets/NAME.yaml`, then gem presets from `config/presets/NAME.yaml`. - `--preset` also accepts an explicit `.yaml` or `.yml` path. - `--default` maps to the `default` preset name. If no `default` preset exists, Railstart falls back to the built-in config. -- `railstart init` copies the full `config/rails8_defaults.yaml` into `~/.config/railstart/config.yaml` and creates `~/.config/railstart/presets/example.yaml`. +- `railstart init` creates a minimal override at `~/.config/railstart/config.yaml` and an example preset at `~/.config/railstart/presets/example.yaml`. +- Keep user overrides minimal. Overriding a `choices` array replaces the full built-in array, so copied full configs can hide choices added by future Railstart releases. - There is no dry-run mode. Use `CommandBuilder` tests or injected generator tests when you need to verify flags without creating a Rails app. Current built-in preset files: @@ -65,7 +66,7 @@ These are the current questions defined in `config/rails8_defaults.yaml`. | ID | Type | Built-in choices/default | Rails flag behavior | | --- | --- | --- | --- | | `database` | `select` | `sqlite3` default, `postgresql`, `mysql` | Question-level `--database=%s` | -| `css` | `select` | `tailwind` default, `bootstrap`, `bulma`, `postcss`, `sass`, `none` | Question-level `--css=%{value}` except custom choice-level overrides you may add in presets | +| `css` | `select` | `tailwind` default, `bootstrap`, `bulma`, `postcss`, `sass`, `none` | Question-level `--css=%{value}`; built-in `none` overrides it with `--skip-css` | | `javascript` | `select` | `importmap` default, `bun`, `esbuild`, `rollup`, `webpack`, `none` | Choice-level flags for built-in choices | | `skip_features` | `multi_select` | empty default | Selected choices emit their own skip flags | | `api_only` | `yes_no` | `false` | `--api` when true | diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c53b7f8..ee4d02a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,14 +8,9 @@ on: pull_request: jobs: - build: + test: runs-on: ubuntu-latest - name: Ruby ${{ matrix.ruby }} - strategy: - matrix: - ruby: - - "3.4" - - "4.0" + name: Test on Ruby 4.0.5 steps: - uses: actions/checkout@v5 @@ -24,7 +19,23 @@ jobs: - name: Set up Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: ${{ matrix.ruby }} + ruby-version: "4.0.5" bundler-cache: true - - name: Run the default task - run: bundle exec rake + - name: Run tests + run: bundle exec rake test + - name: Run RuboCop + run: bundle exec rubocop + - name: Generate documentation + run: bundle exec rake yard + - name: Build gem + run: gem build railstart.gemspec + - name: Check executable syntax + run: ruby -c exe/railstart + - name: Smoke test executable + run: bundle exec exe/railstart version + - name: Install Rails for shipped-config contract test + run: gem install rails --version "~> 8.1" --no-document + - name: Validate shipped config against Rails + env: + RAILS_CONTRACT: "1" + run: bundle exec ruby -Itest test/rails_cli_contract_test.rb diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8a6355e..eeebb91 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,7 +21,7 @@ jobs: - name: Set up Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: "3.4.5" + ruby-version: "4.0.5" bundler-cache: true - uses: rubygems/release-gem@v1 diff --git a/.rubocop.yml b/.rubocop.yml index 4d7e3cd..29f0334 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,5 +1,5 @@ AllCops: - TargetRubyVersion: 3.2 + TargetRubyVersion: 4.0 NewCops: enable SuggestExtensions: false diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 0000000..7636e75 --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +4.0.5 diff --git a/AGENTS.md b/AGENTS.md index 1e645db..17ef666 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ **Type:** Single Ruby gem (interactive Rails application starter) -**Tech Stack:** Ruby 3.2+, Rails 8+, Thor (CLI), TTY::Prompt (interactive prompts), Minitest (testing) +**Tech Stack:** Ruby 4.0.5+, Rails 8+, Thor (CLI), TTY::Prompt (interactive prompts), Minitest (testing) **Purpose:** Provides an opinionated, interactive CLI wizard (`railstart new`) that guides developers through Rails 8 project setup with customizable defaults and post-generation hooks. @@ -34,7 +34,7 @@ bundle exec rake test && bundle exec rubocop - **Linting:** RuboCop (see `.rubocop.yml`) - **Testing Framework:** Minitest (prefer assertions over mocks; integration > unit) - **Commits:** Conventional Commits (feat:, fix:, test:, docs:, refactor:) -- **Ruby Version:** 3.2.0 minimum (see `railstart.gemspec`) +- **Ruby Version:** 4.0.5 minimum (see `railstart.gemspec`) --- diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e52386..6c965d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **Ruby 4 baseline**: Pinned development, CI, and release workflows to Ruby 4.0.5 and raised the gem requirement to Ruby 4.0.5 or newer +- **Rails contract coverage**: Added opt-in `rails new --pretend` checks for the built-in configuration and every shipped preset +- **Config validation**: Added fail-fast validation for duplicate IDs, invalid defaults, malformed flags, dependency conditions, post-action conditions, and template variable names + +### Changed +- **Safe command execution**: Rails generation now executes an argument vector instead of a shell-interpreted command string +- **Init command**: `railstart init` now generates minimal user overrides so future built-in choices and flags are not shadowed by a copied full configuration +- **CI quality gate**: CI now runs tests, RuboCop, YARD, gem packaging, executable smoke checks, and Rails CLI contract checks + +### Fixed +- **API-only preset**: The CSS `none` choice now emits `--skip-css` instead of the invalid `--css=none` +- **Documentation build**: Declared the Markdown documentation dependencies and made YARD fail on warnings + ## [0.4.5] - 2026-04-30 ### Added diff --git a/Gemfile b/Gemfile index 38964f2..b446c29 100644 --- a/Gemfile +++ b/Gemfile @@ -7,5 +7,7 @@ gemspec gem "minitest", "~> 6.0" gem "minitest-mock" gem "rake", "~> 13.0" +gem "rdoc", "~> 6.0" +gem "redcarpet", "~> 3.6" gem "rubocop", "~> 1.86" gem "yard", "~> 0.9" diff --git a/Gemfile.lock b/Gemfile.lock index 3fed424..f64a477 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -10,7 +10,9 @@ GEM remote: https://rubygems.org/ specs: ast (2.4.3) + date (3.5.1) drb (2.2.3) + erb (6.0.4) json (2.19.4) language_server-protocol (3.17.0.5) lint_roller (1.1.0) @@ -25,9 +27,17 @@ GEM pastel (0.8.0) tty-color (~> 0.5) prism (1.9.0) + psych (5.4.0) + date + stringio racc (1.8.1) rainbow (3.1.1) rake (13.3.1) + rdoc (6.17.0) + erb + psych (>= 4.0.0) + tsort + redcarpet (3.6.1) regexp_parser (2.12.0) rubocop (1.86.1) json (~> 2.3) @@ -44,12 +54,14 @@ GEM parser (>= 3.3.7.2) prism (~> 1.7) ruby-progressbar (1.13.0) + stringio (3.2.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.5.0) + tsort (0.2.0) tty-box (0.7.0) pastel (~> 0.8) strings (~> 0.2.0) @@ -78,6 +90,8 @@ DEPENDENCIES minitest-mock railstart! rake (~> 13.0) + rdoc (~> 6.0) + redcarpet (~> 3.6) rubocop (~> 1.86) yard (~> 0.9) diff --git a/README.md b/README.md index 35f3ac0..3367982 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ Interactive CLI wizard for generating Rails 8 applications with customizable con Think of it as `rails new` with an opinion and a friendly interactive experience. +Requires Ruby 4.0.5 or newer. + ## Installation ```bash @@ -231,11 +233,11 @@ railstart init ``` This creates: -- `~/.config/railstart/config.yaml` - Complete configuration template (copy of rails8_defaults.yaml with all available options) +- `~/.config/railstart/config.yaml` - Minimal example containing only a few global overrides - `~/.config/railstart/presets/` - Directory for your presets - `~/.config/railstart/presets/example.yaml` - Example preset to get started -The generated `config.yaml` shows all available questions, choices, flags, and post-actions. You can delete or comment out sections you don't want to customize, and modify the defaults for sections you do want to change. +Keep `config.yaml` minimal so future built-in choices and flags continue to reach your installation. The full shipped schema remains available in `config/rails8_defaults.yaml`, while `examples/config.yml` provides a larger copyable example. ### Built-in Defaults @@ -243,20 +245,15 @@ Railstart ships with sensible Rails 8 defaults defined in `config/rails8_default ### Customize for Your Team -You can create `~/.config/railstart/config.yaml` manually or use `railstart init` to generate a complete template file. The template includes all available options, so you can simply modify the defaults you want to change: +You can create `~/.config/railstart/config.yaml` manually or use `railstart init` to generate a minimal starting point. Add only the defaults you want to change: ```yaml -# After running `railstart init`, your config.yaml will contain all options. -# Simply modify the defaults you want to change: +# After running `railstart init`, config.yaml contains a few example overrides. +# Keep only the overrides you want to apply to every run: questions: - id: database - choices: - - name: PostgreSQL (recommended) - value: postgresql - default: true # Changed from SQLite to PostgreSQL - - # ... other questions with their full configuration ... + default: postgresql post_actions: - id: bundle_install @@ -356,6 +353,9 @@ Key differences from `command` actions: ### Setup ```bash +# Install the required Ruby version +mise install ruby@4.0.5 + # Install dependencies bundle install @@ -394,7 +394,7 @@ bundle exec rubocop bundle exec rubocop -a # Full check -bundle exec rake test && bundle exec rubocop +bundle exec rake test && bundle exec rubocop && bundle exec rake yard && gem build railstart.gemspec ``` ## Architecture diff --git a/Rakefile b/Rakefile index 2565a9f..4a4f941 100644 --- a/Rakefile +++ b/Rakefile @@ -13,7 +13,7 @@ begin require "yard" YARD::Rake::YardocTask.new(:yard) do |t| t.files = ["lib/**/*.rb"] - t.options = ["--output-dir", "doc", "--markup", "markdown"] + t.options = ["--output-dir", "doc", "--markup", "markdown", "--fail-on-warning"] end rescue LoadError # YARD not available diff --git a/config/presets/api-only.yaml b/config/presets/api-only.yaml index a927f21..c69c275 100644 --- a/config/presets/api-only.yaml +++ b/config/presets/api-only.yaml @@ -13,6 +13,7 @@ questions: - name: None value: none default: true + rails_flag: "--skip-css" - id: javascript choices: diff --git a/config/rails8_defaults.yaml b/config/rails8_defaults.yaml index 7bcb49c..b87ac81 100644 --- a/config/rails8_defaults.yaml +++ b/config/rails8_defaults.yaml @@ -34,6 +34,7 @@ questions: value: sass - name: None (skip CSS setup) value: none + rails_flag: "--skip-css" rails_flag: "--css=%{value}" - id: javascript diff --git a/lib/railstart/cli.rb b/lib/railstart/cli.rb index f490516..f61e755 100644 --- a/lib/railstart/cli.rb +++ b/lib/railstart/cli.rb @@ -257,9 +257,26 @@ def preset_file_for(name) end def example_user_config - # Copy the full rails8_defaults.yaml as the user config template - defaults_path = File.expand_path("../../config/rails8_defaults.yaml", __dir__) - File.read(defaults_path) + <<~YAML + --- + # User overrides for every Railstart run. + # + # Keep this file minimal. Entries merge by id with Railstart's built-in + # configuration. Overriding a choices array replaces the entire array. + # See config/rails8_defaults.yaml in the Railstart repository for the + # full schema and every available question, choice, and post-action. + + questions: + - id: database + default: postgresql + + - id: skip_docker + default: true + + post_actions: + - id: bundle_install + enabled: false + YAML end def example_preset_config diff --git a/lib/railstart/command_builder.rb b/lib/railstart/command_builder.rb index 8487458..d460770 100644 --- a/lib/railstart/command_builder.rb +++ b/lib/railstart/command_builder.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "shellwords" + module Railstart # Translates configuration and user answers into a `rails new` command string. # @@ -23,12 +25,28 @@ class << self # @example # Railstart::CommandBuilder.build("todo", config, answers) def build(app_name, config, answers) - flags = collect_flags(config["questions"], answers) - "rails new #{app_name} #{flags.join(" ")}".strip + arguments(app_name, config, answers).map { |argument| display_argument(argument) }.join(" ") + end + + # Build process arguments for `rails new` without shell interpretation. + # + # @param app_name [String] target Rails app name + # @param config [Hash] merged configuration from {Railstart::Config.load} + # @param answers [Hash] user answers keyed by question id + # @return [Array] executable and arguments suitable for `system(*arguments)` + def arguments(app_name, config, answers) + ["rails", "new", app_name.to_s, *collect_flags(config["questions"], answers)] end private + def display_argument(argument) + value = argument.to_s + return value if value.match?(%r{\A[\w@%+=:,./-]+\z}) + + Shellwords.escape(value) + end + def collect_flags(questions, answers) flags = [] Array(questions).each do |question| diff --git a/lib/railstart/config.rb b/lib/railstart/config.rb index 49d5929..90e7ae6 100644 --- a/lib/railstart/config.rb +++ b/lib/railstart/config.rb @@ -34,6 +34,8 @@ def load(builtin_path: BUILTIN_CONFIG_PATH, user_path: USER_CONFIG_PATH, preset_ user = read_yaml(user_path, required: false) preset = preset_path ? read_yaml(preset_path, required: false) : {} + validate_layer_ids!(builtin, user, preset) + merged = merge_config(builtin, user) merged = merge_config(merged, preset) unless preset.empty? validate!(merged) @@ -54,7 +56,7 @@ def interpolate_flag(template, value) return template if template.nil? || (!template.include?("%{") && !template.include?("%<")) format(template, value: value) - rescue KeyError => e + rescue KeyError, ArgumentError => e raise ConfigError, "Invalid interpolation token in rails_flag \"#{template}\": #{e.message}" end @@ -161,64 +163,104 @@ def fetch_id(entry) end def validate!(config) - issues = [] - question_ids = Array(config["questions"]).map { |e| fetch_id(e) }.compact + questions = Array(config["questions"]) + post_actions = Array(config["post_actions"]) + issues = validate_questions(questions) + issues.concat(validate_post_actions(post_actions, questions)) + raise ConfigValidationError.new("Invalid configuration", issues: issues) unless issues.empty? + end - MERGEABLE_COLLECTIONS.each do |collection| - entries = Array(config[collection]) - issues.concat(validate_collection(collection, entries, question_ids)) + def validate_layer_ids!(*layers) + issues = layers.flat_map do |layer| + MERGEABLE_COLLECTIONS.flat_map do |collection| + duplicate_id_issues(collection, Array(layer[collection])) + end end raise ConfigValidationError.new("Invalid configuration", issues: issues) unless issues.empty? end - def validate_collection(name, entries, question_ids = []) + def duplicate_id_issues(collection, entries) + counts = entries.filter_map { |entry| fetch_id(entry) }.tally + counts.filter_map do |id, count| + "#{collection} entry id #{id} is defined #{count} times" if count > 1 + end + end + + def validate_questions(entries) issues = [] id_counts = Hash.new(0) + question_positions = entries.each_with_index.filter_map do |entry, index| + id = fetch_id(entry) + [id, index] if id + end.to_h entries.each_with_index do |entry, index| unless entry.is_a?(Hash) - issues << "#{name} entry at index #{index} must be a Hash" + issues << "questions entry at index #{index} must be a Hash" next end id = fetch_id(entry) if id.nil? || id.to_s.strip.empty? - issues << "#{name} entry at index #{index} is missing an id" + issues << "questions entry at index #{index} is missing an id" else id_counts[id] += 1 end - if name == "questions" - type = entry["type"] || entry[:type] - unless QUESTION_TYPES.include?(type) - issues << "Question #{id || index} has invalid type #{type.inspect}" - next - end + identifier = id || index + type = value_for(entry, "type") + unless QUESTION_TYPES.include?(type) + issues << "Question #{identifier} has invalid type #{type.inspect}" + next + end - issues.concat(validate_question_choices(entry, id || index)) if CHOICE_REQUIRED_TYPES.include?(type) - elsif name == "post_actions" - issues.concat(validate_post_action_entry(entry, id || index)) if entry.fetch("enabled", true) + prompt = value_for(entry, "prompt") + issues << "Question #{identifier} is missing a prompt" if prompt.nil? || prompt.to_s.strip.empty? + issues.concat(validate_question_choices(entry, identifier)) if CHOICE_REQUIRED_TYPES.include?(type) + issues.concat(validate_question_default(entry, identifier, type)) + issues.concat(validate_flag_source(entry, "Question #{identifier}")) + issues.concat(validate_depends_on(entry, identifier, index, question_positions)) + end - if_condition = entry["if"] || entry[:if] - if if_condition.is_a?(Hash) - ref_question_id = if_condition["question"] || if_condition[:question] - if ref_question_id && !question_ids.include?(ref_question_id) - issues << "Post-action #{id || index} references unknown question '#{ref_question_id}'" - end - end + id_counts.each do |id, count| + issues << "questions entry id #{id} is defined #{count} times" if count > 1 + end + + issues + end + + def validate_post_actions(entries, questions) + issues = [] + id_counts = Hash.new(0) + question_ids = questions.filter_map { |entry| fetch_id(entry) } + + entries.each_with_index do |entry, index| + unless entry.is_a?(Hash) + issues << "post_actions entry at index #{index} must be a Hash" + next end + + id = fetch_id(entry) + if id.nil? || id.to_s.strip.empty? + issues << "post_actions entry at index #{index} is missing an id" + else + id_counts[id] += 1 + end + + identifier = id || index + issues.concat(validate_post_action_entry(entry, identifier)) + issues.concat(validate_post_action_condition(entry, identifier, question_ids)) end id_counts.each do |id, count| - issues << "#{name} entry id #{id} is defined #{count} times" if count > 1 + issues << "post_actions entry id #{id} is defined #{count} times" if count > 1 end - issues end def validate_question_choices(entry, question_id) issues = [] - choices = entry["choices"] || entry[:choices] + choices = value_for(entry, "choices") if !choices.is_a?(Array) || choices.empty? issues << "Question #{question_id} (#{entry["type"]}) must define at least one choice" @@ -230,43 +272,183 @@ def validate_question_choices(entry, question_id) issues << "Question #{question_id} choice at index #{cidx} must be a Hash" next end - unless choice["name"] || choice[:name] + unless key_present?(choice, "name") && !value_for(choice, "name").to_s.strip.empty? issues << "Question #{question_id} choice at index #{cidx} missing 'name'" end - unless choice["value"] || choice[:value] + unless key_present?(choice, "value") && !value_for(choice, "value").nil? issues << "Question #{question_id} choice at index #{cidx} missing 'value'" end + if key_present?(choice, "default") && ![true, false].include?(value_for(choice, "default")) + issues << "Question #{question_id} choice at index #{cidx} default must be true or false" + end + issues.concat(validate_flag_source(choice, "Question #{question_id} choice at index #{cidx}")) end + issues.concat(duplicate_choice_issues(choices, question_id, "name")) + issues.concat(duplicate_choice_issues(choices, question_id, "value")) + default_count = choices.count do |choice| + choice.is_a?(Hash) && value_for(choice, "default") == true + end + if value_for(entry, "type") == "select" && default_count > 1 + issues << "Question #{question_id} must define at most one default choice" + end + issues + end + + def duplicate_choice_issues(choices, question_id, field) + values = choices.filter_map do |choice| + value_for(choice, field) if choice.is_a?(Hash) && key_present?(choice, field) + end + values.tally.filter_map do |value, count| + "Question #{question_id} has duplicate choice #{field} #{value.inspect}" if count > 1 + end + end + + def validate_question_default(entry, identifier, type) + return [] unless key_present?(entry, "default") + + default = value_for(entry, "default") + choices = Array(value_for(entry, "choices")) + choice_values = choices.filter_map do |choice| + value_for(choice, "value") if choice.is_a?(Hash) && key_present?(choice, "value") + end + + case type + when "select" + return [] if choice_values.include?(default) + + ["Question #{identifier} default #{default.inspect} is not a defined choice"] + when "multi_select" + return ["Question #{identifier} default must be an Array"] unless default.is_a?(Array) + + unknown = default - choice_values + unknown.empty? ? [] : ["Question #{identifier} has unknown default choice #{unknown.first.inspect}"] + when "yes_no" + [true, false].include?(default) ? [] : ["Question #{identifier} default must be true or false"] + else + [] + end + end + + def validate_flag_source(source, label) + issues = [] + if key_present?(source, "rails_flag") + flag = value_for(source, "rails_flag") + if flag.is_a?(String) + issues.concat(interpolation_issues(flag, label, "rails_flag")) + else + issues << "#{label} rails_flag must be a String" + end + end + + if key_present?(source, "rails_flags") + flags = value_for(source, "rails_flags") + if !flags.is_a?(Array) || flags.any? { |flag| !flag.is_a?(String) } + issues << "#{label} rails_flags must be an Array of Strings" + else + flags.each { |flag| issues.concat(interpolation_issues(flag, label, "rails_flags")) } + end + end + issues + end + + def interpolation_issues(flag, label, field) + interpolate_flag(flag, "value") + [] + rescue ConfigError => e + ["#{label} has invalid #{field} interpolation: #{e.message}"] + end + + def validate_depends_on(entry, identifier, index, question_positions) + return [] unless key_present?(entry, "depends_on") + + condition = value_for(entry, "depends_on") + return ["Question #{identifier} depends_on must be a Hash"] unless condition.is_a?(Hash) + + issues = unsupported_key_issues(condition, %w[question value], "Question #{identifier} depends_on") + reference = value_for(condition, "question") + issues << "Question #{identifier} depends_on is missing question" if reference.to_s.strip.empty? + issues << "Question #{identifier} depends_on is missing value" unless key_present?(condition, "value") + if !reference.to_s.empty? && !question_positions.key?(reference) + issues << "Question #{identifier} references unknown question '#{reference}'" + elsif question_positions[reference] && question_positions[reference] >= index + issues << "Question #{identifier} depends_on must reference an earlier question" + end issues end def validate_post_action_entry(entry, identifier) - action_type = (entry["type"] || entry[:type] || "command").to_s + action_type = (value_for(entry, "type") || "command").to_s + enabled = entry.fetch("enabled", entry.fetch(:enabled, true)) case action_type when "command" - command = entry["command"] || entry[:command] - if command.nil? || command.to_s.strip.empty? + command = value_for(entry, "command") + if enabled && (command.nil? || command.to_s.strip.empty?) ["Post-action #{identifier} is enabled but missing a command"] else [] end when "template" issues = [] - source = entry["source"] || entry[:source] - if source.nil? || source.to_s.strip.empty? + source = value_for(entry, "source") + if enabled && (source.nil? || source.to_s.strip.empty?) issues << "Post-action #{identifier} is a template but missing a source" end - variables = entry["variables"] || entry[:variables] + variables = value_for(entry, "variables") issues << "Post-action #{identifier} template variables must be a Hash" if variables && !variables.is_a?(Hash) + if variables.is_a?(Hash) + variables.each_key do |key| + unless /\A[a-zA-Z_]\w*\z/.match?(key.to_s) + issues << "Post-action #{identifier} has invalid template variable name #{key.inspect}" + end + end + end issues else ["Post-action #{identifier} has unsupported type '#{action_type}'"] end end + def validate_post_action_condition(entry, identifier, question_ids) + return [] unless key_present?(entry, "if") + + condition = value_for(entry, "if") + return ["Post-action #{identifier} condition must be a Hash"] unless condition.is_a?(Hash) + + issues = unsupported_key_issues(condition, %w[question equals includes], "Post-action #{identifier} condition") + reference = value_for(condition, "question") + issues << "Post-action #{identifier} condition is missing question" if reference.to_s.strip.empty? + if !reference.to_s.empty? && !question_ids.include?(reference) + issues << "Post-action #{identifier} references unknown question '#{reference}'" + end + + operators = %w[equals includes].select { |key| key_present?(condition, key) } + unless operators.one? + issues << "Post-action #{identifier} condition must define exactly one of equals or includes" + end + if key_present?(condition, "includes") && !value_for(condition, "includes").is_a?(Array) + issues << "Post-action #{identifier} condition includes must be an Array" + end + issues + end + + def unsupported_key_issues(hash, allowed, label) + unsupported = hash.keys.map(&:to_s) - allowed + unsupported.map { |key| "#{label} has unsupported key '#{key}'" } + end + + def key_present?(hash, key) + hash.key?(key) || hash.key?(key.to_sym) + end + + def value_for(hash, key) + return hash[key] if hash.key?(key) + + hash[key.to_sym] + end + def deep_dup(value) case value when Hash diff --git a/lib/railstart/generator.rb b/lib/railstart/generator.rb index 6133621..e71f654 100644 --- a/lib/railstart/generator.rb +++ b/lib/railstart/generator.rb @@ -16,6 +16,9 @@ module Railstart # @example Run generator non-interactively # Railstart::Generator.new("blog", use_defaults: true).run class Generator + APP_NAME_PATTERN = /\A[a-z0-9_-]+\z/ + APP_NAME_ERROR = "Must be lowercase letters, numbers, underscores, or hyphens" + # # @param app_name [String, nil] preset app name, prompted if nil # @param config [Hash, nil] injected config for testing, defaults to Config.load @@ -46,6 +49,7 @@ def run show_welcome_screen unless @use_defaults ask_app_name unless @app_name + validate_app_name! if @use_defaults collect_defaults @@ -69,10 +73,16 @@ def show_welcome_screen 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") + q.validate(APP_NAME_PATTERN, APP_NAME_ERROR) end end + def validate_app_name! + return if @app_name.to_s.match?(APP_NAME_PATTERN) + + raise Error, "Invalid app name '#{@app_name}': #{APP_NAME_ERROR}" + end + def collect_defaults Array(@config["questions"]).each do |question| next if should_skip_question?(question) @@ -121,9 +131,7 @@ def ask_question(question) def ask_select(question) # Convert to hash format: { 'Display Name' => 'value' } - choices = question["choices"].each_with_object({}) do |choice, hash| - hash[choice["name"]] = choice["value"] - end + choices = question["choices"].to_h { |choice| [choice["name"], choice["value"]] } default_val = find_default(question) # TTY::Prompt expects 1-based index for default @@ -134,9 +142,7 @@ def ask_select(question) def ask_multi_select(question) # Convert to hash format: { 'Display Name' => 'value' } - choices = question["choices"].each_with_object({}) do |choice, hash| - hash[choice["name"]] = choice["value"] - end + choices = question["choices"].to_h { |choice| [choice["name"], choice["value"]] } # Transform value-based defaults to name-based defaults for TTY::Prompt # Config uses stable values (e.g., "action_mailer"), TTY::Prompt needs display names @@ -210,6 +216,7 @@ def confirm_proceed? end def generate_app + arguments = CommandBuilder.arguments(@app_name, @config, @answers) command = CommandBuilder.build(@app_name, @config, @answers) UI.info("Running: #{command}") @@ -217,9 +224,9 @@ def generate_app # Run rails command outside of bundler context to use system Rails success = if defined?(Bundler) - Bundler.with_unbundled_env { system(command) } + Bundler.with_unbundled_env { system(*arguments) } else - system(command) + system(*arguments) end return if success diff --git a/railstart.gemspec b/railstart.gemspec index fd79726..47436c9 100644 --- a/railstart.gemspec +++ b/railstart.gemspec @@ -12,21 +12,24 @@ Gem::Specification.new do |spec| spec.description = "Interactive CLI wizard for Rails app generation with customizable config" spec.homepage = "https://github.com/dpaluy/railstart" spec.license = "MIT" - spec.required_ruby_version = ">= 3.2.0" + spec.required_ruby_version = ">= 4.0.5" spec.metadata["rubygems_mfa_required"] = "true" - spec.metadata["homepage_uri"] = spec.homepage spec.metadata["documentation_uri"] = "https://rubydoc.info/gems/railstart" spec.metadata["source_code_uri"] = "https://github.com/dpaluy/railstart" spec.metadata["changelog_uri"] = "https://github.com/dpaluy/railstart/blob/master/CHANGELOG.md" spec.metadata["bug_tracker_uri"] = "https://github.com/dpaluy/railstart/issues" gemspec = File.basename(__FILE__) + excluded_prefixes = %w[ + bin/ Gemfile .gitignore .ruby-version test/ .github/ .rubocop.yml + docs/ .agents/ .omx/ examples/ + ] spec.files = IO.popen(%w[git ls-files -z], chdir: __dir__, err: IO::NULL) do |ls| ls.readlines("\x0", chomp: true).reject do |f| (f == gemspec) || (f == "AGENTS.md") || - f.start_with?(*%w[bin/ Gemfile .gitignore test/ .github/ .rubocop.yml docs/ .agents/ .omx/ examples/]) + f.start_with?(*excluded_prefixes) end end spec.bindir = "exe" diff --git a/test/cli_test.rb b/test/cli_test.rb index 779e7c8..d3647f8 100644 --- a/test/cli_test.rb +++ b/test/cli_test.rb @@ -30,24 +30,27 @@ def test_missing_explicit_yaml_path_raises_error end end - def test_init_copies_rails8_defaults_yaml_content + def test_init_generates_minimal_user_config_override cli = Railstart::CLI.new user_config = cli.send(:example_user_config) - # Verify it contains the full rails8_defaults.yaml structure - assert_includes user_config, "questions:" - assert_includes user_config, "post_actions:" - assert_includes user_config, "id: database" - assert_includes user_config, "id: css" - assert_includes user_config, "id: javascript" - assert_includes user_config, "id: test_framework" - assert_includes user_config, "id: init_git" - assert_includes user_config, "id: setup_rspec" - - # Verify it's valid YAML parsed = YAML.safe_load(user_config, permitted_classes: [Symbol]) - assert parsed["questions"].is_a?(Array) - assert parsed["post_actions"].is_a?(Array) + + question_ids = parsed.fetch("questions").map { |question| question.fetch("id") } + action_ids = parsed.fetch("post_actions").map { |action| action.fetch("id") } + + assert_equal %w[database skip_docker], question_ids + assert_equal ["bundle_install"], action_ids + refute_includes user_config, "choices:" + assert_includes user_config, "config/rails8_defaults.yaml" + + Dir.mktmpdir do |dir| + path = File.join(dir, "config.yaml") + File.write(path, user_config) + + config = Config.load(user_path: path) + assert_equal "postgresql", config.fetch("questions").first.fetch("default") + end end end end diff --git a/test/command_builder_test.rb b/test/command_builder_test.rb index e9447b4..f2fded6 100644 --- a/test/command_builder_test.rb +++ b/test/command_builder_test.rb @@ -99,6 +99,20 @@ def test_combined_question_types_preserve_order assert_match(/rails new app --database=postgres --api --skip-action-mailer/, command) end + def test_arguments_preserve_user_values_as_single_process_arguments + add_question("label", "input", "rails_flag" => "--label=%s") + + arguments = CommandBuilder.arguments("bad; printf injected", @config, "label" => "x; printf injected") + + assert_equal ["rails", "new", "bad; printf injected", "--label=x; printf injected"], arguments + end + + def test_build_shell_escapes_the_displayed_command + command = CommandBuilder.build("bad; printf injected", @config, {}) + + assert_equal "rails new bad\\;\\ printf\\ injected", command + end + def test_flag_interpolation_occurs_in_output add_question("database", "select", "rails_flag" => "--database=%s") command = CommandBuilder.build("app", @config, "database" => "mysql") diff --git a/test/config_test.rb b/test/config_test.rb index 566d407..3e3f658 100644 --- a/test/config_test.rb +++ b/test/config_test.rb @@ -115,6 +115,42 @@ def test_validation_error_for_duplicate_question_ids end end + def test_validation_error_for_duplicate_question_ids_in_user_overlay + Dir.mktmpdir do |dir| + builtin = { + "questions" => [valid_question("database")] + } + user = { + "questions" => [ + { "id" => "database", "prompt" => "First override?" }, + { "id" => "database", "prompt" => "Second override?" } + ] + } + + error = assert_raises(ConfigValidationError) { merged_config(dir, builtin: builtin, user: user) } + assert_includes error.message, "questions entry id database is defined 2 times" + end + end + + def test_validation_error_for_duplicate_post_action_ids_in_preset + Dir.mktmpdir do |dir| + builtin = { "questions" => [valid_question("database")] } + preset = { + "post_actions" => [ + { "id" => "setup", "enabled" => false }, + { "id" => "setup", "enabled" => false } + ] + } + builtin_path = write_yaml(dir, "builtin.yaml", builtin) + preset_path = write_yaml(dir, "preset.yaml", preset) + + error = assert_raises(ConfigValidationError) do + Config.load(builtin_path: builtin_path, user_path: nil, preset_path: preset_path) + end + assert_includes error.message, "post_actions entry id setup is defined 2 times" + end + end + def test_validation_error_for_invalid_question_type Dir.mktmpdir do |dir| builtin = { @@ -149,6 +185,179 @@ def test_validation_error_for_missing_choices_on_select end end + def test_validation_error_for_missing_question_prompt + Dir.mktmpdir do |dir| + question = valid_question("database").tap { |entry| entry.delete("prompt") } + + error = assert_raises(ConfigValidationError) { merged_config(dir, builtin: { "questions" => [question] }) } + assert_includes error.message, "Question database is missing a prompt" + end + end + + def test_validation_error_for_duplicate_choice_names_and_values + Dir.mktmpdir do |dir| + question = valid_question("database") + question["choices"] << { "name" => "SQLite", "value" => "sqlite3" } + + error = assert_raises(ConfigValidationError) { merged_config(dir, builtin: { "questions" => [question] }) } + assert_includes error.message, "duplicate choice name" + assert_includes error.message, "duplicate choice value" + end + end + + def test_validation_error_for_multiple_select_choice_defaults + Dir.mktmpdir do |dir| + question = valid_question("database") + question["choices"] << { "name" => "PostgreSQL", "value" => "postgresql", "default" => true } + + error = assert_raises(ConfigValidationError) { merged_config(dir, builtin: { "questions" => [question] }) } + assert_includes error.message, "at most one default choice" + end + end + + def test_validation_error_when_select_default_is_not_a_choice + Dir.mktmpdir do |dir| + question = valid_question("database").merge("default" => "postgresql") + + error = assert_raises(ConfigValidationError) { merged_config(dir, builtin: { "questions" => [question] }) } + assert_includes error.message, "default \"postgresql\" is not a defined choice" + end + end + + def test_validation_error_when_multi_select_default_has_wrong_type_or_unknown_values + Dir.mktmpdir do |dir| + question = valid_multi_select_question.merge("default" => "mailer") + wrong_type = assert_raises(ConfigValidationError) do + merged_config(dir, builtin: { "questions" => [question] }) + end + assert_includes wrong_type.message, "default must be an Array" + + question["default"] = %w[mailer unknown] + unknown = assert_raises(ConfigValidationError) do + merged_config(dir, builtin: { "questions" => [question] }) + end + assert_includes unknown.message, "unknown default choice" + end + end + + def test_validation_error_when_yes_no_default_is_not_boolean + Dir.mktmpdir do |dir| + question = { + "id" => "skip_git", "type" => "yes_no", "prompt" => "Skip Git?", "default" => "false" + } + + error = assert_raises(ConfigValidationError) { merged_config(dir, builtin: { "questions" => [question] }) } + assert_includes error.message, "default must be true or false" + end + end + + def test_validation_error_for_invalid_rails_flag_shapes_and_interpolation + Dir.mktmpdir do |dir| + questions = [ + valid_question("scalar_flag").merge("rails_flag" => ["--database=sqlite3"]), + valid_question("flag_list").merge("rails_flags" => "--database=sqlite3"), + valid_question("flag_member").merge("rails_flags" => ["--database=sqlite3", 123]), + valid_question("unknown_token").merge("rails_flag" => "--database=%s"), + valid_question("wrong_format").merge("rails_flag" => "--database=%d") + ] + + error = assert_raises(ConfigValidationError) { merged_config(dir, builtin: { "questions" => questions }) } + assert_includes error.message, "rails_flag must be a String" + assert_includes error.message, "rails_flags must be an Array of Strings" + assert_includes error.message, "invalid rails_flag interpolation" + end + end + + def test_validation_error_for_invalid_choice_level_flag + Dir.mktmpdir do |dir| + question = valid_question("database") + question["choices"][0]["rails_flags"] = [false] + + error = assert_raises(ConfigValidationError) { merged_config(dir, builtin: { "questions" => [question] }) } + assert_includes error.message, "choice at index 0 rails_flags must be an Array of Strings" + end + end + + def test_interpolation_wraps_invalid_format_types + error = assert_raises(ConfigError) { Config.interpolate_flag("--db=%d", "postgres") } + assert_includes error.message, "Invalid interpolation token" + end + + def test_validation_error_for_invalid_depends_on_structure_reference_and_order + Dir.mktmpdir do |dir| + invalid_structure = valid_question("css").merge("depends_on" => "database") + error = assert_raises(ConfigValidationError) do + merged_config(dir, builtin: { "questions" => [invalid_structure] }) + end + assert_includes error.message, "depends_on must be a Hash" + + forward_reference = valid_question("css").merge( + "depends_on" => { "question" => "database", "value" => "sqlite3" } + ) + error = assert_raises(ConfigValidationError) do + merged_config(dir, builtin: { "questions" => [forward_reference, valid_question("database")] }) + end + assert_includes error.message, "must reference an earlier question" + + unknown_reference = valid_question("css").merge( + "depends_on" => { "question" => "missing", "value" => "sqlite3" } + ) + error = assert_raises(ConfigValidationError) do + merged_config(dir, builtin: { "questions" => [valid_question("database"), unknown_reference] }) + end + assert_includes error.message, "references unknown question 'missing'" + end + end + + def test_valid_depends_on_reference_passes + Dir.mktmpdir do |dir| + dependent = valid_question("css").merge( + "depends_on" => { "question" => "database", "value" => "sqlite3" } + ) + + assert merged_config(dir, builtin: { "questions" => [valid_question("database"), dependent] }) + end + end + + def test_validation_error_for_invalid_post_action_condition + Dir.mktmpdir do |dir| + conditions = [ + "database", + { "question" => "missing", "equals" => "sqlite3" }, + { "question" => "database" }, + { "question" => "database", "equals" => "sqlite3", "includes" => ["sqlite3"] }, + { "question" => "database", "includes" => "sqlite3" } + ] + + conditions.each do |condition| + action = { "id" => "setup", "enabled" => false, "if" => condition } + assert_raises(ConfigValidationError) do + merged_config( + dir, + builtin: { "questions" => [valid_question("database")], "post_actions" => [action] } + ) + end + end + end + end + + def test_validation_error_for_invalid_template_variable_key + Dir.mktmpdir do |dir| + action = { + "id" => "template", + "type" => "template", + "enabled" => true, + "source" => "template.rb", + "variables" => { "invalid-name" => "value" } + } + + error = assert_raises(ConfigValidationError) do + merged_config(dir, builtin: { "post_actions" => [action] }) + end + assert_includes error.message, "invalid template variable name" + end + end + def test_validation_error_for_missing_id Dir.mktmpdir do |dir| builtin = { @@ -407,5 +616,27 @@ def write_yaml(dir, filename, data) File.write(path, YAML.dump(data)) path end + + def valid_question(id) + { + "id" => id, + "type" => "select", + "prompt" => "Choose #{id}", + "choices" => [{ "name" => "SQLite", "value" => "sqlite3", "default" => true }], + "rails_flag" => "--#{id}=%s" + } + end + + def valid_multi_select_question + { + "id" => "features", + "type" => "multi_select", + "prompt" => "Choose features", + "choices" => [ + { "name" => "Mailer", "value" => "mailer", "rails_flag" => "--mailer" }, + { "name" => "Storage", "value" => "storage", "rails_flag" => "--storage" } + ] + } + end end end diff --git a/test/generator_test.rb b/test/generator_test.rb index 0bb14df..eac8522 100644 --- a/test/generator_test.rb +++ b/test/generator_test.rb @@ -4,6 +4,42 @@ module Railstart class GeneratorTest < Minitest::Test + def test_rejects_invalid_cli_app_name_before_generation + generator = Generator.new("bad; printf injected", config: { "questions" => [], "post_actions" => [] }) + + error = assert_raises(Railstart::Error) { generator.run } + + assert_includes error.message, "lowercase letters" + end + + def test_generate_app_executes_an_argument_vector + config = { + "questions" => [ + { + "id" => "database", + "type" => "select", + "choices" => [{ "name" => "PostgreSQL", "value" => "postgresql" }], + "rails_flag" => "--database=%s" + } + ], + "post_actions" => [] + } + generator = Generator.new("testapp", config: config) + generator.instance_variable_set(:@answers, "database" => "postgresql") + received = nil + + system_call = lambda do |*arguments| + received = arguments + true + end + + generator.stub(:system, system_call) do + generator.send(:generate_app) + end + + assert_equal ["rails", "new", "testapp", "--database=postgresql"], received + end + def setup @config = { "questions" => [ diff --git a/test/rails_cli_contract_test.rb b/test/rails_cli_contract_test.rb new file mode 100644 index 0000000..5895bd1 --- /dev/null +++ b/test/rails_cli_contract_test.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require "open3" +require "bundler" +require "test_helper" + +module Railstart + class RailsCLIContractTest < Minitest::Test + PRESET_PATHS = Dir[File.expand_path("../config/presets/*.yaml", __dir__)].freeze + + def test_shipped_default_commands_are_accepted_by_rails + skip "Set RAILS_CONTRACT=1 to test against the installed Rails CLI" unless ENV["RAILS_CONTRACT"] == "1" + + configurations.each do |name, config| + arguments = CommandBuilder.arguments("railstart_contract", config, default_answers(config)) + output, error, status = Bundler.with_unbundled_env do + Open3.capture3(*arguments, "--pretend") + end + + assert status.success?, "#{name} config was rejected by Rails:\n#{output}\n#{error}" + end + end + + private + + def configurations + { "built-in" => Config.load(user_path: nil) }.merge( + PRESET_PATHS.to_h do |path| + [File.basename(path, ".yaml"), Config.load(user_path: nil, preset_path: path)] + end + ) + end + + def default_answers(config) + config.fetch("questions").to_h do |question| + default = if question.key?("default") + question["default"] + else + question.fetch("choices", []).find { |choice| choice["default"] }&.fetch("value") + end + [question.fetch("id"), default] + end + end + end +end diff --git a/test/shipped_config_test.rb b/test/shipped_config_test.rb new file mode 100644 index 0000000..a6b7e3a --- /dev/null +++ b/test/shipped_config_test.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require "test_helper" + +module Railstart + class ShippedConfigTest < Minitest::Test + API_ONLY_PRESET_PATH = File.expand_path("../config/presets/api-only.yaml", __dir__) + + def test_builtin_none_css_choice_emits_skip_css + config = Config.load(user_path: nil) + command = CommandBuilder.build("blog", config, { "css" => "none" }) + + assert_includes command, "--skip-css" + refute_includes command, "--css=none" + end + + def test_api_only_preset_default_command_emits_skip_css + config = Config.load(user_path: nil, preset_path: API_ONLY_PRESET_PATH) + command = CommandBuilder.build("api", config, default_answers(config)) + + assert_includes command, "--skip-css" + refute_includes command, "--css=none" + end + + private + + def default_answers(config) + config.fetch("questions").to_h do |question| + default = if question.key?("default") + question["default"] + else + question.fetch("choices", []).find { |choice| choice["default"] }&.fetch("value") + end + [question.fetch("id"), default] + end + end + end +end From b481eb01a1126a63205a628ff0a654a93b65d70c Mon Sep 17 00:00:00 2001 From: David Paluy Date: Sat, 11 Jul 2026 13:32:28 -0500 Subject: [PATCH 2/6] fix: preserve Ruby compatibility matrix --- .github/workflows/ci.yml | 12 +++++++++--- .rubocop.yml | 2 +- AGENTS.md | 4 ++-- CHANGELOG.md | 2 +- README.md | 2 +- railstart.gemspec | 2 +- 6 files changed, 15 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ee4d02a..601da60 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,9 +8,15 @@ on: pull_request: jobs: - test: + build: runs-on: ubuntu-latest - name: Test on Ruby 4.0.5 + name: Ruby ${{ matrix.ruby }} + strategy: + matrix: + ruby: + - "3.4" + - "4.0" + - "4.0.5" steps: - uses: actions/checkout@v5 @@ -19,7 +25,7 @@ jobs: - name: Set up Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: "4.0.5" + ruby-version: ${{ matrix.ruby }} bundler-cache: true - name: Run tests run: bundle exec rake test diff --git a/.rubocop.yml b/.rubocop.yml index 29f0334..4d7e3cd 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,5 +1,5 @@ AllCops: - TargetRubyVersion: 4.0 + TargetRubyVersion: 3.2 NewCops: enable SuggestExtensions: false diff --git a/AGENTS.md b/AGENTS.md index 17ef666..fc9990e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ **Type:** Single Ruby gem (interactive Rails application starter) -**Tech Stack:** Ruby 4.0.5+, Rails 8+, Thor (CLI), TTY::Prompt (interactive prompts), Minitest (testing) +**Tech Stack:** Ruby 3.2+, Rails 8+, Thor (CLI), TTY::Prompt (interactive prompts), Minitest (testing) **Purpose:** Provides an opinionated, interactive CLI wizard (`railstart new`) that guides developers through Rails 8 project setup with customizable defaults and post-generation hooks. @@ -34,7 +34,7 @@ bundle exec rake test && bundle exec rubocop - **Linting:** RuboCop (see `.rubocop.yml`) - **Testing Framework:** Minitest (prefer assertions over mocks; integration > unit) - **Commits:** Conventional Commits (feat:, fix:, test:, docs:, refactor:) -- **Ruby Version:** 4.0.5 minimum (see `railstart.gemspec`) +- **Ruby Version:** 3.2.0 minimum; local and release baseline is 4.0.5 (see `railstart.gemspec` and `.ruby-version`) --- diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c965d6..1f26081 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added -- **Ruby 4 baseline**: Pinned development, CI, and release workflows to Ruby 4.0.5 and raised the gem requirement to Ruby 4.0.5 or newer +- **Ruby 4.0.5 coverage**: Pinned development and release workflows to Ruby 4.0.5 and added exact Ruby 4.0.5 coverage to the existing CI matrix - **Rails contract coverage**: Added opt-in `rails new --pretend` checks for the built-in configuration and every shipped preset - **Config validation**: Added fail-fast validation for duplicate IDs, invalid defaults, malformed flags, dependency conditions, post-action conditions, and template variable names diff --git a/README.md b/README.md index 3367982..f8a65b7 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Interactive CLI wizard for generating Rails 8 applications with customizable con Think of it as `rails new` with an opinion and a friendly interactive experience. -Requires Ruby 4.0.5 or newer. +Requires Ruby 3.2 or newer. Development and release tooling use Ruby 4.0.5. ## Installation diff --git a/railstart.gemspec b/railstart.gemspec index 47436c9..1c89aa5 100644 --- a/railstart.gemspec +++ b/railstart.gemspec @@ -12,7 +12,7 @@ Gem::Specification.new do |spec| spec.description = "Interactive CLI wizard for Rails app generation with customizable config" spec.homepage = "https://github.com/dpaluy/railstart" spec.license = "MIT" - spec.required_ruby_version = ">= 4.0.5" + spec.required_ruby_version = ">= 3.2.0" spec.metadata["rubygems_mfa_required"] = "true" spec.metadata["documentation_uri"] = "https://rubydoc.info/gems/railstart" From 2c1d1734da7262ba880e3cc21ae6f3aac588f011 Mon Sep 17 00:00:00 2001 From: David Paluy Date: Sat, 11 Jul 2026 13:37:48 -0500 Subject: [PATCH 3/6] docs: prepare 0.5.0 changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f26081..c9283b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.5.0] - 2026-07-11 + ### Added - **Ruby 4.0.5 coverage**: Pinned development and release workflows to Ruby 4.0.5 and added exact Ruby 4.0.5 coverage to the existing CI matrix - **Rails contract coverage**: Added opt-in `rails new --pretend` checks for the built-in configuration and every shipped preset From 49ed1bb8d34e05094b542a6d524f18882d95a8d9 Mon Sep 17 00:00:00 2001 From: David Paluy Date: Sat, 11 Jul 2026 13:38:55 -0500 Subject: [PATCH 4/6] chore: bump version to 0.5.0 --- lib/railstart/version.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/railstart/version.rb b/lib/railstart/version.rb index 4b40e2d..dceeb05 100644 --- a/lib/railstart/version.rb +++ b/lib/railstart/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Railstart - VERSION = "0.4.5" + VERSION = "0.5.0" end From 329657521c7c4480110bb209e37c42f588491d6d Mon Sep 17 00:00:00 2001 From: David Paluy Date: Sat, 11 Jul 2026 13:39:05 -0500 Subject: [PATCH 5/6] chore: sync lockfile for 0.5.0 --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index f64a477..fca5141 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - railstart (0.4.5) + railstart (0.5.0) thor tty-box tty-prompt From 7e0e6337dc8a583bcf837d1ec9f4d443a6c49e51 Mon Sep 17 00:00:00 2001 From: David Paluy Date: Sat, 11 Jul 2026 13:41:34 -0500 Subject: [PATCH 6/6] build(deps): update development gems --- CHANGELOG.md | 1 + Gemfile | 4 ++-- Gemfile.lock | 20 ++++++++++---------- 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c9283b3..0aed5fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Config validation**: Added fail-fast validation for duplicate IDs, invalid defaults, malformed flags, dependency conditions, post-action conditions, and template variable names ### Changed +- **Development dependencies**: Updated Minitest to 6.0.6, Rake to 13.4.2, RuboCop to 1.88.2, and YARD to 0.9.44 - **Safe command execution**: Rails generation now executes an argument vector instead of a shell-interpreted command string - **Init command**: `railstart init` now generates minimal user overrides so future built-in choices and flags are not shadowed by a copied full configuration - **CI quality gate**: CI now runs tests, RuboCop, YARD, gem packaging, executable smoke checks, and Rails CLI contract checks diff --git a/Gemfile b/Gemfile index b446c29..293194b 100644 --- a/Gemfile +++ b/Gemfile @@ -6,8 +6,8 @@ gemspec gem "minitest", "~> 6.0" gem "minitest-mock" -gem "rake", "~> 13.0" +gem "rake", "~> 13.4" gem "rdoc", "~> 6.0" gem "redcarpet", "~> 3.6" -gem "rubocop", "~> 1.86" +gem "rubocop", "~> 1.88" gem "yard", "~> 0.9" diff --git a/Gemfile.lock b/Gemfile.lock index fca5141..f8300ee 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -13,14 +13,14 @@ GEM date (3.5.1) drb (2.2.3) erb (6.0.4) - json (2.19.4) - language_server-protocol (3.17.0.5) + json (2.20.0) + language_server-protocol (3.17.0.6) lint_roller (1.1.0) - minitest (6.0.3) + minitest (6.0.6) drb (~> 2.0) prism (~> 1.5) minitest-mock (5.27.0) - parallel (1.28.0) + parallel (2.1.0) parser (3.3.11.1) ast (~> 2.4.1) racc @@ -32,14 +32,14 @@ GEM stringio racc (1.8.1) rainbow (3.1.1) - rake (13.3.1) + rake (13.4.2) rdoc (6.17.0) erb psych (>= 4.0.0) tsort redcarpet (3.6.1) regexp_parser (2.12.0) - rubocop (1.86.1) + rubocop (1.88.2) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) @@ -50,7 +50,7 @@ GEM rubocop-ast (>= 1.49.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 4.0) - rubocop-ast (1.49.1) + rubocop-ast (1.50.0) parser (>= 3.3.7.2) prism (~> 1.7) ruby-progressbar (1.13.0) @@ -79,7 +79,7 @@ GEM unicode-display_width (2.6.0) unicode_utils (1.4.0) wisper (2.0.1) - yard (0.9.38) + yard (0.9.44) PLATFORMS arm64-darwin-24 @@ -89,10 +89,10 @@ DEPENDENCIES minitest (~> 6.0) minitest-mock railstart! - rake (~> 13.0) + rake (~> 13.4) rdoc (~> 6.0) redcarpet (~> 3.6) - rubocop (~> 1.86) + rubocop (~> 1.88) yard (~> 0.9) BUNDLED WITH