Skip to content
Snippets Groups Projects
Code owners
Assign users and groups as approvers for specific file changes. Learn more.
cli.rb 8.76 KiB
require "optparse"
require "shellwords"

require "extend/optparse"

require "hbc/cli/base"
require "hbc/cli/audit"
require "hbc/cli/cat"
require "hbc/cli/cleanup"
require "hbc/cli/create"
require "hbc/cli/doctor"
require "hbc/cli/edit"
require "hbc/cli/fetch"
require "hbc/cli/home"
require "hbc/cli/info"
require "hbc/cli/install"
require "hbc/cli/list"
require "hbc/cli/search"
require "hbc/cli/style"
require "hbc/cli/uninstall"
require "hbc/cli/update"
require "hbc/cli/zap"

require "hbc/cli/internal_use_base"
require "hbc/cli/internal_audit_modified_casks"
require "hbc/cli/internal_checkurl"
require "hbc/cli/internal_dump"
require "hbc/cli/internal_help"
require "hbc/cli/internal_stanza"

module Hbc
  class CLI
    ALIASES = {
                "ls"       => "list",
                "homepage" => "home",
                "-S"       => "search",    # verb starting with "-" is questionable
                "up"       => "update",
                "instal"   => "install",   # gem does the same
                "rm"       => "uninstall",
                "remove"   => "uninstall",
                "abv"      => "info",
                "dr"       => "doctor",
                # aliases from Homebrew that we don't (yet) support
                # 'ln'          => 'link',
                # 'configure'   => 'diy',
                # '--repo'      => '--repository',
                # 'environment' => '--env',
                # '-c1'         => '--config',
              }.freeze

    OPTIONS = {
                "--caskroom="             => :caskroom=,
                "--appdir="               => :appdir=,
                "--colorpickerdir="       => :colorpickerdir=,
                "--prefpanedir="          => :prefpanedir=,
                "--qlplugindir="          => :qlplugindir=,
                "--fontdir="              => :fontdir=,
                "--servicedir="           => :servicedir=,
                "--input_methoddir="      => :input_methoddir=,
                "--internet_plugindir="   => :internet_plugindir=,
                "--audio_unit_plugindir=" => :audio_unit_plugindir=,
                "--vst_plugindir="        => :vst_plugindir=,
                "--vst3_plugindir="       => :vst3_plugindir=,
                "--screen_saverdir="      => :screen_saverdir=,
              }.freeze

    FLAGS = {
              "--no-binaries" => :no_binaries=,
              "--debug"       => :debug=,
              "--verbose"     => :verbose=,
              "--outdated"    => :cleanup_outdated=,
              "--help"        => :help=,
            }.freeze

    def self.command_classes
      @command_classes ||= self.constants
                               .map(&method(:const_get))
                               .select { |sym| sym.respond_to?(:run) }
    end

    def self.commands
      @commands ||= command_classes.map(&:command_name)
    end

    def self.lookup_command(command_string)
      @lookup ||= Hash[commands.zip(command_classes)]
      command_string = ALIASES.fetch(command_string, command_string)
      @lookup.fetch(command_string, command_string)
    end

    # modified from Homebrew
    def self.require?(path)
      require path
      true # OK if already loaded
    rescue LoadError => e
      # HACK: :( because we should raise on syntax errors
      #       but not if the file doesn't exist.
      # TODO: make robust!
      raise unless e.to_s.include? path
    end

    def self.should_init?(command)
      (command.is_a? Class) && (command < CLI::Base) && command.needs_init?
    end

    def self.run_command(command, *rest)
      if command.respond_to?(:run)
        # usual case: built-in command verb
        command.run(*rest)
      elsif require? which("brewcask-#{command}.rb").to_s
        # external command as Ruby library on PATH, Homebrew-style
      elsif command.to_s.include?("/") && require?(command.to_s)
        # external command as Ruby library with literal path, useful
        # for development and troubleshooting
        sym = Pathname.new(command.to_s).basename(".rb").to_s.capitalize
        klass = begin
                  self.const_get(sym)
                rescue NameError
                  nil
                end

        if klass.respond_to?(:run)
          # invoke "run" on a Ruby library which follows our coding conventions
          # other Ruby libraries must do everything via "require"
          klass.run(*rest)
        end
      elsif which("brewcask-#{command}")
        # arbitrary external executable on PATH, Homebrew-style
        exec "brewcask-#{command}", *ARGV[1..-1]
      elsif Pathname.new(command.to_s).executable? &&
            command.to_s.include?("/") &&
            !command.to_s.match(/\.rb$/)
        # arbitrary external executable with literal path, useful
        # for development and troubleshooting
        exec command, *ARGV[1..-1]
      else
        # failure
        NullCommand.new(command).run
      end
    end

    def self.process(arguments)
      command_string, *rest = *arguments
      rest = process_options(rest)
      command = Hbc.help ? "help" : lookup_command(command_string)
      Hbc.default_tap.install unless Hbc.default_tap.installed?
      Hbc.init if should_init?(command)
      run_command(command, *rest)
    rescue CaskError, CaskSha256MismatchError => e
      msg = e.message
      msg << e.backtrace.join("\n") if Hbc.debug
      onoe msg
      exit 1
    rescue StandardError, ScriptError, NoMemoryError => e
      msg = "#{e.message}\n"
      msg << Utils.error_message_with_suggestions
      msg << e.backtrace.join("\n")
      onoe msg
      exit 1
    end

    def self.nice_listing(cask_list)
      cask_taps = {}
      cask_list.each do |c|
        user, repo, token = c.split "/"
        repo.sub!(/^homebrew-/i, "")
        cask_taps[token] ||= []
        cask_taps[token].push "#{user}/#{repo}"
      end
      list = []
      cask_taps.each do |token, taps|
        if taps.length == 1
          list.push token
        else
          taps.each { |r| list.push [r, token].join "/" }
        end
      end
      list.sort
    end

    def self.parser
      # If you modify these arguments, please update USAGE.md
      @parser ||= OptionParser.new do |opts|
        opts.on("--language STRING") do
          # handled in OS::Mac
        end

        OPTIONS.each do |option, method|
          opts.on("#{option}" "PATH", Pathname) do |path|
            Hbc.public_send(method, path)
          end
        end

        opts.on("--binarydir=PATH") do
          opoo <<-EOS.undent
            Option --binarydir is obsolete!
            Homebrew-Cask now uses the same location as your Homebrew installation for executable links.
          EOS
        end

        FLAGS.each do |flag, method|
          opts.on(flag) do
            Hbc.public_send(method, true)
          end
        end

        opts.on("--version") do
          raise OptionParser::InvalidOption # override default handling of --version
        end
      end
    end

    def self.process_options(args)
      all_args = Shellwords.shellsplit(ENV["HOMEBREW_CASK_OPTS"] || "") + args
      remaining = []
      until all_args.empty?
        begin
          head = all_args.shift
          remaining.concat(parser.parse([head]))
        rescue OptionParser::InvalidOption
          remaining << head
          retry
        rescue OptionParser::MissingArgument
          raise CaskError, "The option '#{head}' requires an argument"
        rescue OptionParser::AmbiguousOption
          raise CaskError, "There is more than one possible option that starts with '#{head}'"
        end
      end

      # for compat with Homebrew, not certain if this is desirable
      Hbc.verbose = true if !ENV["VERBOSE"].nil? || !ENV["HOMEBREW_VERBOSE"].nil?

      remaining
    end

    class NullCommand
      def initialize(attempted_verb)
        @attempted_verb = attempted_verb
      end

      def run(*args)
        if args.include?("--version") || @attempted_verb == "--version"
          puts Hbc.full_version
        else
          purpose
          usage
          unless @attempted_verb.to_s.strip.empty? || @attempted_verb == "help"
            raise CaskError, "Unknown command: #{@attempted_verb}"
          end
        end
      end

      def purpose
        puts <<-EOS.undent
          brew-cask provides a friendly homebrew-style CLI workflow for the
          administration of macOS applications distributed as binaries.

        EOS
      end

      def usage
        max_command_len = CLI.commands.map(&:length).max

        puts "Commands:\n\n"
        CLI.command_classes.each do |klass|
          next unless klass.visible
          puts "    #{klass.command_name.ljust(max_command_len)}  #{_help_for(klass)}"
        end
        puts %Q{\nSee also "man brew-cask"}
      end

      def help
        ""
      end

      def _help_for(klass)
        klass.respond_to?(:help) ? klass.help : nil
      end
    end
  end
end