# typed: true # frozen_string_literal: true require "English" require "exceptions" require "extend/ENV" require "utils" require "PATH" module Homebrew module Bundle module Commands module Exec PATH_LIKE_ENV_REGEX = /.+#{File::PATH_SEPARATOR}/ sig { params( args: String, global: T::Boolean, file: T.nilable(String), subcommand: String, services: T::Boolean, check: T::Boolean, ).void } def self.run(*args, global: false, file: nil, subcommand: "", services: false, check: false) if check require "bundle/commands/check" Homebrew::Bundle::Commands::Check.run(global:, file:, quiet: true) end # Store the old environment so we can check if things were already set # before we start mutating it. old_env = ENV.to_h # Setup Homebrew's ENV extensions ENV.activate_extensions! command = args.first raise UsageError, "No command to execute was specified!" if command.blank? require "bundle/brewfile" @dsl = Brewfile.read(global:, file:) require "formula" require "formulary" ENV.deps = @dsl.entries.filter_map do |entry| next if entry.type != :brew Formulary.factory(entry.name) end # Allow setting all dependencies to be keg-only # (i.e. should be explicitly in HOMEBREW_*PATHs ahead of HOMEBREW_PREFIX) ENV.keg_only_deps = if ENV["HOMEBREW_BUNDLE_EXEC_ALL_KEG_ONLY_DEPS"].present? ENV.delete("HOMEBREW_BUNDLE_EXEC_ALL_KEG_ONLY_DEPS") ENV.deps else ENV.deps.select(&:keg_only?) end ENV.setup_build_environment # Enable compiler flag filtering ENV.refurbish_args # Set up `nodenv`, `pyenv` and `rbenv` if present. env_formulae = %w[nodenv pyenv rbenv] ENV.deps.each do |dep| dep_name = dep.name next unless env_formulae.include?(dep_name) dep_root = ENV.fetch("HOMEBREW_#{dep_name.upcase}_ROOT", "#{Dir.home}/.#{dep_name}") ENV.prepend_path "PATH", Pathname.new(dep_root)/"shims" end # Setup pkgconf, if needed, to help locate packages Bundle.prepend_pkgconf_path_if_needed! # For commands which aren't either absolute or relative # Add the command directory to PATH, since it may get blown away by superenv if command.exclude?("/") && (which_command = which(command)).present? ENV.prepend_path "PATH", which_command.dirname.to_s end # Replace the formula versions from the environment variables ENV.deps.each do |formula| formula_name = formula.name formula_version = Bundle.formula_versions_from_env(formula_name) next unless formula_version ENV.each do |key, value| opt = %r{/opt/#{formula_name}([/:$])} next unless value.match(opt) cellar = "/Cellar/#{formula_name}/#{formula_version}\\1" # Look for PATH-like environment variables ENV[key] = if key.include?("PATH") && value.match?(PATH_LIKE_ENV_REGEX) rejected_opts = [] path = PATH.new(ENV.fetch("PATH")) .reject do |path_value| rejected_opts << path_value if path_value.match?(opt) end rejected_opts.each do |path_value| path.prepend(path_value.gsub(opt, cellar)) end path.to_s else value.gsub(opt, cellar) end end end # Ensure brew bundle exec/sh/env commands have access to other tools in the PATH if (homebrew_path = ENV.fetch("HOMEBREW_PATH", nil)) ENV.append_path "PATH", homebrew_path end # For commands which aren't either absolute or relative raise "command was not found in your PATH: #{command}" if command.exclude?("/") && which(command).nil? %w[HOMEBREW_TEMP TMPDIR HOMEBREW_TMPDIR].each do |var| value = ENV.fetch(var, nil) next if value.blank? next if File.writable?(value) ENV.delete(var) end ENV.each do |key, value| # Look for PATH-like environment variables next if key.exclude?("PATH") || !value.match?(PATH_LIKE_ENV_REGEX) # Exclude Homebrew shims from the PATH as they don't work # without all Homebrew environment variables and can interfere with # non-Homebrew builds. ENV[key] = PATH.new(value) .reject do |path_value| path_value.include?("/Homebrew/shims/") end.to_s end if subcommand == "env" ENV.sort.each do |key, value| # Skip exporting Homebrew internal variables that won't be used by other tools. # Those Homebrew needs have already been set to global constants and/or are exported again later. # Setting these globally can interfere with nested Homebrew invocations/environments. if key.start_with?("HOMEBREW_", "PORTABLE_RUBY_") ENV.delete(key) next end # No need to export empty values. next if value.blank? # Skip exporting things that were the same in the old environment. old_value = old_env[key] next if old_value == value # Look for PATH-like environment variables if key.include?("PATH") && value.match?(PATH_LIKE_ENV_REGEX) old_values = old_value.to_s.split(File::PATH_SEPARATOR) path = PATH.new(value) .reject do |path_value| # Exclude existing/old values as they've already been exported. old_values.include?(path_value) end next if path.blank? puts "export #{key}=\"#{Utils::Shell.sh_quote(path.to_s)}:${#{key}:-}\"" else puts "export #{key}=\"#{Utils::Shell.sh_quote(value)}\"" end end return elsif subcommand == "sh" preferred_path = Utils::Shell.preferred_path(default: "/bin/bash") notice = unless Homebrew::EnvConfig.no_env_hints? <<~EOS Your shell has been configured to use a build environment from your `Brewfile`. This should help you build stuff. Hide these hints with HOMEBREW_NO_ENV_HINTS (see `man brew`). When done, type `exit`. EOS end ENV["HOMEBREW_FORCE_API_AUTO_UPDATE"] = nil args = [Utils::Shell.shell_with_prompt("brew bundle", preferred_path:, notice:)] end if services require "bundle/brew_services" exit_code = T.let(0, Integer) run_services(@dsl.entries) do Kernel.system(*args) if (system_exit_code = $CHILD_STATUS&.exitstatus) exit_code = system_exit_code end end exit!(exit_code) else exec(*args) end end sig { params( entries: T::Array[Homebrew::Bundle::Dsl::Entry], _block: T.proc.params( entry: Homebrew::Bundle::Dsl::Entry, info: T::Hash[String, T.untyped], service_file: Pathname, conflicting_services: T::Array[T::Hash[String, T.anything]], ).void, ).void } private_class_method def self.map_service_info(entries, &_block) entries_formulae = entries.filter_map do |entry| next if entry.type != :brew formula = Formula[entry.name] next unless formula.any_version_installed? [entry, formula] end.to_h return if entries_formulae.empty? conflicts = entries_formulae.to_h do |entry, formula| [ entry, ( formula.versioned_formulae_names + formula.conflicts.map(&:name) + Array(entry.options[:conflicts_with]) ).uniq, ] end # The formula + everything that could possible conflict with the service names_to_query = entries_formulae.flat_map do |entry, formula| [ formula.name, *conflicts.fetch(entry), ] end # We parse from a command invocation so that brew wrappers can invoke special actions # for the elevated nature of `brew services` services_info = JSON.parse( Utils.safe_popen_read(HOMEBREW_BREW_FILE, "services", "info", "--json", *names_to_query), ) entries_formulae.filter_map do |entry, formula| service_file = Bundle::BrewServices.versioned_service_file(entry.name) unless service_file&.file? prefix = formula.any_installed_prefix next if prefix.nil? service_file = if Homebrew::Services::System.launchctl? prefix/"#{formula.plist_name}.plist" else prefix/"#{formula.service_name}.service" end end next unless service_file.file? info = services_info.find { |candidate| candidate["name"] == formula.name } conflicting_services = services_info.select do |candidate| next unless candidate["running"] conflicts.fetch(entry).include?(candidate["name"]) end raise "Failed to get service info for #{entry.name}" if info.nil? yield entry, info, service_file, conflicting_services end end sig { params(entries: T::Array[Homebrew::Bundle::Dsl::Entry], _block: T.nilable(T.proc.void)).void } private_class_method def self.run_services(entries, &_block) entries_to_stop = [] services_to_restart = [] map_service_info(entries) do |entry, info, service_file, conflicting_services| # Don't restart if already running this version loaded_file = Pathname.new(info["loaded_file"].to_s) next if info["running"] && loaded_file.file? && loaded_file.realpath == service_file.realpath if info["running"] && !Bundle::BrewServices.stop(info["name"], keep: true) opoo "Failed to stop #{info["name"]} service" end conflicting_services.each do |conflict| if Bundle::BrewServices.stop(conflict["name"], keep: true) services_to_restart << conflict["name"] if conflict["registered"] else opoo "Failed to stop #{conflict["name"]} service" end end unless Bundle::BrewServices.run(info["name"], file: service_file) opoo "Failed to start #{info["name"]} service" end entries_to_stop << entry end return unless block_given? begin yield ensure # Do a full re-evaluation of services instead state has changed stop_services(entries_to_stop) services_to_restart.each do |service| next if Bundle::BrewServices.run(service) opoo "Failed to restart #{service} service" end end end sig { params(entries: T::Array[Homebrew::Bundle::Dsl::Entry]).void } private_class_method def self.stop_services(entries) map_service_info(entries) do |_, info, _, _| next unless info["loaded"] # Try avoid services not started by `brew bundle services` next if Homebrew::Services::System.launchctl? && info["registered"] if info["running"] && !Bundle::BrewServices.stop(info["name"], keep: true) opoo "Failed to stop #{info["name"]} service" end end end end end end end