Skip to content

Commit

Permalink
Add postinstall support for brew and cask
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
MikeMcQuaid committed Jan 9, 2025
1 parent 6c7f828 commit 0dfc121
Show file tree
Hide file tree
Showing 6 changed files with 161 additions and 45 deletions.
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 "[email protected]", restart_service: true, link: true, conflicts_with: ["mysql"]
brew "[email protected]", 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?
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion lib/bundle/brew_dumper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
21 changes: 16 additions & 5 deletions lib/bundle/brew_installer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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?
Expand Down Expand Up @@ -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
Expand Down
52 changes: 35 additions & 17 deletions lib/bundle/cask_installer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
98 changes: 79 additions & 19 deletions spec/bundle/brew_installer_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -177,31 +177,69 @@
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",
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)
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

Expand Down Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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)
Expand All @@ -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
Expand All @@ -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
Expand Down
22 changes: 22 additions & 0 deletions spec/bundle/cask_installer_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

0 comments on commit 0dfc121

Please sign in to comment.