mirror of
https://github.com/Homebrew/brew.git
synced 2025-07-14 16:09:03 +08:00

The previous PR changed how sockets were represented in the JSON API for formulae and that would cause problems when trying to install packages with service sockets. This provides backwards compatibility until all users have upgraded to versions of homebrew that can deserialize sockets hashes (maybe a couple weeks). Essentially, we store the socket string when serializing sockets that were originally defined with only the string parameter otherwise we serialize it to a hash.
645 lines
20 KiB
Ruby
645 lines
20 KiB
Ruby
# typed: true
|
|
# frozen_string_literal: true
|
|
|
|
require "ipaddr"
|
|
require "extend/on_system"
|
|
|
|
module Homebrew
|
|
# The {Service} class implements the DSL methods used in a formula's
|
|
# `service` block and stores related instance variables. Most of these methods
|
|
# also return the related instance variable when no argument is provided.
|
|
class Service
|
|
extend Forwardable
|
|
include OnSystem::MacOSAndLinux
|
|
|
|
RUN_TYPE_IMMEDIATE = :immediate
|
|
RUN_TYPE_INTERVAL = :interval
|
|
RUN_TYPE_CRON = :cron
|
|
|
|
PROCESS_TYPE_BACKGROUND = :background
|
|
PROCESS_TYPE_STANDARD = :standard
|
|
PROCESS_TYPE_INTERACTIVE = :interactive
|
|
PROCESS_TYPE_ADAPTIVE = :adaptive
|
|
|
|
KEEP_ALIVE_KEYS = [:always, :successful_exit, :crashed, :path].freeze
|
|
|
|
# sig { params(formula: Formula).void }
|
|
def initialize(formula, &block)
|
|
@formula = formula
|
|
@run_type = RUN_TYPE_IMMEDIATE
|
|
@run_at_load = true
|
|
@environment_variables = {}
|
|
instance_eval(&block) if block
|
|
end
|
|
|
|
sig { returns(Formula) }
|
|
def f
|
|
@formula
|
|
end
|
|
|
|
sig { returns(String) }
|
|
def default_plist_name
|
|
"homebrew.mxcl.#{@formula.name}"
|
|
end
|
|
|
|
sig { returns(String) }
|
|
def plist_name
|
|
@plist_name ||= default_plist_name
|
|
end
|
|
|
|
sig { returns(String) }
|
|
def default_service_name
|
|
"homebrew.#{@formula.name}"
|
|
end
|
|
|
|
sig { returns(String) }
|
|
def service_name
|
|
@service_name ||= default_service_name
|
|
end
|
|
|
|
sig { params(macos: T.nilable(String), linux: T.nilable(String)).void }
|
|
def name(macos: nil, linux: nil)
|
|
raise TypeError, "Service#name expects at least one String" if [macos, linux].none?(String)
|
|
|
|
@plist_name = macos if macos
|
|
@service_name = linux if linux
|
|
end
|
|
|
|
sig {
|
|
params(
|
|
command: T.nilable(T.any(T::Array[String], String, Pathname)),
|
|
macos: T.nilable(T.any(T::Array[String], String, Pathname)),
|
|
linux: T.nilable(T.any(T::Array[String], String, Pathname)),
|
|
).returns(T.nilable(Array))
|
|
}
|
|
def run(command = nil, macos: nil, linux: nil)
|
|
# Save parameters for serialization
|
|
if command
|
|
@run_params = command
|
|
elsif macos || linux
|
|
@run_params = { macos: macos, linux: linux }.compact
|
|
end
|
|
|
|
command ||= on_system_conditional(macos: macos, linux: linux)
|
|
case command
|
|
when nil
|
|
@run
|
|
when String, Pathname
|
|
@run = [command]
|
|
when Array
|
|
@run = command
|
|
end
|
|
end
|
|
|
|
sig { params(path: T.nilable(T.any(String, Pathname))).returns(T.nilable(String)) }
|
|
def working_dir(path = nil)
|
|
case path
|
|
when nil
|
|
@working_dir
|
|
when String, Pathname
|
|
@working_dir = path.to_s
|
|
end
|
|
end
|
|
|
|
sig { params(path: T.nilable(T.any(String, Pathname))).returns(T.nilable(String)) }
|
|
def root_dir(path = nil)
|
|
case path
|
|
when nil
|
|
@root_dir
|
|
when String, Pathname
|
|
@root_dir = path.to_s
|
|
end
|
|
end
|
|
|
|
sig { params(path: T.nilable(T.any(String, Pathname))).returns(T.nilable(String)) }
|
|
def input_path(path = nil)
|
|
case path
|
|
when nil
|
|
@input_path
|
|
when String, Pathname
|
|
@input_path = path.to_s
|
|
end
|
|
end
|
|
|
|
sig { params(path: T.nilable(T.any(String, Pathname))).returns(T.nilable(String)) }
|
|
def log_path(path = nil)
|
|
case path
|
|
when nil
|
|
@log_path
|
|
when String, Pathname
|
|
@log_path = path.to_s
|
|
end
|
|
end
|
|
|
|
sig { params(path: T.nilable(T.any(String, Pathname))).returns(T.nilable(String)) }
|
|
def error_log_path(path = nil)
|
|
case path
|
|
when nil
|
|
@error_log_path
|
|
when String, Pathname
|
|
@error_log_path = path.to_s
|
|
end
|
|
end
|
|
|
|
sig {
|
|
params(value: T.nilable(T.any(T::Boolean, T::Hash[Symbol, T.untyped])))
|
|
.returns(T.nilable(T::Hash[Symbol, T.untyped]))
|
|
}
|
|
def keep_alive(value = nil)
|
|
case value
|
|
when nil
|
|
@keep_alive
|
|
when true, false
|
|
@keep_alive = { always: value }
|
|
when Hash
|
|
hash = T.cast(value, Hash)
|
|
unless (hash.keys - KEEP_ALIVE_KEYS).empty?
|
|
raise TypeError, "Service#keep_alive allows only #{KEEP_ALIVE_KEYS}"
|
|
end
|
|
|
|
@keep_alive = value
|
|
end
|
|
end
|
|
|
|
sig { params(value: T.nilable(T::Boolean)).returns(T.nilable(T::Boolean)) }
|
|
def require_root(value = nil)
|
|
case value
|
|
when nil
|
|
@require_root
|
|
when true, false
|
|
@require_root = value
|
|
end
|
|
end
|
|
|
|
# Returns a `Boolean` describing if a service requires root access.
|
|
# @return [Boolean]
|
|
sig { returns(T::Boolean) }
|
|
def requires_root?
|
|
@require_root.present? && @require_root == true
|
|
end
|
|
|
|
sig { params(value: T.nilable(T::Boolean)).returns(T.nilable(T::Boolean)) }
|
|
def run_at_load(value = nil)
|
|
case value
|
|
when nil
|
|
@run_at_load
|
|
when true, false
|
|
@run_at_load = value
|
|
end
|
|
end
|
|
|
|
SOCKET_STRING_REGEX = %r{^([a-z]+)://(.+):([0-9]+)$}i.freeze
|
|
|
|
sig {
|
|
params(value: T.nilable(T.any(String, T::Hash[Symbol, String])))
|
|
.returns(T.nilable(T::Hash[Symbol, T::Hash[Symbol, String]]))
|
|
}
|
|
def sockets(value = nil)
|
|
return @sockets if value.nil?
|
|
|
|
@sockets = case value
|
|
when String
|
|
{ listeners: value }
|
|
when Hash
|
|
value
|
|
end.transform_values do |socket_string|
|
|
match = socket_string.match(SOCKET_STRING_REGEX)
|
|
raise TypeError, "Service#sockets a formatted socket definition as <type>://<host>:<port>" if match.blank?
|
|
|
|
type, host, port = match.captures
|
|
|
|
begin
|
|
IPAddr.new(host)
|
|
rescue IPAddr::InvalidAddressError
|
|
raise TypeError, "Service#sockets expects a valid ipv4 or ipv6 host address"
|
|
end
|
|
|
|
{ host: host, port: port, type: type }
|
|
end
|
|
end
|
|
|
|
# Returns a `Boolean` describing if a service is set to be kept alive.
|
|
# @return [Boolean]
|
|
sig { returns(T::Boolean) }
|
|
def keep_alive?
|
|
@keep_alive.present? && @keep_alive[:always] != false
|
|
end
|
|
|
|
sig { params(value: T.nilable(T::Boolean)).returns(T.nilable(T::Boolean)) }
|
|
def launch_only_once(value = nil)
|
|
case value
|
|
when nil
|
|
@launch_only_once
|
|
when true, false
|
|
@launch_only_once = value
|
|
end
|
|
end
|
|
|
|
sig { params(value: T.nilable(Integer)).returns(T.nilable(Integer)) }
|
|
def restart_delay(value = nil)
|
|
case value
|
|
when nil
|
|
@restart_delay
|
|
when Integer
|
|
@restart_delay = value
|
|
end
|
|
end
|
|
|
|
sig { params(value: T.nilable(Symbol)).returns(T.nilable(Symbol)) }
|
|
def process_type(value = nil)
|
|
case value
|
|
when nil
|
|
@process_type
|
|
when :background, :standard, :interactive, :adaptive
|
|
@process_type = value
|
|
when Symbol
|
|
raise TypeError, "Service#process_type allows: " \
|
|
"'#{PROCESS_TYPE_BACKGROUND}'/'#{PROCESS_TYPE_STANDARD}'/" \
|
|
"'#{PROCESS_TYPE_INTERACTIVE}'/'#{PROCESS_TYPE_ADAPTIVE}'"
|
|
end
|
|
end
|
|
|
|
sig { params(value: T.nilable(Symbol)).returns(T.nilable(Symbol)) }
|
|
def run_type(value = nil)
|
|
case value
|
|
when nil
|
|
@run_type
|
|
when :immediate, :interval, :cron
|
|
@run_type = value
|
|
when Symbol
|
|
raise TypeError, "Service#run_type allows: '#{RUN_TYPE_IMMEDIATE}'/'#{RUN_TYPE_INTERVAL}'/'#{RUN_TYPE_CRON}'"
|
|
end
|
|
end
|
|
|
|
sig { params(value: T.nilable(Integer)).returns(T.nilable(Integer)) }
|
|
def interval(value = nil)
|
|
case value
|
|
when nil
|
|
@interval
|
|
when Integer
|
|
@interval = value
|
|
end
|
|
end
|
|
|
|
sig { params(value: T.nilable(String)).returns(T.nilable(Hash)) }
|
|
def cron(value = nil)
|
|
case value
|
|
when nil
|
|
@cron
|
|
when String
|
|
@cron = parse_cron(T.must(value))
|
|
end
|
|
end
|
|
|
|
sig { returns(T::Hash[Symbol, T.any(Integer, String)]) }
|
|
def default_cron_values
|
|
{
|
|
Month: "*",
|
|
Day: "*",
|
|
Weekday: "*",
|
|
Hour: "*",
|
|
Minute: "*",
|
|
}
|
|
end
|
|
|
|
sig { params(cron_statement: String).returns(T::Hash[Symbol, T.any(Integer, String)]) }
|
|
def parse_cron(cron_statement)
|
|
parsed = default_cron_values
|
|
|
|
case cron_statement
|
|
when "@hourly"
|
|
parsed[:Minute] = 0
|
|
when "@daily"
|
|
parsed[:Minute] = 0
|
|
parsed[:Hour] = 0
|
|
when "@weekly"
|
|
parsed[:Minute] = 0
|
|
parsed[:Hour] = 0
|
|
parsed[:Weekday] = 0
|
|
when "@monthly"
|
|
parsed[:Minute] = 0
|
|
parsed[:Hour] = 0
|
|
parsed[:Day] = 1
|
|
when "@yearly", "@annually"
|
|
parsed[:Minute] = 0
|
|
parsed[:Hour] = 0
|
|
parsed[:Day] = 1
|
|
parsed[:Month] = 1
|
|
else
|
|
cron_parts = cron_statement.split
|
|
raise TypeError, "Service#parse_cron expects a valid cron syntax" if cron_parts.length != 5
|
|
|
|
[:Minute, :Hour, :Day, :Month, :Weekday].each_with_index do |selector, index|
|
|
parsed[selector] = Integer(cron_parts.fetch(index)) if cron_parts.fetch(index) != "*"
|
|
end
|
|
end
|
|
|
|
parsed
|
|
end
|
|
|
|
sig { params(variables: T::Hash[Symbol, String]).returns(T.nilable(T::Hash[Symbol, String])) }
|
|
def environment_variables(variables = {})
|
|
case variables
|
|
when Hash
|
|
@environment_variables = variables.transform_values(&:to_s)
|
|
end
|
|
end
|
|
|
|
sig { params(value: T.nilable(T::Boolean)).returns(T.nilable(T::Boolean)) }
|
|
def macos_legacy_timers(value = nil)
|
|
case value
|
|
when nil
|
|
@macos_legacy_timers
|
|
when true, false
|
|
@macos_legacy_timers = value
|
|
end
|
|
end
|
|
|
|
delegate [:bin, :etc, :libexec, :opt_bin, :opt_libexec, :opt_pkgshare, :opt_prefix, :opt_sbin, :var] => :@formula
|
|
|
|
sig { returns(String) }
|
|
def std_service_path_env
|
|
"#{HOMEBREW_PREFIX}/bin:#{HOMEBREW_PREFIX}/sbin:/usr/bin:/bin:/usr/sbin:/sbin"
|
|
end
|
|
|
|
sig { returns(T.nilable(T::Array[String])) }
|
|
def command
|
|
@run&.map(&:to_s)&.map { |arg| arg.start_with?("~") ? File.expand_path(arg) : arg }
|
|
end
|
|
|
|
sig { returns(T::Boolean) }
|
|
def command?
|
|
@run.present?
|
|
end
|
|
|
|
# Returns the `String` command to run manually instead of the service.
|
|
# @return [String]
|
|
sig { returns(String) }
|
|
def manual_command
|
|
vars = @environment_variables.except(:PATH)
|
|
.map { |k, v| "#{k}=\"#{v}\"" }
|
|
|
|
out = vars + T.must(command).map { |arg| Utils::Shell.sh_quote(arg) } if command?
|
|
out.join(" ")
|
|
end
|
|
|
|
# Returns a `Boolean` describing if a service is timed.
|
|
# @return [Boolean]
|
|
sig { returns(T::Boolean) }
|
|
def timed?
|
|
@run_type == RUN_TYPE_CRON || @run_type == RUN_TYPE_INTERVAL
|
|
end
|
|
|
|
# Returns a `String` plist.
|
|
# @return [String]
|
|
sig { returns(String) }
|
|
def to_plist
|
|
# command needs to be first because it initializes all other values
|
|
base = {
|
|
Label: plist_name,
|
|
ProgramArguments: command,
|
|
RunAtLoad: @run_at_load == true,
|
|
}
|
|
|
|
base[:LaunchOnlyOnce] = @launch_only_once if @launch_only_once == true
|
|
base[:LegacyTimers] = @macos_legacy_timers if @macos_legacy_timers == true
|
|
base[:TimeOut] = @restart_delay if @restart_delay.present?
|
|
base[:ProcessType] = @process_type.to_s.capitalize if @process_type.present?
|
|
base[:StartInterval] = @interval if @interval.present? && @run_type == RUN_TYPE_INTERVAL
|
|
base[:WorkingDirectory] = File.expand_path(@working_dir) if @working_dir.present?
|
|
base[:RootDirectory] = File.expand_path(@root_dir) if @root_dir.present?
|
|
base[:StandardInPath] = File.expand_path(@input_path) if @input_path.present?
|
|
base[:StandardOutPath] = File.expand_path(@log_path) if @log_path.present?
|
|
base[:StandardErrorPath] = File.expand_path(@error_log_path) if @error_log_path.present?
|
|
base[:EnvironmentVariables] = @environment_variables unless @environment_variables.empty?
|
|
|
|
if keep_alive?
|
|
if (always = @keep_alive[:always].presence)
|
|
base[:KeepAlive] = always
|
|
elsif @keep_alive.key?(:successful_exit)
|
|
base[:KeepAlive] = { SuccessfulExit: @keep_alive[:successful_exit] }
|
|
elsif @keep_alive.key?(:crashed)
|
|
base[:KeepAlive] = { Crashed: @keep_alive[:crashed] }
|
|
elsif @keep_alive.key?(:path) && @keep_alive[:path].present?
|
|
base[:KeepAlive] = { PathState: @keep_alive[:path].to_s }
|
|
end
|
|
end
|
|
|
|
if @sockets.present?
|
|
base[:Sockets] = {}
|
|
@sockets.each do |name, info|
|
|
base[:Sockets][name] = {
|
|
SockNodeName: info[:host],
|
|
SockServiceName: info[:port],
|
|
SockProtocol: info[:type].upcase,
|
|
}
|
|
end
|
|
end
|
|
|
|
if @cron.present? && @run_type == RUN_TYPE_CRON
|
|
base[:StartCalendarInterval] = @cron.reject { |_, value| value == "*" }
|
|
end
|
|
|
|
# Adding all session types has as the primary effect that if you initialise it through e.g. a Background session
|
|
# and you later "physically" sign in to the owning account (Aqua session), things shouldn't flip out.
|
|
# Also, we're not checking @process_type here because that is used to indicate process priority and not
|
|
# necessarily if it should run in a specific session type. Like database services could run with ProcessType
|
|
# Interactive so they have no resource limitations enforced upon them, but they aren't really interactive in the
|
|
# general sense.
|
|
base[:LimitLoadToSessionType] = %w[Aqua Background LoginWindow StandardIO System]
|
|
|
|
base.to_plist
|
|
end
|
|
|
|
# Returns a `String` systemd unit.
|
|
# @return [String]
|
|
sig { returns(String) }
|
|
def to_systemd_unit
|
|
unit = <<~EOS
|
|
[Unit]
|
|
Description=Homebrew generated unit for #{@formula.name}
|
|
|
|
[Install]
|
|
WantedBy=default.target
|
|
|
|
[Service]
|
|
EOS
|
|
|
|
# command needs to be first because it initializes all other values
|
|
cmd = command&.map { |arg| Utils::Shell.sh_quote(arg) }
|
|
&.join(" ")
|
|
|
|
options = []
|
|
options << "Type=#{(@launch_only_once == true) ? "oneshot" : "simple"}"
|
|
options << "ExecStart=#{cmd}"
|
|
|
|
options << "Restart=always" if @keep_alive.present? && @keep_alive[:always].present?
|
|
options << "RestartSec=#{restart_delay}" if @restart_delay.present?
|
|
options << "WorkingDirectory=#{File.expand_path(@working_dir)}" if @working_dir.present?
|
|
options << "RootDirectory=#{File.expand_path(@root_dir)}" if @root_dir.present?
|
|
options << "StandardInput=file:#{File.expand_path(@input_path)}" if @input_path.present?
|
|
options << "StandardOutput=append:#{File.expand_path(@log_path)}" if @log_path.present?
|
|
options << "StandardError=append:#{File.expand_path(@error_log_path)}" if @error_log_path.present?
|
|
options += @environment_variables.map { |k, v| "Environment=\"#{k}=#{v}\"" } if @environment_variables.present?
|
|
|
|
unit + options.join("\n")
|
|
end
|
|
|
|
# Returns a `String` systemd unit timer.
|
|
# @return [String]
|
|
sig { returns(String) }
|
|
def to_systemd_timer
|
|
timer = <<~EOS
|
|
[Unit]
|
|
Description=Homebrew generated timer for #{@formula.name}
|
|
|
|
[Install]
|
|
WantedBy=timers.target
|
|
|
|
[Timer]
|
|
Unit=#{service_name}
|
|
EOS
|
|
|
|
options = []
|
|
options << "Persistent=true" if @run_type == RUN_TYPE_CRON
|
|
options << "OnUnitActiveSec=#{@interval}" if @run_type == RUN_TYPE_INTERVAL
|
|
|
|
if @run_type == RUN_TYPE_CRON
|
|
minutes = (@cron[:Minute] == "*") ? "*" : format("%02d", @cron[:Minute])
|
|
hours = (@cron[:Hour] == "*") ? "*" : format("%02d", @cron[:Hour])
|
|
options << "OnCalendar=#{@cron[:Weekday]}-*-#{@cron[:Month]}-#{@cron[:Day]} #{hours}:#{minutes}:00"
|
|
end
|
|
|
|
timer + options.join("\n")
|
|
end
|
|
|
|
# Prepare the service hash for inclusion in the formula API JSON.
|
|
sig { returns(Hash) }
|
|
def serialize
|
|
name_params = {
|
|
macos: (plist_name if plist_name != default_plist_name),
|
|
linux: (service_name if service_name != default_service_name),
|
|
}.compact
|
|
|
|
return { name: name_params }.compact_blank if @run_params.blank?
|
|
|
|
cron_string = if @cron.present?
|
|
[:Minute, :Hour, :Day, :Month, :Weekday]
|
|
.map { |key| @cron[key].to_s }
|
|
.join(" ")
|
|
end
|
|
|
|
sockets_var = if @sockets.present?
|
|
@sockets.transform_values { |info| "#{info[:type]}://#{info[:host]}:#{info[:port]}" }
|
|
.then do |sockets_hash|
|
|
# TODO: Remove this code when all users are running on versions of Homebrew
|
|
# that can process sockets hashes (this commit or later).
|
|
if sockets_hash.size == 1 && sockets_hash.key?(:listeners)
|
|
# When original #sockets argument was a string: `sockets "tcp://127.0.0.1:80"`
|
|
sockets_hash.fetch(:listeners)
|
|
else
|
|
# When original #sockets argument was a hash: `sockets http: "tcp://0.0.0.0:80"`
|
|
sockets_hash
|
|
end
|
|
end
|
|
end
|
|
|
|
{
|
|
name: name_params.presence,
|
|
run: @run_params,
|
|
run_type: @run_type,
|
|
interval: @interval,
|
|
cron: cron_string,
|
|
keep_alive: @keep_alive,
|
|
launch_only_once: @launch_only_once,
|
|
require_root: @require_root,
|
|
environment_variables: @environment_variables.presence,
|
|
working_dir: @working_dir,
|
|
root_dir: @root_dir,
|
|
input_path: @input_path,
|
|
log_path: @log_path,
|
|
error_log_path: @error_log_path,
|
|
restart_delay: @restart_delay,
|
|
process_type: @process_type,
|
|
macos_legacy_timers: @macos_legacy_timers,
|
|
sockets: sockets_var,
|
|
}.compact
|
|
end
|
|
|
|
# Turn the service API hash values back into what is expected by the formula DSL.
|
|
sig { params(api_hash: Hash).returns(Hash) }
|
|
def self.deserialize(api_hash)
|
|
hash = {}
|
|
hash[:name] = api_hash["name"].transform_keys(&:to_sym) if api_hash.key?("name")
|
|
|
|
# The service hash might not have a "run" command if it only documents
|
|
# an existing service file with the "name" command.
|
|
return hash unless api_hash.key?("run")
|
|
|
|
hash[:run] =
|
|
case api_hash["run"]
|
|
when String
|
|
replace_placeholders(api_hash["run"])
|
|
when Array
|
|
api_hash["run"].map(&method(:replace_placeholders))
|
|
when Hash
|
|
api_hash["run"].to_h do |key, elem|
|
|
run_cmd = if elem.is_a?(Array)
|
|
elem.map(&method(:replace_placeholders))
|
|
else
|
|
replace_placeholders(elem)
|
|
end
|
|
|
|
[key.to_sym, run_cmd]
|
|
end
|
|
else
|
|
raise ArgumentError, "Unexpected run command: #{api_hash["run"]}"
|
|
end
|
|
|
|
if api_hash.key?("environment_variables")
|
|
hash[:environment_variables] = api_hash["environment_variables"].to_h do |key, value|
|
|
[key.to_sym, replace_placeholders(value)]
|
|
end
|
|
end
|
|
|
|
%w[run_type process_type].each do |key|
|
|
next unless (value = api_hash[key])
|
|
|
|
hash[key.to_sym] = value.to_sym
|
|
end
|
|
|
|
%w[working_dir root_dir input_path log_path error_log_path].each do |key|
|
|
next unless (value = api_hash[key])
|
|
|
|
hash[key.to_sym] = replace_placeholders(value)
|
|
end
|
|
|
|
%w[interval cron launch_only_once require_root restart_delay macos_legacy_timers].each do |key|
|
|
next if (value = api_hash[key]).nil?
|
|
|
|
hash[key.to_sym] = value
|
|
end
|
|
|
|
%w[sockets keep_alive].each do |key|
|
|
next unless (value = api_hash[key])
|
|
|
|
hash[key.to_sym] = if value.is_a?(Hash)
|
|
value.transform_keys(&:to_sym)
|
|
else
|
|
value
|
|
end
|
|
end
|
|
|
|
hash
|
|
end
|
|
|
|
# Replace API path placeholders with local paths.
|
|
sig { params(string: String).returns(String) }
|
|
def self.replace_placeholders(string)
|
|
string.gsub(HOMEBREW_PREFIX_PLACEHOLDER, HOMEBREW_PREFIX)
|
|
.gsub(HOMEBREW_CELLAR_PLACEHOLDER, HOMEBREW_CELLAR)
|
|
.gsub(HOMEBREW_HOME_PLACEHOLDER, Dir.home)
|
|
end
|
|
end
|
|
end
|