├── .gitignore ├── bin └── brew-cask-upgrade ├── .github ├── dependabot.yml └── workflows │ ├── style.yml │ └── ci.yml ├── .editorconfig ├── lib ├── bcu │ ├── command │ │ ├── command.rb │ │ ├── all.rb │ │ ├── pin_export.rb │ │ ├── livecheck.rb │ │ ├── pin_load.rb │ │ ├── pin_list.rb │ │ ├── pin_add.rb │ │ ├── pin_remove.rb │ │ └── upgrade.rb │ ├── module │ │ └── pin.rb │ └── options.rb ├── extend │ ├── version.rb │ ├── cask.rb │ └── formatter.rb └── bcu.rb ├── CONTRIBUTING.md ├── cmd ├── brewcask-upgrade └── brew-cu.rb ├── brew-cask-upgrade.gemspec ├── LICENSE └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .fleet 2 | .idea 3 | *.iml 4 | *.gem 5 | pinned 6 | .DS_Store 7 | -------------------------------------------------------------------------------- /bin/brew-cask-upgrade: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | BREW=`which brew` 4 | 5 | "$BREW" cu 6 | 7 | if [ $? -ne 0 ]; then 8 | BREW tap buo/cask-upgrade 9 | "$BREW" cu 10 | fi 11 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | 4 | # Maintain dependencies for GitHub Actions 5 | - package-ecosystem: "github-actions" 6 | directory: "/" 7 | schedule: 8 | interval: "weekly" 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /lib/bcu/command/command.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bcu 4 | class Command 5 | class CommandNotImplementedError < NoMethodError 6 | end 7 | 8 | def process(_args, _options) 9 | raise Command::CommandNotImplementedError, "#{self.class.name} needs to implement 'process' method!" 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/extend/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Cask 4 | class DSL 5 | class Version 6 | def before_hyphen 7 | version { split("-", 2).first } 8 | end 9 | 10 | def after_hyphen 11 | version { split("-", 2).second } 12 | end 13 | 14 | def before_separators 15 | version { before_hyphen.csv[0] } 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/bcu/command/all.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "#{File.dirname(__FILE__)}/command" 4 | require "#{File.dirname(__FILE__)}/livecheck" 5 | require "#{File.dirname(__FILE__)}/pin_add" 6 | require "#{File.dirname(__FILE__)}/pin_export" 7 | require "#{File.dirname(__FILE__)}/pin_list" 8 | require "#{File.dirname(__FILE__)}/pin_load" 9 | require "#{File.dirname(__FILE__)}/pin_remove" 10 | require "#{File.dirname(__FILE__)}/upgrade" 11 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to brew-cask-upgrade 2 | 3 | ## Report a bug 4 | 5 | - run `brew update` (twice). 6 | - run `brew doctor` and fix all the warnings. 7 | - run `brew cu`. 8 | - [create an issue](https://github.com/buo/homebrew-cask-upgrade/issues/new) if your problem still persists. 9 | 10 | ## Propose a new feature 11 | 12 | - Please create a pull request with your change proposal. 13 | 14 | Your contribution is always welcome. Thanks! 15 | -------------------------------------------------------------------------------- /lib/bcu/command/pin_export.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bcu/module/pin" 4 | 5 | module Bcu 6 | module Pin 7 | class Export < Command 8 | def process(_, options) 9 | file_name = options.backup_filename 10 | File.open(file_name, "w+") do |file| 11 | Pin.pinned.each do |cask_name| 12 | file.puts cask_name 13 | end 14 | end 15 | puts Formatter.success "Pins exported to #{file_name}" 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/bcu/command/livecheck.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bcu/module/pin" 4 | 5 | module Bcu 6 | class Livecheck < Command 7 | def process(_args, options) 8 | return run_process(options) if $stdout.tty? 9 | 10 | redirect_stdout($stderr) do 11 | run_process(options) 12 | end 13 | end 14 | 15 | def run_process(options) 16 | installed = Cask.installed_apps(options) 17 | installed.each do |app| 18 | system "brew", "livecheck", "--cask", app[:token] 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/bcu/module/pin.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bcu 4 | module Pin 5 | PINS_FILE = File.expand_path("#{File.dirname(__FILE__)}/../../../pinned").freeze 6 | 7 | module_function 8 | 9 | def pinned 10 | @pinned ||= begin 11 | # noinspection RubyArgCount 12 | FileUtils.touch PINS_FILE 13 | 14 | pinned = Set[] 15 | File.open(PINS_FILE, "r") do |f| 16 | f.each_line do |cask| 17 | pinned.add(cask.rstrip) 18 | end 19 | end 20 | 21 | pinned 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/bcu/command/pin_load.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bcu/module/pin" 4 | 5 | module Bcu 6 | module Pin 7 | class Load < Command 8 | def process(_, options) 9 | file_name = options.backup_filename 10 | File.open(file_name, "r") do |source_file| 11 | File.open PINS_FILE, "w+" do |file| 12 | source_file.each_line do |cask| 13 | file.puts(cask.rstrip) 14 | end 15 | end 16 | end 17 | puts Formatter.success "Pins loaded from #{file_name}" 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /cmd/brewcask-upgrade: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # 3 | #Create a noninteractive `brew-cask` external command by wrapping `brew-cu` (https://github.com/buo/homebrew-cask-upgrade) 4 | 5 | #Use `brew-cu` to list all outdated casks, but reject upgrading them if `brew-cu` wants interactive input, by feeding it a stream of "n" 6 | yes n | brew cu "${@}" 7 | 8 | if [ "${#}" = "0" ] 9 | then 10 | #If no arguments were passed to `brew-cu`, clear the last line of the terminal, which contains the prompt for user input 11 | tput cr && tput el 12 | 13 | #Print a message explaining how to upgrade outdated casks in place of the interactive prompt 14 | echo 'If there are outdated casks above, you can upgrade them by running `brew cask upgrade --yes`' 15 | fi 16 | -------------------------------------------------------------------------------- /lib/bcu/command/pin_list.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bcu/module/pin" 4 | 5 | module Bcu 6 | module Pin 7 | class List < Command 8 | def process(_args, _options) 9 | list_pinned 10 | end 11 | 12 | private 13 | 14 | def list_pinned 15 | casks = [] 16 | Pin.pinned.each do |cask_name| 17 | add_cask cask_name, casks 18 | end 19 | 20 | Formatter.print_pin_table casks unless casks.empty? 21 | end 22 | 23 | def add_cask(cask_name, casks) 24 | casks.push Cask.load_cask(cask_name) 25 | rescue Cask::CaskUnavailableError 26 | Bcu::Pin::Remove.remove_pin cask_name 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /brew-cask-upgrade.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Gem::Specification.new do |s| 4 | s.name = "brew-cask-upgrade" 5 | s.version = "2.0.2" 6 | s.summary = "A command line tool for Homebrew Cask" 7 | s.description = "A command line tool for upgrading every outdated app installed by Homebrew Cask" 8 | s.authors = ["buo"] 9 | s.email = "buo@users.noreply.github.com" 10 | s.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 11 | s.homepage = "https://github.com/buo/homebrew-cask-upgrade" 12 | s.license = "MIT" 13 | 14 | s.bindir = "bin" 15 | s.executables = %w[brew-cask-upgrade] 16 | s.required_ruby_version = "> 3.4.0" 17 | s.metadata = { 18 | "rubygems_mfa_required" => "true", 19 | } 20 | end 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 buo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/bcu/command/pin_add.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bcu/module/pin" 4 | 5 | module Bcu 6 | module Pin 7 | class Add < Command 8 | def process(args, options) 9 | pin = args[1] 10 | # TODO: If we used deprecated --pin option, the value is not any more in the args 11 | pin = options.pin if pin.nil? 12 | 13 | add_pin pin 14 | end 15 | 16 | private 17 | 18 | def add_pin(cask_name) 19 | return run_add_pin(cask_name) if $stdout.tty? 20 | 21 | redirect_stdout($stderr) do 22 | run_add_pin(cask_name) 23 | end 24 | end 25 | 26 | def run_add_pin(cask_name) 27 | if Pin.pinned.include? cask_name 28 | puts "Already pinned: #{Tty.green}#{cask_name}#{Tty.reset}" 29 | return 30 | end 31 | 32 | cask = Cask.load_cask cask_name 33 | 34 | File.open(PINS_FILE, "a") do |f| 35 | f.puts(cask_name) 36 | end 37 | 38 | formatted_cask_name = "#{Tty.green}#{cask_name}#{Tty.reset}" 39 | formatted_version = "#{Tty.magenta}#{cask.installed_version}#{Tty.reset}" 40 | 41 | puts "Pinned: #{formatted_cask_name} in version #{formatted_version}" 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/bcu.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | $LOAD_PATH.unshift("#{HOMEBREW_REPOSITORY}/Library/Homebrew/cask/lib") 4 | 5 | require "bcu/options" 6 | require "bcu/command/all" 7 | # Causing following issue: 8 | # Error: No such file or directory @ rb_sysopen 9 | # - /opt/homebrew/Library/Homebrew/vendor/bundle/ruby/2.6.0/gems/addressable-2.8.0/data/unicode.data 10 | # 11 | # https://github.com/buo/homebrew-cask-upgrade/issues/205 12 | # require "cask" 13 | require "extend/formatter" 14 | require "extend/cask" 15 | require "extend/version" 16 | require "fileutils" 17 | require "shellwords" 18 | 19 | module Bcu 20 | def self.process(args) 21 | parse!(args) 22 | 23 | command = resolve_command options 24 | command.process args, options 25 | end 26 | 27 | # @param [Struct] options 28 | # @return [Command] 29 | def self.resolve_command(options) 30 | return Bcu::Pin::List.new if options.command == "pinned" 31 | return Bcu::Pin::Export.new if options.command == "export" 32 | return Bcu::Pin::Load.new if options.command == "load" 33 | return Bcu::Pin::Add.new if options.command == "pin" 34 | return Bcu::Pin::Remove.new if options.command == "unpin" 35 | return Bcu::Livecheck.new if options.command == "livecheck" 36 | 37 | Bcu::Upgrade.new 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/bcu/command/pin_remove.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bcu/module/pin" 4 | 5 | module Bcu 6 | module Pin 7 | class Remove < Command 8 | def process(args, options) 9 | pin = args[1] 10 | # TODO: If we used deprecated --pin option, the value is not any more in the args 11 | pin = options.unpin if pin.nil? 12 | 13 | remove_pin_instance pin 14 | end 15 | 16 | # Class method to remove a pin - can be called from other classes 17 | def self.remove_pin(cask) 18 | return run_remove_pin(cask) if $stdout.tty? 19 | 20 | redirect_stdout($stderr) do 21 | run_remove_pin(cask) 22 | end 23 | end 24 | 25 | def self.run_remove_pin(cask) 26 | unless Pin.pinned.include? cask 27 | puts "Not pinned: #{Tty.green}#{cask}#{Tty.reset}" 28 | return 29 | end 30 | 31 | Pin.pinned.delete(cask) 32 | 33 | File.open(PINS_FILE, "w") do |f| 34 | Pin.pinned.each do |csk| 35 | f.puts(csk) 36 | end 37 | end 38 | 39 | puts "Unpinned: #{Tty.green}#{cask}#{Tty.reset}" 40 | end 41 | private_class_method :run_remove_pin 42 | 43 | private 44 | 45 | def remove_pin_instance(cask) 46 | self.class.remove_pin(cask) 47 | end 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /.github/workflows/style.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: Style check 4 | 5 | # Controls when the workflow will run 6 | on: 7 | # Triggers the workflow on push or pull request events but only for the master branch 8 | push: 9 | branches: [ master ] 10 | pull_request: 11 | branches: [ master ] 12 | schedule: 13 | - cron: '0 7 * * 1' 14 | 15 | permissions: 16 | contents: read 17 | 18 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 19 | jobs: 20 | # This workflow contains a job called "style-check" 21 | style-check: 22 | # The type of runner that the job will run on 23 | runs-on: macos-26 24 | 25 | # Steps represent a sequence of tasks that will be executed as part of the job 26 | steps: 27 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 28 | - uses: actions/checkout@v6.0.1 29 | 30 | # Runs a single command using the runners shell 31 | - name: Brew Update 32 | run: | 33 | brew update 34 | brew --version 35 | 36 | - name: Setup Homebrew Tap 37 | run: | 38 | mkdir -p /opt/homebrew/Library/Taps/buo/homebrew-cask-upgrade 39 | cp -rf . /opt/homebrew/Library/Taps/buo/homebrew-cask-upgrade 40 | 41 | - name: Style check 42 | run: brew style --display-cop-names buo/homebrew-cask-upgrade 43 | -------------------------------------------------------------------------------- /cmd/brew-cu.rb: -------------------------------------------------------------------------------- 1 | #!/System/Library/Frameworks/Ruby.framework/Versions/2.0/usr/bin/ruby -W0 -EUTF-8:UTF-8 2 | # frozen_string_literal: true 3 | 4 | #: 5 | #:`USAGE` brew cu [command=run] [CASK] [options] 6 | #: 7 | #: `cu` [`options`] 8 | #: Upgrade every outdated app installed by `brew cask`. 9 | #: 10 | #: `cu` CASK [`options`] 11 | #: Upgrade a specific app. You can use star 12 | #: to include more apps (`brew cu flash-**`) 13 | #: to upgrade all flash related casks 14 | #: (might require escaping `brew cu flash-\*`). 15 | #: 16 | #: `cu pin` CASK 17 | #: Pin the current CASK version, preventing it from being 18 | #: upgraded when running the `brew cu` command. See also `unpin`. 19 | #: 20 | #: `cu unpin` CASK 21 | #: Unpin specified CASK, allowing them to be 22 | #: upgraded by `brew cu` command. See also `pin`. 23 | #: 24 | #: `cu pinned` 25 | #: Lists all CASKs that have been pinned. Add `--export FILENAME` 26 | #: option to export the configuration to a file and `--load` to 27 | #: load it back. 28 | #: 29 | #:`OPTIONS`: 30 | #: 31 | #: If `--all` or `-a` is passed, include apps that auto-update in the 32 | #: upgrade. 33 | #: 34 | #: If `--cleanup` is passed, clean up cached downloads and tracker symlinks 35 | #: after updating. 36 | #: 37 | #: If `--force` or `-f` is passed, include apps that are marked as latest 38 | #: (i.e. force-reinstall them). 39 | #: 40 | #: If `--no-brew-update` is passed, prevent auto-update of Homebrew, taps, 41 | #: and formulae before checking outdated apps. 42 | #: 43 | #: If `--yes` or `-y` is passed, update all outdated apps; answer yes to 44 | #: updating packages. 45 | #: 46 | #: If `--quiet` or `-q` is passed, do not show information about installed 47 | #: apps or current options. 48 | #: 49 | #: If `--verbose` or `-v` is passed, make output more verbose. 50 | #: 51 | #: If `--no-quarantine` is passed, that option will be added to the install 52 | #: command (see `man brew-cask` for reference) 53 | #: 54 | #: If `--include-mas` is passed, command will include Mac App Store apps 55 | #: in the result. It uses mas/mas-cli for such functionality. 56 | #: 57 | #:`INTERACTIVE MODE`: 58 | #: After listing casks to upgrade you want those casks to be upgraded. 59 | #: By using the option `i` you will step into an interactive mode. 60 | #: 61 | #: In the `interactive` mode you will be asked for every single app separately 62 | #: `y` will upgrade the app 63 | #: `p` will pin the app so it will not prompt you again, unless you unpin it 64 | #: `N` will skip the app upgrade 65 | #: 66 | #: All unknown options will be considered as `N` 67 | #: 68 | 69 | require "pathname" 70 | 71 | $LOAD_PATH.unshift(File.expand_path("../../lib", Pathname.new(__FILE__).realpath)) 72 | 73 | require "bcu" 74 | 75 | Bcu.process(ARGV) 76 | -------------------------------------------------------------------------------- /lib/extend/cask.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "livecheck/livecheck" 4 | 5 | # For backward-compatibility 6 | # See https://github.com/buo/homebrew-cask-upgrade/issues/97 7 | CASKROOM = (Cask.methods.include?(:caskroom) ? Cask.caskroom : Cask::Caskroom.path).freeze 8 | 9 | module Cask 10 | # @param [String] token 11 | # @return [DSL::Version] currently installed version 12 | def self.current_version(token, versions = nil) 13 | versions = installed_versions(token) if versions.nil? 14 | DSL::Version.new(versions.first) 15 | end 16 | 17 | def self.installed_apps(options) 18 | # Manually retrieve installed apps instead of using Cask.installed because 19 | # it raises errors while iterating and stops. 20 | installed = Dir["#{CASKROOM}/*"].map { |e| File.basename e } 21 | 22 | installed = installed.map do |token| 23 | puts "Checking installed versions for \"#{token}\"" if options.debug 24 | versions = installed_versions(token) 25 | puts "Versions: #{versions}" if options.debug 26 | current_version = self.current_version(token, versions) 27 | begin 28 | cask = load_cask(token) 29 | { 30 | cask:, 31 | name: cask.name.first, 32 | token: cask.token, 33 | version_full: cask.version.to_s, 34 | version: cask.version.nil? ? "" : cask.version.before_separators.to_s, 35 | current_full: current_version.to_s, 36 | current: current_version.nil? ? "" : current_version.before_separators.to_s, 37 | outdated?: cask.instance_of?(Cask) && versions.exclude?(cask.version.to_s), 38 | auto_updates: cask.auto_updates, 39 | homepage: cask.homepage, 40 | installed_versions: versions, 41 | tap: cask.tap&.name, 42 | mas: false, 43 | } 44 | rescue CaskUnavailableError 45 | { 46 | cask: nil, 47 | name: nil, 48 | token:, 49 | version_full: nil, 50 | version: nil, 51 | current_full: current_version.to_s, 52 | current: current_version.nil? ? "" : current_version.before_separators.to_s, 53 | outdated?: false, 54 | auto_updates: false, 55 | homepage: nil, 56 | installed_versions: versions, 57 | tap: nil, 58 | mas: false, 59 | } 60 | end 61 | end 62 | 63 | installed.sort_by { |a| a[:token] } 64 | end 65 | 66 | # See: https://github.com/buo/homebrew-cask-upgrade/issues/43 67 | def self.load_cask(token) 68 | begin 69 | cask = CaskLoader.load(token) 70 | rescue NoMethodError 71 | cask = Cask.load(token) 72 | end 73 | cask 74 | end 75 | 76 | # Retrieves currently installed versions on the machine. 77 | def self.installed_versions(token) 78 | Dir["#{CASKROOM}/#{token}/*"] 79 | .map { |e| File.basename e } 80 | .reject { |v| v.start_with?(".") } # Filter out hidden files like .metadata 81 | end 82 | 83 | def self.brew_update(verbose) 84 | if verbose 85 | SystemCommand.run(HOMEBREW_BREW_FILE, args: %w[update --verbose], print_stderr: true, print_stdout: true) 86 | else 87 | SystemCommand.run(HOMEBREW_BREW_FILE, args: ["update"], print_stderr: true, print_stdout: false) 88 | end 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | # Triggers the workflow on push or pull request events but only for the master branch 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | upgrade: 14 | strategy: 15 | matrix: 16 | os: 17 | - macos-14 18 | - macos-15 19 | - macos-26 20 | runs-on: ${{ matrix.os }} 21 | steps: 22 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 23 | - uses: actions/checkout@v6.0.1 24 | 25 | - name: Brew Update 26 | run: | 27 | brew update 28 | brew --version 29 | 30 | - name: Setup Homebrew Tap 31 | run: | 32 | mkdir -p /opt/homebrew/Library/Taps/buo/homebrew-cask-upgrade 33 | cp -rf . /opt/homebrew/Library/Taps/buo/homebrew-cask-upgrade 34 | 35 | - name: Run Brew Cask Upgrade 36 | run: brew cu -y -a --no-brew-update 37 | 38 | - name: Check no reported updates 39 | run: brew cu -y -a --no-brew-update --report-only 40 | 41 | pinning-workflow: 42 | strategy: 43 | matrix: 44 | os: 45 | - macos-26 46 | runs-on: ${{ matrix.os }} 47 | steps: 48 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 49 | - uses: actions/checkout@v6.0.1 50 | 51 | - name: Brew Update 52 | run: | 53 | brew update 54 | brew --version 55 | 56 | - name: Setup Homebrew Tap 57 | run: | 58 | mkdir -p /opt/homebrew/Library/Taps/buo/homebrew-cask-upgrade 59 | cp -rf . /opt/homebrew/Library/Taps/buo/homebrew-cask-upgrade 60 | 61 | - name: Verify cask is reporting outdated 62 | run: brew cu --all --no-brew-update google-chrome --report-only || test $? -eq 1 63 | 64 | - name: Pin google-chrome 65 | run: brew cu pin google-chrome 66 | 67 | - name: Verify it is not reporting outdated 68 | run: brew cu --all --no-brew-update google-chrome --report-only 69 | 70 | - name: Unpin google-chrome 71 | run: brew cu unpin google-chrome 72 | 73 | - name: Verify it is reporting outdated 74 | run: brew cu --all --no-brew-update google-chrome --report-only || test $? -eq 1 75 | 76 | pinned-export-workflow: 77 | strategy: 78 | matrix: 79 | os: 80 | - macos-26 81 | runs-on: ${{ matrix.os }} 82 | steps: 83 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 84 | - uses: actions/checkout@v6.0.1 85 | 86 | - name: Brew Update 87 | run: | 88 | brew update 89 | brew --version 90 | 91 | - name: Setup Homebrew Tap 92 | run: | 93 | mkdir -p /opt/homebrew/Library/Taps/buo/homebrew-cask-upgrade 94 | cp -rf . /opt/homebrew/Library/Taps/buo/homebrew-cask-upgrade 95 | 96 | - name: Pin google-chrome 97 | run: brew cu pin google-chrome 98 | 99 | - name: Export pinned casks 100 | run: brew cu pinned --export exported.txt 101 | 102 | - name: Setup expected file 103 | run: echo "google-chrome" > expected.txt 104 | 105 | - name: Verify exported file is as expected 106 | run: cmp exported.txt expected.txt 107 | 108 | pinned-load-workflow: 109 | strategy: 110 | matrix: 111 | os: 112 | - macos-26 113 | runs-on: ${{ matrix.os }} 114 | steps: 115 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 116 | - uses: actions/checkout@v6.0.1 117 | 118 | - name: Brew Update 119 | run: | 120 | brew update 121 | brew --version 122 | 123 | - name: Setup Homebrew Tap 124 | run: | 125 | mkdir -p /opt/homebrew/Library/Taps/buo/homebrew-cask-upgrade 126 | cp -rf . /opt/homebrew/Library/Taps/buo/homebrew-cask-upgrade 127 | 128 | - name: Setup backup file 129 | run: echo "google-chrome" > /opt/homebrew/Library/Taps/buo/homebrew-cask-upgrade/backup.txt 130 | 131 | - name: Import backup file 132 | run: brew cu pinned --load /opt/homebrew/Library/Taps/buo/homebrew-cask-upgrade/backup.txt 133 | 134 | - name: Verify exported file is as expected 135 | run: cd /opt/homebrew/Library/Taps/buo/homebrew-cask-upgrade && cmp pinned backup.txt 136 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![CI](https://github.com/buo/homebrew-cask-upgrade/actions/workflows/ci.yml/badge.svg)](https://github.com/buo/homebrew-cask-upgrade/actions/workflows/ci.yml) 2 | 3 | 4 | # brew-cask-upgrade 5 | 6 | `brew-cask-upgrade` is a command-line tool for upgrading every outdated app 7 | installed by [Homebrew Cask](https://github.com/Homebrew/homebrew-cask). 8 | 9 | Homebrew Cask extends [Homebrew](http://brew.sh) and brings its elegance, simplicity, and speed to the installation and management of GUI macOS applications and large binaries alike. 10 | 11 | `brew-cask-upgrade` is an external command to replace the native `upgrade` by offering interactivity, an improved interface, and higher granularity of what to upgrade. 12 | 13 | ## Installation 14 | 15 | ```shell 16 | brew tap buo/cask-upgrade 17 | ``` 18 | 19 | ### Verification of installation 20 | In order to simply verify that `brew cu` is correctly installed, you can simply run `brew tap` command and see if the repo is included: 21 | 22 | ```shell 23 | > brew tap 24 | buo/cask-upgrade 25 | homebrew/bundle 26 | homebrew/cask 27 | homebrew/core 28 | ``` 29 | 30 | ## Uninstallation 31 | 32 | ```shell 33 | brew untap buo/cask-upgrade 34 | ``` 35 | 36 | ## Usage 37 | 38 | Upgrade outdated apps: 39 | 40 | ```shell 41 | brew cu 42 | ``` 43 | 44 | Upgrade a specific app: 45 | 46 | ```bash 47 | brew cu [CASK] 48 | ``` 49 | 50 | While running the `brew cu` command without any other further options, the script automatically runs `brew update` to get 51 | latest versions of all the installed casks (this can be disabled, see options below). 52 | 53 | It is also possible to use `*` to install multiple casks at once, i.e. `brew cu flash-*` to install all casks starting with `flash-` prefix. 54 | 55 | [![asciicast](https://asciinema.org/a/DlXUmiFFVnDhIDe2tCGo3ecLW.png)](https://asciinema.org/a/DlXUmiFFVnDhIDe2tCGo3ecLW) 56 | 57 | ### Apps with auto-update 58 | 59 | If the app has the auto update functionality (i.e. they ask you themselves if you want to upgrade them), they are not 60 | upgraded while running `brew cu` and will display a `PASS` result. If you want to upgrade them, pass the `-a` or `--all` option to include also those kind of apps. 61 | 62 | Please note, that if you update the apps using their auto-update functionality, that change will not reflect in the 63 | `brew cu` script! Tracked version gets only updated, when the app is upgraded through `brew cu --all`. 64 | 65 | ### Options 66 | 67 | ```text 68 | Usage: brew cu [command=run] [CASK] [options] 69 | Commands: 70 | run Default command, doesn't have to be specified. Executes cask upgrades. 71 | pin Pin the current app version, preventing it from being 72 | upgraded when issuing the `brew cu` command. See also `unpin`. 73 | unpin Unpin the current app version, allowing them to be 74 | upgraded by `brew cu` command. See also `pin`. 75 | pinned Print all pinned apps and its version. See also `pin`. 76 | 77 | Options: 78 | -a, --all Include apps that auto-update in the upgrade. 79 | --cleanup Cleans up cached downloads and tracker symlinks after 80 | updating. 81 | -f --force Include apps that are marked as latest 82 | (i.e. force-reinstall them). 83 | --no-brew-update Prevent auto-update of Homebrew, taps, and formulae 84 | before checking outdated apps. 85 | -y, --yes Update all outdated apps; answer yes to updating packages. 86 | -q, --quiet Do not show information about installed apps or current options. 87 | -v, --verbose Make output more verbose. 88 | --no-quarantine Pass --no-quarantine option to `brew cask install`. 89 | -i, --interactive Running update in interactive mode 90 | --include-mas (Experimental) Include applications from Mac App Store. 91 | ``` 92 | 93 | Display usage instructions: 94 | ```shell 95 | brew help cu 96 | ``` 97 | 98 | ### Mac App Store applications (Experimental) 99 | By adding `--include-mas` parameter to the `brew cu` command, we use [mas](https://github.com/mas-cli/mas/) cli tool to manage 100 | upgrades for Mac App Store applications as well. 101 | 102 | **Note:** This feature is highly experimental and we don't guarantee it's functionality. Use at your own risk. 103 | 104 | ### Interactive mode 105 | 106 | When using interactive mode (by adding `--interactive` argument or confirming app installation with `i`) will trigger per-cask confirmation. 107 | For every cask it is then possible to use following options: 108 | - `y` will install the current cask update 109 | - `N` will skip the installation of current cask 110 | - `p` will pin the current version of the cask (see [version pinning](#version-pinning)) 111 | 112 | ### Version pinning 113 | 114 | Pinned apps will not be updated by `brew cu` until they are unpinned. 115 | NB: version pinning in `brew cu` will not prevent `brew cask upgrade` from updating pinned apps. 116 | 117 | ### Export / Import pinned apps 118 | 119 | In order to export backup of your pinned casks into a file, simply pass `--export` option to the `pinned` command. 120 | ```shell 121 | brew cu pinned --export my-backup-filename.txt 122 | ``` 123 | **Note**: Versions, in which were casks pinned, are not exported as it isn’t possible to install a specific version afterwards. 124 | 125 | In order to load the configuration back, use `--load` option. 126 | ```shell 127 | brew cu pinned --load my-backup-filename.txt 128 | ``` 129 | **Note**: Loading the configuration will **replace** current values. 130 | -------------------------------------------------------------------------------- /lib/bcu/options.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "optparse" 4 | 5 | module Bcu 6 | class << self 7 | include Utils::Output::Mixin 8 | attr_accessor :options 9 | end 10 | 11 | def self.parse!(args) 12 | options_struct = Struct.new(:all, :force, :casks, :cleanup, :force_yes, :no_brew_update, :quiet, :verbose, 13 | :install_options, :list_pinned, :pin, :unpin, :interactive, :command, :report_only, 14 | :backup_filename, :debug, :include_mas) 15 | options = options_struct.new 16 | options.all = false 17 | options.force = false 18 | options.casks = nil 19 | options.cleanup = false 20 | options.force_yes = false 21 | options.no_brew_update = false 22 | options.quiet = false 23 | options.verbose = false 24 | options.install_options = "--cask" 25 | options.list_pinned = false 26 | options.pin = nil 27 | options.unpin = nil 28 | options.interactive = false 29 | options.report_only = false 30 | options.command = "run" 31 | options.backup_filename = "" 32 | options.debug = false 33 | options.include_mas = false 34 | 35 | parser = OptionParser.new do |opts| 36 | opts.banner = "Usage: brew cu [CASK] [options]" 37 | 38 | # Prevent using short -p syntax for pinning 39 | opts.on("-p") do 40 | odie "invalid option -p, did you mean --pin?" 41 | end 42 | 43 | # Prevent using short -u syntax for unpinning 44 | opts.on("-u") do 45 | odie "invalid option -u, did you mean --unpin?" 46 | end 47 | 48 | opts.on("-a", "--all", "Include apps that auto-update in the upgrade") do 49 | options.all = true 50 | end 51 | 52 | opts.on("--cleanup", "Cleans up cached downloads and tracker symlinks after updating") do 53 | options.cleanup = true 54 | end 55 | 56 | opts.on("-f", "--force", "Include apps that are marked as latest (i.e. force-reinstall them)") do 57 | options.force = true 58 | end 59 | 60 | opts.on( 61 | "--no-brew-update", 62 | "Prevent auto-update of Homebrew, taps, and formulae before checking outdated apps", 63 | ) do 64 | options.no_brew_update = true 65 | end 66 | 67 | opts.on("-y", "--yes", "Update all outdated apps; answer yes to updating packages") do 68 | options.force_yes = true 69 | end 70 | 71 | opts.on("-i", "--interactive", "Use interactive mode while installing") do 72 | options.interactive = true 73 | end 74 | 75 | opts.on("-q", "--quiet", "Do not show information about installed apps or current options") do 76 | options.quiet = true 77 | end 78 | 79 | opts.on("-v", "--verbose", "Make output more verbose") do 80 | options.verbose = true 81 | end 82 | 83 | opts.on( 84 | "--no-quarantine", 85 | "Add --no-quarantine option to install command, see brew cask documentation for additional information", 86 | ) do 87 | options.install_options += " --no-quarantine" 88 | end 89 | 90 | opts.on("--pinned", "List pinned apps") do 91 | onoe "Using option --pinned for listing pinned apps is deprecated, please use \"brew cu pinned\" command." 92 | options.command = "pinned" 93 | end 94 | 95 | opts.on("--pin CASK", "Cask to pin") do |cask| 96 | onoe "Using option --pin for pinning is deprecated, please use \"brew cu pin\" command." 97 | options.command = "pin" 98 | options.pin = cask 99 | end 100 | 101 | opts.on("--unpin CASK", "Cask to unpin") do |cask| 102 | onoe "Using option --unpin for unpinning is deprecated, please use \"brew cu unpin\" command." 103 | options.unpin = cask 104 | options.command = "unpin" 105 | end 106 | 107 | opts.on("--include-mas", "Include Mac AppStore applications") do 108 | if IO.popen(%w[which mas]).read.empty? 109 | onoe "In order to use --include-mas the mas-cli has to be installed. Please see the instructions here: https://github.com/mas-cli/mas" 110 | exit 1 111 | end 112 | options.include_mas = true 113 | end 114 | 115 | opts.on("--report-only", "Only report casks to update with exit code") do 116 | options.report_only = true 117 | end 118 | 119 | opts.on("--debug", "Output certain debug statements") do 120 | options.debug = true 121 | options.verbose = true 122 | end 123 | 124 | if args[0] == "pinned" 125 | opts.on("--export FILENAME", "Filename for export") do |filename| 126 | options.backup_filename = filename 127 | options.command = "export" 128 | end 129 | 130 | opts.on("--load FILENAME", "Source filename for loading pinned casks") do |filename| 131 | options.backup_filename = filename 132 | options.command = "load" 133 | end 134 | end 135 | end 136 | 137 | parser.parse!(args) 138 | 139 | if %w[pin unpin pinned livecheck run].include?(args[0]) 140 | options.command = args[0] if options.backup_filename == "" 141 | validate_command_args args, options 142 | end 143 | validate_options options 144 | 145 | options.casks = args 146 | 147 | self.options = options 148 | end 149 | 150 | def self.validate_command_args(args, options) 151 | odie "Missing CASK for #{options.command} command" if %w[pin unpin].include?(options.command) && args[1].nil? 152 | end 153 | 154 | def self.validate_options(options) 155 | # verbose and quiet cannot both exist 156 | odie "--quiet and --verbose cannot be specified at the same time" if options.quiet && options.verbose 157 | end 158 | end 159 | -------------------------------------------------------------------------------- /lib/extend/formatter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: false 2 | 3 | module Formatter 4 | module_function 5 | 6 | class TableColumn 7 | attr_accessor :align, :color, :value, :width 8 | 9 | def initialize(args) 10 | @align = args[:align].nil? ? "left" : args[:align] 11 | @color = args[:color].nil? ? "default" : args[:color] 12 | @value = args[:value].nil? ? "" : args[:value].to_s 13 | @width = args[:width].nil? ? 0 : args[:width] 14 | end 15 | end 16 | 17 | class Table 18 | attr_accessor :header, :rows 19 | 20 | def initialize 21 | @header = [] 22 | @rows = [] 23 | end 24 | 25 | def add_header_column(value, align = "left") 26 | @header << Formatter::TableColumn.new(value:, align:) 27 | end 28 | 29 | def add_row(row) 30 | @rows << row 31 | end 32 | 33 | def output(gutter = 2) 34 | output = "" 35 | 36 | # Maximum number of columns 37 | cols = @header.length 38 | 39 | rows = [@header] + @rows 40 | 41 | # Calculate column widths 42 | col_widths = Array.new(cols, 0) 43 | rows.each do |row| 44 | row.each_with_index do |col, i| 45 | len = col.value.length 46 | col_widths[i] = len if col_widths[i] < len 47 | end 48 | end 49 | 50 | # Calculate table width including gutters 51 | table_width = col_widths.sum + (gutter * (cols - 1)) 52 | 53 | if table_width > Tty.width 54 | content_width = Tty.width - (gutter * (cols - 1)) - gutter 55 | overflow_cols = 0 56 | max_width = content_width 57 | col_widths.each do |width| 58 | if width <= content_width / cols 59 | max_width -= width 60 | else 61 | overflow_cols += 1 62 | end 63 | end 64 | max_width /= overflow_cols 65 | 66 | # Re-calculate column widths 67 | col_widths = Array.new(cols, 0) 68 | rows.each do |row| 69 | row.each_with_index do |col, i| 70 | len = [col.value.length, max_width].min 71 | col_widths[i] = len if col_widths[i] < len 72 | end 73 | end 74 | 75 | # Truncate values 76 | rows = rows.map do |row| 77 | row.map.with_index do |col, i| 78 | col.value = Formatter.truncate(col.value, len: col_widths[i]) 79 | col 80 | end 81 | end 82 | end 83 | 84 | # Print table header 85 | rows.shift.each_with_index do |th, i| 86 | string = "#{Tty.underline}#{th.value}#{Tty.reset}" 87 | padding = col_widths[i] - th.value.length 88 | if th.align == "center" 89 | padding_left = padding / 2 90 | padding_right = padding - padding_left 91 | padding_right += gutter if i - 1 != cols 92 | output << "#{" " * padding_left}#{string}#{" " * padding_right}" 93 | else 94 | padding += gutter if i - 1 != cols 95 | output << "#{string}#{" " * padding}" 96 | end 97 | end 98 | output << "\n" 99 | 100 | # Print table body 101 | rows.each do |row| 102 | row.each_with_index do |td, i| 103 | padding = col_widths[i] - td.value.length 104 | padding += gutter if i - 1 != cols 105 | output << "#{Tty.send(td.color)}#{td.value}#{Tty.reset}#{" " * padding}" 106 | end 107 | output << "\n" 108 | end 109 | 110 | output 111 | end 112 | end 113 | 114 | def colorize(string, color) 115 | "#{Tty.send(color)}#{string}#{Tty.reset}" 116 | end 117 | 118 | def state(string, color: "default") 119 | "[#{Tty.send(color)}#{string}#{Tty.reset}]" 120 | end 121 | 122 | def truncate(string, len: 10, suffix: "...") 123 | return string if string.length <= len 124 | 125 | "#{string[0, len - suffix.length]}#{suffix}" 126 | end 127 | 128 | def print_app_table(apps, state_info, options) 129 | return output_print_app_table(apps, state_info, options) if $stdout.tty? 130 | 131 | redirect_stdout($stderr) do 132 | output_print_app_table(apps, state_info, options) 133 | end 134 | end 135 | 136 | def output_print_app_table(apps, state_info, options) 137 | table = self::Table.new 138 | table.add_header_column "" 139 | table.add_header_column "Cask" 140 | table.add_header_column "Current" 141 | table.add_header_column "Latest" 142 | table.add_header_column "A/U" 143 | table.add_header_column "Result", "center" 144 | table.add_header_column "URL" if options.verbose 145 | 146 | apps.each_with_index do |app, i| 147 | color, result = formatting_for_app(state_info, app, options).values_at(0, 1) 148 | 149 | row = [] 150 | row << self::TableColumn.new(value: "#{(i+1).to_s.rjust(apps.length.to_s.length)}/#{apps.length}") 151 | cask_column_value = app[:mas] ? "#{app[:token]} " : app[:token] 152 | row << self::TableColumn.new(value: cask_column_value, color:) 153 | if options.verbose 154 | row << self::TableColumn.new(value: app[:current_full]) 155 | row << self::TableColumn.new(value: app[:version_full], color: "magenta") 156 | else 157 | row << self::TableColumn.new(value: app[:current]) 158 | row << self::TableColumn.new(value: app[:version], color: "magenta") 159 | end 160 | row << self::TableColumn.new(value: app[:auto_updates] ? " Y " : "", color: "magenta") 161 | row << self::TableColumn.new(value: result, color:) 162 | row << self::TableColumn.new(value: app[:homepage], color: "blue") if options.verbose 163 | 164 | table.add_row row 165 | end 166 | 167 | puts table.output 168 | end 169 | 170 | # @param [Cask[]] pinns Array of casks to print 171 | def print_pin_table(pinns) 172 | return output_print_pin_table(pinns) if $stdout.tty? 173 | 174 | redirect_stdout($stderr) do 175 | output_print_pin_table(pinns) 176 | end 177 | end 178 | 179 | # @param [Cask[]] pinns Array of casks to print 180 | def output_print_pin_table(pinns) 181 | table = self::Table.new 182 | table.add_header_column "" 183 | table.add_header_column "Cask" 184 | table.add_header_column "Current" 185 | table.add_header_column "Latest" 186 | 187 | pinns.each_with_index do |cask, i| 188 | row = [] 189 | row << self::TableColumn.new(value: "#{(i+1).to_s.rjust(pinns.length.to_s.length)}/#{pinns.length}") 190 | row << self::TableColumn.new(value: cask.token, color: "green") 191 | row << self::TableColumn.new(value: Cask.current_version(cask.token)) 192 | row << self::TableColumn.new(value: cask.version.to_s, color: "magenta") 193 | table.add_row row 194 | end 195 | 196 | puts table.output 197 | end 198 | 199 | def formatting_for_app(state_info, app, options) 200 | if state_info[app] == "pinned" 201 | color = "cyan" 202 | if ENV["HOMEBREW_CASK_UPGRADE_PINNED_OUTDATED_COLORIZE"] && app[:current_full] != app[:version_full] 203 | color = "blue" 204 | end 205 | result = "[ PINNED ]" 206 | elsif state_info[app][0, 6] == "forced" 207 | color = "yellow" 208 | result = "[ FORCED ]" 209 | elsif app[:auto_updates] 210 | if options.all 211 | color = "green" 212 | result = "[ OK ]" 213 | else 214 | color = "default" 215 | result = "[ PASS ]" 216 | end 217 | elsif state_info[app] == "outdated" 218 | color = "red" 219 | result = "[OUTDATED]" 220 | else 221 | color = "green" 222 | result = "[ OK ]" 223 | end 224 | 225 | [color, result] 226 | end 227 | end 228 | -------------------------------------------------------------------------------- /lib/bcu/command/upgrade.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bcu/module/pin" 4 | 5 | module Bcu 6 | class Upgrade < Command 7 | include Utils::Output::Mixin 8 | 9 | def process(_args, options) 10 | return run_process(options) if $stdout.tty? 11 | 12 | redirect_stdout($stderr) do 13 | run_process(options) 14 | end 15 | end 16 | 17 | def run_process(options) 18 | unless options.quiet 19 | ohai "Options" 20 | auto_update_message = Formatter.colorize(options.all, options.all ? "green" : "red") 21 | puts "Include auto-update (-a): #{auto_update_message}" 22 | latest_message = Formatter.colorize(options.force, options.force ? "green" : "red") 23 | puts "Include latest (-f): #{latest_message}" 24 | include_mas_message = Formatter.colorize(options.include_mas, options.include_mas ? "green" : "red") 25 | puts "Include mac app store (--include-mas): #{include_mas_message}" if options.include_mas 26 | end 27 | 28 | unless options.no_brew_update 29 | ohai "Updating Homebrew" 30 | puts Cask.brew_update(options.verbose).stdout 31 | end 32 | 33 | installed = Cask.installed_apps(options) 34 | include_mas_applications installed if options.include_mas 35 | 36 | ohai "Finding outdated apps" 37 | outdated, state_info = find_outdated_apps(installed, options) 38 | Formatter.print_app_table(installed, state_info, options) unless options.quiet 39 | if outdated.empty? 40 | puts "No outdated apps found." if options.quiet 41 | cleanup(options, true) 42 | return 43 | end 44 | 45 | ohai "Found outdated apps" 46 | Formatter.print_app_table(outdated, state_info, options) 47 | printf "\n" 48 | 49 | exit(outdated.length) if options.report_only 50 | 51 | if !options.interactive && !options.force_yes 52 | printf "Do you want to upgrade %d app%s or enter [i]nteractive mode [y/i/N]? ", 53 | count: outdated.length, 54 | s: (outdated.length > 1) ? "s" : "" 55 | input = $stdin.gets.strip 56 | 57 | if input.casecmp("i").zero? 58 | options.interactive = true 59 | else 60 | return unless input.casecmp("y").zero? 61 | end 62 | end 63 | 64 | # In interactive flow we're not sure if we need to clean up 65 | cleanup_necessary = !options.interactive 66 | 67 | if options.interactive 68 | for_upgrade = to_upgrade_interactively outdated, options, state_info 69 | upgrade for_upgrade, options 70 | else 71 | upgrade outdated, options 72 | end 73 | 74 | cleanup(options, cleanup_necessary) 75 | end 76 | 77 | private 78 | 79 | def cleanup(options, cleanup_necessary) 80 | should_cleanup = options.cleanup && cleanup_necessary 81 | return unless should_cleanup 82 | 83 | ohai "Running cleanup" 84 | verbose_flag = options.verbose ? "--verbose" : "" 85 | cmd = "brew cleanup #{verbose_flag}" 86 | system cmd.to_s 87 | end 88 | 89 | def include_mas_applications(installed) 90 | result = IO.popen(%w[mas list]).read 91 | mac_apps = result.split("\n") 92 | 93 | region = IO.popen(%w[mas region]).read.strip.downcase 94 | 95 | mas_outdated = mas_load_outdated 96 | 97 | mac_apps.each do |app| 98 | data = parse_mas_app app 99 | next if data[:name].nil? 100 | 101 | new_version = mas_outdated[data[:name]] 102 | mas_cask = { 103 | cask: nil, 104 | name: data[:name], 105 | token: data[:name], 106 | version_full: new_version.nil? ? data[:installed_version] : new_version, 107 | version: new_version.nil? ? data[:installed_version] : new_version, 108 | current_full: data[:installed_version], 109 | current: data[:installed_version], 110 | outdated?: !new_version.nil?, 111 | auto_updates: false, 112 | homepage: "https://apps.apple.com/#{region}/app/id#{data[:id]}", 113 | installed_versions: [data[:installed_version]], 114 | mas: true, 115 | mas_id: data[:id], 116 | } 117 | installed.push(mas_cask) 118 | end 119 | installed.sort_by! { |cask| cask[:token] } 120 | end 121 | 122 | def to_upgrade_interactively(outdated, options, state_info) 123 | for_upgrade = [] 124 | outdated.each do |app| 125 | formatting = Formatter.formatting_for_app(state_info, app, options) 126 | printf 'Do you want to upgrade "%s", [p]in it to exclude it from updates or [q]uit [y/p/q/N]? ', 127 | app: Formatter.colorize(app[:token], formatting[0]) 128 | input = $stdin.gets.strip 129 | 130 | if input.casecmp("p").zero? 131 | if app[:mas] 132 | onoe "Pinning is not yet supported for MAS applications." 133 | else 134 | cmd = Bcu::Pin::Add.new 135 | args = [] 136 | args[1] = app[:token] 137 | cmd.process args, options 138 | end 139 | end 140 | 141 | exit 0 if input.casecmp("q").zero? 142 | 143 | for_upgrade.push app if input.casecmp("y").zero? 144 | end 145 | for_upgrade 146 | end 147 | 148 | def upgrade(apps, options) 149 | return if apps.blank? 150 | 151 | if apps.length > 1 152 | ohai "Upgrading #{apps.length} apps" 153 | if options.verbose 154 | apps.each do |app| 155 | puts "#{app[:token]} #{app[:current]} -> #{app[:version]}" 156 | end 157 | end 158 | else 159 | ohai "Upgrading #{apps[0][:token]} to #{apps[0][:version]}" 160 | end 161 | 162 | installation_successful = install apps, options 163 | return unless installation_successful 164 | 165 | ohai "Cleaning up old versions" if options.verbose 166 | apps.each do |app| 167 | installation_cleanup app unless app[:mas] 168 | end 169 | end 170 | 171 | def install(apps, options) 172 | verbose_flag = options.verbose ? " --verbose" : "" 173 | 174 | # Split MAS and Homebrew apps 175 | mas_apps = apps.select { |app| app[:mas] } 176 | brew_apps = apps.reject { |app| app[:mas] } 177 | 178 | begin 179 | mas_cmd = nil 180 | unless mas_apps.empty? 181 | mas_ids = mas_apps.map { |app| app[:mas_id] }.join(" ") 182 | mas_cmd = "mas upgrade#{verbose_flag} #{mas_ids}" 183 | end 184 | 185 | brew_cmd = nil 186 | unless brew_apps.empty? 187 | # Force to install the latest version. 188 | brew_ids = brew_apps.map do |app| 189 | app[:tap].nil? ? app[:token] : "#{app[:tap]}/#{app[:token]}" 190 | end.join(" ") 191 | brew_cmd = "brew reinstall #{options.install_options} #{brew_ids} --force#{verbose_flag}" 192 | end 193 | 194 | success = true 195 | success &&= system brew_cmd.to_s if brew_cmd 196 | 197 | if mas_cmd && success 198 | ohai "Upgrading Mac App Store apps" if options.verbose 199 | success &&= system mas_cmd.to_s 200 | end 201 | rescue 202 | success = false 203 | end 204 | 205 | success 206 | end 207 | 208 | def installation_cleanup(app) 209 | # Remove the old versions. 210 | app[:installed_versions].each do |version| 211 | system "rm", "-rf", "#{CASKROOM}/#{app[:token]}/#{Shellwords.escape(version)}" if version != "latest" 212 | end 213 | end 214 | 215 | def find_outdated_apps(installed, options) 216 | outdated = [] 217 | state_info = Hash.new("") 218 | 219 | unless options.casks.empty? 220 | installed = installed.select do |app| 221 | found = false 222 | options.casks.each do |arg| 223 | found = true if app[:token] == arg || (arg.end_with?("*") && app[:token].start_with?(arg.slice(0..-2))) 224 | end 225 | found 226 | end 227 | 228 | odie(install_empty_message(options.casks)) if installed.empty? 229 | end 230 | 231 | installed.each do |app| 232 | version_latest = (app[:version] == "latest") 233 | if Pin.pinned.include?(app[:token]) 234 | state_info[app] = "pinned" 235 | elsif (options.force && version_latest && app[:auto_updates] && options.all) || 236 | (options.force && version_latest && !app[:auto_updates]) 237 | outdated.push app 238 | state_info[app] = "forced to reinstall" 239 | elsif options.all && !version_latest && app[:auto_updates] && app[:outdated?] 240 | outdated.push app 241 | state_info[app] = "forced to upgrade" 242 | elsif !version_latest && !app[:auto_updates] && app[:outdated?] 243 | outdated.push app 244 | state_info[app] = "outdated" 245 | elsif version_latest || app[:outdated?] 246 | state_info[app] = "ignored" 247 | elsif app[:cask].nil? 248 | state_info[app] = "no cask available" 249 | end 250 | end 251 | 252 | [outdated, state_info] 253 | end 254 | 255 | def mas_load_outdated 256 | result = IO.popen(%w[mas outdated]).read 257 | mac_apps = result.split("\n") 258 | outdated = {} 259 | mac_apps.each do |app| 260 | match = parse_mas_app app 261 | outdated[match[:name]] = match[:new_version] if match[:new_version] 262 | end 263 | outdated 264 | end 265 | 266 | def parse_mas_app(app) 267 | match = app.strip.split(/^(\d+)\s+(.+?)\s+\((.+)\)$/) 268 | version_upgrade = match[3].split(" -> ") 269 | if version_upgrade.length == 2 270 | installed_version = version_upgrade[0] 271 | version = version_upgrade[1] 272 | else 273 | installed_version = match[3] 274 | version = nil 275 | end 276 | { 277 | id: match[1], 278 | name: match[2].downcase.strip, 279 | installed_version: installed_version, 280 | new_version: version, 281 | } 282 | end 283 | 284 | def install_empty_message(cask_searched) 285 | if cask_searched.length == 1 286 | if cask_searched[0].end_with? "*" 287 | "#{Tty.red}No Cask matching \"#{cask_searched[0]}\" is installed.#{Tty.reset}" 288 | else 289 | "#{Tty.red}Cask \"#{cask_searched[0]}\" is not installed.#{Tty.reset}" 290 | end 291 | else 292 | "#{Tty.red}No casks matching your arguments found.#{Tty.reset}" 293 | end 294 | end 295 | end 296 | end 297 | --------------------------------------------------------------------------------