diff --git a/.gitignore b/.gitignore index 8d57097e..7fdfa5c3 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,6 @@ log .history .DS_Store + +# Leaked app config from running generators at the repo root during dev/specs +/config/ diff --git a/README.md b/README.md index 8dd0cb77..aa5f7d30 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,14 @@ And then execute: ## Usage -Run all generators (useful on a new app) +Run the core generators (recommended on a new app — sets up our standard +baseline so the app is ready to push straight to Heroku) + +```shell +bin/rails g rolemodel:core_setup +``` + +Or run every generator, including app-specific extras like React, SaaS/Devise, and GoodJob ```shell bin/rails g rolemodel:all @@ -64,10 +71,37 @@ You can see complete list of available generators (including those under the Rol bin/rails g ``` +## Generator Registry + +Every rolemodel generator that is run records itself in your app's +`config/initializers/rolemodel_generators.rb`. This registry powers coupling +declarations between generators — for example, the sentry and webpack generators +know about each other through the registry and automatically wire the Sentry +webpack plugin when both are present. + +For apps set up before the registry existed, run the seeding generator: + +```shell +bin/rails g rolemodel:registry +``` + +The seeder probes for each generator's characteristic output files and writes +entries marked as `seeded-by-detection`. It is safe to run multiple times. + +### Opting out + +* **Persistent opt-out:** Set `g.rolemodel : false` in the initializer. + The generator will never re-record over a `false` entry. +* **Per-invocation opt-out:** Pass `--no-` to any generator that declares + a coupling (e.g. `rails g rolemodel:sentry --no-sentry-webpack`). +* **Drift recovery:** If the managed block markers are missing from the + initializer, delete the file and run `rails g rolemodel:registry` to + rebuild it. + ## Generators +* [Core Setup](./lib/generators/rolemodel/core_setup) * [Github](./lib/generators/rolemodel/github) -* [Semaphore](./lib/generators/rolemodel/semaphore) * [Heroku](./lib/generators/rolemodel/heroku) * [Readme](./lib/generators/rolemodel/readme) * [Webpack](./lib/generators/rolemodel/webpack) @@ -101,10 +135,11 @@ bin/rails g * [Editors](./lib/generators/rolemodel/editors) * [Tailored Select](./lib/generators/rolemodel/tailored_select) * [Lograge](./lib/generators/rolemodel/lograge) +* [Registry](./lib/generators/rolemodel/registry) ## Development -Install the versions of Node and Ruby specified in `.node-version` and `.ruby-version` on your machine. https://asdf-vm.com/ is a great tool for managing language versions. Then run `npm install -g yarn`. +Install the versions of Node and Ruby specified in `.node-version` and `.ruby-version` on your machine. https://asdf-vm.com/ is a great tool for managing language versions. Then run `corepack enable` to activate the Yarn 4+ version pinned by each project's `packageManager` field. ## Adding new Generators diff --git a/lib/generators/rolemodel/README.md b/lib/generators/rolemodel/README.md index d4583568..8dfb059f 100644 --- a/lib/generators/rolemodel/README.md +++ b/lib/generators/rolemodel/README.md @@ -13,13 +13,32 @@ * [Optics](./optics) * [README](./readme) * [SaaS](./saas) -* [Semaphore](./semaphore) * [SimpleForm](./simple_form) * [Slim](./slim) * [SoftDestroyable](./soft_destroyable) * [Source Map](./source_map) * [Testing](./testing) * [Webpack](./webpack) +* [Registry](./registry) + +## Generator Registry + +Each generator records itself in `config/initializers/rolemodel_generators.rb` +so that other generators can detect whether it has been applied. See +[Registry](./registry) for the seeding tool. + +### Generator coupling + +Some generators declare optional couplings: + +* **sentry ↔ webpack:** Whichever is installed second wires the Sentry webpack + plugin into `webpack.config.js` via the `sentry_webpack` hook sub-generator. + Pass `--no-sentry-webpack` to suppress. Set + `g.rolemodel sentry_webpack: false` in the initializer for a persistent opt-out. +* **tailored_select ↔ simple_form:** Installing simple_form with + `--tailored-select` installs the tailored_select package and its input. + Installing tailored_select standalone installs the package without the input. + Pass `--simple-form-input` to force the input regardless. ## Helpful documentation diff --git a/lib/generators/rolemodel/all_generator.rb b/lib/generators/rolemodel/all_generator.rb index ce5cae4f..83a3fd01 100644 --- a/lib/generators/rolemodel/all_generator.rb +++ b/lib/generators/rolemodel/all_generator.rb @@ -2,12 +2,16 @@ module Rolemodel class AllGenerator < GeneratorBase source_root File.expand_path('templates', __dir__) + # Composite generator: it orchestrates other generators and installs nothing + # itself, so it is not recorded in the registry. + skip_registry_entry! + def run_all_the_generators generate 'rolemodel:github' - generate 'rolemodel:semaphore' generate 'rolemodel:heroku' generate 'rolemodel:readme' generate 'rolemodel:webpack' + generate 'rolemodel:sentry' generate 'rolemodel:react' generate 'rolemodel:slim' generate 'rolemodel:optics:all' diff --git a/lib/generators/rolemodel/core_setup/README.md b/lib/generators/rolemodel/core_setup/README.md new file mode 100644 index 00000000..6b7c3116 --- /dev/null +++ b/lib/generators/rolemodel/core_setup/README.md @@ -0,0 +1,27 @@ +# Core Setup Generator + +Runs the core generators every new Rails app should have. Unlike +`rolemodel:all`, this skips the app-specific extras (React, SaaS/Devise, +GoodJob, Kaminari, etc.) so you get just the standard baseline — a new Rails +app you can push straight to Heroku right after generation. + +## Prerequisites + + - A freshly generated Rails app + +## What you get + + - [GitHub](../github) — standard GitHub configuration + - [Heroku](../heroku) — standard Heroku deployment configuration + - [Readme](../readme) — standard project README + - [Webpack](../webpack) — Webpack v5 for JS and CSS + - [Sentry](../sentry) — error monitoring for Ruby and JavaScript + - [Slim](../slim) — Slim templates + - [Optics](../optics) — Optics design system + - [Testing](../testing) — RSpec, FactoryBot, parallel_tests, TestProf + (pass `--js-runner` to include jasmine-playwright-runner) + - [SimpleForm](../simple_form) — SimpleForm with our configuration + - [Linters](../linters) — Rubocop and ESLint + - [UI Components](../ui_components) — flash, the modal pattern, & Turbo 8 support + - [Editors](../editors) — EditorConfig and recommended VSCode extensions + - [Lograge](../lograge) — condensed request logging diff --git a/lib/generators/rolemodel/core_setup/USAGE b/lib/generators/rolemodel/core_setup/USAGE new file mode 100644 index 00000000..386c64f6 --- /dev/null +++ b/lib/generators/rolemodel/core_setup/USAGE @@ -0,0 +1,12 @@ +Description: + Runs the core generators every new Rails app should have + + Sets up GitHub config, Heroku deployment, README, Webpack, Sentry, Slim, + Optics, testing (RSpec & friends), SimpleForm, linters, Turbo 8+ support, + and Lograge. The result is an app that is ready to push directly to Heroku. + + Pass --js-runner to also include jasmine-playwright-runner for browser + JS testing. + +Example: + rails generate rolemodel:core_setup diff --git a/lib/generators/rolemodel/core_setup/core_setup_generator.rb b/lib/generators/rolemodel/core_setup/core_setup_generator.rb new file mode 100644 index 00000000..bf44b1bb --- /dev/null +++ b/lib/generators/rolemodel/core_setup/core_setup_generator.rb @@ -0,0 +1,23 @@ +module Rolemodel + class CoreSetupGenerator < ::Rolemodel::GeneratorBase + # Composite generator: it orchestrates other generators and installs nothing + # itself, so it is not recorded in the registry. + skip_registry_entry! + + def run_the_core_generators + generate 'rolemodel:github' + generate 'rolemodel:heroku' + generate 'rolemodel:readme' + generate 'rolemodel:webpack' + generate 'rolemodel:sentry' + generate 'rolemodel:slim' + generate 'rolemodel:optics:all' + generate 'rolemodel:testing:all' + generate 'rolemodel:simple_form' + generate 'rolemodel:linters:all' + generate 'rolemodel:ui_components:flash' + generate 'rolemodel:ui_components:modals' + generate 'rolemodel:lograge' + end + end +end diff --git a/lib/generators/rolemodel/github/README.md b/lib/generators/rolemodel/github/README.md index 0fdfda59..6cf1a08b 100644 --- a/lib/generators/rolemodel/github/README.md +++ b/lib/generators/rolemodel/github/README.md @@ -9,6 +9,10 @@ It doesn't need to be run first, but the parallel_tests generator must be run in - CI workflow - A sensible default `ci.yml` to get you started with Github Actions. This will run linters, model tests, and system tests. - Along with the `ci.yml`, your `database.yml` will be modified to be able to be run in GHA. +- Deploy workflows + - `deploy-staging.yml` deploys to Heroku on every push to `main` (or manually); `deploy-production.yml` deploys manually via `workflow_dispatch`. + - Both target a GitHub deployment environment (`Staging`/`Production`) that provides `HEROKU_APP_NAME` and `HEROKU_APP_URL` variables, and authenticate with the org-level `HEROKU_IT_SUPPORT_API_KEY` secret and `HEROKU_IT_SUPPORT_EMAIL` variable. + - Note: the staging workflow will fail on pushes to `main` until the environment exists — run the `deploy-app` agent skill (installed by the heroku generator) to create the Heroku app and the GitHub environment. - Pull Request Template - When you open a Pull Request in Github it will use the Markdown file as a [template](./templates/pull_request_template.md) for the content of the PR. - Helpful for reminding collaborators to add specific details to the PR. diff --git a/lib/generators/rolemodel/github/templates/workflows/deploy-production.yml b/lib/generators/rolemodel/github/templates/workflows/deploy-production.yml new file mode 100644 index 00000000..18784b34 --- /dev/null +++ b/lib/generators/rolemodel/github/templates/workflows/deploy-production.yml @@ -0,0 +1,34 @@ +name: Deploy to Heroku Production + +on: + workflow_dispatch: + inputs: + sha: + description: 'Specific SHA or Branch name (optional)' + required: false + type: string + +concurrency: production-deployment + +jobs: + deploy: + name: Deploy + runs-on: ubuntu-latest + timeout-minutes: 20 + environment: + name: Production + url: ${{ vars.HEROKU_APP_URL }} + steps: + - uses: actions/checkout@v6 + + - name: Install Heroku CLI + run: | + curl https://cli-assets.heroku.com/install.sh | sh + + - uses: akhileshns/heroku-deploy@v3.15.15 + with: + heroku_api_key: ${{ secrets.HEROKU_IT_SUPPORT_API_KEY }} + heroku_app_name: ${{ vars.HEROKU_APP_NAME }} + heroku_email: ${{ vars.HEROKU_IT_SUPPORT_EMAIL }} + branch: ${{ inputs.sha || 'HEAD' }} + healthcheck: '${{ vars.HEROKU_APP_URL }}/up' diff --git a/lib/generators/rolemodel/github/templates/workflows/deploy-staging.yml b/lib/generators/rolemodel/github/templates/workflows/deploy-staging.yml new file mode 100644 index 00000000..515b464e --- /dev/null +++ b/lib/generators/rolemodel/github/templates/workflows/deploy-staging.yml @@ -0,0 +1,35 @@ +name: Deploy to Heroku Staging + +on: + workflow_dispatch: + inputs: + sha: + description: 'Specific SHA or Branch name (optional)' + required: false + type: string + push: { branches: [ staging ] } + +concurrency: staging-deployment + +jobs: + deploy: + name: Deploy + runs-on: ubuntu-latest + timeout-minutes: 20 + environment: + name: Staging + url: ${{ vars.HEROKU_APP_URL }} + steps: + - uses: actions/checkout@v6 + + - name: Install Heroku CLI + run: | + curl https://cli-assets.heroku.com/install.sh | sh + + - uses: akhileshns/heroku-deploy@v3.15.15 + with: + heroku_api_key: ${{ secrets.HEROKU_IT_SUPPORT_API_KEY }} + heroku_app_name: ${{ vars.HEROKU_APP_NAME }} + heroku_email: ${{ vars.HEROKU_IT_SUPPORT_EMAIL }} + branch: ${{ inputs.sha || 'HEAD' }} + healthcheck: "${{ vars.HEROKU_APP_URL }}/up" diff --git a/lib/generators/rolemodel/good_job/good_job_generator.rb b/lib/generators/rolemodel/good_job/good_job_generator.rb index 8da7d0d3..ef47b564 100644 --- a/lib/generators/rolemodel/good_job/good_job_generator.rb +++ b/lib/generators/rolemodel/good_job/good_job_generator.rb @@ -64,8 +64,6 @@ def copy_initializers def finishing_notes say <<~NOTES - *** Reminder to update Honeybadger gem to version 5.7.0 or later to get correct GoodJob error notifications in Honeybadger - *** Reminder to also update your job classes to include appropriate concurrency controls (enqueue_limit/perform_limit with keys) NOTES end diff --git a/lib/generators/rolemodel/heroku/README.md b/lib/generators/rolemodel/heroku/README.md index 1781c971..b564d789 100644 --- a/lib/generators/rolemodel/heroku/README.md +++ b/lib/generators/rolemodel/heroku/README.md @@ -4,5 +4,15 @@ * Procfile configured for a server process and a release command to run migrations * Basic app.json preconfigured with a script to initialize the database and common environment variables, addons, and buildpacks +* A pointer in `AGENTS.md` to the `deploy-app` agent skill, which ships inside the + `rolemodel-rails` gem (`lib/rolemodel/skills/deploy-app/SKILL.md`) rather than being copied + into your repo — it's one-time deployment setup, so nothing skill-related is committed to the + app. The skill cleans up the generated Gemfile (merges duplicate groups, removes comments, + alphabetizes), verifies the test suite and RuboCop pass, creates the Sentry project and wires + up the DSN, creates and deploys the Heroku staging app (buildpacks, dynos, Postgres, + Papertrail), and creates the GitHub `Staging` environment with the + `HEROKU_APP_NAME`/`HEROKU_APP_URL` variables the deploy workflow consumes. The skill is + LLM-agnostic (Agent Skills format): locate it with `bundle show rolemodel-rails` and point any + coding agent at the SKILL.md. Requires the Heroku CLI, the GitHub CLI, and the Sentry MCP server. This is the basic config needed to deploy to Heroku. diff --git a/lib/generators/rolemodel/heroku/heroku_generator.rb b/lib/generators/rolemodel/heroku/heroku_generator.rb index 186dc962..8f44d7c0 100644 --- a/lib/generators/rolemodel/heroku/heroku_generator.rb +++ b/lib/generators/rolemodel/heroku/heroku_generator.rb @@ -15,6 +15,47 @@ def install_procfile template 'Procfile' end + def reference_deploy_app_skill + say 'Reference the deploy-app agent skill from rolemodel-rails', :green + + # The deploy-app skill is one-time, run-once deployment setup. Rather than + # copying ~200 lines of instructions into every app repo, point the agent at + # the SKILL.md that ships inside the rolemodel-rails gem (already a bundled + # dependency). Nothing skill-related is committed to the generated app. + if File.exist?(File.join(destination_root, 'AGENTS.md')) + append_to_file 'AGENTS.md', agents_md_skill_entry + else + create_file 'AGENTS.md', "# Agent instructions\n#{agents_md_skill_entry}" + end + end + + def pin_ruby_version_for_buildpack + say 'Pin the Ruby version in the Gemfile so the Heroku buildpack respects it.', :green + + # A bare .ruby-version file is not enough: without a `ruby` directive the version + # never lands in Gemfile.lock, so the Heroku Ruby buildpack falls back to its own + # default and can install an incompatible Ruby. Tie the Gemfile to .ruby-version. + gemfile = File.join(destination_root, 'Gemfile') + return if File.exist?(gemfile) && File.read(gemfile).match?(/^\s*ruby\s/) + + inject_into_file 'Gemfile', "\nruby file: '.ruby-version'\n", after: /^source .*$/ + end + + def use_database_url_in_production + say 'Point the production database at DATABASE_URL for Heroku.', :green + + # Rails' generated production block hardcodes database/username/_DATABASE_PASSWORD, + # none of which exist on Heroku, where the Postgres add-on provides a full DATABASE_URL. + # Replace the whole block with a url-based config. + gsub_file 'config/database.yml', + /^production:\n(?:[ \t]+.*\n?)+/, + <<~YAML + production: + <<: *default + url: <%= ENV["DATABASE_URL"] %> + YAML + end + def force_ssl say 'Require SSL for production environment.', :green @@ -48,5 +89,26 @@ def create_assets_rake_tasks # rubocop:disable Metrics/MethodLength end RAKE end + + private + + def agents_md_skill_entry + <<~MD + + ## Agent skills + + Reusable, agent-agnostic task instructions (Agent Skills format) ship inside the + `rolemodel-rails` gem — a bundled dependency of this app — rather than being copied + into this repo. Locate the gem's skills directory with + `bundle show rolemodel-rails` (the skills live under `lib/rolemodel/skills/`), then + read the relevant `SKILL.md` and follow it directly. + + * `deploy-app` (`$(bundle show rolemodel-rails)/lib/rolemodel/skills/deploy-app/SKILL.md`) + — one-time deployment setup: cleans up the generated Gemfile, verifies the test + suite and RuboCop pass, creates the Sentry project and wires up the DSN, creates + and deploys the Heroku staging app, and creates the GitHub `Staging` environment + + deploy workflow. Use when asked to set up staging, Heroku, or deployment. + MD + end end end diff --git a/lib/generators/rolemodel/heroku/templates/app.json.tt b/lib/generators/rolemodel/heroku/templates/app.json.tt index 767d4da4..4554c39b 100644 --- a/lib/generators/rolemodel/heroku/templates/app.json.tt +++ b/lib/generators/rolemodel/heroku/templates/app.json.tt @@ -4,12 +4,15 @@ "postdeploy": "bin/rails db:seed" }, "env": { - "HONEYBADGER_API_KEY": { + "SENTRY_DSN": { "required": true }, - "HONEYBADGER_ENV": { + "SENTRY_ENVIRONMENT": { "required": true, "value": "review-app" + }, + "SENTRY_AUTH_TOKEN": { + "required": false } }, "addons": [ diff --git a/lib/generators/rolemodel/linters/all_generator.rb b/lib/generators/rolemodel/linters/all_generator.rb index 432fe4a0..e78cc986 100644 --- a/lib/generators/rolemodel/linters/all_generator.rb +++ b/lib/generators/rolemodel/linters/all_generator.rb @@ -3,6 +3,9 @@ module Linters class AllGenerator < GeneratorBase source_root File.expand_path('templates', __dir__) + # Composite generator: orchestrates other generators, not recorded itself. + skip_registry_entry! + def run_all_the_generators Dir.glob(Pathname(File.expand_path('.', __dir__)).join('*', '*generator.rb')).each do |generator| name = File.basename(generator, '_generator.rb') diff --git a/lib/generators/rolemodel/linters/eslint/eslint_generator.rb b/lib/generators/rolemodel/linters/eslint/eslint_generator.rb index 583d32d7..248cf01a 100644 --- a/lib/generators/rolemodel/linters/eslint/eslint_generator.rb +++ b/lib/generators/rolemodel/linters/eslint/eslint_generator.rb @@ -18,6 +18,7 @@ class EslintGenerator < GeneratorBase ].freeze def install_eslint + ensure_yarn run "yarn add --dev #{DEV_DEPENDENCIES.join(' ')}" end diff --git a/lib/generators/rolemodel/linters/rubocop/rubocop_generator.rb b/lib/generators/rolemodel/linters/rubocop/rubocop_generator.rb index c2c2a3ad..dcb99c08 100644 --- a/lib/generators/rolemodel/linters/rubocop/rubocop_generator.rb +++ b/lib/generators/rolemodel/linters/rubocop/rubocop_generator.rb @@ -16,7 +16,11 @@ def install_rubocop def add_config copy_file '.rubocop.yml', force: true - directory 'lib/cops' + # Custom cops live in .rubocop/cops (loaded via .rubocop.yml's require:), + # not lib/cops — keeping them out of Rails' autoload/eager-load paths so + # they don't crash production boot referencing the dev/test-only RuboCop + # constant. + directory '.rubocop/cops' end end end diff --git a/lib/generators/rolemodel/linters/rubocop/templates/.rubocop.yml b/lib/generators/rolemodel/linters/rubocop/templates/.rubocop.yml index afbbf8ca..d60de63e 100644 --- a/lib/generators/rolemodel/linters/rubocop/templates/.rubocop.yml +++ b/lib/generators/rolemodel/linters/rubocop/templates/.rubocop.yml @@ -1,6 +1,6 @@ require: - - ./lib/cops/form_error_response.rb - - ./lib/cops/no_chrome_tag.rb + - ./.rubocop/cops/form_error_response.rb + - ./.rubocop/cops/no_chrome_tag.rb plugins: - rubocop-rails diff --git a/lib/generators/rolemodel/linters/rubocop/templates/lib/cops/form_error_response.rb b/lib/generators/rolemodel/linters/rubocop/templates/.rubocop/cops/form_error_response.rb similarity index 100% rename from lib/generators/rolemodel/linters/rubocop/templates/lib/cops/form_error_response.rb rename to lib/generators/rolemodel/linters/rubocop/templates/.rubocop/cops/form_error_response.rb diff --git a/lib/generators/rolemodel/linters/rubocop/templates/lib/cops/no_chrome_tag.rb b/lib/generators/rolemodel/linters/rubocop/templates/.rubocop/cops/no_chrome_tag.rb similarity index 100% rename from lib/generators/rolemodel/linters/rubocop/templates/lib/cops/no_chrome_tag.rb rename to lib/generators/rolemodel/linters/rubocop/templates/.rubocop/cops/no_chrome_tag.rb diff --git a/lib/generators/rolemodel/lograge/README.md b/lib/generators/rolemodel/lograge/README.md index 0ec4e9c3..403d0002 100644 --- a/lib/generators/rolemodel/lograge/README.md +++ b/lib/generators/rolemodel/lograge/README.md @@ -7,4 +7,4 @@ Installs [Lograge](https://github.com/roidrage/lograge). Lograge is an opinionated gem that slims down the amount of logs that Rails generates, with the goal of reducing -noise that's handled by other services. For instance, it removes view rendering logs, which is a duplication of Skylight. +noise that's handled by other services. diff --git a/lib/generators/rolemodel/lograge/templates/config/initializers/lograge.rb b/lib/generators/rolemodel/lograge/templates/config/initializers/lograge.rb index a07aaf99..14b66b2b 100644 --- a/lib/generators/rolemodel/lograge/templates/config/initializers/lograge.rb +++ b/lib/generators/rolemodel/lograge/templates/config/initializers/lograge.rb @@ -4,7 +4,7 @@ config.lograge.enabled = true config.lograge.custom_payload do |controller| { - user_id: controller.current_user.try(:id) + user_id: controller.try(:current_user).try(:id) } end config.lograge.custom_options = lambda do |event| diff --git a/lib/generators/rolemodel/optics/all_generator.rb b/lib/generators/rolemodel/optics/all_generator.rb index 8b6937bf..db08a7df 100644 --- a/lib/generators/rolemodel/optics/all_generator.rb +++ b/lib/generators/rolemodel/optics/all_generator.rb @@ -3,6 +3,9 @@ module Optics class AllGenerator < Rolemodel::GeneratorBase source_root File.expand_path('templates', __dir__) + # Composite generator: orchestrates other generators, not recorded itself. + skip_registry_entry! + def run_all_the_generators generate 'rolemodel:optics:base' generate 'rolemodel:optics:icons' diff --git a/lib/generators/rolemodel/optics/base/base_generator.rb b/lib/generators/rolemodel/optics/base/base_generator.rb index 2bb5d10b..60c200e0 100644 --- a/lib/generators/rolemodel/optics/base/base_generator.rb +++ b/lib/generators/rolemodel/optics/base/base_generator.rb @@ -6,6 +6,7 @@ class BaseGenerator < Rolemodel::GeneratorBase def add_optics_package say 'installing Optics package', :green + ensure_yarn run 'yarn add @rolemodel/optics' end diff --git a/lib/generators/rolemodel/react/react_generator.rb b/lib/generators/rolemodel/react/react_generator.rb index 661801db..7b5b283e 100644 --- a/lib/generators/rolemodel/react/react_generator.rb +++ b/lib/generators/rolemodel/react/react_generator.rb @@ -6,6 +6,7 @@ def add_npm_packages @add_react = yes?('Would you like to add react?') if @add_react + ensure_yarn run 'yarn add react react-dom' end end diff --git a/lib/generators/rolemodel/readme/templates/README.md.erb b/lib/generators/rolemodel/readme/templates/README.md.erb index b3fad0d3..eade6e41 100644 --- a/lib/generators/rolemodel/readme/templates/README.md.erb +++ b/lib/generators/rolemodel/readme/templates/README.md.erb @@ -81,12 +81,10 @@ When finished with the feature and the code has been reviewed, the commits shoul ## [Staging](http://staging..com) ## [Production](http://app..com) ## External services -* [HoneyBadger](http://honeybadger.io) -* [Skylight](http://skylight.io) +* [Sentry](https://sentry.io) * [SendGrid](http://sendgrid.com/RoleModel) * [Heroku](http://herokuapp.com) -## [CI](http://semaphoreci.com/RoleModel) ## [Core project presentation](http://docs.google.com) ## [List of contributors](http://github.com/RoleModel) ## [Change log](file://./docs/change_log.md) @@ -113,4 +111,4 @@ The application is deployed to Heroku. They are also hosting the DNS. We certifi * Larry Anderson - 919-555-1213 # Copyright & licensing -Copyright (c) 2019 Closed Source @CompanyName +Copyright (c) 2026 Closed Source @CompanyName diff --git a/lib/generators/rolemodel/registry/README.md b/lib/generators/rolemodel/registry/README.md new file mode 100644 index 00000000..66ee8d52 --- /dev/null +++ b/lib/generators/rolemodel/registry/README.md @@ -0,0 +1,32 @@ +# Registry Generator + +One-time seeding tool for apps that were set up before the rolemodel_rails +generator registry existed. It probes for the characteristic output files of +each generator in the suite and writes detection-based entries into your app's +`config/initializers/rolemodel_generators.rb`. + +## How it works + +The seeder checks for known file artifacts that each generator creates — for +example, `webpack.config.js` for the webpack generator, +`config/initializers/sentry.rb` for sentry, `Procfile` for heroku. When it +finds one, it writes a `g.rolemodel : true` entry with a `seeded-by-detection` +comment. + +Entries from genuine generator runs (carrying a `rolemodel_rails X.Y.Z` version +stamp) are **never overwritten**. The seeder also never writes `false` entries. + +## Running it + +``` +rails generate rolemodel:registry +``` + +Review the output carefully. Any generator that shows "not detected" may still +have been applied (some generators leave no reliable file footprint). Check +those manually and add entries by hand if needed. + +## Re-running + +Re-running is a no-op that reports current state. Already-seeded entries are +skipped; genuinely recorded entries are untouched. diff --git a/lib/generators/rolemodel/registry/USAGE b/lib/generators/rolemodel/registry/USAGE new file mode 100644 index 00000000..d4109914 --- /dev/null +++ b/lib/generators/rolemodel/registry/USAGE @@ -0,0 +1,15 @@ +Description: + Seed the rolemodel_rails generator registry for an existing app. Scans + for characteristic output files of previously applied generators and + writes detection-based entries into config/initializers/rolemodel_generators.rb. + + Safe to run multiple times — never overwrites existing version-stamped + entries and never writes false entries. + +Example: + rails generate rolemodel:registry + + This will scan your app for signs of previously applied RoleModel generators + (webpack, sentry, simple_form, etc.) and create or update the registry + initializer with seeded entries. Review the output for any "not detected" + generators that you know were applied and adjust the initializer manually. diff --git a/lib/generators/rolemodel/registry/registry_generator.rb b/lib/generators/rolemodel/registry/registry_generator.rb new file mode 100644 index 00000000..2f7c704d --- /dev/null +++ b/lib/generators/rolemodel/registry/registry_generator.rb @@ -0,0 +1,137 @@ +# frozen_string_literal: true + +module Rolemodel + # One-time seeding generator for consuming apps that predate the registry. + # Feature-detects which generators have been applied by probing for their + # characteristic output files, then writes seeded entries into the + # config/initializers/rolemodel_generators.rb managed block. + # + # Safe to run multiple times: never overwrites existing entries, never writes + # false. A re-run simply reports current state and no-ops. + # + # Exempt from registry recording — it bootstraps the registry, it is not + # itself a product-of-installation generator. + class RegistryGenerator < GeneratorBase + skip_registry_entry! + + # Map from registry key → detection probe. Each entry is a lambda that + # receives the app's root and returns true if that generator's footprint + # is detected. + # + # The probes derive from each generator's idempotency guards and + # characteristic output files. Low-confidence probes are omitted rather + # than risk false positives; those generators will show as "not detected" + # and the human can decide. + DETECTION_MAP = { + webpack: ->(root) { File.exist?(File.join(root, 'webpack.config.js')) }, + sentry: ->(root) { File.exist?(File.join(root, 'config/initializers/sentry.rb')) }, + simple_form: ->(root) { File.exist?(File.join(root, 'config/initializers/simple_form.rb')) }, + good_job: ->(root) { File.exist?(File.join(root, 'config/initializers/good_job.rb')) }, + lograge: ->(root) { File.exist?(File.join(root, 'config/initializers/lograge.rb')) }, + slim: ->(root) { File.exist?(File.join(root, 'app/views/layouts/application.html.slim')) }, + github: ->(root) { Dir.exist?(File.join(root, '.github/workflows')) }, + heroku: ->(root) { File.exist?(File.join(root, 'Procfile')) }, + tailored_select: ->(root) { File.exist?(File.join(root, 'app/inputs/tailored_select_input.rb')) }, + react: ->(root) { File.exist?(File.join(root, 'app/javascript/controllers/react_controller.js')) }, + editors: ->(root) { + f = File.join(root, '.vscode/extensions.json') + File.exist?(f) && File.read(f).include?('EditorConfig') + }, + kaminari: ->(root) { Dir.exist?(File.join(root, 'app/views/kaminari')) }, + mailers: ->(root) { File.exist?(File.join(root, 'config/initializers/premailer_rails.rb')) }, + soft_destroyable: ->(root) { File.exist?(File.join(root, 'app/models/concerns/soft_destroyable.rb')) }, + source_map: ->(root) { File.exist?(File.join(root, 'lib/middleware/rolemodel/source_map.rb')) }, + optics_base: ->(root) { + scss = Dir.glob(File.join(root, 'app/assets/stylesheets/application.*')).first + scss && File.read(scss).include?('@rolemodel/optics') + }, + optics_icons: ->(root) { File.exist?(File.join(root, 'app/helpers/icon_helper.rb')) }, + testing_rspec: ->(root) { File.exist?(File.join(root, 'spec/spec_helper.rb')) }, + testing_factory_bot: ->(root) { File.exist?(File.join(root, 'spec/support/factory_bot.rb')) }, + testing_parallel_tests: ->(root) { File.exist?(File.join(root, '.rspec_parallel')) }, + testing_vitest: ->(root) { File.exist?(File.join(root, 'vitest.config.js')) }, + testing_jasmine_playwright: ->(root) { File.exist?(File.join(root, 'jp-runner.config.mjs')) }, + saas_devise: ->(root) { File.exist?(File.join(root, 'config/initializers/devise.rb')) }, + linters_eslint: ->(root) { File.exist?(File.join(root, 'eslint.config.js')) }, + linters_rubocop: ->(root) { File.exist?(File.join(root, '.rubocop.yml')) }, + ui_components_flash: ->(root) { File.exist?(File.join(root, 'app/views/application/_flash.html.slim')) }, + ui_components_modals: ->(root) { File.exist?(File.join(root, 'app/javascript/initializers/turbo_confirm.js')) }, + ui_components_navbar: ->(root) { File.exist?(File.join(root, 'app/views/layouts/_navbar.html.slim')) }, + }.freeze + + def detect_and_seed + say 'Scanning for previously applied generators…', :blue + + # Ensure the initializer file and managed block exist + create_initializer + + say '', :blue + + seeded = 0 + skipped = 0 + not_detected = 0 + + DETECTION_MAP.each do |key, probe| + detected = probe.call(destination_root) + + if already_recorded?(key) + skipped += 1 + say " #{key.to_s.ljust(30)} already recorded — skipping", :cyan + next + end + + if detected + Registry.record(key, destination_root:, comment: 'seeded-by-detection') + seeded += 1 + say " #{key.to_s.ljust(30)} detected & seeded", :green + else + not_detected += 1 + say " #{key.to_s.ljust(30)} not detected", :yellow + end + end + + initializer_path = File.expand_path(Registry::INITIALIZER_PATH, destination_root) + + say '', :green + say "Seeded: #{seeded} | Skipped (already recorded): #{skipped} | Not detected: #{not_detected}", :green + say '', :blue + say "Review #{initializer_path} in your app and adjust entries as needed.", :blue + end + + private + + # Create the initializer from the template if it doesn't already exist. + def create_initializer + path = File.expand_path(Registry::INITIALIZER_PATH, destination_root) + return if File.exist?(path) + + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, Registry::FILE_TEMPLATE) + say ' Created empty registry initializer', :blue + end + + # Check if a key already has a genuine (version-stamped) entry in the + # managed block. A seeded-by-detection entry is NOT considered "already + # recorded" for the purpose of skip logic — the seeder overwrites its own + # prior seeds to keep the comment clean. A version-stamped entry (from a + # genuine generator run) is never touched. + def already_recorded?(key) + path = File.expand_path(Registry::INITIALIZER_PATH, destination_root) + return false unless File.exist?(path) + + content = File.read(path) + return false unless content.include?(Registry::BEGIN_MARKER) + + content.lines.each do |line| + match = Registry::ENTRY_PATTERN.match(line) + next unless match && match[:key] == key.to_s + + # Only skip if the entry has a version stamp (genuine run), + # not a seeded-by-detection comment + return line.include?('rolemodel_rails') + end + + false + end + end +end diff --git a/lib/generators/rolemodel/saas/all_generator.rb b/lib/generators/rolemodel/saas/all_generator.rb index 2e12740f..42e2b7d1 100644 --- a/lib/generators/rolemodel/saas/all_generator.rb +++ b/lib/generators/rolemodel/saas/all_generator.rb @@ -3,6 +3,9 @@ module Saas class AllGenerator < GeneratorBase source_root File.expand_path('templates', __dir__) + # Composite generator: orchestrates other generators, not recorded itself. + skip_registry_entry! + def run_all_the_generators # no guaranteed order to this list with Dir.glob Dir.glob(Pathname(File.expand_path('.', __dir__)).join('*', '*generator.rb')).each do |generator| diff --git a/lib/generators/rolemodel/semaphore/README.md b/lib/generators/rolemodel/semaphore/README.md deleted file mode 100644 index ebcb866e..00000000 --- a/lib/generators/rolemodel/semaphore/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# Semaphore Generator - -## What you get - -* semaphore.yml file to run the relevant CI commands -* heroku-deployment-commands.sh file with commands to deploy to heroku -* staging-deploy.yml file with commands to deploy to staging -* production-deploy.yml file with commands to deploy to production - -This is the basic config needed to use semaphore. - -## Note -This config assumes - - `main` is the base branch - - `yarn test` is the command to run JS tests diff --git a/lib/generators/rolemodel/semaphore/USAGE b/lib/generators/rolemodel/semaphore/USAGE deleted file mode 100644 index 5382907f..00000000 --- a/lib/generators/rolemodel/semaphore/USAGE +++ /dev/null @@ -1,11 +0,0 @@ -Description: - Sets up our standard semaphore configuration - -Example: - rails generate rolemodel:semaphore - - This will create: - semaphore.yml - heroku-deployment-commands.sh - staging-deploy.yml - production-deploy.yml diff --git a/lib/generators/rolemodel/semaphore/semaphore_generator.rb b/lib/generators/rolemodel/semaphore/semaphore_generator.rb deleted file mode 100644 index 61f62e12..00000000 --- a/lib/generators/rolemodel/semaphore/semaphore_generator.rb +++ /dev/null @@ -1,25 +0,0 @@ -module Rolemodel - class SemaphoreGenerator < GeneratorBase - source_root File.expand_path('templates', __dir__) - - def create_base_semaphore_config - @project_name = Rails.application.class.try(:parent_name) || Rails.application.class.module_parent_name - template 'semaphore.yml.erb', '.semaphore/semaphore.yml' - - if yes?('Does your project have JavaScript tests and/or eslint?') - uncomment_lines('.semaphore/semaphore.yml', '- yarn test') - uncomment_lines('.semaphore/semaphore.yml', '- yarn run eslint') - end - end - - def create_deplyment_commands - default_heroku_prefix = (Rails.application.class.try(:parent_name) || Rails.application.class.module_parent_name).underscore.dasherize - - @heroku_prefix = ask('Enter the heroku project prefix', default: default_heroku_prefix) - - template 'heroku-deployment-commands.sh', '.semaphore/heroku-deployment-commands.sh' - template 'staging-deploy.yml.erb', '.semaphore/staging-deploy.yml' - template 'production-deploy.yml.erb', '.semaphore/production-deploy.yml' - end - end -end diff --git a/lib/generators/rolemodel/semaphore/templates/heroku-deployment-commands.sh b/lib/generators/rolemodel/semaphore/templates/heroku-deployment-commands.sh deleted file mode 100644 index b23afa98..00000000 --- a/lib/generators/rolemodel/semaphore/templates/heroku-deployment-commands.sh +++ /dev/null @@ -1,3 +0,0 @@ -checkout --use-cache -heroku git:remote -a $HEROKU_APP_NAME -git push heroku -f $SEMAPHORE_GIT_BRANCH:main diff --git a/lib/generators/rolemodel/semaphore/templates/production-deploy.yml.erb b/lib/generators/rolemodel/semaphore/templates/production-deploy.yml.erb deleted file mode 100644 index 42bddf27..00000000 --- a/lib/generators/rolemodel/semaphore/templates/production-deploy.yml.erb +++ /dev/null @@ -1,17 +0,0 @@ -version: v1.0 -name: Deploy to Production -agent: - machine: - type: e1-standard-2 - os_image: ubuntu1804 -blocks: - - name: Deploy - task: - secrets: - - name: heroku_http_auth - env_vars: - - name: HEROKU_APP_NAME - value: <%= "#{@heroku_prefix}-production" %> - jobs: - - name: 'Push code to Production' - commands_file: heroku-deployment-commands.sh diff --git a/lib/generators/rolemodel/semaphore/templates/semaphore.yml.erb b/lib/generators/rolemodel/semaphore/templates/semaphore.yml.erb deleted file mode 100644 index 1a2daf96..00000000 --- a/lib/generators/rolemodel/semaphore/templates/semaphore.yml.erb +++ /dev/null @@ -1,145 +0,0 @@ -version: v1.0 -name: <%= @project_name %> -agent: - machine: - type: e1-standard-2 - os_image: ubuntu2004 -auto_cancel: - running: - when: branch != 'main' -global_job_config: - env_vars: - - name: RAILS_ENV - value: test - - name: HONEYBADGER_SOURCE_MAP_DISABLED - value: 'true' - - name: DATABASE_URL - value: 'postgres://postgres:@0.0.0.0/<%= Rails.application.config.database_configuration['test']['database'] %>' - - name: GOOGLE_CHROME_BIN - value: 'google-chrome' - # https://github.com/renderedtext/test-boosters#rspec-booster - - name: TB_RSPEC_OPTIONS - value: '--format RspecJunitFormatter --out tmp/test_results/rspec_junit.xml --format documentation' - secrets: - - name: rmsbackup_ssh_private_key - prologue: - commands: - # Add keys - - ssh-keyscan -H github.com >> ~/.ssh/known_hosts - - chmod 600 ~/.ssh/id_rsa_rmsbackup_ssh_private_key - - ssh-add ~/.ssh/id_rsa_rmsbackup_ssh_private_key - - - checkout - - # Ruby setup - - sem-service start postgres 14 - - sem-version ruby $(cat .ruby-version) - - gem install bundler --no-document - - bundle config set deployment 'true' - - bundle config set path 'vendor/bundle' - - # Node setup - - sem-version node $(cat .node-version) - - npm i -g yarn - - # Cache setup - - cache restore gems-$(checksum .ruby-version)-$(checksum Gemfile.lock) - - cache restore yarn-cache-$(checksum .node-version)-$(checksum yarn.lock) - - cache restore yarn-node-modules-$(checksum .node-version)-$(checksum yarn.lock) -blocks: - - name: Build - dependencies: [] - task: - jobs: - - name: Install Dependencies - commands: - - bundle check || bundle install - - yarn check || yarn install - epilogue: - commands: - - cache store gems-$(checksum .ruby-version)-$(checksum Gemfile.lock) vendor/bundle - - cache store yarn-cache-$(checksum .node-version)-$(checksum yarn.lock) /home/semaphore/.cache/yarn - - cache store yarn-node-modules-$(checksum .node-version)-$(checksum yarn.lock) node_modules - - name: Ruby/JS/Linting - execution_time_limit: - minutes: 20 - dependencies: - - Build - task: - jobs: - - name: Ruby Test/JS Tests/Audit/Linting - commands: - - bundle exec rails db:setup db:test:prepare - - bundle exec rspec --exclude-pattern "spec/system/**/*_spec.rb" --format RspecJunitFormatter --out tmp/test_results/rspec_junit.xml --format documentation - - bundle exec bundle-audit update - - bundle exec bundle-audit check - # - bundle exec rubocop --fail-level warning --display-only-fail-level-offenses - # - yarn test - # - yarn run eslint - epilogue: - always: - commands: - - test-results publish tmp/test_results - on_fail: - commands: - - artifact push job log/test.log - - name: Assets Compile - dependencies: - - Build - execution_time_limit: - minutes: 20 - task: - jobs: - - name: Webpack Compile and Store Assets - commands: - - bundle check || bundle install --without legacy_data_migration - - yarn check || yarn install - - yarn build - - cache store assets-public-$SEMAPHORE_WORKFLOW_ID public - - name: System Tests - dependencies: - - Assets Compile - execution_time_limit: - minutes: 20 - task: - agent: - machine: - type: e1-standard-2 - os_image: ubuntu2004 - env_vars: - - name: TEST_BOOSTERS_RSPEC_TEST_FILE_PATTERN - value: spec/system/**/*_spec.rb - prologue: - commands: - - gem install semaphore_test_boosters - - bundle exec rails db:setup db:test:prepare - - cache restore assets-public-$SEMAPHORE_WORKFLOW_ID - jobs: - - name: Ruby System Tests - parallelism: 2 - commands: - - rspec_booster --job $SEMAPHORE_JOB_INDEX/$SEMAPHORE_JOB_COUNT - epilogue: - always: - commands: - - test-results publish tmp/test_results - - cache delete assets-public-$SEMAPHORE_WORKFLOW_ID - on_fail: - commands: - - artifact push job log/test.log - - artifact push workflow tmp/capybara -after_pipeline: - task: - jobs: - - name: Publish Results - commands: - - test-results gen-pipeline-report -promotions: - - name: Deploy Staging - pipeline_file: staging-deploy.yml - auto_promote_on: - - result: passed - branch: - - main - - name: Deploy Production - pipeline_file: production-deploy.yml diff --git a/lib/generators/rolemodel/semaphore/templates/staging-deploy.yml.erb b/lib/generators/rolemodel/semaphore/templates/staging-deploy.yml.erb deleted file mode 100644 index b5545ffe..00000000 --- a/lib/generators/rolemodel/semaphore/templates/staging-deploy.yml.erb +++ /dev/null @@ -1,17 +0,0 @@ -version: v1.0 -name: Deploy to Staging -agent: - machine: - type: e1-standard-2 - os_image: ubuntu1804 -blocks: - - name: Deploy - task: - secrets: - - name: heroku_http_auth - env_vars: - - name: HEROKU_APP_NAME - value: <%= "#{@heroku_prefix}-staging" %> - jobs: - - name: 'Push code to staging' - commands_file: heroku-deployment-commands.sh diff --git a/lib/generators/rolemodel/sentry/README.md b/lib/generators/rolemodel/sentry/README.md new file mode 100644 index 00000000..3c5df4b0 --- /dev/null +++ b/lib/generators/rolemodel/sentry/README.md @@ -0,0 +1,37 @@ +# Sentry Generator + +## What you get + +Error monitoring and performance tracing via [Sentry](https://sentry.io) for both the Ruby and JavaScript sides of the app. + +### Ruby + +* The `sentry-rails` gem +* `config/initializers/sentry.rb` with sensible defaults: traces sampling, profiling, PII filtering via Rails' parameter filter, noisy-exception exclusion, and health-check transaction filtering +* Sentry user context wired into `app/controllers/application_controller.rb` via a `set_sentry_user` before_action (Devise-friendly, gated on `user_signed_in?`) + +### JavaScript + +* `@sentry/browser` and `@sentry/webpack-plugin` dependencies +* `app/javascript/initializers/sentry.js`, which initializes Sentry in production and staging only (avoids ad-blocker noise in development) and attaches the current user from a `current-user-id` meta tag +* The `sentryWebpackPlugin` wired into `webpack.config.js` to upload source maps in production + +Depends on the `rolemodel:webpack` generator having already created `webpack.config.js` and `app/javascript/application.js`. + +## Coupling with webpack + +When `rolemodel:webpack` is already recorded in the app's generator registry, +running the sentry generator automatically wires the `sentryWebpackPlugin` into +`webpack.config.js` via the shared `rolemodel:sentry_webpack` hook sub-generator. + +* Pass `--no-sentry-webpack` to suppress this wiring. +* Set `g.rolemodel sentry_webpack: false` in `config/initializers/rolemodel_generators.rb` + for a persistent opt-out. +* If webpack is installed *after* sentry, running `rolemodel:webpack` will + likewise wire the Sentry plugin automatically. + +## After running + +* Update the `project` and `applicationKey` in `webpack.config.js`, and the matching `filterKeys` in `app/javascript/initializers/sentry.js`, to match your Sentry project. +* Set the `SENTRY_DSN`, `SENTRY_ENVIRONMENT`, and `SENTRY_AUTH_TOKEN` environment variables. +* Render a `current-user-id` meta tag in your layout so the browser SDK can attach user context. diff --git a/lib/generators/rolemodel/sentry/USAGE b/lib/generators/rolemodel/sentry/USAGE new file mode 100644 index 00000000..35cc57a9 --- /dev/null +++ b/lib/generators/rolemodel/sentry/USAGE @@ -0,0 +1,13 @@ +Description: + Sets up Sentry error monitoring for both Ruby and JavaScript + +Example: + rails generate rolemodel:sentry + + This will: + Add the sentry-rails gem + Create config/initializers/sentry.rb + Add Sentry user context to app/controllers/application_controller.rb + Add @sentry/browser and @sentry/webpack-plugin JS dependencies + Create app/javascript/initializers/sentry.js + Wire the Sentry webpack plugin into webpack.config.js diff --git a/lib/generators/rolemodel/sentry/sentry_generator.rb b/lib/generators/rolemodel/sentry/sentry_generator.rb new file mode 100644 index 00000000..e1a82158 --- /dev/null +++ b/lib/generators/rolemodel/sentry/sentry_generator.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +module Rolemodel + class SentryGenerator < GeneratorBase + source_root File.expand_path('templates', __dir__) + + JS_DEPS = %w[ + @sentry/browser + @sentry/webpack-plugin + ] + + def install_gem + say 'Adding sentry-rails gem', :green + + bundle_command 'add sentry-rails' + end + + def install_profiler_gem + say 'Adding stackprof gem for Sentry profiling', :green + + # config/initializers/sentry.rb sets profiles_sample_rate; without stackprof + # the SDK logs a warning on every boot and profiling is silently disabled. + # stackprof is a native MRI extension, so restrict it to compatible platforms. + gem 'stackprof', platforms: :ruby + run_bundle + end + + def add_ruby_initializer + say 'Setting up Sentry for Ruby error reporting', :green + + copy_file 'config/initializers/sentry.rb' + end + + def add_user_context + say 'Adding Sentry user context to ApplicationController', :green + + inject_into_class 'app/controllers/application_controller.rb', 'ApplicationController', + " before_action :set_sentry_user\n" + + inject_into_file 'app/controllers/application_controller.rb', before: /^end\b/ do + <<-RUBY + + private + + # Guarded so it works whether or not an authentication generator has been run. + def set_sentry_user + return unless respond_to?(:current_user) && current_user + + Sentry.set_user(id: current_user.id) + end + RUBY + end + end + + def add_js_dependencies + say 'Adding Sentry JS dependencies to package.json', :green + + ensure_yarn + run "yarn add --dev #{JS_DEPS.join(' ')}" + end + + def add_js_initializer + say 'Setting up Sentry for JS error reporting', :green + + copy_file 'app/javascript/initializers/sentry.js' + append_to_file 'app/javascript/application.js', <<~JS + import './initializers/sentry' + JS + end + + # Optional coupling: when rolemodel:webpack is already installed, wire the + # Sentry plugin into its webpack.config.js. Declared after the action + # methods so the wiring runs once the config file it edits is present. + coupling_hook :sentry_webpack, with: :webpack + + def finishing_notes + say <<~NOTES + + *** Update the sentryWebpackPlugin `project` and `applicationKey` in webpack.config.js, + and the matching `filterKeys` in app/javascript/initializers/sentry.js, to your Sentry project. + + *** Set the SENTRY_DSN, SENTRY_ENVIRONMENT, and SENTRY_AUTH_TOKEN environment variables. + NOTES + end + end +end diff --git a/lib/generators/rolemodel/sentry/templates/app/javascript/initializers/sentry.js b/lib/generators/rolemodel/sentry/templates/app/javascript/initializers/sentry.js new file mode 100644 index 00000000..4d88614d --- /dev/null +++ b/lib/generators/rolemodel/sentry/templates/app/javascript/initializers/sentry.js @@ -0,0 +1,34 @@ +import * as Sentry from '@sentry/browser' + +// Only initialize Sentry in production and staging environments +// This avoids issues with ad blockers during local development +const environment = process.env.SENTRY_ENVIRONMENT || process.env.RAILS_ENV +const shouldInitialize = environment === 'production' || environment === 'staging' + +if (shouldInitialize) { + Sentry.init({ + dsn: process.env.SENTRY_DSN, + environment: environment, + sendDefaultPii: false, + tracesSampleRate: 0.1, + replaysOnErrorSampleRate: 1.0, + integrations: [ + Sentry.thirdPartyErrorFilterIntegration({ + // Must match the applicationKey in the sentryWebpackPlugin configuration + filterKeys: ['app-frontend'], + behaviour: 'drop-error-if-exclusively-contains-third-party-frames' + }) + ], + + // Capture user context from meta tags rendered by Rails + beforeSend(event) { + const userId = document.querySelector('meta[name="current-user-id"]')?.content + + if (userId) { + event.user = { id: userId } + } + + return event + } + }) +} diff --git a/lib/generators/rolemodel/sentry/templates/config/initializers/sentry.rb b/lib/generators/rolemodel/sentry/templates/config/initializers/sentry.rb new file mode 100644 index 00000000..b85dad7f --- /dev/null +++ b/lib/generators/rolemodel/sentry/templates/config/initializers/sentry.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +Sentry.init do |config| + config.dsn = ENV['SENTRY_DSN'] + config.enable_logs = true + config.debug = Rails.env.local? + + # Traces sampling — 5% for HTTP requests, 10% for other transactions + config.traces_sampler = lambda do |sampling_context| + transaction_context = sampling_context[:transaction_context] + + case transaction_context[:op] + when /^http/ + 0.05 + else + 0.10 + end + end + + # Enable profiling at 100% of sampled transactions + config.profiles_sample_rate = 1.0 + + # Breadcrumbs configuration + config.breadcrumbs_logger = [:sentry_logger, :http_logger] + + # Exclude common noisy exceptions + config.excluded_exceptions += [ + 'ActiveRecord::RecordNotFound', + 'ActionController::RoutingError', + ] + + # Include local variables for debugging + config.include_local_variables = true + + # Filter out health check transactions + config.before_send_transaction = lambda do |event, _hint| + return nil if event.transaction&.match?(/health|ping|metrics/) + event + end + + # Filter sensitive data using Rails' parameter filter + filter = ActiveSupport::ParameterFilter.new(Rails.application.config.filter_parameters) + config.send_default_pii = false + + config.before_send = lambda do |event, _hint| + if event.extra + event.extra = filter.filter(event.extra) + end + + if event.user + event.user = filter.filter(event.user) + end + + if event.contexts + event.contexts = filter.filter(event.contexts) + end + + event + end + + config.enabled_patches << :logger + config.std_lib_logger_filter = proc do |_logger, _message, severity| + [:warn, :error, :fatal].include?(severity) + end + + config.rails.register_error_subscriber = Rails.env.production? + config.rails.structured_logging.subscribers = config.rails.structured_logging.subscribers.slice(:action_controller) +end diff --git a/lib/generators/rolemodel/sentry_webpack/README.md b/lib/generators/rolemodel/sentry_webpack/README.md new file mode 100644 index 00000000..92c9bf85 --- /dev/null +++ b/lib/generators/rolemodel/sentry_webpack/README.md @@ -0,0 +1,26 @@ +# Sentry Webpack Generator + +Wiring-only hook sub-generator that injects the `sentryWebpackPlugin` into +`webpack.config.js` so production builds upload source maps to Sentry. + +## Why it exists + +The sentry and webpack generators are coupled: whichever one is installed +second should wire Sentry into the webpack config. Rather than duplicating that +wiring on both generators, both declare a `coupling_hook :sentry_webpack`, and +this sub-generator is the single shared target that does the wiring. + +You normally never run this directly. It fires automatically when: + +* you run `rolemodel:sentry` in an app where `rolemodel:webpack` is recorded, or +* you run `rolemodel:webpack` in an app where `rolemodel:sentry` is recorded. + +Pass `--sentry-webpack` to force it, or `--no-sentry-webpack` to suppress it. + +## Behavior + +* Idempotent: a no-op when there is no `webpack.config.js` or the plugin is + already wired. +* Not recorded in the registry — it is not an installable generator on its own. +* After running, update the `project` and `applicationKey` in + `webpack.config.js` to match your Sentry project. diff --git a/lib/generators/rolemodel/sentry_webpack/USAGE b/lib/generators/rolemodel/sentry_webpack/USAGE new file mode 100644 index 00000000..de211cf3 --- /dev/null +++ b/lib/generators/rolemodel/sentry_webpack/USAGE @@ -0,0 +1,10 @@ +Description: + Wires the Sentry webpack plugin into webpack.config.js. This is a hook + sub-generator invoked automatically by the sentry<->webpack coupling; you + normally do not run it directly. + +Example: + rails generate rolemodel:sentry_webpack + + This will inject the sentryWebpackPlugin into webpack.config.js (a no-op if + webpack.config.js is missing or the plugin is already wired). diff --git a/lib/generators/rolemodel/sentry_webpack/sentry_webpack_generator.rb b/lib/generators/rolemodel/sentry_webpack/sentry_webpack_generator.rb new file mode 100644 index 00000000..0c04190f --- /dev/null +++ b/lib/generators/rolemodel/sentry_webpack/sentry_webpack_generator.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +module Rolemodel + # Wiring-only hook sub-generator: injects the Sentry webpack plugin into + # webpack.config.js. It is the shared target of the sentry<->webpack + # coupling_hook on both the sentry and webpack generators, so the wiring + # lives in exactly one place regardless of which side is installed second. + # + # Exempt from registry recording (it is not an installable generator in its + # own right) and idempotent: a no-op when there is no webpack.config.js or + # the plugin is already wired. + class SentryWebpackGenerator < GeneratorBase + skip_registry_entry! + + def wire_sentry_into_webpack + config = File.expand_path('webpack.config.js', destination_root) + return unless File.exist?(config) + return if File.read(config).include?('@sentry/webpack-plugin') + + say 'Wiring Sentry into webpack.config.js', :green + + inject_into_file 'webpack.config.js', + "import { sentryWebpackPlugin } from '@sentry/webpack-plugin'\n", + after: "import CssMinimizerPlugin from 'css-minimizer-webpack-plugin'\n" + + inject_into_file 'webpack.config.js', after: "'process.env.RAILS_ENV': JSON.stringify(process.env.RAILS_ENV),\n" do + <<-JS + 'process.env.SENTRY_DSN': JSON.stringify(process.env.SENTRY_DSN), + 'process.env.SENTRY_ENVIRONMENT': JSON.stringify(process.env.SENTRY_ENVIRONMENT), + 'process.env.SENTRY_AUTH_TOKEN': JSON.stringify(process.env.SENTRY_AUTH_TOKEN), + JS + end + + gsub_file 'webpack.config.js', " })\n ].filter(Boolean)", <<-JS.chomp + }), + + // Upload source maps to Sentry in production for easier debugging + mode === 'production' && + !process.env.CI && + sentryWebpackPlugin({ + authToken: process.env.SENTRY_AUTH_TOKEN, + org: 'rolemodel-software', + project: '#{project_name}', + telemetry: false, + applicationKey: 'app-frontend' + }) + ].filter(Boolean) + JS + end + + private + + # Sentry project slug for the webpack plugin. Guarded so the generator is + # safe to load and run in unbooted contexts (falls back to a placeholder + # the finishing notes tell the user to replace). + def project_name + return 'app-frontend' unless defined?(::Rails) && ::Rails.application + + ::Rails.application.class.module_parent_name.underscore + end + end +end diff --git a/lib/generators/rolemodel/simple_form/README.md b/lib/generators/rolemodel/simple_form/README.md index cd2e56d4..e8c1867d 100644 --- a/lib/generators/rolemodel/simple_form/README.md +++ b/lib/generators/rolemodel/simple_form/README.md @@ -8,3 +8,9 @@ * SimpleForm scaffold generation template Adds SimpleForm for simplified and unified styling that automatically wraps inputs with classes + +## Custom inputs + +All standard custom inputs are included by default. The generator prompts you to +confirm whether to include the `tailored_select` custom input, since not every +project uses the tailored-select web component. diff --git a/lib/generators/rolemodel/simple_form/USAGE b/lib/generators/rolemodel/simple_form/USAGE index 3be4ad28..a4f0750a 100644 --- a/lib/generators/rolemodel/simple_form/USAGE +++ b/lib/generators/rolemodel/simple_form/USAGE @@ -9,3 +9,6 @@ Example: config/locales/simple_form.en.yml simple_form input wrapper classes simple_form template file + + Pass --tailored_select to also install the experimental tailored_select + component input (via the rolemodel:tailored_select generator). diff --git a/lib/generators/rolemodel/simple_form/simple_form_generator.rb b/lib/generators/rolemodel/simple_form/simple_form_generator.rb index 377b1310..5f7af266 100644 --- a/lib/generators/rolemodel/simple_form/simple_form_generator.rb +++ b/lib/generators/rolemodel/simple_form/simple_form_generator.rb @@ -4,6 +4,9 @@ module Rolemodel class SimpleFormGenerator < GeneratorBase source_root File.expand_path('templates', __dir__) + class_option :tailored_select, type: :boolean, default: false, + desc: 'Install the tailored_select experimental component input' + def add_gem Bundler.with_unbundled_env do bundle_command 'add simple_form' @@ -16,5 +19,15 @@ def add_files copy_file 'config/initializers/simple_form.rb' copy_file 'config/locales/simple_form.en.yml' end + + def install_tailored_select + return unless options.tailored_select? + + # The tailored_select generator owns the input template and installs it + # here because simple_form is now present. simple_form is not recorded in + # the registry until this run completes, so pass the switch explicitly + # rather than relying on the child's registry-based default. + generate 'rolemodel:tailored_select', '--simple-form-input' + end end end diff --git a/lib/generators/rolemodel/simple_form/templates/app/inputs/switch_checkbox_input.rb b/lib/generators/rolemodel/simple_form/templates/app/inputs/switch_checkbox_input.rb index 413c13f4..56d36dbb 100644 --- a/lib/generators/rolemodel/simple_form/templates/app/inputs/switch_checkbox_input.rb +++ b/lib/generators/rolemodel/simple_form/templates/app/inputs/switch_checkbox_input.rb @@ -14,7 +14,7 @@ # <%= f.input :my_field, as: :switch_checkbox, label_after_input: true %> # <%= f.input :my_field, as: :switch_checkbox, wrapper: :switch_wrapper %> class SwitchCheckboxInput < SimpleForm::Inputs::BooleanInput - def input(wrapper_options = nil) + def input(wrapper_options = nil) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength merged_input_options = merge_wrapper_options(input_html_options, wrapper_options) switch_group = template.content_tag(:div, class: "switch #{'switch--small' if options[:small]}") do diff --git a/lib/generators/rolemodel/tailored_select/README.md b/lib/generators/rolemodel/tailored_select/README.md index 20a7d26d..8184ac87 100644 --- a/lib/generators/rolemodel/tailored_select/README.md +++ b/lib/generators/rolemodel/tailored_select/README.md @@ -5,3 +5,16 @@ ## What you get The [Tailored Select](https://github.com/RoleModel/tailored-select) web component + +## Coupling with simple_form + +When `rolemodel:simple_form` is recorded in the app's generator registry, +the tailored_select generator installs its SimpleForm input template +(`app/inputs/tailored_select_input.rb`) automatically. + +* Standalone install (without simple_form): installs only the JS package. +* Pass `--simple-form-input` to force the input installation regardless of + registry state. +* Pass `--no-simple-form-input` to suppress it. +* The simple_form generator can delegate this explicitly with + `--tailored-select`, which forces the input during that child run. diff --git a/lib/generators/rolemodel/tailored_select/USAGE b/lib/generators/rolemodel/tailored_select/USAGE index 57754b53..c469091f 100644 --- a/lib/generators/rolemodel/tailored_select/USAGE +++ b/lib/generators/rolemodel/tailored_select/USAGE @@ -1,5 +1,6 @@ Description: - runs the tailored select generator + Installs the @rolemodel/tailored-select package. If the app is using + SimpleForm, it also installs the matching tailored_select SimpleForm input. Example: rails generate rolemodel:tailored_select diff --git a/lib/generators/rolemodel/tailored_select/tailored_select_generator.rb b/lib/generators/rolemodel/tailored_select/tailored_select_generator.rb index 47f2e79b..51d978dd 100644 --- a/lib/generators/rolemodel/tailored_select/tailored_select_generator.rb +++ b/lib/generators/rolemodel/tailored_select/tailored_select_generator.rb @@ -4,10 +4,32 @@ module Rolemodel class TailoredSelectGenerator < GeneratorBase source_root File.expand_path('templates', __dir__) + # The simple_form input is only useful once simple_form is installed. Its + # default reflects whether simple_form is recorded in the app's registry; + # --simple-form-input / --no-simple-form-input override per invocation, and + # simple_form's own delegation passes it explicitly (that run records + # simple_form only at completion, after this child has already run). + class_option :simple_form_input, type: :boolean, default: Registry.recorded?(:simple_form), + desc: 'Install the tailored_select SimpleForm input' + def add_tailored_select_package say 'Installing Tailored Select package', :green + ensure_yarn run 'yarn add @rolemodel/tailored-select' end + + def add_simple_form_input + unless options.simple_form_input? + say 'Skipping the Tailored Select SimpleForm input — simple_form is not recorded in ' \ + "#{Registry::INITIALIZER_PATH}. Re-run after rolemodel:simple_form, or pass --simple-form-input.", + :yellow + return + end + + say 'Installing the Tailored Select SimpleForm input', :green + + copy_file 'app/inputs/tailored_select_input.rb' + end end end diff --git a/lib/generators/rolemodel/simple_form/templates/app/inputs/tailored_select_input.rb b/lib/generators/rolemodel/tailored_select/templates/app/inputs/tailored_select_input.rb similarity index 100% rename from lib/generators/rolemodel/simple_form/templates/app/inputs/tailored_select_input.rb rename to lib/generators/rolemodel/tailored_select/templates/app/inputs/tailored_select_input.rb diff --git a/lib/generators/rolemodel/testing/all_generator.rb b/lib/generators/rolemodel/testing/all_generator.rb index db1b1515..e6529af2 100644 --- a/lib/generators/rolemodel/testing/all_generator.rb +++ b/lib/generators/rolemodel/testing/all_generator.rb @@ -3,6 +3,9 @@ module Testing class AllGenerator < GeneratorBase source_root File.expand_path('templates', __dir__) + # Composite generator: orchestrates other generators, not recorded itself. + skip_registry_entry! + class_option :js_runner, type: :boolean, default: false, desc: 'Include jasmine-playwright-runner for browser testing' def run_all_the_generators diff --git a/lib/generators/rolemodel/testing/factory_bot/templates/support/factory_bot.rb b/lib/generators/rolemodel/testing/factory_bot/templates/support/factory_bot.rb index c7890e49..2e7665cc 100644 --- a/lib/generators/rolemodel/testing/factory_bot/templates/support/factory_bot.rb +++ b/lib/generators/rolemodel/testing/factory_bot/templates/support/factory_bot.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + RSpec.configure do |config| config.include FactoryBot::Syntax::Methods end diff --git a/lib/generators/rolemodel/testing/jasmine_playwright/jasmine_playwright_generator.rb b/lib/generators/rolemodel/testing/jasmine_playwright/jasmine_playwright_generator.rb index 21ad744f..58e51ba1 100644 --- a/lib/generators/rolemodel/testing/jasmine_playwright/jasmine_playwright_generator.rb +++ b/lib/generators/rolemodel/testing/jasmine_playwright/jasmine_playwright_generator.rb @@ -18,8 +18,8 @@ def fail_without_github_token raise Thor::InvocationError, 'a --github_package_token option or GITHUB_PACKAGES_TOKEN environment variable is required' if options[:github_package_token].blank? end - def yarn_init_unless_package_json_exists - run 'yarn init' unless File.exist?(File.expand_path('package.json', destination_root)) + def enable_corepack_and_yarn + ensure_yarn end def add_browser_test_script diff --git a/lib/generators/rolemodel/testing/rspec/templates/spec/rails_helper.rb.tt b/lib/generators/rolemodel/testing/rspec/templates/spec/rails_helper.rb.tt index 88b6230d..ba6cc9c6 100644 --- a/lib/generators/rolemodel/testing/rspec/templates/spec/rails_helper.rb.tt +++ b/lib/generators/rolemodel/testing/rspec/templates/spec/rails_helper.rb.tt @@ -70,7 +70,10 @@ RSpec.configure do |config| prep_passed = system 'rails spec:prepare' ENV['ASSET_PRECOMPILE_SUCCESSFUL'] = 'true' - abort "\nYour assets didn't compile. Exiting WITHOUT running any tests. Review the output above to resolve any errors." unless prep_passed + unless prep_passed + abort "\nYour assets didn't compile. Exiting WITHOUT running any tests. " \ + 'Review the output above to resolve any errors.' + end end end end diff --git a/lib/generators/rolemodel/testing/rspec/templates/spec/support/capybara_drivers.rb.tt b/lib/generators/rolemodel/testing/rspec/templates/spec/support/capybara_drivers.rb.tt index 50fd5009..87dcc2bc 100644 --- a/lib/generators/rolemodel/testing/rspec/templates/spec/support/capybara_drivers.rb.tt +++ b/lib/generators/rolemodel/testing/rspec/templates/spec/support/capybara_drivers.rb.tt @@ -1,3 +1,5 @@ +# frozen_string_literal: true + RSpec.configure do |config| Capybara.register_driver :playwright_headless do |app| create_driver(app) diff --git a/lib/generators/rolemodel/testing/rspec/templates/spec/support/helpers.rb.tt b/lib/generators/rolemodel/testing/rspec/templates/spec/support/helpers.rb.tt index 7d456362..98b2db47 100644 --- a/lib/generators/rolemodel/testing/rspec/templates/spec/support/helpers.rb.tt +++ b/lib/generators/rolemodel/testing/rspec/templates/spec/support/helpers.rb.tt @@ -1,4 +1,6 @@ -Dir[Rails.root.join('spec', 'support', 'helpers', '**', '*.rb')].each { |f| require f } +# frozen_string_literal: true + +Rails.root.glob('spec/support/helpers/**/*.rb').each { |f| require f } # this is a place to pull in all your app specific DSL methods. diff --git a/lib/generators/rolemodel/testing/rspec/templates/spec/support/helpers/action_cable_helper.rb b/lib/generators/rolemodel/testing/rspec/templates/spec/support/helpers/action_cable_helper.rb index 91f990bb..d62805c0 100644 --- a/lib/generators/rolemodel/testing/rspec/templates/spec/support/helpers/action_cable_helper.rb +++ b/lib/generators/rolemodel/testing/rspec/templates/spec/support/helpers/action_cable_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ActionCableHelper def wait_for_stream_connection expect(page).to have_selector('turbo-cable-stream-source[connected]', visible: false) diff --git a/lib/generators/rolemodel/testing/rspec/templates/spec/support/helpers/test_element_helper.rb b/lib/generators/rolemodel/testing/rspec/templates/spec/support/helpers/test_element_helper.rb index b1e71808..61e2f008 100644 --- a/lib/generators/rolemodel/testing/rspec/templates/spec/support/helpers/test_element_helper.rb +++ b/lib/generators/rolemodel/testing/rspec/templates/spec/support/helpers/test_element_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module TestElementHelper include ActionView::RecordIdentifier diff --git a/lib/generators/rolemodel/testing/vitest/vitest_generator.rb b/lib/generators/rolemodel/testing/vitest/vitest_generator.rb index cd2134ca..5564974c 100644 --- a/lib/generators/rolemodel/testing/vitest/vitest_generator.rb +++ b/lib/generators/rolemodel/testing/vitest/vitest_generator.rb @@ -23,6 +23,7 @@ def update_test_script def add_dev_dependencies say 'Adding new dev dependency to package.json', :green + ensure_yarn run "yarn add --dev #{DEV_DEPENDENCIES.join(' ')}" end diff --git a/lib/generators/rolemodel/ui_components/all_generator.rb b/lib/generators/rolemodel/ui_components/all_generator.rb index 0f9fcf26..0b9bfc42 100644 --- a/lib/generators/rolemodel/ui_components/all_generator.rb +++ b/lib/generators/rolemodel/ui_components/all_generator.rb @@ -3,6 +3,9 @@ module UiComponents class AllGenerator < GeneratorBase source_root File.expand_path('templates', __dir__) + # Composite generator: orchestrates other generators, not recorded itself. + skip_registry_entry! + def run_all_the_generators # no guaranteed order to this list with Dir.glob Dir.glob(Pathname(File.expand_path('.', __dir__)).join('*', '*generator.rb')).each do |generator| diff --git a/lib/generators/rolemodel/ui_components/modals/modals_generator.rb b/lib/generators/rolemodel/ui_components/modals/modals_generator.rb index d18b61a0..87991b16 100644 --- a/lib/generators/rolemodel/ui_components/modals/modals_generator.rb +++ b/lib/generators/rolemodel/ui_components/modals/modals_generator.rb @@ -7,6 +7,7 @@ class ModalsGenerator < GeneratorBase def turbo_confirm say 'Installing Turbo Confirm package', :green + ensure_yarn run 'yarn add @rolemodel/turbo-confirm' end diff --git a/lib/generators/rolemodel/ui_components/modals/templates/app/helpers/turbo_frame_link_helper.rb b/lib/generators/rolemodel/ui_components/modals/templates/app/helpers/turbo_frame_link_helper.rb index 4f16d98c..c1b398c6 100644 --- a/lib/generators/rolemodel/ui_components/modals/templates/app/helpers/turbo_frame_link_helper.rb +++ b/lib/generators/rolemodel/ui_components/modals/templates/app/helpers/turbo_frame_link_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module TurboFrameLinkHelper def panel_link_to(...) link_to_frame('panel', ...) diff --git a/lib/generators/rolemodel/ui_components/navbar/navbar_generator.rb b/lib/generators/rolemodel/ui_components/navbar/navbar_generator.rb index 3538ffb4..a48b15f1 100644 --- a/lib/generators/rolemodel/ui_components/navbar/navbar_generator.rb +++ b/lib/generators/rolemodel/ui_components/navbar/navbar_generator.rb @@ -25,6 +25,7 @@ def insert_navbar_before_content def install_shoelace say 'Installing Shoelace package', :green + ensure_yarn run 'yarn add @shoelace-style/shoelace' end diff --git a/lib/generators/rolemodel/webpack/README.md b/lib/generators/rolemodel/webpack/README.md index e0fd16a5..f621bc48 100644 --- a/lib/generators/rolemodel/webpack/README.md +++ b/lib/generators/rolemodel/webpack/README.md @@ -5,4 +5,16 @@ * Webpack v5 * Uses `esbuild-loader` instead of Babel to transpile JS * Uses PostCSS to compile CSS and SCSS -* Honeybadger error monitoring for JS. Run `bundle exec honeybadger install [YOUR API KEY HERE]` to setup Honeybadger for Ruby. + +## Coupling with sentry + +When `rolemodel:sentry` is already recorded in the app's generator registry, +running the webpack generator automatically wires the `sentryWebpackPlugin` into +the freshly created `webpack.config.js` via the shared `rolemodel:sentry_webpack` +hook sub-generator. + +* Pass `--no-sentry-webpack` to suppress this wiring. +* Set `g.rolemodel sentry_webpack: false` in `config/initializers/rolemodel_generators.rb` + for a persistent opt-out. +* If sentry is installed *after* webpack, running `rolemodel:sentry` will + likewise wire the Sentry plugin automatically. diff --git a/lib/generators/rolemodel/webpack/templates/app/javascript/initializers/honeybadger.js b/lib/generators/rolemodel/webpack/templates/app/javascript/initializers/honeybadger.js deleted file mode 100644 index ca7a9705..00000000 --- a/lib/generators/rolemodel/webpack/templates/app/javascript/initializers/honeybadger.js +++ /dev/null @@ -1,26 +0,0 @@ -import Honeybadger from '@honeybadger-io/js' - -if (process.env.RAILS_ENV === 'production') { - Honeybadger.configure({ - apiKey: process.env.HONEYBADGER_API_KEY, - environment: process.env.HONEYBADGER_ENV,// ‘production’ or ‘review-app’ from app.json - revision: process.env.SOURCE_VERSION // provided by heroku - }) - - const IGNORE_ERRORS = [ - /AbortError/, - /UnhandledPromiseRejectionWarning: {}/, - /UnhandledPromiseRejectionWarning.*Load failed/, - /UnhandledPromiseRejectionWarning: Object Not Found Matching/, - /UnhandledPromiseRejectionWarning.*Failed to fetch/, - /ResizeObserver loop completed with undelivered notifications./, - ] - - Honeybadger.beforeNotify((notice) => { - for (const ignoreError of IGNORE_ERRORS) { - if (ignoreError.test(notice.message)) { - return false - } - } - }) -} diff --git a/lib/generators/rolemodel/webpack/templates/webpack.config.js b/lib/generators/rolemodel/webpack/templates/webpack.config.js index 2b91953d..af66c4c7 100644 --- a/lib/generators/rolemodel/webpack/templates/webpack.config.js +++ b/lib/generators/rolemodel/webpack/templates/webpack.config.js @@ -1,7 +1,5 @@ import path from 'path' import webpack from 'webpack' -import TerserPlugin from 'terser-webpack-plugin' -import HoneybadgerSourceMapPlugin from '@honeybadger-io/webpack' import MiniCssExtractPlugin from 'mini-css-extract-plugin' import CssMinimizerPlugin from 'css-minimizer-webpack-plugin' @@ -73,7 +71,8 @@ export default { optimization: { minimize: mode === 'production', minimizer: [ - new TerserPlugin(), + // '...' keeps webpack 5's built-in JS minimizer (terser) + '...', new CssMinimizerPlugin() ] }, @@ -88,17 +87,8 @@ export default { // Replace ENV variables at build time new webpack.DefinePlugin({ - 'process.env.HONEYBADGER_API_KEY': JSON.stringify(process.env.HONEYBADGER_API_KEY), - 'process.env.HONEYBADGER_ENV': JSON.stringify(process.env.HONEYBADGER_ENV), 'process.env.RAILS_ENV': JSON.stringify(process.env.RAILS_ENV), 'process.env.SOURCE_VERSION': JSON.stringify(process.env.SOURCE_VERSION) - }), - - // Send source maps to HoneyBadger in production for easier debugging - (mode === 'production' && !process.env.CI) && new HoneybadgerSourceMapPlugin({ - apiKey: process.env.HONEYBADGER_API_KEY, - assetsUrl: process.env.ASSETS_URL, - revision: process.env.SOURCE_VERSION }) ].filter(Boolean) } diff --git a/lib/generators/rolemodel/webpack/webpack_generator.rb b/lib/generators/rolemodel/webpack/webpack_generator.rb index d62c37b6..0a0d13e9 100644 --- a/lib/generators/rolemodel/webpack/webpack_generator.rb +++ b/lib/generators/rolemodel/webpack/webpack_generator.rb @@ -3,8 +3,6 @@ class WebpackGenerator < GeneratorBase source_root File.expand_path('templates', __dir__) DEV_DEPS = %w[ - @honeybadger-io/webpack - @honeybadger-io/js esbuild esbuild-loader webpack @@ -31,6 +29,12 @@ def ensure_node_version create_file '.node-version', NODE_VERSION, force: true end + def enable_corepack_and_yarn + say 'Enabling Corepack and pinning the project to Yarn 4+', :green + + ensure_yarn + end + def force_node_to_use_es_modules say 'Configuring project to use ES Modules instead of CommonJS', :green @@ -50,15 +54,6 @@ def add_npm_packages run "yarn add --dev #{dependencies.join(' ')}" end - def honeybadger_setup - say 'Setting up Honeybadger for JS error reporting', :green - - copy_file 'app/javascript/initializers/honeybadger.js' - append_to_file 'app/javascript/application.js', <<~JS - import './initializers/honeybadger' - JS - end - def replace_css_entrypoint_with_scss say 'Replacing CSS entrypoint file with SCSS version', :green @@ -72,5 +67,10 @@ def add_webpack_config copy_file 'postcss.config.cjs', force: true copy_file 'webpack.config.js', force: true end + + # Optional coupling: when rolemodel:sentry is already installed, wire its + # plugin into the webpack.config.js this generator just created. Declared + # after the action methods so the config file exists before wiring runs. + coupling_hook :sentry_webpack, with: :sentry end end diff --git a/lib/rolemodel-rails.rb b/lib/rolemodel-rails.rb index 06e1ef26..2b61880f 100644 --- a/lib/rolemodel-rails.rb +++ b/lib/rolemodel-rails.rb @@ -2,6 +2,7 @@ module Rolemodel NODE_VERSION = '24.12.0' + YARN_VERSION = '4.13.0' RUBY_VERSION = '4.0.1' GEM_LIB = File.expand_path(__dir__) diff --git a/lib/rolemodel/generator_base.rb b/lib/rolemodel/generator_base.rb index 52cfc99b..31b27f70 100644 --- a/lib/rolemodel/generator_base.rb +++ b/lib/rolemodel/generator_base.rb @@ -3,15 +3,174 @@ require 'rails/generators' require 'rails/generators/bundle_helper' require_relative 'replace_content_helper' +require_relative 'registry' module Rolemodel + # Shared base class for all rolemodel_rails generators: common helpers plus + # the registry recording seam that runs after every successful invocation. class GeneratorBase < ::Rails::Generators::Base include ::Rails::Generators::BundleHelper, ReplaceContentHelper + # Exempts a generator from registry recording (composites, hook + # sub-generators, the seeding generator). Inherited by subclasses. + def self.skip_registry_entry! + @skip_registry_entry = true + end + + def self.skip_registry_entry? + if instance_variable_defined?(:@skip_registry_entry) + @skip_registry_entry + else + superclass.respond_to?(:skip_registry_entry?) && superclass.skip_registry_entry? + end + end + + # Declares an optional coupling with another generator: a boolean hook_for + # sharing +hook_key+ with the other side, defaulting to whether +with+ is + # recorded in the app's registry. The framework provides the rest: the + # --/--no- switch pair, the rolemodel: + # sub-generator lookup, and config-key precedence (an explicit + # `g.rolemodel : ...` entry overrides the computed default). + # + # The default resolves at class-DEFINITION time. That is correct because + # generator classes load after Rails::Generators.configure! in real runs — + # which is also why a coupling-declaring generator must never be + # eagerly required from the engine's generators block. + # + # Declaration position matters: hook invocations run where they are + # declared, so coupling_hook belongs AFTER the action methods it depends + # on — the wiring sub-generator must find the files those actions create. + def self.coupling_hook(hook_key, with:) + hook_for hook_key, type: :boolean, default: Registry.recorded?(with) + + skip_note = "Skipping #{hook_key} — #{with} is not recorded in #{Registry::INITIALIZER_PATH}. " \ + "Re-run this generator after installing rolemodel:#{with}, " \ + "or pass --#{hook_key.to_s.tr('_', '-')}." + + # Public on purpose: Thor registers it as the command immediately after + # the hook invocation, so the skip note prints exactly where the hook + # would have run. When the hook fires, its own invoke status line is + # the user-facing output and this stays silent. + class_eval <<~RUBY, __FILE__, __LINE__ + 1 + def #{hook_key}_skip_note + say #{skip_note.inspect}, :yellow unless options[:#{hook_key}] + end + RUBY + end + + # Declares a hard prerequisite: the generator aborts (via + # ensure_required_generators below) before any of its actions run unless + # +key+ is recorded in the app's registry. Deliberately exposes no CLI + # switch — hard prerequisites are not bypassable per-invocation; the + # remedies are installing the missing generator or seeding the registry. + def self.requires_generator(key) + @required_generator_keys = required_generator_keys + [key.to_sym] + end + + # Accumulated prerequisite keys, inherited by subclasses. + def self.required_generator_keys + if instance_variable_defined?(:@required_generator_keys) + @required_generator_keys + else + superclass.respond_to?(:required_generator_keys) ? superclass.required_generator_keys : [] + end + end + + # Thor's method_added turns public instance methods into commands, and + # invoke_all is NOT in THOR_RESERVED_WORDS — without no_commands this + # override would itself become a command and recurse. + no_commands do + # The registry recording seam: once a run completes without raising, + # record this generator in the consuming app's registry initializer + # (or remove the entry under `rails destroy`). An exception during the + # run propagates and records nothing. + def invoke_all + super.tap { update_registry_entry } + end + end + + # The requires_generator guard. Public on purpose: Thor turns it into a + # command, and because inherited commands run before subclass commands it + # executes before any subclass action for every generator. It shows up in + # every generator's command list, so the name must say what it does — + # and nothing else on GeneratorBase may be public. + def ensure_required_generators + return unless behavior == :invoke # rails destroy teardown is never blocked + + missing = self.class.required_generator_keys.reject { |key| Registry.recorded?(key) } + return if missing.empty? + + raise Thor::Error, <<~MESSAGE + #{self.class.namespace} requires #{missing.join(', ')} to be installed first, + but no entry is recorded in #{Registry::INITIALIZER_PATH}. + Install the missing generator (bin/rails generate rolemodel:#{missing.first}) and re-run, + or run bin/rails generate rolemodel:registry to seed the registry in an existing app. + MESSAGE + end + private + # based on https://github.com/rails/rails/blob/main/railties/lib/rails/generators/app_base.rb#L713 def run_bundle bundle_command("install --quiet", "BUNDLE_IGNORE_MESSAGES" => "1") end + + # Enable Corepack and pin the project to Yarn 4+ (instead of the classic + # Yarn 1.22). Idempotent, so any generator can call it before running a + # `yarn` command to guarantee the modern toolchain is in place. + def ensure_yarn + return if @yarn_ensured + + run 'corepack enable' + + unless File.exist?(File.expand_path('package.json', destination_root)) + create_file 'package.json', JSON.pretty_generate({}) + "\n" + end + + # Configure the node-modules linker so webpack, Playwright, and the Rails + # asset pipeline keep working (Yarn 4 defaults to Plug'n'Play otherwise). + create_file '.yarnrc.yml', "nodeLinker: node-modules\n", force: true + + pin_yarn_version + ignore_yarn_install_state + + @yarn_ensured = true + end + + def pin_yarn_version + modify_json_file('package.json') do |hash| + hash['packageManager'] = "yarn@#{YARN_VERSION}" + hash + end + end + + def ignore_yarn_install_state + gitignore = File.expand_path('.gitignore', destination_root) + return create_file '.gitignore', "/.yarn/install-state.gz\n" unless File.exist?(gitignore) + return if File.read(gitignore).include?('/.yarn/install-state.gz') + + append_to_file '.gitignore', "\n/.yarn/install-state.gz\n" + end + + def update_registry_entry + return if self.class.skip_registry_entry? + + if behavior == :revoke + Registry.remove(Registry.key_for(self.class), destination_root: destination_root) + elsif behavior == :invoke && !options[:pretend] + record_registry_entry + end + end + + def record_registry_entry + key = Registry.key_for(self.class) + + case Registry.record(key, destination_root: destination_root) + when :recorded + say "Recorded #{key} in #{Registry::INITIALIZER_PATH}", :green + when :skipped_opt_out + say "Not recording #{key} — explicitly set to false in #{Registry::INITIALIZER_PATH}", :yellow + end + end end end diff --git a/lib/rolemodel/registry.rb b/lib/rolemodel/registry.rb new file mode 100644 index 00000000..1ed53ec0 --- /dev/null +++ b/lib/rolemodel/registry.rb @@ -0,0 +1,199 @@ +# frozen_string_literal: true + +require 'date' +require 'fileutils' +require_relative 'version' + +module Rolemodel + # Persistent record of which rolemodel_rails generators have been applied to + # a consuming app. Entries live in a marker-delimited managed block inside + # config/initializers/rolemodel_generators.rb and are read back through + # Rails' own config.generators option resolution (Rails::Generators.options). + # + # Deliberately a plain module with no generator requires: it is loaded from + # the engine path (via GeneratorBase) and must stay safe to load there. + module Registry + # Raised when the initializer exists but its managed-block markers are + # missing — the writer never guesses where entries belong. + class MissingMarkersError < StandardError; end + + INITIALIZER_PATH = 'config/initializers/rolemodel_generators.rb' + BEGIN_MARKER = '# rolemodel_rails:begin' + END_MARKER = '# rolemodel_rails:end' + + ENTRY_PATTERN = /\A\s*g\.rolemodel (?\w+):\s*(?true|false)\b/ + + FILE_TEMPLATE = <<~RUBY.freeze + # frozen_string_literal: true + + # Records which rolemodel_rails generators have been applied to this app. + # Generators read this through Rails' own config.generators mechanism. + # Set an entry to `false` to permanently opt out of a coupling or + # prevent re-recording; delete the file and run rails g rolemodel:registry + # to rebuild it. Only edit between the markers if you know what you're doing. + Rails.application.config.generators do |g| + #{BEGIN_MARKER} + #{END_MARKER} + end + RUBY + + class << self + # Registry key for a generator class: its generator namespace with the + # rolemodel: prefix stripped and colons underscored. + # Rolemodel::WebpackGenerator -> :webpack + # Rolemodel::Optics::BaseGenerator -> :optics_base + def key_for(generator_class) + generator_class.namespace.delete_prefix('rolemodel:').tr(':', '_').to_sym + end + + # Whether a key is recorded, read through Rails::Generators.options — + # the hash the framework populates from the consuming app's + # config.generators. Guarded: returns false (never raises) in unbooted + # or no-config contexts, and for an explicit false opt-out entry. + def recorded?(key) + namespace = configured_namespace + return false unless namespace + + namespace[key.to_sym] == true + end + + # Upserts the entry line for +key+ inside the managed block of the + # initializer under +destination_root+, creating the whole file when + # absent. Returns :recorded, or :skipped_opt_out when the existing entry + # is an explicit false (the user's persistent opt-out — never overwritten). + def record(key, destination_root:, comment: default_comment) + path = initializer_path(destination_root) + write_template(path) unless File.exist?(path) + + block = ManagedBlock.new(path) + return :skipped_opt_out if block.opt_out?(key) + + block.upsert(key, comment) + :recorded + end + + # Deletes the entry line for +key+ (used by rails destroy). Returns + # :removed, :skipped_opt_out for an explicit false entry, or + # :not_recorded when there is nothing to remove. + def remove(key, destination_root:) + path = initializer_path(destination_root) + return :not_recorded unless File.exist?(path) + + block = ManagedBlock.new(path) + return :not_recorded unless block.entry?(key) + return :skipped_opt_out if block.opt_out?(key) + + block.delete(key) + :removed + end + + private + + def configured_namespace + return nil unless defined?(::Rails::Generators) + return nil unless ::Rails::Generators.respond_to?(:options) + + options = ::Rails::Generators.options + options[:rolemodel] if options.is_a?(Hash) + end + + def default_comment + "rolemodel_rails #{Rolemodel::VERSION}, #{Date.today.iso8601}" + end + + def initializer_path(destination_root) + File.expand_path(INITIALIZER_PATH, destination_root) + end + + def write_template(path) + FileUtils.mkdir_p(File.dirname(path)) + File.write(path, FILE_TEMPLATE) + end + end + + # Line-level editor for the marker-delimited block of one initializer + # file. All mutation is anchored to entry lines between the markers; + # everything outside them is preserved byte for byte. + class ManagedBlock + def initialize(path) + @path = path + @lines = File.read(path).lines + @begin_index = marker_index(BEGIN_MARKER) + @end_index = marker_index(END_MARKER) + validate_markers! + end + + def entry?(key) + !entry_index(key).nil? + end + + def opt_out?(key) + index = entry_index(key) + index ? ENTRY_PATTERN.match(@lines[index])[:value] == 'false' : false + end + + def upsert(key, comment) + entry = entry_line(key, comment) + index = entry_index(key) + + if index + @lines[index] = entry + else + @lines.insert(insertion_index(key), entry) + end + + save + end + + def delete(key) + @lines.delete_at(entry_index(key)) + save + end + + private + + def marker_index(marker) + @lines.index { |line| line.strip == marker } + end + + def validate_markers! + return if @begin_index && @end_index && @begin_index < @end_index + + raise MissingMarkersError, <<~MESSAGE + #{@path} exists but its managed-block markers are missing. + Re-add `#{BEGIN_MARKER}` and `#{END_MARKER}` lines inside the + `Rails.application.config.generators` block (entries go between them), + or delete the file and run `bin/rails generate rolemodel:registry` to rebuild it. + MESSAGE + end + + def entry_range + (@begin_index + 1)...@end_index + end + + def entry_index(key) + entry_range.find do |index| + ENTRY_PATTERN.match(@lines[index])&.[](:key) == key.to_s + end + end + + # New entries slot in alphabetically among existing entry lines, or at + # the end of the block when no later key exists. + def insertion_index(key) + entry_range.find do |index| + match = ENTRY_PATTERN.match(@lines[index]) + match && match[:key] > key.to_s + end || @end_index + end + + def entry_line(key, comment) + indent = @lines[@begin_index][/\A[ \t]*/] + "#{indent}g.rolemodel #{key}: true # #{comment}\n" + end + + def save + File.write(@path, @lines.join) + end + end + end +end diff --git a/lib/rolemodel/skills/deploy-app/SKILL.md b/lib/rolemodel/skills/deploy-app/SKILL.md new file mode 100644 index 00000000..46f94f95 --- /dev/null +++ b/lib/rolemodel/skills/deploy-app/SKILL.md @@ -0,0 +1,202 @@ +--- +name: deploy-app +description: > + Set up deployment for a RoleModel Rails app after running the rolemodel_rails + core_setup generator. Cleans up the generated Gemfile, verifies RuboCop, the test + suite, and a local production smoke test pass, creates the Sentry project and wires + up the DSN, creates and deploys the Heroku app (buildpacks, dynos, Postgres, + Papertrail), and creates the GitHub deployment environment + deploy workflow. Use + when the user says "deploy app", + "set up staging", "set up heroku", "set up sentry for deploy", or "set up production + deploy". Defaults to staging; pass "production" for the production flow. +--- + +# Deploy App + +Completes deployment setup for a RoleModel Rails app in five phases: Gemfile cleanup, +green build (RuboCop + tests + production smoke test), Sentry, Heroku, GitHub. Default +mode is **staging**. If invoked with `production`, follow the +[Production mode](#production-mode) differences. + +Every step must be **idempotent**: check whether the resource already exists before +creating it, and skip or repair rather than failing. Report each phase's outcome as you +go. Ask the user questions **one at a time**. + +## Phase 0: Preflight + +Run these checks before touching anything. Stop and tell the user how to fix any that +fail: + +1. `git remote get-url origin` — must be a GitHub repo. Derive `REPO_NAME` from + `gh repo view --json name -q .name` (fallback: basename of the origin URL). +2. `gh auth status` — GitHub CLI authenticated. +3. `heroku auth:whoami` — Heroku CLI installed and logged in. +4. Sentry MCP tools available (try listing organizations). If no Sentry MCP server is + connected, ask the user to add/authorize the Sentry MCP server + (https://mcp.sentry.dev) in their coding agent's MCP configuration and stop — do not + fall back to another mechanism. +5. Read `Procfile` — note whether a `worker` process exists. +6. Read `config/initializers/sentry.rb` — determine how the DSN is consumed: + - reads `ENV['SENTRY_DSN']` → **env-var mode** (RoleModel standard) + - reads `Rails.application.credentials...` → **credentials mode** +7. Deploy-readiness (recent `rolemodel-rails` bakes these in; older apps need the manual + fixes noted in each phase). Confirm the generated app already has: + - `terser-webpack-plugin` in `package.json` (webpack config imports it) + - custom cops under `.rubocop/cops/`, not `lib/cops/` (the latter crashes prod boot) + - `config/initializers/lograge.rb` uses `controller.try(:current_user)` + - `config/database.yml` `production:` block uses `url: <%= ENV["DATABASE_URL"] %>` + - a `ruby` directive in the `Gemfile` (so the version reaches `Gemfile.lock`) + - `stackprof` in the `Gemfile` (Sentry profiling) + If any are missing, apply the fix inline and note the app predates the generator update. + +Set `APP_NAME` = `-staging` (staging) or `` (production), but +confirm it with the user before creating anything on Heroku. + +## Phase 1: Gemfile cleanup + +The rolemodel_rails generators append gems as they run, leaving multiple blocks for the +same group and top-level gems scattered between them. Normalize the Gemfile first (skip +this phase if it's already clean): + +1. Merge duplicate `group` blocks so each unique group combination + (e.g. `:development, :test`, `:development`, `:test`) appears exactly once. +2. Remove all comments, including commented-out gems (e.g. `# gem "redis" ...`). +3. Alphabetize the gems — the top-level list and the list within each group. +4. Preserve every gem's version constraint and options (`require:`, `platforms:`, + `path:`, etc.) exactly as written. File order: `source`, then `ruby` (if present), + then top-level gems, then the group blocks. +5. Run `bundle install` and confirm it succeeds. `git diff Gemfile.lock` must show no + changes — this is an ordering-only cleanup; if the lockfile changed, you altered a + dependency and must fix it. + +## Phase 2: Green build + production smoke test + +Do not deploy a broken app. This is a **verification gate**: on a current generated app +these should pass with no changes. A failure here usually means a generator regression — +fix the root cause (and flag it for the generator), never paper over it. + +1. RuboCop: `bin/rubocop` (fallback: `bundle exec rubocop`). If there are offenses, + apply safe autocorrections (`rubocop -a`), then fix the remainder by hand. +2. Test suite: `bundle exec rspec` (fallback: `bin/rspec`). Fix any failures at the + root cause — never skip, pend, or delete tests to get to green. +3. **Local production smoke test** — this is what catches the boot/asset/DB failures that + only surface under `RAILS_ENV=production` (eager loading, `force_ssl`, DATABASE_URL). + Run against a throwaway local Postgres DB (do not touch development/test data): + ``` + export PROD="RAILS_ENV=production SECRET_KEY_BASE=smoketest \ + DATABASE_URL=postgres://$(whoami)@localhost/_prodsmoke" + env $PROD bundle exec rails db:create + env $PROD bundle exec rails assets:precompile # webpack + asset pipeline + env $PROD RAILS_SERVE_STATIC_FILES=true PORT=3999 bundle exec puma -C config/puma.rb & + sleep 6 + curl -s -o /dev/null -w '%{http_code}\n' -H 'X-Forwarded-Proto: https' \ + http://localhost:3999/up # expect 200 (plain http returns 301 due to force_ssl) + kill %1; env $PROD bundle exec rails db:drop + ``` + `assets:precompile` succeeding and `/up` → 200 is the gate. If `/up` 500s, read the + server log for the root cause before proceeding. +4. Re-run until clean, then commit the Gemfile cleanup and any fixes (the deploy later + pushes this branch, so everything must be committed). + +## Phase 3: Sentry project + DSN + +1. List the Sentry organizations/teams via the Sentry MCP. Ask the user which team the + project belongs under (skip the question if there is only one option). +2. Check whether a project named `REPO_NAME` already exists in that org. If it does, + reuse it and fetch its DSN instead of creating a duplicate. +3. Otherwise create the project: slug/name = `REPO_NAME`, platform `ruby-rails`, + assigned to the confirmed team. +4. Fetch the project's client key (DSN). +5. Deliver the DSN according to the mode detected in preflight: + - **env-var mode**: no code change needed. The DSN is set as a Heroku config var in + Phase 4 (`SENTRY_DSN`, plus `SENTRY_ENVIRONMENT=staging`). Mention that local + error reporting (if ever wanted) uses the same `SENTRY_DSN` env var. + - **credentials mode**: do NOT edit credentials yourself. Print the DSN and exact + instructions — e.g. `bin/rails credentials:edit --environment production`, add + `sentry_dsn: ` — then wait for the user to confirm they've saved it. Verify + afterwards with + `bin/rails runner "abort 'missing' unless Rails.application.credentials.sentry_dsn"` + (adjust env/key path to match the initializer) before moving on. + +## Phase 4: Heroku app + +1. `heroku teams` — ask the user which team to create the app in. **Do not create the + app until they confirm the team and the app name** (default `-staging`). +2. Ensure the current branch is pushed: check `git status` and + `git rev-parse @ @{u}`. If there's no upstream or local is ahead, ask the user to + push (or push for them if they say so) before continuing. +3. Create the app if it doesn't exist (`heroku apps:info -a $APP_NAME` to check): + `heroku apps:create $APP_NAME --team ` +4. Buildpacks, in this exact order (check `heroku buildpacks -a $APP_NAME` first): + 1. `heroku buildpacks:add heroku/nodejs -a $APP_NAME` + 2. `heroku buildpacks:add heroku/ruby -a $APP_NAME` +5. Add-ons (skip any that already exist per `heroku addons -a $APP_NAME`): + - `heroku addons:create heroku-postgresql:essential-0 -a $APP_NAME` + - Papertrail: run `heroku addons:plans papertrail` and pick the plan whose + name/description matches "Development" (case-insensitive); if no plan matches, + show the plan list and ask the user which to use. Then + `heroku addons:create papertrail: -a $APP_NAME`. +6. Config vars: + - `RAILS_MASTER_KEY`: **never read `config/master.key` (or + `config/credentials/production.key`) — the key must not enter the model's context.** + Pause and tell the user to run this in their own terminal: + `heroku config:set RAILS_MASTER_KEY=$(cat config/master.key) -a $APP_NAME` + (substitute `config/credentials/production.key` if per-environment credentials are + in use). Once they confirm, verify presence without exposing the value: + `heroku config --json -a $APP_NAME | jq 'has("RAILS_MASTER_KEY")'` — must be `true`. + - env-var mode only, set these yourself: + `heroku config:set SENTRY_DSN= SENTRY_ENVIRONMENT=staging -a $APP_NAME` +7. Enable runtime dyno metadata (idempotent) so Sentry can detect releases: + `heroku labs:enable runtime-dyno-metadata -a $APP_NAME`. This adds `HEROKU_*` + env vars (e.g. `HEROKU_SLUG_COMMIT`) on the next release; without it the release + command logs a warning about dyno metadata. +8. Initial deploy from the local machine: + - `heroku git:remote -a $APP_NAME -r heroku-staging` + - `git push heroku-staging :main` + - If the build or release phase fails, read the build output / `heroku logs` and fix + the root cause before proceeding — do not create the GitHub environment until the + app deploys and boots. +9. Dyno formation (after the first successful deploy): + - `heroku ps:type web=basic -a $APP_NAME` + - If the Procfile has a `worker` process: `heroku ps:scale worker=1:basic -a $APP_NAME` +10. Verify: get the app URL from `heroku apps:info -a $APP_NAME --json` (`web_url`), then + curl `/up` and confirm a 200. Save the URL as `APP_URL` (no trailing slash). + +## Phase 5: GitHub environment + deploy workflow + +1. Create the environment (idempotent PUT): + `gh api -X PUT repos/{owner}/{repo}/environments/Staging` +2. Set the environment variables: + - `gh variable set HEROKU_APP_NAME --env Staging --body "$APP_NAME"` + - `gh variable set HEROKU_APP_URL --env Staging --body "$APP_URL"` +3. Confirm `.github/workflows/deploy-staging.yml` exists (the rolemodel-rails github + generator installs it). If it's missing, the app skipped the github generator — tell + the user to run it (`rails generate rolemodel:github`) rather than hand-writing the + workflow. The workflow relies on the RoleModel **org-level** + `HEROKU_IT_SUPPORT_API_KEY` secret and `HEROKU_IT_SUPPORT_EMAIL` variable — do not + create per-repo copies. +4. Verify end-to-end: `gh workflow run deploy-staging.yml` then `gh run watch` the run. + A green run (including its `/up` healthcheck) is the definition of done. + +## Production mode + +Same phases with these differences — ask, don't assume, on every sizing choice: + +- App name defaults to `` (confirm with the user). +- Sentry: reuse the existing project; set `SENTRY_ENVIRONMENT=production`. +- Heroku tiers: ask the user for dyno type (basic/standard-1x/standard-2x/performance), + Postgres plan, and Papertrail plan instead of assuming the staging defaults. +- GitHub environment is `Production`; the workflow is + `.github/workflows/deploy-production.yml` (also installed by the github generator; manual + `workflow_dispatch` only — production never auto-deploys on push). + +## Notes + +- Never run destructive Heroku commands (`apps:destroy`, `addons:destroy`, + `pg:reset`) as part of this skill. +- Never read secret material into the model's context: `config/master.key`, + `config/credentials/*.key`, or the output of `heroku config` without `--json | jq` + filtering. When a secret must be set, have the user run the command themselves and + verify only the key's presence afterwards. +- If any phase was already completed on a previous run, say so and continue with the + next phase rather than starting over. diff --git a/spec/cops/form_error_response_spec.rb b/spec/cops/form_error_response_spec.rb index 37da21e2..7117e60d 100644 --- a/spec/cops/form_error_response_spec.rb +++ b/spec/cops/form_error_response_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' require 'rubocop' require 'rubocop/rspec/support' -require 'generators/rolemodel/linters/rubocop/templates/lib/cops/form_error_response' +require 'generators/rolemodel/linters/rubocop/templates/.rubocop/cops/form_error_response' RSpec.describe Cops::FormErrorResponse, :config do include RuboCop::RSpec::ExpectOffense diff --git a/spec/cops/no_chrome_tag_spec.rb b/spec/cops/no_chrome_tag_spec.rb index f11f0335..b5d03f31 100644 --- a/spec/cops/no_chrome_tag_spec.rb +++ b/spec/cops/no_chrome_tag_spec.rb @@ -1,5 +1,5 @@ require 'spec_helper' -require 'generators/rolemodel/linters/rubocop/templates/lib/cops/no_chrome_tag' +require 'generators/rolemodel/linters/rubocop/templates/.rubocop/cops/no_chrome_tag' require 'rubocop' require 'rubocop/rspec/support' diff --git a/spec/generators/rolemodel/core_setup_generator_spec.rb b/spec/generators/rolemodel/core_setup_generator_spec.rb new file mode 100644 index 00000000..807d5478 --- /dev/null +++ b/spec/generators/rolemodel/core_setup_generator_spec.rb @@ -0,0 +1,62 @@ +RSpec.describe Rolemodel::CoreSetupGenerator do + let(:invocations) { [] } + + def build_generator(args = [], &stub) + described_class.new([], args).tap do |generator| + allow(generator).to receive(:generate) do |*invocation| + invocations << invocation + stub&.call(invocation) + end + end + end + + def invoke_core_setup(args = [], &stub) + build_generator(args, &stub).invoke_all + end + + it 'runs the core generators in order without the removed --sentry flag' do + invoke_core_setup + + expect(invocations).to eq [ + ['rolemodel:github'], + ['rolemodel:heroku'], + ['rolemodel:readme'], + ['rolemodel:webpack'], + ['rolemodel:sentry'], + ['rolemodel:slim'], + ['rolemodel:optics:all'], + ['rolemodel:testing:all'], + ['rolemodel:simple_form'], + ['rolemodel:linters:all'], + ['rolemodel:ui_components:flash'], + ['rolemodel:ui_components:modals'], + ['rolemodel:lograge'] + ] + end + + it 'does not invoke the removed semaphore generator' do + invoke_core_setup + + expect(invocations).not_to include(['rolemodel:semaphore']) + end + + it 'aborts the whole run when a child generator fails' do + expect do + invoke_core_setup do |invocation| + raise SystemExit if invocation == ['rolemodel:webpack'] + end + end.to raise_error(SystemExit) + + # Children after the failing one never run. + expect(invocations).to eq [ + ['rolemodel:github'], + ['rolemodel:heroku'], + ['rolemodel:readme'], + ['rolemodel:webpack'] + ] + end + + it 'is exempt from registry recording (it is a composite)' do + expect(described_class.skip_registry_entry?).to be(true) + end +end diff --git a/spec/generators/rolemodel/heroku_generator_spec.rb b/spec/generators/rolemodel/heroku_generator_spec.rb index a8bfd5f6..f6643562 100644 --- a/spec/generators/rolemodel/heroku_generator_spec.rb +++ b/spec/generators/rolemodel/heroku_generator_spec.rb @@ -27,4 +27,12 @@ it 'creates assets.rake task to remove node_modules directory during production build' do assert_file 'lib/tasks/assets.rake' end + + it 'references the deploy-app skill from AGENTS.md instead of copying it into the repo' do + assert_file 'AGENTS.md' do |content| + expect(content).to include('deploy-app') + expect(content).to include('bundle show rolemodel-rails') + end + assert_no_file '.claude/skills/deploy-app/SKILL.md' + end end diff --git a/spec/generators/rolemodel/linters/rubocop_generator_spec.rb b/spec/generators/rolemodel/linters/rubocop_generator_spec.rb index d4b1a3c9..9d4453c4 100644 --- a/spec/generators/rolemodel/linters/rubocop_generator_spec.rb +++ b/spec/generators/rolemodel/linters/rubocop_generator_spec.rb @@ -3,7 +3,7 @@ it 'adds the correct helpers' do assert_file '.rubocop.yml' - assert_file 'lib/cops/form_error_response.rb' - assert_file 'lib/cops/no_chrome_tag.rb' + assert_file '.rubocop/cops/form_error_response.rb' + assert_file '.rubocop/cops/no_chrome_tag.rb' end end diff --git a/spec/generators/rolemodel/registry_generator_spec.rb b/spec/generators/rolemodel/registry_generator_spec.rb new file mode 100644 index 00000000..2c9d7fe7 --- /dev/null +++ b/spec/generators/rolemodel/registry_generator_spec.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +RSpec.describe Rolemodel::RegistryGenerator, type: :generator do + include ExampleApp + + let(:initializer_path) { File.expand_path(Rolemodel::Registry::INITIALIZER_PATH, destination_root) } + + before { prepare_test_app } + + context 'with no existing registry file' do + it 'creates the initializer and seeds detected generators' do + expect(File.exist?(initializer_path)).to be(false) + + run_generator_against_test_app + + expect(File.exist?(initializer_path)).to be(true) + content = File.read(initializer_path) + expect(content).to include(Rolemodel::Registry::BEGIN_MARKER) + expect(content).to include(Rolemodel::Registry::END_MARKER) + + # The example app has webpack config and a Procfile (from heroku setup) + # so those should be detected + expect(content).to include('seeded-by-detection') + end + end + + context 'with an existing registry file that has a genuine entry' do + before do + # Simulate a genuine registry entry with a version stamp + FileUtils.mkdir_p(File.dirname(initializer_path)) + File.write(initializer_path, <<~RUBY) + # frozen_string_literal: true + + Rails.application.config.generators do |g| + #{Rolemodel::Registry::BEGIN_MARKER} + g.rolemodel webpack: true # rolemodel_rails 1.0.0, 2026-01-01 + #{Rolemodel::Registry::END_MARKER} + end + RUBY + end + + it 'never overwrites a genuine version-stamped entry' do + run_generator_against_test_app + + content = File.read(initializer_path) + # The existing webpack entry with its version stamp should be untouched + expect(content).to include('g.rolemodel webpack: true # rolemodel_rails 1.0.0, 2026-01-01') + # Other detected generators may be seeded alongside it + expect(content).to include('seeded-by-detection') + end + end + + context 'with a seeded entry from a prior run' do + before do + FileUtils.mkdir_p(File.dirname(initializer_path)) + File.write(initializer_path, <<~RUBY) + # frozen_string_literal: true + + Rails.application.config.generators do |g| + #{Rolemodel::Registry::BEGIN_MARKER} + g.rolemodel sentry: true # seeded-by-detection + #{Rolemodel::Registry::END_MARKER} + end + RUBY + end + + it 'overwrites its own seeded entries to keep the comment clean' do + run_generator_against_test_app + + content = File.read(initializer_path) + # Should still have sentry, but not duplicate it + expect(content.scan(/g\.rolemodel sentry:/).length).to eq(1) + end + end + + context 'with an explicit opt-out entry' do + before do + FileUtils.mkdir_p(File.dirname(initializer_path)) + File.write(initializer_path, <<~RUBY) + # frozen_string_literal: true + + Rails.application.config.generators do |g| + #{Rolemodel::Registry::BEGIN_MARKER} + g.rolemodel webpack: false + #{Rolemodel::Registry::END_MARKER} + end + RUBY + end + + it 'never overwrites a false (opt-out) entry' do + run_generator_against_test_app + + content = File.read(initializer_path) + expect(content).to include('g.rolemodel webpack: false') + # The false entry should still be there, not replaced with true + expect(content).not_to include('g.rolemodel webpack: true') + end + end + + context 're-running on the same app' do + before do + # First run + run_generator_against_test_app + end + + it 'is a no-op that reports current state' do + first_run_content = File.read(initializer_path) + + run_generator_against_test_app + + second_run_content = File.read(initializer_path) + + # Content should not change on re-run + expect(second_run_content).to eq(first_run_content) + end + end + + context 'exemption from registry recording' do + it 'is exempt from registry recording (it is a composite/bootstrap generator)' do + expect(described_class.skip_registry_entry?).to be(true) + end + end +end diff --git a/spec/generators/rolemodel/sentry_generator_spec.rb b/spec/generators/rolemodel/sentry_generator_spec.rb new file mode 100644 index 00000000..ee9e22b9 --- /dev/null +++ b/spec/generators/rolemodel/sentry_generator_spec.rb @@ -0,0 +1,51 @@ +RSpec.describe Rolemodel::SentryGenerator, type: :generator do + before do + # The webpack generator lays down the webpack.config.js and application.js + # that this generator injects into. In the eager-load spec context webpack + # is not "recorded", so the sentry<->webpack coupling is forced explicitly + # via --sentry-webpack (the coupling's own resolution is covered in + # spec/rolemodel/coupling_hook_spec.rb). + run_generator_against_test_app generator: ::Rolemodel::WebpackGenerator + run_generator_against_test_app(['--sentry-webpack']) + end + + it 'sets up the Ruby side' do + assert_file 'config/initializers/sentry.rb' do |content| + expect(content).to include('Sentry.init') + end + + assert_file 'Gemfile' do |content| + expect(content).to include('sentry-rails') + end + + assert_file 'app/controllers/application_controller.rb' do |content| + expect(content).to include('before_action :set_sentry_user') + expect(content).to include('respond_to?(:current_user) && current_user') + expect(content).to include('Sentry.set_user(id: current_user.id)') + end + end + + it 'sets up the JS side' do + assert_file 'app/javascript/initializers/sentry.js' do |content| + expect(content).to include("import * as Sentry from '@sentry/browser'") + end + + assert_file 'app/javascript/application.js' do |content| + expect(content).to include("import './initializers/sentry'") + end + + assert_file 'package.json' do |content| + dependencies = JSON.parse(content)['devDependencies'].keys + expect(dependencies).to include(*Rolemodel::SentryGenerator::JS_DEPS) + end + end + + it 'wires the Sentry plugin into webpack.config.js' do + assert_file 'webpack.config.js' do |content| + expect(content).to include("import { sentryWebpackPlugin } from '@sentry/webpack-plugin'") + expect(content).to include("'process.env.SENTRY_DSN': JSON.stringify(process.env.SENTRY_DSN),") + expect(content).to include('sentryWebpackPlugin({') + expect(content).to include('].filter(Boolean)') + end + end +end diff --git a/spec/generators/rolemodel/sentry_webpack_generator_spec.rb b/spec/generators/rolemodel/sentry_webpack_generator_spec.rb new file mode 100644 index 00000000..7708c517 --- /dev/null +++ b/spec/generators/rolemodel/sentry_webpack_generator_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +RSpec.describe Rolemodel::SentryWebpackGenerator, type: :generator do + it 'wires the Sentry plugin into the webpack.config.js' do + # The webpack generator lays down the modern config with the anchors this + # hook injects into. + run_generator_against_test_app generator: ::Rolemodel::WebpackGenerator + run_generator_against_test_app + + assert_file 'webpack.config.js' do |content| + expect(content).to include("import { sentryWebpackPlugin } from '@sentry/webpack-plugin'") + expect(content).to include("'process.env.SENTRY_DSN': JSON.stringify(process.env.SENTRY_DSN),") + expect(content).to include('sentryWebpackPlugin({') + expect(content).to include('].filter(Boolean)') + end + end + + it 'is a no-op when there is no webpack.config.js' do + File.delete(File.expand_path('webpack.config.js', destination_root)) + + run_generator_against_test_app + + assert_no_file 'webpack.config.js' + end + + it 'is idempotent when the plugin is already wired' do + run_generator_against_test_app generator: ::Rolemodel::WebpackGenerator + run_generator_against_test_app + first_pass = File.read(File.expand_path('webpack.config.js', destination_root)) + + run_generator_against_test_app + + expect(File.read(File.expand_path('webpack.config.js', destination_root))).to eq first_pass + end + + it 'records no registry entry (it is a wiring-only hook)' do + run_generator_against_test_app + + assert_no_file 'config/initializers/rolemodel_generators.rb' + end +end diff --git a/spec/generators/rolemodel/simple_form_generator_spec.rb b/spec/generators/rolemodel/simple_form_generator_spec.rb index 9952dd8c..d81fff47 100644 --- a/spec/generators/rolemodel/simple_form_generator_spec.rb +++ b/spec/generators/rolemodel/simple_form_generator_spec.rb @@ -1,23 +1,51 @@ RSpec.describe Rolemodel::SimpleFormGenerator, type: :generator do - before { run_generator_against_test_app } - it 'generates a simple form initializer' do + run_generator_against_test_app + assert_file 'config/initializers/simple_form.rb' end - it 'generates custom input files' do + it 'generates the default custom input files' do + run_generator_against_test_app + assert_file 'app/inputs/collection_check_boxes_input.rb' assert_file 'app/inputs/collection_select_input.rb' assert_file 'app/inputs/grouped_collection_select_input.rb' assert_file 'app/inputs/segmented_control_input.rb' assert_file 'app/inputs/switch_checkbox_input.rb' - assert_file 'app/inputs/tailored_select_input.rb' end it 'generates slim scaffold templates for simple form' do + run_generator_against_test_app + assert_file 'lib/templates/slim/scaffold/_form.html.slim' do |content| expect(content).to include("<%=") expect(content).not_to include("<%%=") end end + + context 'the --tailored_select option' do + let(:invocations) { [] } + + def invoke_simple_form(args = []) + generator = described_class.new([], args) + allow(generator).to receive(:generate) { |*invocation| invocations << invocation } + # Stub the parts that touch the filesystem/network; we only care about + # whether the tailored_select generator is delegated to. + %i[add_gem add_files update_registry_entry].each { |step| allow(generator).to receive(step) } + generator.invoke_all + end + + it 'delegates to the tailored_select generator when passed' do + invoke_simple_form(['--tailored_select']) + + expect(invocations).to include(['rolemodel:tailored_select', '--simple-form-input']) + end + + it 'does not delegate to the tailored_select generator by default' do + invoke_simple_form + + expect(invocations).not_to include(['rolemodel:tailored_select']) + end + end end diff --git a/spec/generators/rolemodel/tailored_select_generator_spec.rb b/spec/generators/rolemodel/tailored_select_generator_spec.rb index 777804ec..592ac8e2 100644 --- a/spec/generators/rolemodel/tailored_select_generator_spec.rb +++ b/spec/generators/rolemodel/tailored_select_generator_spec.rb @@ -1,9 +1,21 @@ RSpec.describe Rolemodel::TailoredSelectGenerator, type: :generator do - before { run_generator_against_test_app } - it 'adds tailored select to package.json' do + run_generator_against_test_app + assert_file 'package.json' do |content| expect(content).to include('"@rolemodel/tailored-select":') end end + + it 'does not install the SimpleForm input by default (SimpleForm not recorded)' do + run_generator_against_test_app + + assert_no_file 'app/inputs/tailored_select_input.rb' + end + + it 'installs the SimpleForm input when passed --simple-form-input' do + run_generator_against_test_app(['--simple-form-input']) + + assert_file 'app/inputs/tailored_select_input.rb' + end end diff --git a/spec/generators/rolemodel/webpack_generator_spec.rb b/spec/generators/rolemodel/webpack_generator_spec.rb index 07f76401..9ba7bf37 100644 --- a/spec/generators/rolemodel/webpack_generator_spec.rb +++ b/spec/generators/rolemodel/webpack_generator_spec.rb @@ -10,7 +10,6 @@ assert_file 'postcss.config.cjs' assert_file 'webpack.config.js' assert_file 'app/assets/stylesheets/application.scss' - assert_file 'app/javascript/initializers/honeybadger.js' end it 'adds webpack dev dependencies to package.json' do @@ -18,4 +17,36 @@ expect(JSON.parse(content)['devDependencies'].keys).to include(*dev_dependencies) end end + + it 'pins the project to Yarn 4+ via Corepack' do + assert_file 'package.json' do |content| + expect(JSON.parse(content)['packageManager']).to eq "yarn@#{Rolemodel::YARN_VERSION}" + end + + assert_file '.yarnrc.yml' do |content| + expect(content).to include('nodeLinker: node-modules') + end + + assert_file '.gitignore' do |content| + expect(content).to include('/.yarn/install-state.gz') + end + end + + it 'does not wire Sentry into webpack.config.js by default' do + assert_file 'webpack.config.js' do |content| + expect(content).not_to include('@sentry/webpack-plugin') + end + end + + context 'with the --sentry-webpack option' do + before { run_generator_against_test_app(['--sentry-webpack']) } + + it 'wires the Sentry plugin into webpack.config.js' do + assert_file 'webpack.config.js' do |content| + expect(content).to include("import { sentryWebpackPlugin } from '@sentry/webpack-plugin'") + expect(content).to include('sentryWebpackPlugin({') + expect(content).to include('].filter(Boolean)') + end + end + end end diff --git a/spec/rolemodel/coupling_hook_spec.rb b/spec/rolemodel/coupling_hook_spec.rb new file mode 100644 index 00000000..be9519ca --- /dev/null +++ b/spec/rolemodel/coupling_hook_spec.rb @@ -0,0 +1,160 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# Runtime trace of throwaway generator commands, so examples can assert +# whether the hook target actually ran. +COUPLING_HOOK_EVENTS = [] + +RSpec.describe 'Rolemodel::GeneratorBase.coupling_hook', :generator_config do + after { COUPLING_HOOK_EVENTS.clear } + + # Runs a throwaway generator and returns everything it printed. The hosts + # create no files and are marked skip_registry_entry!, so no destination + # root is needed. + def run_generator_class(klass, *args) + output = StringIO.new + original = $stdout + $stdout = output + klass.start(args) + output.string + ensure + $stdout = original + end + + it 'invokes the rolemodel: target after the actions when the other side is recorded' do + apply_generator_config { |g| g.rolemodel other_side_probe: true } + + class Rolemodel::CouplingProbeGenerator < ::Rails::Generators::Base + def record_target_run + COUPLING_HOOK_EVENTS << :target + end + end + + class Rolemodel::CouplingHostGenerator < Rolemodel::GeneratorBase + skip_registry_entry! + + def host_action + COUPLING_HOOK_EVENTS << :action + end + + coupling_hook :coupling_probe, with: :other_side_probe + end + + output = run_generator_class(Rolemodel::CouplingHostGenerator) + + expect(COUPLING_HOOK_EVENTS).to eq %i[action target] + expect(output).not_to include('Skipping coupling_probe') + ensure + remove_generators Rolemodel::CouplingProbeGenerator, Rolemodel::CouplingHostGenerator + end + + it 'skips the hook and prints the re-run note when the other side is not recorded' do + class Rolemodel::CouplingProbeGenerator < ::Rails::Generators::Base + def record_target_run + COUPLING_HOOK_EVENTS << :target + end + end + + class Rolemodel::CouplingHostGenerator < Rolemodel::GeneratorBase + skip_registry_entry! + + def host_action + COUPLING_HOOK_EVENTS << :action + end + + coupling_hook :coupling_probe, with: :other_side_probe + end + + output = run_generator_class(Rolemodel::CouplingHostGenerator) + + expect(COUPLING_HOOK_EVENTS).to eq %i[action] + expect(output).to include( + 'Skipping coupling_probe — other_side_probe is not recorded in ' \ + 'config/initializers/rolemodel_generators.rb. ' \ + 'Re-run this generator after installing rolemodel:other_side_probe, ' \ + 'or pass --coupling-probe.' + ) + ensure + remove_generators Rolemodel::CouplingProbeGenerator, Rolemodel::CouplingHostGenerator + end + + it 'honors an explicit config opt-out even when the other side is recorded' do + apply_generator_config { |g| g.rolemodel other_side_probe: true, coupling_probe: false } + + class Rolemodel::CouplingProbeGenerator < ::Rails::Generators::Base + def record_target_run + COUPLING_HOOK_EVENTS << :target + end + end + + class Rolemodel::CouplingHostGenerator < Rolemodel::GeneratorBase + skip_registry_entry! + + def host_action + COUPLING_HOOK_EVENTS << :action + end + + coupling_hook :coupling_probe, with: :other_side_probe + end + + output = run_generator_class(Rolemodel::CouplingHostGenerator) + + expect(COUPLING_HOOK_EVENTS).to eq %i[action] + expect(output).to include('Skipping coupling_probe') + ensure + remove_generators Rolemodel::CouplingProbeGenerator, Rolemodel::CouplingHostGenerator + end + + it 'fires the hook on an explicit -- switch with an empty registry (AE1)' do + class Rolemodel::CouplingProbeGenerator < ::Rails::Generators::Base + def record_target_run + COUPLING_HOOK_EVENTS << :target + end + end + + class Rolemodel::CouplingHostGenerator < Rolemodel::GeneratorBase + skip_registry_entry! + + def host_action + COUPLING_HOOK_EVENTS << :action + end + + coupling_hook :coupling_probe, with: :other_side_probe + end + + output = run_generator_class(Rolemodel::CouplingHostGenerator, '--coupling-probe') + + expect(COUPLING_HOOK_EVENTS).to eq %i[action target] + expect(output).not_to include('Skipping coupling_probe') + ensure + remove_generators Rolemodel::CouplingProbeGenerator, Rolemodel::CouplingHostGenerator + end + + it 'skips the hook on --no- even when the other side is recorded' do + apply_generator_config { |g| g.rolemodel other_side_probe: true } + + class Rolemodel::CouplingProbeGenerator < ::Rails::Generators::Base + def record_target_run + COUPLING_HOOK_EVENTS << :target + end + end + + class Rolemodel::CouplingHostGenerator < Rolemodel::GeneratorBase + skip_registry_entry! + + def host_action + COUPLING_HOOK_EVENTS << :action + end + + coupling_hook :coupling_probe, with: :other_side_probe + end + + output = run_generator_class(Rolemodel::CouplingHostGenerator, '--no-coupling-probe') + + expect(COUPLING_HOOK_EVENTS).to eq %i[action] + expect(output).to include('Skipping coupling_probe') + ensure + remove_generators Rolemodel::CouplingProbeGenerator, Rolemodel::CouplingHostGenerator + end +end diff --git a/spec/rolemodel/generator_base_recording_spec.rb b/spec/rolemodel/generator_base_recording_spec.rb new file mode 100644 index 00000000..40cb07cb --- /dev/null +++ b/spec/rolemodel/generator_base_recording_spec.rb @@ -0,0 +1,145 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'tmpdir' + +RSpec.describe Rolemodel::GeneratorBase, 'registry recording' do + around do |example| + Dir.mktmpdir do |dir| + @destination_root = dir + example.run + end + end + + attr_reader :destination_root + + def initializer_path + File.expand_path(Rolemodel::Registry::INITIALIZER_PATH, destination_root) + end + + def run_generator_class(klass, *args, behavior: :invoke) + output = StringIO.new + original = $stdout + $stdout = output + klass.start(args, destination_root: destination_root, behavior: behavior) + output.string + ensure + $stdout = original + end + + it 'does not register the invoke_all override as a Thor command' do + # An unwrapped public invoke_all would become a command (it is not in + # THOR_RESERVED_WORDS) and recurse on every generator run. + expect(described_class.all_commands.keys).not_to include('invoke_all') + end + + it 'records the derived key after a successful run and says so' do + class Rolemodel::RecordingProbeGenerator < Rolemodel::GeneratorBase + def leave_a_mark + create_file 'probe.txt', "probe\n" + end + end + + output = run_generator_class(Rolemodel::RecordingProbeGenerator) + + expect(File.read(initializer_path)) + .to include("g.rolemodel recording_probe: true # rolemodel_rails #{Rolemodel::VERSION}") + expect(output).to include('Recorded recording_probe') + ensure + remove_generators Rolemodel::RecordingProbeGenerator + end + + it 'records nothing under --pretend' do + class Rolemodel::RecordingProbeGenerator < Rolemodel::GeneratorBase + def leave_a_mark + create_file 'probe.txt', "probe\n" + end + end + + run_generator_class(Rolemodel::RecordingProbeGenerator, '--pretend') + + expect(File).not_to exist(initializer_path) + ensure + remove_generators Rolemodel::RecordingProbeGenerator + end + + it 'removes the entry under rails destroy (behavior :revoke)' do + Rolemodel::Registry.record(:recording_probe, destination_root: destination_root) + Rolemodel::Registry.record(:webpack, destination_root: destination_root) + + class Rolemodel::RecordingProbeGenerator < Rolemodel::GeneratorBase + def leave_a_mark + create_file 'probe.txt', "probe\n" + end + end + + run_generator_class(Rolemodel::RecordingProbeGenerator, behavior: :revoke) + + expect(File.read(initializer_path)).not_to include('recording_probe') + expect(File.read(initializer_path)).to include('g.rolemodel webpack: true') + ensure + remove_generators Rolemodel::RecordingProbeGenerator + end + + it 'records nothing when an action raises mid-run' do + class Rolemodel::ExplodingProbeGenerator < Rolemodel::GeneratorBase + def explode + raise 'boom' + end + end + + expect { run_generator_class(Rolemodel::ExplodingProbeGenerator) }.to raise_error('boom') + expect(File).not_to exist(initializer_path) + ensure + remove_generators Rolemodel::ExplodingProbeGenerator + end + + it 'records nothing for a generator marked skip_registry_entry!' do + class Rolemodel::ExemptProbeGenerator < Rolemodel::GeneratorBase + skip_registry_entry! + + def leave_a_mark + create_file 'probe.txt', "probe\n" + end + end + + run_generator_class(Rolemodel::ExemptProbeGenerator) + + expect(File).not_to exist(initializer_path) + expect(File).to exist(File.expand_path('probe.txt', destination_root)) + ensure + remove_generators Rolemodel::ExemptProbeGenerator + end + + it 'inherits skip_registry_entry? and defaults it to false' do + class Rolemodel::ExemptProbeGenerator < Rolemodel::GeneratorBase + skip_registry_entry! + end + + class Rolemodel::ExemptChildProbeGenerator < Rolemodel::ExemptProbeGenerator; end + + expect(Rolemodel::GeneratorBase.skip_registry_entry?).to be false + expect(Rolemodel::ExemptChildProbeGenerator.skip_registry_entry?).to be true + ensure + remove_generators Rolemodel::ExemptChildProbeGenerator, Rolemodel::ExemptProbeGenerator + end + + it 'leaves an explicit false entry untouched and says why recording was skipped' do + Rolemodel::Registry.record(:recording_probe, destination_root: destination_root) + opted_out = File.read(initializer_path).sub('recording_probe: true', 'recording_probe: false') + File.write(initializer_path, opted_out) + + class Rolemodel::RecordingProbeGenerator < Rolemodel::GeneratorBase + def leave_a_mark + create_file 'probe.txt', "probe\n" + end + end + + output = run_generator_class(Rolemodel::RecordingProbeGenerator) + + expect(File.read(initializer_path)).to eq opted_out + expect(output).to include('Not recording recording_probe') + ensure + remove_generators Rolemodel::RecordingProbeGenerator + end +end diff --git a/spec/rolemodel/registry_resolution_spec.rb b/spec/rolemodel/registry_resolution_spec.rb new file mode 100644 index 00000000..9898f63c --- /dev/null +++ b/spec/rolemodel/registry_resolution_spec.rb @@ -0,0 +1,139 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'open3' + +# Executable verification of the framework mechanics the generator registry +# design rests on. Each example pins a fact about Rails' resolution chain; +# if one fails after a Rails upgrade, revisit the registry design before +# debugging anything built on top of it. +REGISTRY_RESOLUTION_ORDER = [] + +RSpec.describe 'Registry resolution chain', :generator_config do + after { REGISTRY_RESOLUTION_ORDER.clear } + + describe 'config.generators -> Rails::Generators.options' do + it 'routes g.rolemodel entries into the :rolemodel namespace' do + apply_generator_config { |g| g.rolemodel registry_probe: true } + + expect(Rails::Generators.options[:rolemodel][:registry_probe]).to be true + end + end + + describe 'boolean hook_for default resolution at class definition' do + it 'resolves the default from the rolemodel namespace' do + apply_generator_config { |g| g.rolemodel registry_probe: true } + + class Rolemodel::ProbeAlphaGenerator < ::Rails::Generators::Base + hook_for :registry_probe, type: :boolean + end + + expect(Rolemodel::ProbeAlphaGenerator.class_options[:registry_probe].default).to be true + ensure + remove_generators Rolemodel::ProbeAlphaGenerator + end + + it 'prefers generator_name config over base_name config' do + apply_generator_config do |g| + g.rolemodel registry_probe: true + g.probe_beta registry_probe: false + end + + class Rolemodel::ProbeBetaGenerator < ::Rails::Generators::Base + hook_for :registry_probe, type: :boolean + end + + expect(Rolemodel::ProbeBetaGenerator.class_options[:registry_probe].default).to be false + ensure + remove_generators Rolemodel::ProbeBetaGenerator + end + + it 'falls back to the class default when no config exists (spec-harness condition)' do + class Rolemodel::ProbeGammaGenerator < ::Rails::Generators::Base + hook_for :registry_probe, type: :boolean + end + + expect(Rolemodel::ProbeGammaGenerator.class_options[:registry_probe].default).to be_falsey + ensure + remove_generators Rolemodel::ProbeGammaGenerator + end + end + + describe 'boolean hook target lookup' do + it 'resolves rolemodel: ahead of a bare generator' do + class Rolemodel::RegistryProbeGenerator < ::Rails::Generators::Base; end + class RegistryProbeGenerator < ::Rails::Generators::Base; end + + class Rolemodel::ProbeDeltaGenerator < ::Rails::Generators::Base + hook_for :registry_probe, type: :boolean + end + + resolved = Rolemodel::ProbeDeltaGenerator.prepare_for_invocation(:registry_probe, :registry_probe) + expect(resolved).to eq Rolemodel::RegistryProbeGenerator + ensure + remove_generators Rolemodel::RegistryProbeGenerator, ::RegistryProbeGenerator, + Rolemodel::ProbeDeltaGenerator + end + end + + describe 'hook execution order' do + it 'runs hook invocations at their declaration position, not before or after all actions' do + apply_generator_config { |g| g.rolemodel registry_probe: true } + + class Rolemodel::RegistryProbeGenerator < ::Rails::Generators::Base + def record_hook_ran + REGISTRY_RESOLUTION_ORDER << :hook + end + end + + class Rolemodel::ProbeEpsilonGenerator < ::Rails::Generators::Base + def first_step + REGISTRY_RESOLUTION_ORDER << :first + end + + hook_for :registry_probe, type: :boolean + + def last_step + REGISTRY_RESOLUTION_ORDER << :last + end + end + + quietly { Rolemodel::ProbeEpsilonGenerator.start([]) } + + expect(REGISTRY_RESOLUTION_ORDER).to eq %i[first hook last] + ensure + remove_generators Rolemodel::RegistryProbeGenerator, Rolemodel::ProbeEpsilonGenerator + end + end + + describe 'engine eager-require path' do + it 'defines no hook-declaring generators when all_generator is required' do + root = File.expand_path('../..', __dir__) + script = <<~RUBY + require 'bundler/setup' + require 'rails' + require 'rails/generators' + $LOAD_PATH.unshift File.expand_path('lib') + require 'rolemodel-rails' + require 'generators/rolemodel/all_generator' + + hooked = Rails::Generators.subclasses.select do |klass| + klass.name.to_s.start_with?('Rolemodel') && klass.hooks.any? + end + if hooked.any? + warn "hook-declaring generators on the eager-require path: \#{hooked.map(&:name).join(', ')}" + exit 1 + end + RUBY + + _out, err, status = Open3.capture3(RbConfig.ruby, '-e', script, chdir: root) + + # Classes loaded eagerly by the engine's generators block resolve their + # hook defaults BEFORE Rails::Generators.configure! runs (verified against + # railties 8.1.2 Rails::Engine#load_generators), so they would silently + # ignore the registry. Coupling declarations belong on lazily-loaded leaf + # generators only. + expect(status).to be_success, err + end + end +end diff --git a/spec/rolemodel/registry_spec.rb b/spec/rolemodel/registry_spec.rb new file mode 100644 index 00000000..57f771dd --- /dev/null +++ b/spec/rolemodel/registry_spec.rb @@ -0,0 +1,188 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'tmpdir' + +RSpec.describe Rolemodel::Registry do + around do |example| + Dir.mktmpdir do |dir| + @destination_root = dir + example.run + end + end + + attr_reader :destination_root + + def initializer_path + File.expand_path(Rolemodel::Registry::INITIALIZER_PATH, destination_root) + end + + def initializer_content + File.read(initializer_path) + end + + def recorded_keys + initializer_content.scan(/g\.rolemodel (\w+):/).flatten + end + + describe '.key_for' do + it 'derives the key from the generator namespace with the rolemodel: prefix stripped' do + expect(described_class.key_for(Rolemodel::WebpackGenerator)).to eq :webpack + end + + it 'underscores nested namespaces' do + expect(described_class.key_for(Rolemodel::Optics::BaseGenerator)).to eq :optics_base + end + + it 'derives distinct keys for distinct AllGenerator classes' do + expect(described_class.key_for(Rolemodel::AllGenerator)).to eq :all + expect(described_class.key_for(Rolemodel::Optics::AllGenerator)).to eq :optics_all + end + end + + describe '.recorded?', :generator_config do + it 'returns false (does not raise) when no rolemodel config exists' do + expect(Rails::Generators.options[:rolemodel]).to be_nil + expect(described_class.recorded?(:webpack)).to be false + end + + it 'returns true for a key configured true' do + apply_generator_config { |g| g.rolemodel webpack: true } + + expect(described_class.recorded?(:webpack)).to be true + end + + it 'returns false for a key explicitly configured false' do + apply_generator_config { |g| g.rolemodel webpack: false } + + expect(described_class.recorded?(:webpack)).to be false + end + + it 'returns false for a key absent from the rolemodel namespace' do + apply_generator_config { |g| g.rolemodel webpack: true } + + expect(described_class.recorded?(:sentry)).to be false + end + end + + describe '.record' do + it 'creates the initializer with markers and one commented entry on first run' do + result = described_class.record(:webpack, destination_root: destination_root) + + expect(result).to eq :recorded + expect(initializer_content).to include(Rolemodel::Registry::BEGIN_MARKER) + expect(initializer_content).to include(Rolemodel::Registry::END_MARKER) + expect(initializer_content) + .to include("g.rolemodel webpack: true # rolemodel_rails #{Rolemodel::VERSION}, #{Date.today.iso8601}") + end + + it 'creates a file that is valid Ruby' do + described_class.record(:webpack, destination_root: destination_root) + + expect { RubyVM::InstructionSequence.compile(initializer_content) }.not_to raise_error + end + + it 'round-trips entries into config.generators, deep-merging into one namespace' do + described_class.record(:webpack, destination_root: destination_root) + described_class.record(:sentry, destination_root: destination_root) + + config = Rails::Configuration::Generators.new + app_config = double('app config') + allow(app_config).to receive(:generators) { |&block| block.call(config) } + allow(Rails).to receive(:application).and_return(double('application', config: app_config)) + + load initializer_path + + expect(config.options[:rolemodel]).to include(webpack: true, sentry: true) + end + + it 'appends new entries alphabetically without disturbing existing ones' do + described_class.record(:webpack, destination_root: destination_root, comment: 'first') + described_class.record(:sentry, destination_root: destination_root) + described_class.record(:optics_base, destination_root: destination_root) + + expect(recorded_keys).to eq %w[optics_base sentry webpack] + expect(initializer_content).to include('g.rolemodel webpack: true # first') + end + + it 're-recording refreshes the comment without duplicating the entry' do + described_class.record(:webpack, destination_root: destination_root, comment: 'rolemodel_rails 1.0.0, 2020-01-01') + result = described_class.record(:webpack, destination_root: destination_root, + comment: 'rolemodel_rails 2.0.0, 2021-02-02') + + expect(result).to eq :recorded + expect(initializer_content.scan(/g\.rolemodel webpack:/).length).to eq 1 + expect(initializer_content).to include('rolemodel_rails 2.0.0, 2021-02-02') + expect(initializer_content).not_to include('1.0.0') + end + + it 'never overwrites an explicit false entry' do + described_class.record(:webpack, destination_root: destination_root) + File.write(initializer_path, initializer_content.sub('webpack: true', 'webpack: false')) + opted_out = initializer_content + + result = described_class.record(:webpack, destination_root: destination_root) + + expect(result).to eq :skipped_opt_out + expect(initializer_content).to eq opted_out + end + + it 'raises with recovery instructions when the file exists without markers' do + FileUtils.mkdir_p(File.dirname(initializer_path)) + user_content = "# frozen_string_literal: true\n\n# hand-rolled file\n" + File.write(initializer_path, user_content) + + expect { described_class.record(:webpack, destination_root: destination_root) } + .to raise_error(Rolemodel::Registry::MissingMarkersError, /rolemodel:registry/) + expect(initializer_content).to eq user_content + end + + it 'leaves content outside the markers untouched' do + described_class.record(:webpack, destination_root: destination_root) + File.write(initializer_path, "# user note above the block\n#{initializer_content}# user note below\n") + + described_class.record(:sentry, destination_root: destination_root) + + expect(initializer_content).to start_with("# user note above the block\n") + expect(initializer_content).to end_with("# user note below\n") + expect(recorded_keys).to eq %w[sentry webpack] + end + end + + describe '.remove' do + it 'removes the entry line and leaves the others' do + described_class.record(:webpack, destination_root: destination_root) + described_class.record(:sentry, destination_root: destination_root) + + result = described_class.remove(:webpack, destination_root: destination_root) + + expect(result).to eq :removed + expect(recorded_keys).to eq %w[sentry] + expect(initializer_content).to include(Rolemodel::Registry::BEGIN_MARKER) + end + + it 'skips an explicit false entry' do + described_class.record(:webpack, destination_root: destination_root) + File.write(initializer_path, initializer_content.sub('webpack: true', 'webpack: false')) + + result = described_class.remove(:webpack, destination_root: destination_root) + + expect(result).to eq :skipped_opt_out + expect(initializer_content).to include('g.rolemodel webpack: false') + end + + it 'is a no-op when the file does not exist' do + result = described_class.remove(:webpack, destination_root: destination_root) + + expect(result).to eq :not_recorded + expect(File).not_to exist(initializer_path) + end + + it 'is a no-op when the key is not recorded' do + described_class.record(:webpack, destination_root: destination_root) + + expect(described_class.remove(:sentry, destination_root: destination_root)).to eq :not_recorded + expect(recorded_keys).to eq %w[webpack] + end + end +end diff --git a/spec/rolemodel/requires_generator_spec.rb b/spec/rolemodel/requires_generator_spec.rb new file mode 100644 index 00000000..b8cccf3c --- /dev/null +++ b/spec/rolemodel/requires_generator_spec.rb @@ -0,0 +1,145 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'tmpdir' + +RSpec.describe 'Rolemodel::GeneratorBase.requires_generator', :generator_config do + around do |example| + Dir.mktmpdir do |dir| + @destination_root = dir + example.run + end + end + + attr_reader :destination_root + + # Thor::Base.start rescues Thor::Error (prints it and exits non-zero), so + # examples asserting on the raised error drive the generator through + # invoke_all — the same entry point start uses underneath. + def build_generator(klass, behavior: :invoke) + klass.new([], {}, destination_root: destination_root, behavior: behavior) + end + + def probe_file + File.expand_path('probe.txt', destination_root) + end + + it 'raises Thor::Error before any action runs when the key is not recorded' do + class Rolemodel::GuardedProbeGenerator < Rolemodel::GeneratorBase + skip_registry_entry! + requires_generator :prerequisite_probe + + def leave_a_mark + create_file 'probe.txt', "probe\n" + end + end + + expect { build_generator(Rolemodel::GuardedProbeGenerator).invoke_all } + .to raise_error(Thor::Error, /prerequisite_probe/) + expect(File).not_to exist(probe_file) + ensure + remove_generators Rolemodel::GuardedProbeGenerator + end + + it 'names the missing generator and the seeder in the abort message' do + class Rolemodel::GuardedProbeGenerator < Rolemodel::GeneratorBase + skip_registry_entry! + requires_generator :prerequisite_probe + end + + expect { build_generator(Rolemodel::GuardedProbeGenerator).invoke_all } + .to raise_error(Thor::Error) do |error| + expect(error.message).to include('bin/rails generate rolemodel:prerequisite_probe') + expect(error.message).to include('bin/rails generate rolemodel:registry') + end + ensure + remove_generators Rolemodel::GuardedProbeGenerator + end + + it 'aborts a start-driven run with the message on stderr and no files created' do + # Thor::Base.start rescues Thor::Error and reports it through the shell; + # Rails::Generators::Base.exit_on_failure? is false (railties 8.1.2), so + # no SystemExit is raised here — bin/rails handles the exit status. + class Rolemodel::GuardedProbeGenerator < Rolemodel::GeneratorBase + skip_registry_entry! + requires_generator :prerequisite_probe + + def leave_a_mark + create_file 'probe.txt', "probe\n" + end + end + + expect { Rolemodel::GuardedProbeGenerator.start([], destination_root: destination_root) } + .to output(/prerequisite_probe/).to_stderr + expect(File).not_to exist(probe_file) + ensure + remove_generators Rolemodel::GuardedProbeGenerator + end + + it 'proceeds when the key is recorded' do + apply_generator_config { |g| g.rolemodel prerequisite_probe: true } + + class Rolemodel::GuardedProbeGenerator < Rolemodel::GeneratorBase + skip_registry_entry! + requires_generator :prerequisite_probe + + def leave_a_mark + create_file 'probe.txt', "probe\n" + end + end + + quietly { build_generator(Rolemodel::GuardedProbeGenerator).invoke_all } + + expect(File).to exist(probe_file) + ensure + remove_generators Rolemodel::GuardedProbeGenerator + end + + it 'no-ops for a generator with no declarations' do + class Rolemodel::UnguardedProbeGenerator < Rolemodel::GeneratorBase + skip_registry_entry! + + def leave_a_mark + create_file 'probe.txt', "probe\n" + end + end + + quietly { build_generator(Rolemodel::UnguardedProbeGenerator).invoke_all } + + expect(File).to exist(probe_file) + ensure + remove_generators Rolemodel::UnguardedProbeGenerator + end + + it 'never blocks rails destroy: an unmet prerequisite proceeds under behavior :revoke' do + class Rolemodel::GuardedProbeGenerator < Rolemodel::GeneratorBase + skip_registry_entry! + requires_generator :prerequisite_probe + + def leave_a_mark + create_file 'probe.txt', "probe\n" + end + end + + expect { quietly { build_generator(Rolemodel::GuardedProbeGenerator, behavior: :revoke).invoke_all } } + .not_to raise_error + ensure + remove_generators Rolemodel::GuardedProbeGenerator + end + + it 'accumulates keys down the inheritance chain without polluting ancestors' do + class Rolemodel::GuardedParentGenerator < Rolemodel::GeneratorBase + requires_generator :alpha_probe + end + + class Rolemodel::GuardedChildGenerator < Rolemodel::GuardedParentGenerator + requires_generator :beta_probe + end + + expect(Rolemodel::GuardedChildGenerator.required_generator_keys).to eq %i[alpha_probe beta_probe] + expect(Rolemodel::GuardedParentGenerator.required_generator_keys).to eq %i[alpha_probe] + expect(Rolemodel::GeneratorBase.required_generator_keys).to eq [] + ensure + remove_generators Rolemodel::GuardedChildGenerator, Rolemodel::GuardedParentGenerator + end +end diff --git a/spec/support/helpers/generator_config.rb b/spec/support/helpers/generator_config.rb new file mode 100644 index 00000000..1f82efec --- /dev/null +++ b/spec/support/helpers/generator_config.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require 'rails/configuration' + +# Helpers for specs that exercise the generator registry's read path: +# Rails::Generators.options populated from a consuming app's config.generators. +# +# Rails::Generators memoizes that state process-globally and configure! +# deep-merges into it, so any example that touches it must run under the +# :generator_config metadata, which snapshots and restores the state around +# the example. Without it, configured keys leak into later examples and +# registry-dependent specs become order-dependent. +module GeneratorConfig + GENERATOR_STATE = %i[ + @options @aliases @fallbacks @templates_path @hidden_namespaces @after_generate_callbacks + ].freeze + + # Applies config the same way a consuming app's `config.generators` block + # does: through Rails::Configuration::Generators and Rails::Generators.configure!. + def apply_generator_config + config = Rails::Configuration::Generators.new + yield config + Rails::Generators.configure!(config) + end + + # Removes generator classes defined inside an example: deregisters them from + # Rails::Generators.subclasses (the namespace lookup index) and drops the constant. + def remove_generators(*klasses) + klasses.each do |klass| + Rails::Generators.subclasses.delete(klass) + parts = klass.name.split('::') + const = parts.pop + mod = parts.empty? ? Object : Object.const_get(parts.join('::')) + mod.send(:remove_const, const) if mod.const_defined?(const, false) + end + end + + def quietly + original = $stdout + $stdout = StringIO.new + yield + ensure + $stdout = original + end +end + +RSpec.configure do |config| + config.include GeneratorConfig + + config.around(:each, :generator_config) do |example| + saved = GeneratorConfig::GENERATOR_STATE.to_h do |ivar| + [ivar, Rails::Generators.instance_variable_get(ivar).deep_dup] + end + example.run + ensure + saved.each { |ivar, value| Rails::Generators.instance_variable_set(ivar, value) } + end +end