lib/rails/generators/rails/app/app_generator.rb



# frozen_string_literal: true

require "rails/generators/app_base"
require "rails/generators/rails/devcontainer/devcontainer_generator"

module Rails
  module ActionMethods # :nodoc:
    attr_reader :options

    def initialize(generator)
      @generator = generator
      @options   = generator.options
    end

    private
      %w(template copy_file directory empty_directory inside
         empty_directory_with_keep_file create_file chmod shebang).each do |method|
        class_eval <<-RUBY, __FILE__, __LINE__ + 1
          def #{method}(...)
            @generator.send(:#{method}, ...)
          end
        RUBY
      end

      def method_missing(...)
        @generator.send(...)
      end
  end

  # The application builder allows you to override elements of the application
  # generator without being forced to reverse the operations of the default
  # generator.
  #
  # This allows you to override entire operations, like the creation of the
  # Gemfile, README, or JavaScript files, without needing to know exactly
  # what those operations do so you can create another template action.
  #
  #  class CustomAppBuilder < Rails::AppBuilder
  #    def test
  #      @generator.gem "rspec-rails", group: [:development, :test]
  #      run "bundle install"
  #      generate "rspec:install"
  #    end
  #  end
  class AppBuilder
    def rakefile
      template "Rakefile"
    end

    def readme
      copy_file "README.md", "README.md"
    end

    def ruby_version
      template "ruby-version", ".ruby-version"
    end

    def node_version
      template "node-version", ".node-version"
    end

    def gemfile
      template "Gemfile"
    end

    def configru
      template "config.ru"
    end

    def gitignore
      template "gitignore", ".gitignore"
    end

    def gitattributes
      template "gitattributes", ".gitattributes"
    end

    def dockerfiles
      template "Dockerfile"
      template "dockerignore", ".dockerignore"

      template "docker-entrypoint", "bin/docker-entrypoint"
      chmod "bin/docker-entrypoint", 0755 & ~File.umask, verbose: false
    end

    def cifiles
      empty_directory ".github/workflows"
      template "github/ci.yml", ".github/workflows/ci.yml"
      template "github/dependabot.yml", ".github/dependabot.yml"
    end

    def rubocop
      template "rubocop.yml", ".rubocop.yml"
    end

    def version_control
      if !options[:skip_git] && !options[:pretend]
        run git_init_command, capture: options[:quiet], abort_on_failure: false
      end
    end

    def app
      directory "app"

      empty_directory_with_keep_file "app/assets/images"

      keep_file  "app/controllers/concerns"
      keep_file  "app/models/concerns"
    end

    def bin
      exclude_pattern = Regexp.union([(/rubocop/ if skip_rubocop?), (/brakeman/ if skip_brakeman?)].compact)
      directory "bin", { exclude_pattern: exclude_pattern } do |content|
        "#{shebang}\n" + content
      end
      chmod "bin", 0755 & ~File.umask, verbose: false
    end

    def bin_when_updating
      bin
    end

    def config
      empty_directory "config"

      inside "config" do
        template "routes.rb" unless options[:update]
        template "application.rb"
        template "environment.rb"
        template "cable.yml" unless options[:update] || options[:skip_action_cable]
        template "puma.rb"
        template "storage.yml" unless options[:update] || skip_active_storage?

        directory "environments"
        directory "initializers"
        directory "locales" unless options[:update]
      end
    end

    def config_when_updating
      action_cable_config_exist       = File.exist?("config/cable.yml")
      active_storage_config_exist     = File.exist?("config/storage.yml")
      rack_cors_config_exist          = File.exist?("config/initializers/cors.rb")
      assets_config_exist             = File.exist?("config/initializers/assets.rb")
      asset_manifest_exist            = File.exist?("app/assets/config/manifest.js")
      asset_app_stylesheet_exist      = File.exist?("app/assets/stylesheets/application.css")
      csp_config_exist                = File.exist?("config/initializers/content_security_policy.rb")
      permissions_policy_config_exist = File.exist?("config/initializers/permissions_policy.rb")

      @config_target_version = Rails.application.config.loaded_config_version || "5.0"

      config

      if !options[:skip_action_cable] && !action_cable_config_exist
        template "config/cable.yml"
      end

      if !skip_active_storage? && !active_storage_config_exist
        template "config/storage.yml"
      end

      if skip_sprockets? && skip_propshaft? && !assets_config_exist
        remove_file "config/initializers/assets.rb"
      end

      if skip_sprockets? && !asset_manifest_exist
        remove_file "app/assets/config/manifest.js"
      end

      if skip_sprockets? && !asset_app_stylesheet_exist
        remove_file "app/assets/stylesheets/application.css"
      end

      unless rack_cors_config_exist
        remove_file "config/initializers/cors.rb"
      end

      if options[:api]
        unless csp_config_exist
          remove_file "config/initializers/content_security_policy.rb"
        end

        unless permissions_policy_config_exist
          remove_file "config/initializers/permissions_policy.rb"
        end
      end
    end

    def master_key
      return if options[:pretend] || options[:dummy_app]

      require "rails/generators/rails/master_key/master_key_generator"
      master_key_generator = Rails::Generators::MasterKeyGenerator.new([], quiet: options[:quiet], force: options[:force])
      master_key_generator.add_master_key_file_silently
      master_key_generator.ignore_master_key_file_silently
    end

    def credentials
      return if options[:pretend] || options[:dummy_app]

      require "rails/generators/rails/credentials/credentials_generator"
      Rails::Generators::CredentialsGenerator.new([], quiet: true).add_credentials_file
    end

    def credentials_diff_enroll
      return if options[:skip_decrypted_diffs] || options[:dummy_app] || options[:pretend]

      @generator.shell.mute do
        rails_command "credentials:diff --enroll", inline: true, shell: @generator.shell
      end
    end

    def database_yml
      template "config/databases/#{options[:database]}.yml", "config/database.yml"
    end

    def db
      directory "db"
    end

    def lib
      empty_directory "lib"
      empty_directory_with_keep_file "lib/tasks"
      empty_directory_with_keep_file "lib/assets"
    end

    def log
      empty_directory_with_keep_file "log"
    end

    def public_directory
      return if options[:update] && options[:api]

      directory "public", "public", recursive: false
    end

    def storage
      empty_directory_with_keep_file "storage"
      empty_directory_with_keep_file "tmp/storage"
    end

    def test
      empty_directory_with_keep_file "test/fixtures/files"
      empty_directory_with_keep_file "test/controllers"
      empty_directory_with_keep_file "test/mailers"
      empty_directory_with_keep_file "test/models"
      empty_directory_with_keep_file "test/helpers"
      empty_directory_with_keep_file "test/integration"

      template "test/channels/application_cable/connection_test.rb"
      template "test/test_helper.rb"
    end

    def system_test
      empty_directory_with_keep_file "test/system"

      template "test/application_system_test_case.rb"
    end

    def tmp
      empty_directory_with_keep_file "tmp"
      empty_directory_with_keep_file "tmp/pids"
    end

    def vendor
      empty_directory_with_keep_file "vendor"
    end

    def config_target_version
      @config_target_version || Rails::VERSION::STRING.to_f
    end

    def devcontainer
      devcontainer_options = {
        database: options[:database],
        redis: !(options[:skip_action_cable] && options[:skip_active_job]),
        system_test: depends_on_system_test?,
        active_storage: !options[:skip_active_storage],
        dev: options[:dev],
        node: using_node?,
        app_name: app_name
      }

      Rails::Generators::DevcontainerGenerator.new([], devcontainer_options).invoke_all
    end
  end

  module Generators
    class AppGenerator < AppBase
      # :stopdoc:

      add_shared_options_for "application"

      # Add rails command options
      class_option :version, type: :boolean, aliases: "-v", group: :rails, desc: "Show Rails version number and quit"
      class_option :api, type: :boolean, desc: "Preconfigure smaller stack for API only apps"
      class_option :minimal, type: :boolean, desc: "Preconfigure a minimal rails app"
      class_option :javascript, type: :string, aliases: ["-j", "--js"], default: "importmap", enum: JAVASCRIPT_OPTIONS, desc: "Choose JavaScript approach"
      class_option :css, type: :string, aliases: "-c", enum: CSS_OPTIONS, desc: "Choose CSS processor. Check https://github.com/rails/cssbundling-rails for more options"
      class_option :skip_bundle, type: :boolean, aliases: "-B", default: nil, desc: "Don't run bundle install"
      class_option :skip_decrypted_diffs, type: :boolean, default: nil, desc: "Don't configure git to show decrypted diffs of encrypted credentials"

      OPTION_IMPLICATIONS = # :nodoc:
        AppBase::OPTION_IMPLICATIONS.merge(
          skip_git: [:skip_decrypted_diffs],
          minimal: [
            :skip_action_cable,
            :skip_action_mailbox,
            :skip_action_mailer,
            :skip_action_text,
            :skip_active_job,
            :skip_active_storage,
            :skip_bootsnap,
            :skip_dev_gems,
            :skip_hotwire,
            :skip_javascript,
            :skip_jbuilder,
            :skip_system_test,
          ],
          api: [
            :skip_asset_pipeline,
            :skip_javascript,
          ],
        ) do |option, implications, more_implications|
          implications + more_implications
        end

      META_OPTIONS = [:minimal] # :nodoc:

      def self.apply_rails_template(template, destination) # :nodoc:
        generator = new([destination], { template: template }, { destination_root: destination })
        generator.set_default_accessors!
        generator.apply_rails_template
        generator.run_bundle
        generator.run_after_bundle_callbacks
      end

      def initialize(*args)
        super

        imply_options(OPTION_IMPLICATIONS, meta_options: META_OPTIONS)

        @after_bundle_callbacks = []
      end

      public_task :report_implied_options
      public_task :set_default_accessors!
      public_task :create_root
      public_task :target_rails_prerelease

      def create_root_files
        build(:readme)
        build(:rakefile)
        build(:node_version) if using_node?
        build(:ruby_version)
        build(:configru)

        unless options[:skip_git]
          build(:gitignore)
          build(:gitattributes)
        end

        build(:gemfile)
        build(:version_control)
      end

      def create_app_files
        build(:app)
      end

      def create_bin_files
        build(:bin)
      end

      def update_bin_files
        build(:bin_when_updating)
      end
      remove_task :update_bin_files

      def update_active_storage
        unless skip_active_storage?
          rails_command "active_storage:update", inline: true
        end
      end
      remove_task :update_active_storage

      def create_dockerfiles
        return if options[:skip_docker] || options[:dummy_app]
        build(:dockerfiles)
      end

      def create_rubocop_file
        return if skip_rubocop?
        build(:rubocop)
      end

      def create_cifiles
        return if skip_ci?
        build(:cifiles)
      end

      def create_config_files
        build(:config)
      end

      def update_config_files
        build(:config_when_updating)
      end
      remove_task :update_config_files

      def create_master_key
        build(:master_key)
      end

      def create_credentials
        build(:credentials)
        build(:credentials_diff_enroll)
      end

      def create_boot_file
        template "config/boot.rb"
      end

      def create_active_record_files
        return if options[:skip_active_record]
        build(:database_yml)
      end

      def create_db_files
        return if options[:skip_active_record]
        build(:db)
      end

      def create_lib_files
        build(:lib)
      end

      def create_log_files
        build(:log)
      end

      def create_public_files
        build(:public_directory)
      end

      def create_tmp_files
        build(:tmp)
      end

      def create_vendor_files
        build(:vendor)
      end

      def create_test_files
        build(:test) unless options[:skip_test]
      end

      def create_system_test_files
        build(:system_test) if depends_on_system_test?
      end

      def create_storage_files
        build(:storage) unless skip_storage?
      end

      def create_devcontainer_files
        return if skip_devcontainer? || options[:dummy_app]
        build(:devcontainer)
      end

      def delete_app_assets_if_api_option
        if options[:api]
          remove_dir "app/assets"
          remove_dir "lib/assets"
        end
      end

      def delete_app_helpers_if_api_option
        if options[:api]
          remove_dir "app/helpers"
          remove_dir "test/helpers"
        end
      end

      def delete_app_views_if_api_option
        if options[:api]
          if options[:skip_action_mailer]
            remove_dir "app/views"
          else
            remove_file "app/views/layouts/application.html.erb"
            remove_dir  "app/views/pwa"
          end
        end
      end

      def delete_public_files_if_api_option
        if options[:api]
          remove_file "public/404.html"
          remove_file "public/406-unsupported-browser.html"
          remove_file "public/422.html"
          remove_file "public/500.html"
          remove_file "public/icon.png"
          remove_file "public/icon.svg"
        end
      end

      def delete_assets_initializer_skipping_sprockets_and_propshaft
        if skip_sprockets? && skip_propshaft?
          remove_file "config/initializers/assets.rb"
        end

        if skip_sprockets?
          remove_file "app/assets/config/manifest.js"
          remove_dir  "app/assets/config"
          remove_file "app/assets/stylesheets/application.css"
          create_file "app/assets/stylesheets/application.css", "/* Application styles */\n" unless options[:api]
        end
      end

      def delete_application_record_skipping_active_record
        if options[:skip_active_record]
          remove_file "app/models/application_record.rb"
        end
      end

      def delete_active_job_folder_if_skipping_active_job
        if options[:skip_active_job]
          remove_dir "app/jobs"
        end
      end

      def delete_action_mailer_files_skipping_action_mailer
        if options[:skip_action_mailer]
          remove_file "app/views/layouts/mailer.html.erb"
          remove_file "app/views/layouts/mailer.text.erb"
          remove_dir "app/mailers"
          remove_dir "test/mailers"
        end
      end

      def delete_action_cable_files_skipping_action_cable
        if options[:skip_action_cable]
          remove_dir "app/javascript/channels"
          remove_dir "app/channels"
          remove_dir "test/channels"
        end
      end

      def delete_non_api_initializers_if_api_option
        if options[:api]
          remove_file "config/initializers/content_security_policy.rb"
          remove_file "config/initializers/permissions_policy.rb"
        end
      end

      def delete_api_initializers
        unless options[:api]
          remove_file "config/initializers/cors.rb"
        end
      end

      def delete_new_framework_defaults
        unless options[:update]
          remove_file "config/initializers/new_framework_defaults_#{Rails::VERSION::MAJOR}_#{Rails::VERSION::MINOR}.rb"
        end
      end

      def finish_template
        build(:leftovers)
      end

      public_task :apply_rails_template
      public_task :run_bundle
      public_task :add_bundler_platforms
      public_task :generate_bundler_binstub
      public_task :run_javascript
      public_task :run_hotwire
      public_task :run_css

      def run_after_bundle_callbacks
        @after_bundle_callbacks.each(&:call)
      end

      def self.banner
        "rails new #{arguments.map(&:usage).join(' ')} [options]"
      end

    # :startdoc:

    private
      # Define file as an alias to create_file for backwards compatibility.
      def file(*args, &block)
        create_file(*args, &block)
      end

      # Registers a callback to be executed after bundle binstubs
      # have run.
      #
      #   after_bundle do
      #     git add: '.'
      #   end
      def after_bundle(&block) # :doc:
        @after_bundle_callbacks << block
      end

      def get_builder_class
        defined?(::AppBuilder) ? ::AppBuilder : Rails::AppBuilder
      end
    end

    # This class handles preparation of the arguments before the AppGenerator is
    # called. The class provides version or help information if they were
    # requested, and also constructs the railsrc file (used for extra configuration
    # options).
    #
    # This class should be called before the AppGenerator is required and started
    # since it configures and mutates ARGV correctly.
    class ARGVScrubber # :nodoc:
      def initialize(argv = ARGV)
        @argv = argv
      end

      def prepare!
        handle_version_request!(@argv.first)
        handle_invalid_command!(@argv.first, @argv) do
          handle_rails_rc!(@argv.drop(1))
        end
      end

      def self.default_rc_file
        xdg_config_home = ENV["XDG_CONFIG_HOME"].presence || "~/.config"
        xdg_railsrc = File.expand_path("rails/railsrc", xdg_config_home)
        if File.exist?(xdg_railsrc)
          xdg_railsrc
        else
          File.expand_path("~/.railsrc")
        end
      end

      private
        def handle_version_request!(argument)
          if ["--version", "-v"].include?(argument)
            require "rails/version"
            puts "Rails #{Rails::VERSION::STRING}"
            exit(0)
          end
        end

        def handle_invalid_command!(argument, argv)
          if argument == "new"
            yield
          else
            ["--help"] + argv.drop(1)
          end
        end

        def handle_rails_rc!(argv)
          if argv.find { |arg| arg == "--no-rc" }
            argv.reject { |arg| arg == "--no-rc" }
          else
            railsrc(argv) { |rc_argv, rc| insert_railsrc_into_argv!(rc_argv, rc) }
          end
        end

        def railsrc(argv)
          if (customrc = argv.index { |x| x.include?("--rc=") })
            fname = File.expand_path(argv[customrc].gsub(/--rc=/, ""))
            yield(argv.take(customrc) + argv.drop(customrc + 1), fname)
          else
            yield argv, self.class.default_rc_file
          end
        end

        def read_rc_file(railsrc)
          extra_args = File.readlines(railsrc).flat_map.each { |line| line.split("#", 2).first.split }
          puts "Using #{extra_args.join(" ")} from #{railsrc}"
          extra_args
        end

        def insert_railsrc_into_argv!(argv, railsrc)
          return argv unless File.exist?(railsrc)
          extra_args = read_rc_file railsrc
          argv.take(1) + extra_args + argv.drop(1)
        end
    end
  end
end