From 0dfc121243bcba4066e4d52e589efd536d466995 Mon Sep 17 00:00:00 2001 From: Mike McQuaid Date: Thu, 9 Jan 2025 16:29:49 +0000 Subject: [PATCH] Add `postinstall` support for `brew` and `cask` This allows `postinstall` to be used to run a command after a formula or cask is installed. While we're here, also adjust the `restart_service` behaviour to correctly match the comment i.e. require `always` to restart every time and otherwise just restart on an install or upgrade of a formula. --- README.md | 11 +++- lib/bundle/brew_dumper.rb | 2 +- lib/bundle/brew_installer.rb | 21 +++++-- lib/bundle/cask_installer.rb | 52 ++++++++++------ spec/bundle/brew_installer_spec.rb | 98 ++++++++++++++++++++++++------ spec/bundle/cask_installer_spec.rb | 22 +++++++ 6 files changed, 161 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index 7ad789443..3e1f82c30 100644 --- a/README.md +++ b/README.md @@ -37,10 +37,13 @@ cask_args appdir: "~/Applications", require_sha: true # 'brew install' brew "imagemagick" -# 'brew install --with-rmtp', 'brew link --overwrite', 'brew services restart' on version changes -brew "denji/nginx/nginx-full", link: :overwrite, args: ["with-rmtp"], restart_service: :changed +# 'brew install --with-rmtp', 'brew link --overwrite', 'brew services restart' even if no install/upgrade +brew "denji/nginx/nginx-full", link: :overwrite, args: ["with-rmtp"], restart_service: :always # 'brew install', always 'brew services restart', 'brew link', 'brew unlink mysql' (if it is installed) -brew "mysql@5.6", restart_service: true, link: true, conflicts_with: ["mysql"] +brew "mysql@5.6", restart_service: :changed, link: true, conflicts_with: ["mysql"] +# 'brew install' and run a command if installer or upgraded. +brew "postgresql@16", + postinstall: "${HOMEBREW_PREFIX}/opt/postgresql@16/bin/postgres -D ${HOMEBREW_PREFIX}/var/postgresql@16" # install only on specified OS brew "gnupg" if OS.mac? brew "glibc" if OS.linux? @@ -55,6 +58,8 @@ cask "firefox", args: { no_quarantine: true } cask "opera", greedy: true # 'brew install --cask' only if '/usr/libexec/java_home --failfast' fails cask "java" unless system "/usr/libexec/java_home", "--failfast" +# 'brew install --cask' and run a command if installer or upgraded. +cask "google-cloud-sdk", postinstall: "${HOMEBREW_PREFIX}/bin/gcloud components update" # 'mas install' mas "1Password", id: 443_987_910 diff --git a/lib/bundle/brew_dumper.rb b/lib/bundle/brew_dumper.rb index 504193392..af2ade27b 100644 --- a/lib/bundle/brew_dumper.rb +++ b/lib/bundle/brew_dumper.rb @@ -65,7 +65,7 @@ def dump(describe: false, no_restart: false) args = f[:args].map { |arg| "\"#{arg}\"" }.sort.join(", ") brewline += ", args: [#{args}]" unless f[:args].empty? - brewline += ", restart_service: true" if !no_restart && BrewServices.started?(f[:full_name]) + brewline += ", restart_service: :changed" if !no_restart && BrewServices.started?(f[:full_name]) brewline += ", link: #{f[:link?]}" unless f[:link?].nil? brewline end.join("\n") diff --git a/lib/bundle/brew_installer.rb b/lib/bundle/brew_installer.rb index 02499ab8d..e7e7ea5e7 100644 --- a/lib/bundle/brew_installer.rb +++ b/lib/bundle/brew_installer.rb @@ -24,6 +24,7 @@ def initialize(name, options = {}) @restart_service = options[:restart_service] @start_service = options.fetch(:start_service, @restart_service) @link = options.fetch(:link, nil) + @postinstall = options.fetch(:postinstall, nil) @changed = nil end @@ -46,12 +47,14 @@ def install(preinstall: true, no_upgrade: false, verbose: false, force: false) result = install_result if installed? - if install_result - service_result = service_change_state!(verbose:) - result &&= service_result - end + service_result = service_change_state!(verbose:) + result &&= service_result + link_result = link_change_state!(verbose:) result &&= link_result + + postinstall_result = postinstall_change_state!(verbose:) + result &&= postinstall_result end result @@ -83,7 +86,7 @@ def restart_service_needed? return false unless restart_service? # Restart if `restart_service: :always`, or if the formula was installed or upgraded - @restart_service.to_s != "changed" || changed? + @restart_service.to_s == "always" || changed? end def changed? @@ -132,6 +135,14 @@ def link_change_state!(verbose: false) true end + def postinstall_change_state!(verbose:) + return true if @postinstall.blank? + return true unless changed? + + puts "Running postinstall for #{@name}." if verbose + Bundle.system(@postinstall, verbose:) + end + def self.formula_installed_and_up_to_date?(formula, no_upgrade: false) return false unless formula_installed?(formula) return true if no_upgrade diff --git a/lib/bundle/cask_installer.rb b/lib/bundle/cask_installer.rb index 2f0dd653d..173848fa9 100644 --- a/lib/bundle/cask_installer.rb +++ b/lib/bundle/cask_installer.rb @@ -31,33 +31,51 @@ def install(name, preinstall: true, no_upgrade: false, verbose: false, force: fa full_name = options.fetch(:full_name, name) - if installed_casks.include?(name) && upgrading?(no_upgrade, name, options) + install_result = if installed_casks.include?(name) && upgrading?(no_upgrade, name, options) status = "#{options[:greedy] ? "may not be" : "not"} up-to-date" puts "Upgrading #{name} cask. It is installed but #{status}." if verbose - return Bundle.brew("upgrade", "--cask", full_name, verbose:) - end + Bundle.brew("upgrade", "--cask", full_name, verbose:) + else + args = options.fetch(:args, []).filter_map do |k, v| + case v + when TrueClass + "--#{k}" + when FalseClass + nil + else + "--#{k}=#{v}" + end + end + + args << "--force" if force + args.uniq! + + with_args = " with #{args.join(" ")}" if args.present? + puts "Installing #{name} cask#{with_args}. It is not currently installed." if verbose - args = options.fetch(:args, []).filter_map do |k, v| - case v - when TrueClass - "--#{k}" - when FalseClass - nil + if Bundle.brew("install", "--cask", full_name, *args, verbose:) + installed_casks << name + true else - "--#{k}=#{v}" + false end end + result = install_result - args << "--force" if force - args.uniq! + if cask_installed?(name) + postinstall_result = postinstall_change_state!(name:, options:, verbose:) + result &&= postinstall_result + end - with_args = " with #{args.join(" ")}" if args.present? - puts "Installing #{name} cask#{with_args}. It is not currently installed." if verbose + result + end - return false unless Bundle.brew("install", "--cask", full_name, *args, verbose:) + def postinstall_change_state!(name:, options:, verbose:) + postinstall = options.fetch(:postinstall, nil) + return true if postinstall.blank? - installed_casks << name - true + puts "Running postinstall for #{name}." if verbose + Bundle.system(postinstall, verbose:) end def self.cask_installed_and_up_to_date?(cask, no_upgrade: false) diff --git a/spec/bundle/brew_installer_spec.rb b/spec/bundle/brew_installer_spec.rb index 3641c87ac..944b73f1f 100644 --- a/spec/bundle/brew_installer_spec.rb +++ b/spec/bundle/brew_installer_spec.rb @@ -62,7 +62,7 @@ end end - context "with a true restart_service option" do + context "with an always restart_service option" do before do allow_any_instance_of(described_class).to receive(:install_change_state!).and_return(true) allow_any_instance_of(described_class).to receive(:installed?).and_return(true) @@ -71,15 +71,15 @@ context "with a successful installation" do it "restart service" do expect(Bundle::BrewServices).to receive(:restart).with(formula, verbose: false).and_return(true) - described_class.preinstall(formula, restart_service: true) - described_class.install(formula, restart_service: true) + described_class.preinstall(formula, restart_service: :always) + described_class.install(formula, restart_service: :always) end end context "with a skipped installation" do it "restart service" do expect(Bundle::BrewServices).to receive(:restart).with(formula, verbose: false).and_return(true) - described_class.install(formula, preinstall: false, restart_service: true) + described_class.install(formula, preinstall: false, restart_service: :always) end end end @@ -177,7 +177,8 @@ allow_any_instance_of(described_class).to receive(:upgrade!).and_return(true) end - def expectations(verbose:) + it "unlinks conflicts and stops their services" do + verbose = false expect(Bundle).to receive(:system).with(HOMEBREW_BREW_FILE, "unlink", "mysql55", verbose:).and_return(true) expect(Bundle).to receive(:system).with(HOMEBREW_BREW_FILE, "unlink", "mysql56", @@ -185,23 +186,60 @@ def expectations(verbose:) expect(Bundle::BrewServices).to receive(:stop).with("mysql55", verbose:).and_return(true) expect(Bundle::BrewServices).to receive(:stop).with("mysql56", verbose:).and_return(true) expect(Bundle::BrewServices).to receive(:restart).with(formula, verbose:).and_return(true) - end - - # These tests wrap expect() calls in `expectations` - # rubocop:disable RSpec/NoExpectationExample - it "unlinks conflicts and stops their services" do - expectations(verbose: false) - described_class.preinstall(formula, restart_service: true, conflicts_with: ["mysql56"]) - described_class.install(formula, restart_service: true, conflicts_with: ["mysql56"]) + described_class.preinstall(formula, restart_service: :always, conflicts_with: ["mysql56"]) + described_class.install(formula, restart_service: :always, conflicts_with: ["mysql56"]) end it "prints a message" do allow_any_instance_of(described_class).to receive(:puts) - expectations(verbose: true) - described_class.preinstall(formula, restart_service: true, conflicts_with: ["mysql56"], verbose: true) - described_class.install(formula, restart_service: true, conflicts_with: ["mysql56"], verbose: true) + verbose = true + expect(Bundle).to receive(:system).with(HOMEBREW_BREW_FILE, "unlink", "mysql55", + verbose:).and_return(true) + expect(Bundle).to receive(:system).with(HOMEBREW_BREW_FILE, "unlink", "mysql56", + verbose:).and_return(true) + expect(Bundle::BrewServices).to receive(:stop).with("mysql55", verbose:).and_return(true) + expect(Bundle::BrewServices).to receive(:stop).with("mysql56", verbose:).and_return(true) + expect(Bundle::BrewServices).to receive(:restart).with(formula, verbose:).and_return(true) + described_class.preinstall(formula, restart_service: :always, conflicts_with: ["mysql56"], verbose: true) + described_class.install(formula, restart_service: :always, conflicts_with: ["mysql56"], verbose: true) + end + end + + context "when the postinstall option is provided" do + before do + allow_any_instance_of(described_class).to receive(:install_change_state!).and_return(true) + allow_any_instance_of(described_class).to receive(:installed?).and_return(true) + end + + context "when formula has changed" do + before do + allow_any_instance_of(described_class).to receive(:changed?).and_return(true) + end + + it "runs the postinstall command" do + expect(Bundle).to receive(:system).with("custom command", verbose: false).and_return(true) + described_class.preinstall(formula, postinstall: "custom command") + described_class.install(formula, postinstall: "custom command") + end + + it "reports a failure" do + expect(Bundle).to receive(:system).with("custom command", verbose: false).and_return(false) + described_class.preinstall(formula, postinstall: "custom command") + expect(described_class.install(formula, postinstall: "custom command")).to be(false) + end + end + + context "when formula has not changed" do + before do + allow_any_instance_of(described_class).to receive(:changed?).and_return(false) + end + + it "does not run the postinstall command" do + expect(Bundle).not_to receive(:system) + described_class.preinstall(formula, postinstall: "custom command") + described_class.install(formula, postinstall: "custom command") + end end - # rubocop:enable RSpec/NoExpectationExample end end @@ -396,6 +434,10 @@ def expectations(verbose:) it "is false with {restart_service: :changed}" do expect(described_class.new(formula, restart_service: :changed).start_service_needed?).to be(false) end + + it "is false with {restart_service: :always}" do + expect(described_class.new(formula, restart_service: :always).start_service_needed?).to be(false) + end end context "when a service is not started" do @@ -418,6 +460,10 @@ def expectations(verbose:) it "is true if {restart_service: :changed}" do expect(described_class.new(formula, restart_service: :changed).start_service_needed?).to be(true) end + + it "is true if {restart_service: :always}" do + expect(described_class.new(formula, restart_service: :always).start_service_needed?).to be(true) + end end end @@ -432,6 +478,12 @@ def expectations(verbose:) end end + context "when the restart_service option is always" do + it "is true" do + expect(described_class.new(formula, restart_service: :always).restart_service?).to be(true) + end + end + context "when the restart_service option is changed" do it "is true" do expect(described_class.new(formula, restart_service: :changed).restart_service?).to be(true) @@ -449,8 +501,12 @@ def expectations(verbose:) allow_any_instance_of(described_class).to receive(:changed?).and_return(false) end - it "is true with {restart_service: true}" do - expect(described_class.new(formula, restart_service: true).restart_service_needed?).to be(true) + it "is false with {restart_service: true}" do + expect(described_class.new(formula, restart_service: true).restart_service_needed?).to be(false) + end + + it "is true with {restart_service: :always}" do + expect(described_class.new(formula, restart_service: :always).restart_service_needed?).to be(true) end it "is false if {restart_service: :changed}" do @@ -467,6 +523,10 @@ def expectations(verbose:) expect(described_class.new(formula, restart_service: true).restart_service_needed?).to be(true) end + it "is true with {restart_service: :always}" do + expect(described_class.new(formula, restart_service: :always).restart_service_needed?).to be(true) + end + it "is true if {restart_service: :changed}" do expect(described_class.new(formula, restart_service: :changed).restart_service_needed?).to be(true) end diff --git a/spec/bundle/cask_installer_spec.rb b/spec/bundle/cask_installer_spec.rb index 6e767c1a0..5036960e3 100644 --- a/spec/bundle/cask_installer_spec.rb +++ b/spec/bundle/cask_installer_spec.rb @@ -134,5 +134,27 @@ end end end + + context "when the postinstall option is provided" do + before do + Bundle::CaskDumper.reset! + allow(Bundle::CaskDumper).to receive_messages(cask_names: ["google-chrome"], + outdated_cask_names: ["google-chrome"]) + allow(Bundle).to receive(:brew).and_return(true) + allow(described_class).to receive(:upgrading?).and_return(true) + end + + it "runs the postinstall command" do + expect(Bundle).to receive(:system).with("custom command", verbose: false).and_return(true) + expect(described_class.preinstall("google-chrome", postinstall: "custom command")).to be(true) + expect(described_class.install("google-chrome", postinstall: "custom command")).to be(true) + end + + it "reports a failure when postinstall fails" do + expect(Bundle).to receive(:system).with("custom command", verbose: false).and_return(false) + expect(described_class.preinstall("google-chrome", postinstall: "custom command")).to be(true) + expect(described_class.install("google-chrome", postinstall: "custom command")).to be(false) + end + end end end