diff --git a/.gitignore b/.gitignore index 4e0f123..ccdde23 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ /spec/reports/ /tmp/ /Gemfile.lock +/.idea/ diff --git a/README.md b/README.md index 9aedf61..03343bf 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,13 @@ gem exec nextgen create myapp This will download the latest version of the `nextgen` gem and use it to create an app in the `myapp` directory. You'll be asked to configure the tech stack through several interactive prompts. If you have a `~/.railsrc` file, it will be ignored. +Options: +- `style`: control the **optional enhancements** you can choose in the generator. + - defaults to `default`, [enhancements list](config) + - presets: + - `full` (`--style=full`), [enhancements list](config/styles/full) + - your local configs: `--style=path/to/your/style_dir` + > [!TIP] > If you get an "Unknown command exec" error, fix it by upgrading rubygems: `gem update --system`. @@ -59,7 +66,7 @@ Check out the [examples directory](./examples) to see some Rails apps that were On top of that foundation, Nextgen offers dozens of useful enhancements to the vanilla Rails experience. You are free to pick and choose which (if any) of these to apply to your new project. Behind the scenes, **each enhancement is applied in a separate git commit,** so that you can later see what was applied and why, and revert the suggestions if necessary. > [!TIP] -> For the full list of what Nextgen provides, check out [config/generators.yml](https://github.com/mattbrictson/nextgen/tree/main/config/generators.yml). The source code of each generator can be found in [lib/nextgen/generators](https://github.com/mattbrictson/nextgen/tree/main/lib/nextgen/generators). +> For the full list of what Nextgen provides, check out [config/*.yml](https://github.com/mattbrictson/nextgen/tree/main/config). The source code of each generator can be found in [lib/nextgen/generators](https://github.com/mattbrictson/nextgen/tree/main/lib/nextgen/generators). Here are some highlights of what Nextgen brings to the table: @@ -71,6 +78,11 @@ Nextgen can optionally set up a GitHub Actions CI workflow for your app that aut Prefer RSpec? Nextgen can set you up with RSpec, plus the gems and configuration you need for system specs (browser testing). Or stick with the Rails Minitest defaults. In either case, Nextgen will set up a good default Rake task and appropriate CI job. +### Job Backends + +- [sidekiq](https://github.com/sidekiq/sidekiq) +- [solid_queue](https://github.com/basecamp/solid_queue) (`--style=full`) + ### Gems Nextgen can install and configure your choice of these recommended gems: @@ -90,7 +102,6 @@ Nextgen can install and configure your choice of these recommended gems: - [rack-mini-profiler](https://github.com/MiniProfiler/rack-mini-profiler) - [rubocop](https://github.com/rubocop/rubocop) - [shoulda-matchers](https://github.com/thoughtbot/shoulda-matchers) -- [sidekiq](https://github.com/sidekiq/sidekiq) - [thor](https://github.com/rails/thor) - [tomo](https://github.com/mattbrictson/tomo) - [vcr](https://github.com/vcr/vcr) diff --git a/config/basic.yml b/config/basic.yml new file mode 100644 index 0000000..71c20e3 --- /dev/null +++ b/config/basic.yml @@ -0,0 +1,29 @@ +initial_git_commit: + +base: + description: "Enhance base Rails template with better docs, etc" + +clean_gemfile: + description: "Clean up Gemfile" + +git_safe: + +action_mailer: + description: "Improve Action Mailer support for absolute URLs and testing" + requires: action_mailer + +rspec_rails: + description: "Install and configure rspec-rails" + requires: rspec + +rspec_system_testing: + description: "Install capybara + selenium-webdriver and set up system specs" + requires: + - rspec + - system_testing + +tailwind: + questions: + - variable: tailwind_puma__no + method: no? + question: "Integrate tailwind watch with Puma (as plugin)? *defaults to NO*" diff --git a/config/checkers.yml b/config/checkers.yml new file mode 100644 index 0000000..ed583e4 --- /dev/null +++ b/config/checkers.yml @@ -0,0 +1,41 @@ + +node: + description: "Set up Node and Yarn" + +brakeman: + prompt: "Brakeman" + description: "Install brakeman gem for security checks" + +bundler_audit: + prompt: "Bundler Audit" + description: "Install bundler-audit gem to detect CVEs in Ruby dependencies" + +erb_lint: + prompt: "ERB Lint" + description: "Install erb_lint gem and correct existing issues" + requires: frontend + +eslint: + prompt: "ESLint" + description: "Install eslint + supporting packages; apply prettier format" + requires: frontend + node: true + +good_migrations: + prompt: "good_migrations" + description: "Install good_migrations gem" + requires: active_record + +stylelint: + prompt: "Stylelint" + description: "Install stylelint and apply prettier format to CSS" + requires: frontend + node: true + +rubocop: + prompt: "RuboCop" + description: "Install rubocop gems; apply formatting rules" + +overcommit: + prompt: "Overcommit" + description: "Configure overcommit pre-commit git hooks" diff --git a/config/code_snippets.yml b/config/code_snippets.yml new file mode 100644 index 0000000..b9f5f53 --- /dev/null +++ b/config/code_snippets.yml @@ -0,0 +1,12 @@ + +home_controller: + description: "Create a controller, view, and route for the home page" + requires: frontend + +basic_auth: + prompt: "BasicAuth controller concern" + description: "Allow app to be secured with ENV-based basic auth credentials" + +current: + prompt: "Current.user" + description: "Intro thread-isolated attributes singleton `Current`, which resets automatically before and after each request" diff --git a/config/gems.yml b/config/gems.yml new file mode 100644 index 0000000..1106faf --- /dev/null +++ b/config/gems.yml @@ -0,0 +1,69 @@ + +node: + description: "Set up Node and Yarn" + +vite: + description: "Replace the asset pipeline with Vite in app/frontend" + requires: vite + node: true + +annotate: + prompt: "Annotate Models" + description: "Install annotate gem to auto-generate schema annotations" + requires: active_record + +capybara_lockstep: + prompt: "capybara-lockstep" + description: "Install capybara-lockstep gem for less-flaky browser testing" + requires: system_testing + +dotenv: + prompt: "dotenv" + description: "Install dotenv gem and add .env.sample" + +factory_bot_rails: + prompt: "Factory Bot" + description: "Install and configure factory_bot_rails gem" + requires: active_record + +letter_opener: + prompt: "letter_opener" + description: "Install letter_opener gem to use with Action Mailer in dev" + requires: action_mailer + +open_browser_on_start: + prompt: "Open browser on startup" + description: "Configure puma to launch browser on startup in development" + requires: frontend + +pgcli_rails: + prompt: "pgcli_rails" + description: "Install pgcli_rails gem to allow easy use of pgcli" + requires: postgresql + +rack_canonical_host: + prompt: "rack-canonical-host" + description: "Install rack-canonical-host gem; use RAILS_HOSTNAME" + +rack_mini_profiler: + prompt: "rack-mini-profiler" + description: "Install rack-mini-profiler gem in development" + requires: frontend + +shoulda: + prompt: "shoulda" + description: "Install shoulda-matchers gem for concise model testing" + requires: test_framework + +thor: + prompt: "Thor" + description: "Configure Thor for ease of writing Rails tasks" + +tomo: + prompt: "Tomo" + description: "Install tomo gem for SSH-based deployment" + +vcr: + prompt: "VCR" + description: "Install and configure vcr and webmock gems" + requires: test_framework diff --git a/config/generators.yml b/config/generators.yml deleted file mode 100644 index 74e5c4f..0000000 --- a/config/generators.yml +++ /dev/null @@ -1,151 +0,0 @@ -initial_git_commit: - -base: - description: "Enhance base Rails template with better docs, etc" - -clean_gemfile: - description: "Clean up Gemfile" - -rspec_rails: - description: "Install and configure rspec-rails" - requires: rspec - -rspec_system_testing: - description: "Install capybara + selenium-webdriver and set up system specs" - requires: - - rspec - - system_testing - -node: - description: "Set up Node and Yarn" - -vite: - description: "Replace the asset pipeline with Vite in app/frontend" - requires: vite - node: true - -action_mailer: - description: "Improve Action Mailer support for absolute URLs and testing" - requires: action_mailer - -annotate: - prompt: "Annotate Models" - description: "Install annotate gem to auto-generate schema annotations" - requires: active_record - -basic_auth: - prompt: "BasicAuth controller concern" - description: "Allow app to be secured with ENV-based basic auth credentials" - -brakeman: - prompt: "Brakeman" - description: "Install brakeman gem for security checks" - -bundler_audit: - prompt: "Bundler Audit" - description: "Install bundler-audit gem to detect CVEs in Ruby dependencies" - -capybara_lockstep: - prompt: "capybara-lockstep" - description: "Install capybara-lockstep gem for less-flaky browser testing" - requires: system_testing - -dotenv: - prompt: "dotenv" - description: "Install dotenv gem and add .env.sample" - -erb_lint: - prompt: "ERB Lint" - description: "Install erb_lint gem and correct existing issues" - requires: frontend - -eslint: - prompt: "ESLint" - description: "Install eslint + supporting packages; apply prettier format" - requires: frontend - node: true - -factory_bot_rails: - prompt: "Factory Bot" - description: "Install and configure factory_bot_rails gem" - requires: active_record - -github_pr_template: - prompt: "GitHub PR template" - description: "Add GitHub pull request template" - -git_safe: - -good_migrations: - prompt: "good_migrations" - description: "Install good_migrations gem" - requires: active_record - -home_controller: - description: "Create a controller, view, and route for the home page" - requires: frontend - -letter_opener: - prompt: "letter_opener" - description: "Install letter_opener gem to use with Action Mailer in dev" - requires: action_mailer - -open_browser_on_start: - prompt: "Open browser on startup" - description: "Configure puma to launch browser on startup in development" - requires: frontend - -pgcli_rails: - prompt: "pgcli_rails" - description: "Install pgcli_rails gem to allow easy use of pgcli" - requires: postgresql - -rack_canonical_host: - prompt: "rack-canonical-host" - description: "Install rack-canonical-host gem; use RAILS_HOSTNAME" - -rack_mini_profiler: - prompt: "rack-mini-profiler" - description: "Install rack-mini-profiler gem in development" - requires: frontend - -shoulda: - prompt: "shoulda" - description: "Install shoulda-matchers gem for concise model testing" - requires: test_framework - -sidekiq: - prompt: "Sidekiq" - description: "Install sidekiq gem to use in production" - requires: active_job - -stylelint: - prompt: "Stylelint" - description: "Install stylelint and apply prettier format to CSS" - requires: frontend - node: true - -thor: - prompt: "Thor" - description: "Configure Thor for ease of writing Rails tasks" - -tomo: - prompt: "Tomo" - description: "Install tomo gem for SSH-based deployment" - -vcr: - prompt: "VCR" - description: "Install and configure vcr and webmock gems" - requires: test_framework - -rubocop: - prompt: "RuboCop" - description: "Install rubocop gems; apply formatting rules" - -overcommit: - prompt: "Overcommit" - description: "Configure overcommit pre-commit git hooks" - -github_actions: - prompt: "GitHub Actions" - description: "Configure GitHub Actions workflow for CI" diff --git a/config/job_backend.yml b/config/job_backend.yml new file mode 100644 index 0000000..09ea387 --- /dev/null +++ b/config/job_backend.yml @@ -0,0 +1,5 @@ + +sidekiq: + prompt: "Sidekiq (Redis-backed)" + description: "Install sidekiq gem to use in production" + requires: active_job diff --git a/config/styles/full/checkers.yml b/config/styles/full/checkers.yml new file mode 100644 index 0000000..e69de29 diff --git a/config/styles/full/code_snippets.yml b/config/styles/full/code_snippets.yml new file mode 100644 index 0000000..e69de29 diff --git a/config/styles/full/gems.yml b/config/styles/full/gems.yml new file mode 100644 index 0000000..e69de29 diff --git a/config/styles/full/job_backend.yml b/config/styles/full/job_backend.yml new file mode 100644 index 0000000..bfe741f --- /dev/null +++ b/config/styles/full/job_backend.yml @@ -0,0 +1,9 @@ + +solid_queue: + prompt: "SolidQueue (Database-backed)" + description: "Install solid_queue as ActiveJob's backend" + requires: active_job + questions: + - variable: solid_queue_puma__no + method: no? + question: "Run the SolidQueue supervisor together with Puma (as plugin)? *defaults to NO*" diff --git a/config/styles/full/workflows.yml b/config/styles/full/workflows.yml new file mode 100644 index 0000000..e69de29 diff --git a/config/workflows.yml b/config/workflows.yml new file mode 100644 index 0000000..56fadfb --- /dev/null +++ b/config/workflows.yml @@ -0,0 +1,8 @@ + +github_actions: + prompt: "GitHub Actions" + description: "Configure GitHub Actions workflow for CI" + +github_pr_template: + prompt: "GitHub PR template" + description: "Add GitHub pull request template" diff --git a/lib/nextgen.rb b/lib/nextgen.rb index df2e244..79349e9 100644 --- a/lib/nextgen.rb +++ b/lib/nextgen.rb @@ -7,11 +7,35 @@ loader.setup module Nextgen - def self.generators_path - Pathname.new(__dir__).join("nextgen/generators") + def self.generators_path(scope = "") + Pathname.new(__dir__).join("nextgen/generators", scope) end def self.template_path Pathname.new(__dir__).join("../template") end + + def self.config_path(style: nil) + if style + if style.match?("/") + Pathname.new(style) + else + Pathname.new(__dir__).join("../config/styles", style) + end + else + Pathname.new(__dir__).join("../config") + end + end + + def self.config_for(scope:, style: nil) + base = YAML.load_file("#{Nextgen.config_path}/#{scope}.yml") + if style + base.merge!(YAML.load_file("#{Nextgen.config_path(style: style)}/#{scope}.yml") || {}) + end + base + end + + def self.scopes_for(style: nil) + Dir[Nextgen.config_path(style: style) + "*.yml"].map { _1.match(/([_a-z]*)\.yml/)[1] } + end end diff --git a/lib/nextgen/cli.rb b/lib/nextgen/cli.rb index 4ba5a94..a4d5197 100644 --- a/lib/nextgen/cli.rb +++ b/lib/nextgen/cli.rb @@ -6,6 +6,7 @@ class CLI < Thor map %w[-v --version] => "version" + option :style, type: :string, default: nil desc "create APP_PATH", "Generate a Rails app interactively in APP_PATH" def create(app_path) Commands::Create.run(app_path, options) diff --git a/lib/nextgen/commands/create.rb b/lib/nextgen/commands/create.rb index 2fce5d9..9bb2b66 100644 --- a/lib/nextgen/commands/create.rb +++ b/lib/nextgen/commands/create.rb @@ -5,36 +5,28 @@ require "open3" require "tmpdir" require "tty-prompt" +require "rainbow" require "nextgen/ext/prompt/list" require "nextgen/ext/prompt/multilist" module Nextgen - class Commands::Create # rubocop:disable Metrics/ClassLength + class Commands::Create extend Forwardable + include Commands::Helpers def self.run(app_path, options) new(app_path, options).run end - def initialize(app_path, _options) + def initialize(app_path, options) @app_path = File.expand_path(app_path) @app_name = File.basename(@app_path).gsub(/\W/, "_").squeeze("_").camelize @rails_opts = RailsOptions.new + @style = options[:style] end def run # rubocop:disable Metrics/MethodLength, Metrics/PerceivedComplexity - say <<~BANNER - Welcome to nextgen, the interactive Rails app generator! - - You are about to create a Rails app named "#{app_name}" in the following directory: - - #{app_path} - - You'll be asked ~10 questions about database, test framework, and other options. - The standard Rails "omakase" experience will be selected by default. - - BANNER - + say_banner continue_if "Ready to start?" ask_rails_version @@ -46,50 +38,25 @@ def run # rubocop:disable Metrics/MethodLength, Metrics/PerceivedComplexity ask_rails_frameworks ask_test_framework ask_system_testing if rails_opts.frontend? && rails_opts.test_framework? - ask_optional_enhancements - - say <<~SUMMARY - - OK! Your Rails app is ready to be created. - The following options will be passed to `rails new`: + say - #{rails_new_args.join("\n ")} - - The following nextgen enhancements will also be applied in individual git commits via `rails app:template`: - - #{selected_generators.join(", ").scan(/\S.{0,75}(?:,|$)/).join("\n ")} - - SUMMARY - - if node? - say <<~NODE - Based on the options you selected, your app will require Node and Yarn. For reference, you are using these versions: - - Node: #{capture_version("node")} - Yarn: #{capture_version("yarn")} - - NODE + if prompt.yes?("More enhancements? [ job, code snippets, gems ... ] ↵") + ask_styled_enhancements end + say_summary + say_node if node? continue_if "Continue?" create_initial_commit_message copy_package_json if node? Nextgen::Rails.run "new", *rails_new_args Dir.chdir(app_path) do - Nextgen::Rails.run "app:template", "LOCATION=#{write_generators_script}" + generators.each_value do |g| + Nextgen::Rails.run "app:template", "LOCATION=#{write_generators_script(g)}" + end end - - say <<~DONE.gsub(/^/, " ") - - - #{set_color("Done!", :green)} - - A Rails #{rails_version} app was generated in #{set_color(app_path, :cyan)}. - Run #{set_color("bin/setup", :yellow)} in that directory to get started. - - - DONE + say_done end private @@ -98,30 +65,9 @@ def run # rubocop:disable Metrics/MethodLength, Metrics/PerceivedComplexity def_delegators :shell, :say, :set_color - def continue_if(question) - if prompt.yes?(question) - say - else - say "Canceled", :red - exit - end - end - - def copy_package_json - FileUtils.mkdir_p(app_path) - FileUtils.cp( - Nextgen.template_path.join("package.json"), - File.join(app_path, "package.json") - ) - end - - def rails_version - rails_opts.edge? ? "edge (#{Rails.edge_branch} branch)" : Rails.version - end - def ask_rails_version selected = prompt.select( - "What version of Rails will you use?", + "What #{underline("version")} of Rails will you use?", Rails.version => :current, "edge (#{Rails.edge_branch} branch)" => :edge ) @@ -129,38 +75,37 @@ def ask_rails_version end def ask_database - common_databases = { + databases = { "SQLite3 (default)" => "sqlite3", "PostgreSQL (recommended)" => "postgresql", - "MySQL" => "mysql" - } - all_databases = common_databases.merge( - %w[MySQL Trilogy Oracle SQLServer JDBCMySQL JDBCSQLite3 JDBCPostgreSQL JDBC].to_h do |name| + **%w[MySQL Trilogy Oracle SQLServer JDBCMySQL JDBCSQLite3 JDBCPostgreSQL JDBC].to_h do |name| [name, name.downcase] end, "None (disable Active Record)" => nil + } + rails_opts.database = select( + "Which #{underline("database")}?", databases ) - rails_opts.database = - prompt.select("Which database?", common_databases.merge("More options..." => false)) || - prompt.select("Which database?", all_databases) end def ask_full_stack_or_api - api = prompt.select( + api = select( "What style of Rails app do you need?", "Standard, full-stack Rails (default)" => false, "API only" => true ) rails_opts.api! if api + @generators = {basic: Generators.compatible_with(rails_opts: rails_opts, style: nil, scope: "basic")} end def ask_frontend_management - frontend = prompt.select( - "How will you manage frontend assets?", + frontend = select( + "How will you manage frontend #{underline("assets")}?", "Sprockets (default)" => "sprockets", "Propshaft" => "propshaft", "Vite" => :vite ) + if frontend == :vite rails_opts.asset_pipeline = nil rails_opts.javascript = "vite" @@ -170,8 +115,8 @@ def ask_frontend_management end def ask_css - rails_opts.css = prompt.select( - "Which CSS framework will you use with the asset pipeline?", + rails_opts.css = select( + "Which #{underline("CSS")} framework will you use with the asset pipeline?", "None (default)" => nil, "Bootstrap" => "bootstrap", "Bulma" => "bulma", @@ -179,11 +124,12 @@ def ask_css "Sass" => "sass", "Tailwind" => "tailwind" ) + generators[:basic].ask_second_level_questions(for_selected: rails_opts.css, prompt: prompt) end def ask_javascript - rails_opts.javascript = prompt.select( - "Which JavaScript bundler will you use with the asset pipeline?", + rails_opts.javascript = select( + "Which #{underline("JavaScript")} bundler will you use with the asset pipeline?", "Importmap (default)" => "importmap", "Bun" => "bun", "ESBuild" => "esbuild", @@ -213,8 +159,8 @@ def ask_rails_frameworks ) end - answers = prompt.multi_select( - "Which optional Rails frameworks do you need?", + answers = multi_select( + "Which optional Rails #{underline("frameworks")} do you need?", frameworks, default: frameworks.keys.reverse ) @@ -223,8 +169,8 @@ def ask_rails_frameworks end def ask_test_framework - rails_opts.test_framework = prompt.select( - "Which test framework will you use?", + rails_opts.test_framework = select( + "Which #{underline("test")} framework will you use?", "Minitest (default)" => "minitest", "RSpec" => "rspec", "None" => nil @@ -232,82 +178,26 @@ def ask_test_framework end def ask_system_testing - system_testing = prompt.select( - "Include system testing (capybara)?", + system_testing = select( + "Include #{underline("system testing")} (capybara)?", "Yes (default)" => true, "No" => false ) rails_opts.skip_system_test! unless system_testing end - def ask_optional_enhancements - @generators = Generators.compatible_with(rails_opts: rails_opts) - - answers = prompt.multi_select( - "Which optional enhancements would you like to add?", - generators.optional.sort_by { |label, _| label.downcase }.to_h - ) - generators.activate(*answers) - end - - def create_initial_commit_message - path = File.join(app_path, "tmp", "initial_nextgen_commit") - FileUtils.mkdir_p(File.dirname(path)) - File.write(path, <<~COMMIT) - Init project with `rails new` (#{Nextgen::Rails.version}) + def ask_styled_enhancements + say " ↪ style: #{cyan(@style || "default")}" + Nextgen.scopes_for(style: @style).each do |scope| + gen = Generators.compatible_with(rails_opts: rails_opts, style: @style, scope: scope) + next if gen.empty? || scope == "basic" - Nextgen generated this project with the following `rails new` options: - - ``` - #{rails_opts.to_args.join("\n")} - ``` - COMMIT - end - - def rails_new_args - [app_path, "--no-rc", *rails_opts.to_args].tap do |args| - # Work around a Rails bug where --edge causes --no-rc to get ignored. - # Specifying --rc= with a non-existent file has the same effect as --no-rc. - @rc_token ||= SecureRandom.hex(8) - args << "--rc=#{@rc_token}" if rails_opts.edge? + key_word = underline(scope.tr("_", " ")) + multi = scope == scope.pluralize + sort = gen.optional.size > 10 + gen.ask_select("Which #{key_word} would you like to add?", prompt: prompt, multi: multi, sort: sort) + generators[scope.to_sym] = gen end end - - def node? - generators.node_active? - end - - def capture_version(command) - out, _err, status = Open3.capture3(command, "--version") - version = status.success? && out[/\d[.\d]+\d/] - - version || "" - end - - def selected_generators - optional = generators.optional.invert - selected = generators.all_active.filter_map { |name| optional[name] } - - selected.any? ? selected.sort_by(&:downcase) : [""] - end - - def write_generators_script - new_tempfile_path.tap do |location| - File.write(location, generators.to_ruby_script) - end - end - - def new_tempfile_path - token = SecureRandom.hex(8) - File.join(Dir.tmpdir, "nextgen_create_#{token}.rb") - end - - def prompt - @prompt ||= TTY::Prompt.new - end - - def shell - @shell ||= Thor::Base.shell.new - end end end diff --git a/lib/nextgen/commands/helpers.rb b/lib/nextgen/commands/helpers.rb new file mode 100644 index 0000000..fcf9566 --- /dev/null +++ b/lib/nextgen/commands/helpers.rb @@ -0,0 +1,142 @@ +module Nextgen::Commands + module Helpers + private + + def say_banner + say <<~BANNER + Welcome to nextgen, the interactive Rails app generator! + + You are about to create a Rails app named "#{cyan(app_name)}" in the following directory: + + #{cyan(app_path)} + + You'll be asked ~10 (or more) questions about database, test framework, and other options. + The standard Rails "omakase" experience will be selected by default. + + BANNER + end + + def say_summary + say <<~SUMMARY + + OK! Your Rails app is ready to be created. + The following options will be passed to `rails new`: + + #{rails_new_args.join("\n ")} + + The following nextgen enhancements will also be applied in individual git commits via `rails app:template`: + + #{activated_generators.join(", ").scan(/\S.{0,75}(?:,|$)/).join("\n ")} + + SUMMARY + end + + def say_node + say <<~NODE + Based on the options you selected, your app will require Node and Yarn. For reference, you are using these versions: + + Node: #{capture_version("node")} + Yarn: #{capture_version("yarn")} + + NODE + end + + def say_done + say <<~DONE.gsub(/^/, " ") + + + #{green("Done!")} + + A Rails #{rails_version} app was generated in #{cyan(app_path)}. + Run #{set_color("bin/setup", :yellow)} in that directory to get started. + + + DONE + end + + def continue_if(question) + if prompt.yes?("#{question} ↵") + say + else + say "Canceled", :red + exit + end + end + + def copy_package_json + FileUtils.mkdir_p(app_path) + FileUtils.cp( + Nextgen.template_path.join("package.json"), + File.join(app_path, "package.json") + ) + end + + def rails_version + rails_opts.edge? ? "edge (#{Rails.edge_branch} branch)" : Rails.version + end + + def create_initial_commit_message + path = File.join(app_path, "tmp", "initial_nextgen_commit") + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, <<~COMMIT) + Init project with `rails new` (#{Nextgen::Rails.version}) + + Nextgen generated this project with the following `rails new` options: + + ``` + #{rails_opts.to_args.join("\n")} + ``` + COMMIT + end + + def rails_new_args + [app_path, "--no-rc", *rails_opts.to_args].tap do |args| + # Work around a Rails bug where --edge causes --no-rc to get ignored. + # Specifying --rc= with a non-existent file has the same effect as --no-rc. + @rc_token ||= SecureRandom.hex(8) + args << "--rc=#{@rc_token}" if rails_opts.edge? + end + end + + def node? + generators.values.any?(&:node_active?) + end + + def capture_version(command) + out, _err, status = Open3.capture3(command, "--version") + version = status.success? && out[/\d[.\d]+\d/] + + version || "" + end + + def activated_generators + activated = generators.values.flat_map(&:all_active_names) + activated.any? ? activated.sort_by(&:downcase) : [""] + end + + def write_generators_script(g) + new_tempfile_path.tap do |location| + File.write(location, g.to_ruby_script) + end + end + + def new_tempfile_path + token = SecureRandom.hex(8) + File.join(Dir.tmpdir, "nextgen_create_#{token}.rb") + end + + def prompt + @prompt ||= TTY::Prompt.new + end + + def shell + @shell ||= Thor::Base.shell.new + end + + def green(string) = set_color(string, :green) + def cyan(string) = set_color(string, :cyan) + def underline(string) = Rainbow(string).underline + def select(question, choices) = prompt.select(question, choices, enum: ".", cycle: true) + def multi_select(question, choices, **opts) = prompt.multi_select(question, choices, filter: true, cycle: true, **opts) + end +end diff --git a/lib/nextgen/generators.rb b/lib/nextgen/generators.rb index 946f193..0105871 100644 --- a/lib/nextgen/generators.rb +++ b/lib/nextgen/generators.rb @@ -2,32 +2,54 @@ module Nextgen class Generators - def self.compatible_with(rails_opts:) - yaml_path = File.expand_path("../../config/generators.yml", __dir__) - new.tap do |g| - YAML.load_file(yaml_path).each do |name, options| + def self.compatible_with(rails_opts:, style:, scope:) + new(scope, api: rails_opts.api?).tap do |generators| + Nextgen.config_for(style: style, scope: scope).each do |name, options| options ||= {} requirements = Array(options["requires"]) next unless requirements.all? { |req| rails_opts.public_send(:"#{req}?") } - g.add( + generators.add( name.to_sym, prompt: options["prompt"], description: options["description"], - node: !!options["node"] + node: !!options["node"], + questions: options["questions"] ) end - - g.deactivate_node unless rails_opts.requires_node? + generators.deactivate_node unless rails_opts.requires_node? end end - def initialize + def initialize(scope, **vars) @generators = {} + @variables = vars + @scope = scope + end + + def empty? = @generators.empty? + + def ask_select(question, multi: false, sort: false, prompt: TTY::Prompt.new) + opts = sort ? optional.sort_by { |label, _| label.downcase }.to_h : optional + args = [question, opts, {cycle: true, filter: true}] + answers = multi ? prompt.multi_select(*args) : [prompt.select(*args)] + + answers.each do |answer| + ask_second_level_questions(for_selected: answer, prompt: prompt) + end + activate(*answers) + end + + def ask_second_level_questions(for_selected:, prompt:) + (generators[for_selected&.to_sym]&.[](:questions) || []).each do |q| + variables[q.fetch("variable").to_sym] = prompt.public_send( + q.fetch("method"), " ↪ #{q.fetch("question")}" + ) + end end def node_active? - !!generators.fetch(:node)[:active] + !!generators[:node]&.[](:active) end def all_active @@ -36,11 +58,16 @@ def all_active end end - def add(name, node: false, prompt: nil, description: nil) + def all_active_names + opts = optional.invert + all_active.filter_map { |name| opts[name] } + end + + def add(name, node: false, prompt: nil, description: nil, questions: nil) name = name.to_sym raise ArgumentError, "Generator #{name.inspect} was already added" if generators.key?(name) - generators[name] = {node: node, prompt: prompt, description: description} + generators[name] = {node: node, prompt: prompt, description: description, questions: questions} activate(name) unless prompt end @@ -60,19 +87,20 @@ def activate(*optional_generators) end def deactivate_node - generators.fetch(:node)[:active] = false + generators[:node][:active] = false if generators.key?(:node) end def to_ruby_script apply_statements = all_active.map do |generator| description = generators.fetch(generator)[:description] - path = Nextgen.generators_path.join("#{generator}.rb") + path = Nextgen.generators_path(scope).join("#{generator}.rb") "apply_as_git_commit #{path.to_s.inspect}, message: #{description.inspect}" end <<~SCRIPT require #{File.expand_path("../nextgen", __dir__).inspect} extend Nextgen::Actions + @variables = #{@variables} with_nextgen_source_path do #{apply_statements.join("\n ")} @@ -82,6 +110,6 @@ def to_ruby_script private - attr_reader :generators + attr_reader :generators, :variables, :scope end end diff --git a/lib/nextgen/generators/action_mailer.rb b/lib/nextgen/generators/basic/action_mailer.rb similarity index 100% rename from lib/nextgen/generators/action_mailer.rb rename to lib/nextgen/generators/basic/action_mailer.rb diff --git a/lib/nextgen/generators/base.rb b/lib/nextgen/generators/basic/base.rb similarity index 100% rename from lib/nextgen/generators/base.rb rename to lib/nextgen/generators/basic/base.rb diff --git a/lib/nextgen/generators/clean_gemfile.rb b/lib/nextgen/generators/basic/clean_gemfile.rb similarity index 100% rename from lib/nextgen/generators/clean_gemfile.rb rename to lib/nextgen/generators/basic/clean_gemfile.rb diff --git a/lib/nextgen/generators/git_safe.rb b/lib/nextgen/generators/basic/git_safe.rb similarity index 100% rename from lib/nextgen/generators/git_safe.rb rename to lib/nextgen/generators/basic/git_safe.rb diff --git a/lib/nextgen/generators/initial_git_commit.rb b/lib/nextgen/generators/basic/initial_git_commit.rb similarity index 100% rename from lib/nextgen/generators/initial_git_commit.rb rename to lib/nextgen/generators/basic/initial_git_commit.rb diff --git a/lib/nextgen/generators/rspec_rails.rb b/lib/nextgen/generators/basic/rspec_rails.rb similarity index 100% rename from lib/nextgen/generators/rspec_rails.rb rename to lib/nextgen/generators/basic/rspec_rails.rb diff --git a/lib/nextgen/generators/rspec_system_testing.rb b/lib/nextgen/generators/basic/rspec_system_testing.rb similarity index 100% rename from lib/nextgen/generators/rspec_system_testing.rb rename to lib/nextgen/generators/basic/rspec_system_testing.rb diff --git a/lib/nextgen/generators/basic/tailwind.rb b/lib/nextgen/generators/basic/tailwind.rb new file mode 100644 index 0000000..3a86c8a --- /dev/null +++ b/lib/nextgen/generators/basic/tailwind.rb @@ -0,0 +1,5 @@ + +unless @variables[:tailwind_puma__no] + say_git "Add tailwindcss plugin to puma.rb" + append_to_file "config/puma.rb", %(plugin :tailwindcss if ENV.fetch("RAILS_ENV", "development") == "development"\n) +end diff --git a/lib/nextgen/generators/brakeman.rb b/lib/nextgen/generators/checkers/brakeman.rb similarity index 100% rename from lib/nextgen/generators/brakeman.rb rename to lib/nextgen/generators/checkers/brakeman.rb diff --git a/lib/nextgen/generators/bundler_audit.rb b/lib/nextgen/generators/checkers/bundler_audit.rb similarity index 100% rename from lib/nextgen/generators/bundler_audit.rb rename to lib/nextgen/generators/checkers/bundler_audit.rb diff --git a/lib/nextgen/generators/erb_lint.rb b/lib/nextgen/generators/checkers/erb_lint.rb similarity index 100% rename from lib/nextgen/generators/erb_lint.rb rename to lib/nextgen/generators/checkers/erb_lint.rb diff --git a/lib/nextgen/generators/eslint.rb b/lib/nextgen/generators/checkers/eslint.rb similarity index 100% rename from lib/nextgen/generators/eslint.rb rename to lib/nextgen/generators/checkers/eslint.rb diff --git a/lib/nextgen/generators/good_migrations.rb b/lib/nextgen/generators/checkers/good_migrations.rb similarity index 100% rename from lib/nextgen/generators/good_migrations.rb rename to lib/nextgen/generators/checkers/good_migrations.rb diff --git a/lib/nextgen/generators/checkers/node.rb b/lib/nextgen/generators/checkers/node.rb new file mode 100644 index 0000000..794ae0e --- /dev/null +++ b/lib/nextgen/generators/checkers/node.rb @@ -0,0 +1,7 @@ +unless File.read(".gitignore").match?("node_modules") + say_git "Add Node and Yarn prerequisites" + copy_file "package.json" unless File.exist?("package.json") + inject_into_file "README.md", "\n- Node 18 (LTS) or newer\n- Yarn 1.x (classic)", after: /^- Ruby.*$/ + inject_into_file "README.md", "\nbrew install node\nbrew install yarn", after: /^brew install rbenv.*$/ + gitignore "node_modules/" +end diff --git a/lib/nextgen/generators/overcommit.rb b/lib/nextgen/generators/checkers/overcommit.rb similarity index 100% rename from lib/nextgen/generators/overcommit.rb rename to lib/nextgen/generators/checkers/overcommit.rb diff --git a/lib/nextgen/generators/rubocop.rb b/lib/nextgen/generators/checkers/rubocop.rb similarity index 100% rename from lib/nextgen/generators/rubocop.rb rename to lib/nextgen/generators/checkers/rubocop.rb diff --git a/lib/nextgen/generators/stylelint.rb b/lib/nextgen/generators/checkers/stylelint.rb similarity index 100% rename from lib/nextgen/generators/stylelint.rb rename to lib/nextgen/generators/checkers/stylelint.rb diff --git a/lib/nextgen/generators/basic_auth.rb b/lib/nextgen/generators/code_snippets/basic_auth.rb similarity index 100% rename from lib/nextgen/generators/basic_auth.rb rename to lib/nextgen/generators/code_snippets/basic_auth.rb diff --git a/lib/nextgen/generators/code_snippets/current.rb b/lib/nextgen/generators/code_snippets/current.rb new file mode 100644 index 0000000..7b5ec82 --- /dev/null +++ b/lib/nextgen/generators/code_snippets/current.rb @@ -0,0 +1 @@ +copy_file "app/models/current.rb" diff --git a/lib/nextgen/generators/home_controller.rb b/lib/nextgen/generators/code_snippets/home_controller.rb similarity index 100% rename from lib/nextgen/generators/home_controller.rb rename to lib/nextgen/generators/code_snippets/home_controller.rb diff --git a/lib/nextgen/generators/annotate.rb b/lib/nextgen/generators/gems/annotate.rb similarity index 100% rename from lib/nextgen/generators/annotate.rb rename to lib/nextgen/generators/gems/annotate.rb diff --git a/lib/nextgen/generators/capybara_lockstep.rb b/lib/nextgen/generators/gems/capybara_lockstep.rb similarity index 100% rename from lib/nextgen/generators/capybara_lockstep.rb rename to lib/nextgen/generators/gems/capybara_lockstep.rb diff --git a/lib/nextgen/generators/dotenv.rb b/lib/nextgen/generators/gems/dotenv.rb similarity index 100% rename from lib/nextgen/generators/dotenv.rb rename to lib/nextgen/generators/gems/dotenv.rb diff --git a/lib/nextgen/generators/factory_bot_rails.rb b/lib/nextgen/generators/gems/factory_bot_rails.rb similarity index 100% rename from lib/nextgen/generators/factory_bot_rails.rb rename to lib/nextgen/generators/gems/factory_bot_rails.rb diff --git a/lib/nextgen/generators/letter_opener.rb b/lib/nextgen/generators/gems/letter_opener.rb similarity index 100% rename from lib/nextgen/generators/letter_opener.rb rename to lib/nextgen/generators/gems/letter_opener.rb diff --git a/lib/nextgen/generators/gems/node.rb b/lib/nextgen/generators/gems/node.rb new file mode 100644 index 0000000..794ae0e --- /dev/null +++ b/lib/nextgen/generators/gems/node.rb @@ -0,0 +1,7 @@ +unless File.read(".gitignore").match?("node_modules") + say_git "Add Node and Yarn prerequisites" + copy_file "package.json" unless File.exist?("package.json") + inject_into_file "README.md", "\n- Node 18 (LTS) or newer\n- Yarn 1.x (classic)", after: /^- Ruby.*$/ + inject_into_file "README.md", "\nbrew install node\nbrew install yarn", after: /^brew install rbenv.*$/ + gitignore "node_modules/" +end diff --git a/lib/nextgen/generators/open_browser_on_start.rb b/lib/nextgen/generators/gems/open_browser_on_start.rb similarity index 100% rename from lib/nextgen/generators/open_browser_on_start.rb rename to lib/nextgen/generators/gems/open_browser_on_start.rb diff --git a/lib/nextgen/generators/pgcli_rails.rb b/lib/nextgen/generators/gems/pgcli_rails.rb similarity index 100% rename from lib/nextgen/generators/pgcli_rails.rb rename to lib/nextgen/generators/gems/pgcli_rails.rb diff --git a/lib/nextgen/generators/rack_canonical_host.rb b/lib/nextgen/generators/gems/rack_canonical_host.rb similarity index 100% rename from lib/nextgen/generators/rack_canonical_host.rb rename to lib/nextgen/generators/gems/rack_canonical_host.rb diff --git a/lib/nextgen/generators/rack_mini_profiler.rb b/lib/nextgen/generators/gems/rack_mini_profiler.rb similarity index 100% rename from lib/nextgen/generators/rack_mini_profiler.rb rename to lib/nextgen/generators/gems/rack_mini_profiler.rb diff --git a/lib/nextgen/generators/shoulda.rb b/lib/nextgen/generators/gems/shoulda.rb similarity index 100% rename from lib/nextgen/generators/shoulda.rb rename to lib/nextgen/generators/gems/shoulda.rb diff --git a/lib/nextgen/generators/thor.rb b/lib/nextgen/generators/gems/thor.rb similarity index 100% rename from lib/nextgen/generators/thor.rb rename to lib/nextgen/generators/gems/thor.rb diff --git a/lib/nextgen/generators/tomo.rb b/lib/nextgen/generators/gems/tomo.rb similarity index 100% rename from lib/nextgen/generators/tomo.rb rename to lib/nextgen/generators/gems/tomo.rb diff --git a/lib/nextgen/generators/vcr.rb b/lib/nextgen/generators/gems/vcr.rb similarity index 100% rename from lib/nextgen/generators/vcr.rb rename to lib/nextgen/generators/gems/vcr.rb diff --git a/lib/nextgen/generators/vite.rb b/lib/nextgen/generators/gems/vite.rb similarity index 100% rename from lib/nextgen/generators/vite.rb rename to lib/nextgen/generators/gems/vite.rb diff --git a/lib/nextgen/generators/sidekiq.rb b/lib/nextgen/generators/job_backend/sidekiq.rb similarity index 100% rename from lib/nextgen/generators/sidekiq.rb rename to lib/nextgen/generators/job_backend/sidekiq.rb diff --git a/lib/nextgen/generators/job_backend/solid_queue.rb b/lib/nextgen/generators/job_backend/solid_queue.rb new file mode 100644 index 0000000..994f1af --- /dev/null +++ b/lib/nextgen/generators/job_backend/solid_queue.rb @@ -0,0 +1,30 @@ +say_git "Install solid_queue as ActiveJob's backend" +install_gem "solid_queue", version: "~> 0.2" + +if @variables[:solid_queue_puma__no] + say_git "Add a solid_queue entry to the Procfile" + append_to_file "Procfile", "worker: bundle exec rake solid_queue:start\n" +else + say_git "Add solid_queue plugin to puma.rb" + append_to_file "config/puma.rb", "plugin :solid_queue\n" +end + +say_git "Configure Active Job to use the solid_queue adapter" +gsub_file "config/environments/production.rb", + /(# )?config\.active_job\.queue_adapter\s+=.*/, + "config.active_job.queue_adapter = :solid_queue" +inject_into_file "config/environments/development.rb", + " config.active_job.queue_adapter = :solid_queue\n", + after: "config.active_job.verbose_enqueue_logs = true\n" +copy_file "config/solid_queue.yml" + +say_git "Add the solid_queue migrations" +system "rails", "solid_queue:install:migrations", exception: true + +unless @variables[:api] + say_git "Mount the solid_queue web console at /jobs" + install_gem "mission_control-jobs" + route "# See [https://github.com/basecamp/mission_control-jobs#authentication-and-base-controller-class]" + route '# MissionControl::Jobs.base_controller_class = "AdminController"' + route 'mount MissionControl::Jobs::Engine, at: "/jobs"' +end diff --git a/lib/nextgen/generators/node.rb b/lib/nextgen/generators/node.rb deleted file mode 100644 index a771e2e..0000000 --- a/lib/nextgen/generators/node.rb +++ /dev/null @@ -1,5 +0,0 @@ -say_git "Add Node and Yarn prerequisites" -copy_file "package.json" unless File.exist?("package.json") -inject_into_file "README.md", "\n- Node 18 (LTS) or newer\n- Yarn 1.x (classic)", after: /^- Ruby.*$/ -inject_into_file "README.md", "\nbrew install node\nbrew install yarn", after: /^brew install rbenv.*$/ -gitignore "node_modules/" diff --git a/lib/nextgen/generators/github_actions.rb b/lib/nextgen/generators/workflows/github_actions.rb similarity index 100% rename from lib/nextgen/generators/github_actions.rb rename to lib/nextgen/generators/workflows/github_actions.rb diff --git a/lib/nextgen/generators/github_pr_template.rb b/lib/nextgen/generators/workflows/github_pr_template.rb similarity index 100% rename from lib/nextgen/generators/github_pr_template.rb rename to lib/nextgen/generators/workflows/github_pr_template.rb diff --git a/lib/nextgen/rails_options.rb b/lib/nextgen/rails_options.rb index 78fc696..8a2e7de 100644 --- a/lib/nextgen/rails_options.rb +++ b/lib/nextgen/rails_options.rb @@ -156,6 +156,10 @@ def active_job? !skip_optional_framework?("active_job") end + def tailwind? + css == "tailwind" + end + def skip_optional_framework!(framework) raise ArgumentError, "Unknown framework: #{framework}" unless OPTIONAL_FRAMEWORKS.include?(framework) diff --git a/template/app/models/current.rb b/template/app/models/current.rb new file mode 100644 index 0000000..73a9744 --- /dev/null +++ b/template/app/models/current.rb @@ -0,0 +1,3 @@ +class Current < ActiveSupport::CurrentAttributes + attribute :user +end diff --git a/template/config/solid_queue.yml b/template/config/solid_queue.yml new file mode 100644 index 0000000..7a33b9c --- /dev/null +++ b/template/config/solid_queue.yml @@ -0,0 +1,18 @@ + default: &default + dispatchers: + - polling_interval: 1 + batch_size: 500 + workers: + - queues: "*" + threads: 5 + processes: <%= Integer(ENV.fetch("WEB_CONCURRENCY") { Concurrent.physical_processor_count }) %> + polling_interval: 0.1 + + development: + <<: *default + + test: + <<: *default + + production: + <<: *default