bundle: refactor go and uv extensions

Consolidate the bundle go and uv dumper, checker, and installer behavior into single-file extension classes and infer the shared bundle command flags, docs, and workflows for those types from the registered extensions instead of hardcoding them separately.

Keep the existing explicit plumbing for the other dependency types for now so the refactor stays focused on establishing the new extension pattern and compatibility path with a smaller review surface.
This commit is contained in:
Mike McQuaid committed 2026-03-15 15:30:24 +00:00
1 parent b881fd9882
commit eac1886dc8
32 files changed
+1123 -651

No files matched your search

+4
View File
@@ -62,6 +62,8 @@ module Homebrew
@which_vscode ||= which("code-insiders", ORIGINAL_PATHS)
end
# TODO: Remove these go helpers once the bundle extension specs stop
# stubbing them directly.
sig { returns(T.nilable(Pathname)) }
def which_go
@which_go ||= which("go", ORIGINAL_PATHS)
@@ -82,6 +84,8 @@ module Homebrew
@cargo_installed ||= which_cargo.present?
end
# TODO: Remove these uv helpers once the bundle extension specs stop
# stubbing them directly.
sig { returns(T.nilable(Pathname)) }
def which_uv
@which_uv ||= which("uv", ORIGINAL_PATHS)
+52 -83
View File
@@ -1,88 +1,37 @@
# typed: true
# frozen_string_literal: true
require "bundle/checker/base"
require "bundle/dsl"
require "bundle/extensions"
module Homebrew
module Bundle
module Checker
class Base
# Implement these in any subclass
# PACKAGE_TYPE = :pkg
# PACKAGE_TYPE_NAME = "Package"
def exit_early_check(packages, no_upgrade:)
work_to_be_done = packages.find do |pkg|
!installed_and_up_to_date?(pkg, no_upgrade:)
end
Array(work_to_be_done)
end
def failure_reason(name, no_upgrade:)
reason = if no_upgrade && Bundle.upgrade_formulae.exclude?(name)
"needs to be installed."
else
"needs to be installed or updated."
end
"#{self.class.const_get(:PACKAGE_TYPE_NAME)} #{name} #{reason}"
end
def full_check(packages, no_upgrade:)
packages.reject { |pkg| installed_and_up_to_date?(pkg, no_upgrade:) }
.map { |pkg| failure_reason(pkg, no_upgrade:) }
end
def checkable_entries(all_entries)
require "bundle/skipper"
all_entries.select { |e| e.type == self.class.const_get(:PACKAGE_TYPE) }
.reject(&Bundle::Skipper.method(:skip?))
end
def format_checkable(entries)
checkable_entries(entries).map(&:name)
end
def installed_and_up_to_date?(_pkg, no_upgrade: false)
raise NotImplementedError
end
def find_actionable(entries, exit_on_first_error: false, no_upgrade: false, verbose: false)
requested = format_checkable entries
if exit_on_first_error
exit_early_check(requested, no_upgrade:)
else
full_check(requested, no_upgrade:)
end
end
end
CheckResult = Struct.new :work_to_be_done, :errors
CheckStep = T.type_alias { Symbol }
CHECKS = {
taps_to_tap: "Taps",
casks_to_install: "Casks",
extensions_to_install: "VSCode Extensions",
apps_to_install: "Apps",
formulae_to_install: "Formulae",
formulae_to_start: "Services",
go_packages_to_install: "Go Packages",
cargo_packages_to_install: "Cargo Packages",
uv_packages_to_install: "uv Tools",
flatpaks_to_install: "Flatpaks",
}.freeze
CORE_CHECKS = T.let([
:taps_to_tap,
:casks_to_install,
:extensions_to_install,
:apps_to_install,
:formulae_to_install,
:formulae_to_start,
:registered_extensions_to_install,
:cargo_packages_to_install,
:flatpaks_to_install,
].freeze, T::Array[CheckStep])
def self.check(global: false, file: nil, exit_on_first_error: false, no_upgrade: false, verbose: false)
require "bundle/brewfile"
@dsl ||= Brewfile.read(global:, file:)
check_method_names = CHECKS.keys
errors = []
enumerator = exit_on_first_error ? :find : :map
work_to_be_done = check_method_names.public_send(enumerator) do |check_method|
check_errors =
send(check_method, exit_on_first_error:, no_upgrade:, verbose:)
work_to_be_done = check_steps.public_send(enumerator) do |check_step|
check_errors = run_check_step(check_step, exit_on_first_error:, no_upgrade:, verbose:)
any_errors = check_errors.any?
errors.concat(check_errors) if any_errors
any_errors
@@ -141,14 +90,6 @@ module Homebrew
)
end
def self.go_packages_to_install(exit_on_first_error: false, no_upgrade: false, verbose: false)
require "bundle/go_checker"
Homebrew::Bundle::Checker::GoChecker.new.find_actionable(
@dsl.entries,
exit_on_first_error:, no_upgrade:, verbose:,
)
end
def self.cargo_packages_to_install(exit_on_first_error: false, no_upgrade: false, verbose: false)
require "bundle/cargo_checker"
Homebrew::Bundle::Checker::CargoChecker.new.find_actionable(
@@ -165,12 +106,22 @@ module Homebrew
)
end
def self.uv_packages_to_install(exit_on_first_error: false, no_upgrade: false, verbose: false)
require "bundle/uv_checker"
Homebrew::Bundle::Checker::UvChecker.new.find_actionable(
@dsl.entries,
exit_on_first_error:, no_upgrade:, verbose:,
)
def self.registered_extensions_to_install(exit_on_first_error: false, no_upgrade: false, verbose: false)
errors = T.let([], T::Array[Object])
Homebrew::Bundle.extensions.each do |extension|
check_errors = extension.check(
@dsl.entries,
exit_on_first_error:, no_upgrade:, verbose:,
)
next if check_errors.empty?
return check_errors if exit_on_first_error
errors.concat(check_errors)
end
errors
end
def self.reset!
@@ -186,6 +137,24 @@ module Homebrew
Homebrew::Bundle::MacAppStoreDumper.reset!
Homebrew::Bundle::TapDumper.reset!
Homebrew::Bundle::BrewServices.reset!
Homebrew::Bundle.extensions.each(&:reset!)
end
sig { returns(T::Array[CheckStep]) }
def self.check_steps
CORE_CHECKS
end
sig {
params(
check_step: CheckStep,
exit_on_first_error: T::Boolean,
no_upgrade: T::Boolean,
verbose: T::Boolean,
).returns(T::Array[Object])
}
def self.run_check_step(check_step, exit_on_first_error:, no_upgrade:, verbose:)
public_send(check_step, exit_on_first_error:, no_upgrade:, verbose:)
end
end
end
+6
View File
@@ -0,0 +1,6 @@
# typed: strict
# frozen_string_literal: true
# TODO: Remove this compatibility require once the remaining bundle callers load
# `bundle/extensions/extension` directly.
require "bundle/extensions/extension"
+5 -5
View File
@@ -10,14 +10,14 @@ module Homebrew
sig {
params(global: T::Boolean, file: T.nilable(String), describe: T::Boolean, force: T::Boolean,
no_restart: T::Boolean, taps: T::Boolean, formulae: T::Boolean, casks: T::Boolean,
mas: T::Boolean, vscode: T::Boolean, go: T::Boolean, cargo: T::Boolean,
uv: T::Boolean, flatpak: T::Boolean).void
mas: T::Boolean, vscode: T::Boolean, cargo: T::Boolean, flatpak: T::Boolean,
extension_types: Homebrew::Bundle::ExtensionTypes).void
}
def self.run(global:, file:, describe:, force:, no_restart:, taps:, formulae:, casks:, mas:,
vscode:, go:, cargo:, uv:, flatpak:)
vscode:, cargo:, flatpak:, extension_types: {})
Homebrew::Bundle::Dumper.dump_brewfile(
global:, file:, describe:, force:, no_restart:, taps:, formulae:, casks:, mas:, vscode:,
go:, cargo:, uv:, flatpak:
global:, file:, describe:, force:, no_restart:, taps:, formulae:, casks:, mas:, vscode:, cargo:,
flatpak:, extension_types:
)
end
end
+4 -4
View File
@@ -10,14 +10,14 @@ module Homebrew
module List
sig {
params(global: T::Boolean, file: T.nilable(String), formulae: T::Boolean, casks: T::Boolean,
taps: T::Boolean, mas: T::Boolean, vscode: T::Boolean,
go: T::Boolean, cargo: T::Boolean, uv: T::Boolean, flatpak: T::Boolean).void
taps: T::Boolean, mas: T::Boolean, vscode: T::Boolean, cargo: T::Boolean,
flatpak: T::Boolean, extension_types: Homebrew::Bundle::ExtensionTypes).void
}
def self.run(global:, file:, formulae:, casks:, taps:, mas:, vscode:, go:, cargo:, uv:, flatpak:)
def self.run(global:, file:, formulae:, casks:, taps:, mas:, vscode:, cargo:, flatpak:, extension_types: {})
parsed_entries = Brewfile.read(global:, file:).entries
Homebrew::Bundle::Lister.list(
parsed_entries,
formulae:, casks:, taps:, mas:, vscode:, go:, cargo:, uv:, flatpak:,
formulae:, casks:, taps:, mas:, vscode:, cargo:, flatpak:, extension_types:,
)
end
end
+36 -23
View File
@@ -77,33 +77,11 @@ module Homebrew
@entries << Entry.new(:vscode, name)
end
sig { params(name: String).void }
def go(name)
@entries << Entry.new(:go, name)
end
sig { params(name: String).void }
def cargo(name)
@entries << Entry.new(:cargo, name)
end
sig { params(name: String, options: T::Hash[Symbol, T.untyped]).void }
def uv(name, options = {})
unknown_options = options.keys - [:with]
raise "unknown options(#{unknown_options.inspect}) for uv" if unknown_options.present?
with = options[:with]
if with && (!with.is_a?(Array) || with.any? { |requirement| !requirement.is_a?(String) })
raise "options[:with](#{with.inspect}) should be an Array of String objects"
end
normalized_options = {}
normalized_with = Array(with).map(&:strip).reject(&:empty?).uniq.sort
normalized_options[:with] = normalized_with if normalized_with.present?
@entries << Entry.new(:uv, name, normalized_options)
end
sig { params(name: String, options: T::Hash[Symbol, String]).void }
def flatpak(name, options = {})
# Validate: url: can only be used with a named remote (not a URL remote)
@@ -138,8 +116,10 @@ module Homebrew
Regexp.last_match(1)
elsif name =~ HOMEBREW_TAP_FORMULA_REGEX
user = Regexp.last_match(1)
repo = T.must(Regexp.last_match(2))
repo = Regexp.last_match(2)
name = Regexp.last_match(3)
return name if repo.nil? || name.nil?
"#{user}/#{repo.sub("homebrew-", "")}/#{name}"
else
name
@@ -159,6 +139,39 @@ module Homebrew
name = name.split("/").last if name.include?("/")
name.downcase
end
def method_missing(method_name, *args, **options, &block)
extension = Homebrew::Bundle.extension(method_name)
return super if extension.nil?
raise ArgumentError, "blocks are not supported for #{method_name}" if block
# Extension DSL entries follow the existing Brewfile calling convention:
# a required name plus an optional options hash, passed positionally,
# with keywords, or both.
unless (1..2).cover?(args.length)
raise ArgumentError,
"wrong number of arguments (given #{args.length}, expected 1..2)"
end
positional_options = {}
if args.length == 2
positional_options = args[1]
unless positional_options.is_a? Hash
raise ArgumentError,
"options(#{positional_options.inspect}) should be a Hash object"
end
end
@entries << extension.entry(args.first, positional_options.merge(options))
end
def respond_to_missing?(method_name, include_private = false)
Homebrew::Bundle.extension(method_name).present? || super
end
end
end
end
# Load extensions after `Dsl` is defined because their `entry` methods build
# `Dsl::Entry` instances.
require "bundle/extensions"
+41 -33
View File
@@ -2,6 +2,8 @@
# frozen_string_literal: true
require "fileutils"
require "bundle/dsl"
require "bundle/extensions"
module Homebrew
module Bundle
@@ -15,29 +17,26 @@ module Homebrew
sig {
params(
describe: T::Boolean,
no_restart: T::Boolean,
formulae: T::Boolean,
taps: T::Boolean,
casks: T::Boolean,
mas: T::Boolean,
vscode: T::Boolean,
go: T::Boolean,
cargo: T::Boolean,
uv: T::Boolean,
flatpak: T::Boolean,
describe: T::Boolean,
no_restart: T::Boolean,
formulae: T::Boolean,
taps: T::Boolean,
casks: T::Boolean,
mas: T::Boolean,
vscode: T::Boolean,
cargo: T::Boolean,
flatpak: T::Boolean,
extension_types: Homebrew::Bundle::ExtensionTypes,
).returns(String)
}
def self.build_brewfile(describe:, no_restart:, formulae:, taps:, casks:, mas:, vscode:, go:, cargo:,
uv:, flatpak:)
def self.build_brewfile(describe:, no_restart:, formulae:, taps:, casks:, mas:, vscode:, cargo:, flatpak:,
extension_types: {})
require "bundle/tap_dumper"
require "bundle/formula_dumper"
require "bundle/cask_dumper"
require "bundle/mac_app_store_dumper"
require "bundle/vscode_extension_dumper"
require "bundle/go_dumper"
require "bundle/cargo_dumper"
require "bundle/uv_dumper"
require "bundle/flatpak_dumper"
content = []
@@ -46,37 +45,46 @@ module Homebrew
content << CaskDumper.dump(describe:) if casks
content << MacAppStoreDumper.dump if mas
content << VscodeExtensionDumper.dump if vscode
content << GoDumper.dump if go
if extension_types.fetch(:go, false) && (go = Homebrew::Bundle.extension(:go))
content << go.dump
end
content << CargoDumper.dump if cargo
content << UvDumper.dump if uv
if extension_types.fetch(:uv, false) && (uv = Homebrew::Bundle.extension(:uv))
content << uv.dump
end
content << FlatpakDumper.dump if flatpak
Homebrew::Bundle.extensions.select(&:dump_supported?).each do |extension|
next if [:go, :uv].include?(extension.type)
next unless extension_types.fetch(extension.type, false)
content << extension.dump
end
"#{content.reject(&:empty?).join("\n")}\n"
end
sig {
params(
global: T::Boolean,
file: T.nilable(String),
describe: T::Boolean,
force: T::Boolean,
no_restart: T::Boolean,
formulae: T::Boolean,
taps: T::Boolean,
casks: T::Boolean,
mas: T::Boolean,
vscode: T::Boolean,
go: T::Boolean,
cargo: T::Boolean,
uv: T::Boolean,
flatpak: T::Boolean,
global: T::Boolean,
file: T.nilable(String),
describe: T::Boolean,
force: T::Boolean,
no_restart: T::Boolean,
formulae: T::Boolean,
taps: T::Boolean,
casks: T::Boolean,
mas: T::Boolean,
vscode: T::Boolean,
cargo: T::Boolean,
flatpak: T::Boolean,
extension_types: Homebrew::Bundle::ExtensionTypes,
).void
}
def self.dump_brewfile(global:, file:, describe:, force:, no_restart:, formulae:, taps:, casks:, mas:,
vscode:, go:, cargo:, uv:, flatpak:)
vscode:, cargo:, flatpak:, extension_types: {})
path = brewfile_path(global:, file:)
can_write_to_brewfile?(path, force:)
content = build_brewfile(
describe:, no_restart:, taps:, formulae:, casks:, mas:, vscode:, go:, cargo:, uv:, flatpak:,
describe:, no_restart:, taps:, formulae:, casks:, mas:, vscode:, cargo:, flatpak:, extension_types:,
)
write_file path, content
end
+4
View File
@@ -0,0 +1,4 @@
# typed: strict
# frozen_string_literal: true
require "bundle/extensions/extension"
+12
View File
@@ -0,0 +1,12 @@
# typed: strict
# frozen_string_literal: true
require "bundle/extensions/extension"
extensions_dir = File.join(__dir__, "extensions")
Dir.glob(File.join(extensions_dir, "*.rb")).each do |file|
basename = File.basename(file, ".rb")
next if basename == "extension"
require "bundle/extensions/#{basename}"
end
@@ -0,0 +1,374 @@
# typed: strict
# frozen_string_literal: true
module Homebrew
module Bundle
module Checker
class Base
# Implement these in any subclass
# PACKAGE_TYPE = :pkg
# PACKAGE_TYPE_NAME = "Package"
# TODO: Replace these `T.untyped` checker-base signatures once the
# remaining non-extension checkers share a typed package model.
sig { params(packages: T.untyped, no_upgrade: T::Boolean).returns(T::Array[T.untyped]) }
def exit_early_check(packages, no_upgrade:)
work_to_be_done = packages.find do |pkg|
!installed_and_up_to_date?(pkg, no_upgrade:)
end
Array(work_to_be_done)
end
sig { params(name: T.untyped, no_upgrade: T::Boolean).returns(String) }
def failure_reason(name, no_upgrade:)
reason = if no_upgrade && Bundle.upgrade_formulae.exclude?(name)
"needs to be installed."
else
"needs to be installed or updated."
end
"#{self.class.const_get(:PACKAGE_TYPE_NAME)} #{name} #{reason}"
end
sig { params(packages: T.untyped, no_upgrade: T::Boolean).returns(T::Array[String]) }
def full_check(packages, no_upgrade:)
packages.reject { |pkg| installed_and_up_to_date?(pkg, no_upgrade:) }
.map { |pkg| failure_reason(pkg, no_upgrade:) }
end
sig { params(all_entries: T::Array[T.untyped]).returns(T::Array[T.untyped]) }
def checkable_entries(all_entries)
require "bundle/skipper"
all_entries.filter_map do |entry|
entry = T.cast(entry, Dsl::Entry)
next if entry.type != self.class.const_get(:PACKAGE_TYPE)
next if Bundle::Skipper.skip?(entry)
entry
end
end
sig { params(entries: T::Array[T.untyped]).returns(T.untyped) }
def format_checkable(entries)
checkable_entries(entries).map do |entry|
entry = T.cast(entry, Dsl::Entry)
entry.name
end
end
sig { params(_pkg: T.untyped, no_upgrade: T::Boolean).returns(T::Boolean) }
def installed_and_up_to_date?(_pkg, no_upgrade: false)
raise NotImplementedError
end
sig {
params(
entries: T::Array[T.untyped],
exit_on_first_error: T::Boolean,
no_upgrade: T::Boolean,
verbose: T::Boolean,
).returns(T::Array[T.untyped])
}
def find_actionable(entries, exit_on_first_error: false, no_upgrade: false, verbose: false)
requested = format_checkable(entries)
if exit_on_first_error
exit_early_check(requested, no_upgrade:)
else
full_check(requested, no_upgrade:)
end
end
end
end
ExtensionTypes = T.type_alias { T::Hash[Symbol, T::Boolean] }
class Extension < Homebrew::Bundle::Checker::Base
extend T::Helpers
EntryOptions = T.type_alias { T::Hash[Symbol, Object] }
abstract!
sig { params(subclass: T.class_of(Homebrew::Bundle::Extension)).void }
def self.inherited(subclass)
super
Homebrew::Bundle.register_extension(subclass)
end
sig { returns(Symbol) }
def self.type
T.cast(const_get(:PACKAGE_TYPE), Symbol)
end
sig { returns(String) }
def self.check_label
T.cast(const_get(:PACKAGE_TYPE_NAME), String)
end
sig { returns(String) }
def self.banner_name
T.cast(const_get(:BANNER_NAME), String)
end
sig { returns(String) }
def self.switch_description
"`list` or `dump` #{banner_name}."
end
sig { abstract.params(name: String, options: EntryOptions).returns(Dsl::Entry) }
def self.entry(name, options = {}); end
sig { returns(String) }
def self.flag
type.to_s.tr("_", "-")
end
sig { returns(Symbol) }
def self.predicate_method
:"#{type}?"
end
sig { returns(String) }
def self.package_manager_name
flag
end
# TODO: Route these through each extension once the go/uv specs stop
# stubbing `Homebrew::Bundle.which_*` and `*_installed?` directly.
sig { returns(T::Boolean) }
def self.package_manager_installed?
Bundle.public_send(:"#{type}_installed?")
end
sig { returns(T.nilable(Pathname)) }
def self.package_manager_executable
Bundle.public_send(:"which_#{type}")
end
sig { returns(String) }
def self.package_description
check_label.downcase
end
sig { returns(T::Boolean) }
def self.dump_supported?
true
end
sig { returns(String) }
def self.dump_disable_description
"`dump` without #{banner_name}."
end
sig { returns(Symbol) }
def self.dump_disable_env
:"bundle_dump_no_#{type}"
end
sig { returns(T::Boolean) }
def self.dump_disable_supported?
true
end
sig { returns(Symbol) }
def self.dump_disable_predicate_method
:"no_#{type}?"
end
sig { returns(T::Boolean) }
def self.add_supported?
true
end
sig { returns(T::Boolean) }
def self.remove_supported?
true
end
sig { returns(T::Boolean) }
def self.install_supported?
true
end
sig { returns(T.nilable(String)) }
def self.cleanup_heading
nil
end
sig { returns(T::Boolean) }
def self.cleanup_supported?
!cleanup_heading.nil?
end
sig { abstract.void }
def self.reset!; end
# TODO: Replace these `T.untyped` package collections once extensions can
# share a typed package interface without breaking Sorbet override checks.
sig { abstract.returns(T::Array[T.untyped]) }
def self.packages; end
sig { abstract.returns(T::Array[T.untyped]) }
def self.installed_packages; end
sig { params(package: Object).returns(String) }
def self.dump_entry(package)
line = "#{type} #{quote(dump_name(package))}"
with = dump_with(package)
return line if with.blank?
formatted_with = with.map { |requirement| quote(requirement) }.join(", ")
"#{line}, with: [#{formatted_with}]"
end
sig { params(value: String).returns(String) }
def self.quote(value)
value.inspect
end
sig { params(package: Object).returns(String) }
def self.dump_name(package)
package.to_s
end
sig { params(_package: Object).returns(T.nilable(T::Array[String])) }
def self.dump_with(_package)
nil
end
sig { returns(String) }
def self.dump
packages.map { |package| dump_entry(package) }.join("\n")
end
sig {
params(
entries: T::Array[Object],
exit_on_first_error: T::Boolean,
no_upgrade: T::Boolean,
verbose: T::Boolean,
).returns(T::Array[Object])
}
def self.check(entries, exit_on_first_error: false, no_upgrade: false, verbose: false)
new.find_actionable(entries, exit_on_first_error:, no_upgrade:, verbose:)
end
sig { params(_entries: T::Array[Object]).returns(T::Array[String]) }
def self.cleanup_items(_entries)
[]
end
sig { params(_items: T::Array[String]).void }
def self.cleanup!(_items); end
sig { params(name: String, with: T.nilable(T::Array[String])).returns(Object) }
def self.package_record(name, with: nil)
_ = with
name
end
sig { params(name: String, with: T.nilable(T::Array[String])).returns(T::Boolean) }
def self.package_installed?(name, with: nil)
installed_packages.include?(package_record(name, with:))
end
sig {
params(
name: String,
with: T.nilable(T::Array[String]),
no_upgrade: T::Boolean,
verbose: T::Boolean,
).returns(T::Boolean)
}
def self.preinstall!(name, with: nil, no_upgrade: false, verbose: false)
_ = no_upgrade
unless package_manager_installed?
puts "Installing #{package_manager_name}. It is not currently installed." if verbose
Bundle.brew("install", "--formula", package_manager_name, verbose:)
upgrade_formulae = Bundle.upgrade_formulae
Bundle.reset!
Bundle.upgrade_formulae = upgrade_formulae.join(",")
unless package_manager_installed?
raise "Unable to install #{name} #{package_description}. " \
"#{package_manager_name} installation failed."
end
end
if package_installed?(name, with:)
puts "Skipping install of #{name} #{package_description}. It is already installed." if verbose
return false
end
true
end
sig {
params(
name: String,
with: T.nilable(T::Array[String]),
preinstall: T::Boolean,
no_upgrade: T::Boolean,
verbose: T::Boolean,
force: T::Boolean,
).returns(T::Boolean)
}
def self.install!(name, with: nil, preinstall: true, no_upgrade: false, verbose: false, force: false)
_ = no_upgrade
_ = force
return true unless preinstall
puts "Installing #{name} #{package_description}. It is not currently installed." if verbose
return false unless install_package!(name, with:, verbose:)
package = package_record(name, with:)
installed_packages << package unless installed_packages.include?(package)
packages << package unless packages.include?(package)
true
end
sig { override.params(package: Object, no_upgrade: T::Boolean).returns(String) }
def failure_reason(package, no_upgrade:)
"#{self.class.check_label} #{self.class.dump_name(package)} needs to be installed."
end
sig { override.params(package: Object, no_upgrade: T::Boolean).returns(T::Boolean) }
def installed_and_up_to_date?(package, no_upgrade: false)
self.class.package_installed?(self.class.dump_name(package), with: self.class.dump_with(package))
end
sig {
abstract.params(
name: String,
with: T.nilable(T::Array[String]),
verbose: T::Boolean,
).returns(T::Boolean)
}
def self.install_package!(name, with: nil, verbose: false); end
end
class << self
sig { params(extension: T.class_of(Extension)).void }
def register_extension(extension)
@extensions ||= T.let([], T.nilable(T::Array[T.class_of(Extension)]))
@extensions.reject! { |registered| registered.name == extension.name }
@extensions << extension
end
sig { returns(T::Array[T.class_of(Extension)]) }
def extensions
@extensions ||= T.let([], T.nilable(T::Array[T.class_of(Extension)]))
@extensions
end
sig { params(type: T.any(Symbol, String)).returns(T.nilable(T.class_of(Extension))) }
def extension(type)
requested_type = type.to_sym
extensions.find { |registered| registered.type == requested_type }
end
end
end
end
+112
View File
@@ -0,0 +1,112 @@
# typed: strict
# frozen_string_literal: true
require "bundle/extensions/extension"
module Homebrew
module Bundle
class Go < Extension
PACKAGE_TYPE = :go
PACKAGE_TYPE_NAME = "Go Package"
BANNER_NAME = "Go packages"
class << self
sig { override.params(name: String, options: Homebrew::Bundle::Extension::EntryOptions).returns(Dsl::Entry) }
def entry(name, options = {})
raise "unknown options(#{options.keys.inspect}) for go" if options.present?
Dsl::Entry.new(:go, name)
end
sig { override.void }
def reset!
@packages = T.let(nil, T.nilable(T::Array[String]))
@installed_packages = T.let(nil, T.nilable(T::Array[String]))
end
sig { override.returns(T::Array[String]) }
def packages
packages = @packages
return packages if packages
@packages = if Bundle.go_installed?
go = Bundle.which_go
return [] if go.nil?
ENV["GOBIN"] = ENV.fetch("HOMEBREW_GOBIN", nil)
ENV["GOPATH"] = ENV.fetch("HOMEBREW_GOPATH", nil)
gobin = `#{go} env GOBIN`.chomp
gopath = `#{go} env GOPATH`.chomp
bin_dir = gobin.empty? ? "#{gopath}/bin" : gobin
return [] unless File.directory?(bin_dir)
binaries = Dir.glob("#{bin_dir}/*").select do |file|
File.executable?(file) && !File.directory?(file) && !File.symlink?(file)
end
binaries.filter_map do |binary|
output = `#{go} version -m "#{binary}" 2>/dev/null`
next if output.empty?
lines = output.split("\n")
path_line = lines.find { |line| line.strip.start_with?("path\t") }
next unless path_line
# Parse the output to find the path line
# Format: "\tpath\tgithub.com/user/repo"
parts = path_line.split("\t")
# Extract the package path (second field after splitting by tab)
# The line format is: "\tpath\tgithub.com/user/repo"
path = parts[2]&.strip
# `command-line-arguments` is a dummy package name for binaries built
# from a list of source files instead of a specific package name.
# https://github.com/golang/go/issues/36043
next if path == "command-line-arguments"
path
end.uniq
else
[]
end
end
sig {
override.params(
name: String,
with: T.nilable(T::Array[String]),
verbose: T::Boolean,
).returns(T::Boolean)
}
def install_package!(name, with: nil, verbose: false)
_ = with
go = package_manager_executable
return false if go.nil?
Bundle.system(go.to_s, "install", "#{name}@latest", verbose:)
end
sig { override.returns(T::Array[String]) }
def installed_packages
installed_packages = @installed_packages
return installed_packages if installed_packages
@installed_packages = packages.dup
end
end
end
# TODO: Remove these compatibility aliases once bundle callers and tests
# stop requiring separate go dumper/installer/checker constants.
GoDumper = Go
GoInstaller = Go
module Checker
# TODO: Remove this compatibility alias once bundle callers and tests stop
# requiring a separate go checker constant.
GoChecker = Homebrew::Bundle::Go
end
end
end
+247
View File
@@ -0,0 +1,247 @@
# typed: strict
# frozen_string_literal: true
require "bundle/extensions/extension"
module Homebrew
module Bundle
class Uv < Extension
WithOptions = T.type_alias { T::Hash[Symbol, T::Array[String]] }
Tool = T.type_alias { { name: String, with: T::Array[String] } }
Checkable = T.type_alias { { name: String, options: WithOptions } }
ToolEntry = T.type_alias { T.any(Tool, Checkable) }
PACKAGE_TYPE = :uv
PACKAGE_TYPE_NAME = "uv Tool"
BANNER_NAME = "uv tools"
class << self
sig { override.params(name: String, options: Homebrew::Bundle::Extension::EntryOptions).returns(Dsl::Entry) }
def entry(name, options = {})
unknown_options = options.keys - [:with]
raise "unknown options(#{unknown_options.inspect}) for uv" if unknown_options.present?
with = options[:with]
if !with.nil? && (!with.is_a?(Array) || with.any? { |requirement| !requirement.is_a?(String) })
raise "options[:with](#{with.inspect}) should be an Array of String objects"
end
normalized_options = {}
normalized_with = normalize_with(with || [])
normalized_options[:with] = normalized_with if normalized_with.present?
Dsl::Entry.new(:uv, name, normalized_options)
end
sig { override.void }
def reset!
@packages = T.let(nil, T.nilable(T::Array[Tool]))
@installed_packages = T.let(nil, T.nilable(T::Array[Tool]))
end
sig { override.returns(T::Array[Tool]) }
def packages
packages = @packages
return packages if packages
@packages = if Bundle.uv_installed?
uv = Bundle.which_uv
return [] if uv.nil?
output = `#{uv} tool list --show-with --show-extras 2>/dev/null`
parse_tool_list(output)
else
[]
end
end
sig { override.params(package: Object).returns(String) }
def dump_name(package)
package_name(T.cast(package, ToolEntry))
end
sig { override.params(package: Object).returns(T.nilable(T::Array[String])) }
def dump_with(package)
package_with(T.cast(package, ToolEntry))
end
sig {
override.params(
name: String,
with: T.nilable(T::Array[String]),
verbose: T::Boolean,
).returns(T::Boolean)
}
def install_package!(name, with: nil, verbose: false)
uv = package_manager_executable
return false if uv.nil?
args = ["tool", "install", name]
normalize_with(with || []).each do |requirement|
args << "--with"
args << requirement
end
Bundle.system(uv.to_s, *args, verbose:)
end
sig { override.returns(T::Array[Tool]) }
def installed_packages
installed_packages = @installed_packages
return installed_packages if installed_packages
@installed_packages = packages.dup
end
sig { params(output: String).returns(T::Array[Tool]) }
def parse_tool_list(output)
entries = T.let([], T::Array[Tool])
output.each_line do |line|
match = line.match(/\A(\S+)\s+v\S+/)
next unless match
name = match[1]
next if name.nil?
extras_raw = line[/\[extras:\s*([^\]]+)\]/, 1]
name = name_with_extras(name, extras_raw)
with_raw = line[/\[with:\s*([^\]]+)\]/, 1]
entries << {
name: name,
with: parse_with_requirements(with_raw),
}
end
entries.sort_by { |entry| entry[:name].to_s }
end
private :parse_tool_list
sig { params(name: String, extras_raw: T.nilable(String)).returns(String) }
def name_with_extras(name, extras_raw)
return name if extras_raw.blank?
extras = extras_raw.split(",").map(&:strip).reject(&:empty?).uniq.sort
return name if extras.empty?
"#{name}[#{extras.join(",")}]"
end
private :name_with_extras
sig { params(with_raw: T.nilable(String)).returns(T::Array[String]) }
def parse_with_requirements(with_raw)
return [] if with_raw.blank?
entries = T.let([], T::Array[String])
with_raw.split(", ").each do |token|
requirement = token.strip
next if requirement.empty?
if continuation_constraint?(requirement) && entries.any?
last_requirement = entries.pop
entries << "#{last_requirement}, #{normalize_constraint(requirement)}" if last_requirement
else
entries << requirement
end
end
entries.uniq.sort
end
private :parse_with_requirements
sig { params(requirement: String).returns(T::Boolean) }
def continuation_constraint?(requirement)
requirement.match?(/\A(?:<=|>=|!=|==|~=|<|>)\s*\S/)
end
private :continuation_constraint?
sig { params(requirement: String).returns(String) }
def normalize_constraint(requirement)
requirement.strip.sub(/\A(<=|>=|!=|==|~=|<|>)\s+/, "\\1")
end
private :normalize_constraint
sig { params(with: T::Array[String]).returns(T::Array[String]) }
def normalize_with(with)
with.map(&:strip).reject(&:empty?).uniq.sort
end
private :normalize_with
sig { params(name: String).returns(String) }
def normalize_name(name)
match = name.strip.match(/\A(?<base>[^\[\]]+)(?:\[(?<extras>[^\]]+)\])?\z/)
return name.strip unless match
base = match[:base]
return name.strip if base.nil?
extras_raw = match[:extras]
return base.strip if extras_raw.blank?
extras = extras_raw.split(",").map(&:strip).reject(&:empty?).uniq.sort
return base.strip if extras.empty?
"#{base.strip}[#{extras.join(",")}]"
end
private :normalize_name
sig { override.params(name: String, with: T.nilable(T::Array[String])).returns(Object) }
def package_record(name, with: nil)
normalized_options(name, with: with || [])
end
sig { params(name: String, with: T::Array[String]).returns(Tool) }
def normalized_options(name, with:)
{
name: normalize_name(name),
with: normalize_with(with),
}
end
private :normalized_options
sig { params(package: ToolEntry).returns(String) }
def package_name(package)
package[:name]
end
private :package_name
sig { params(package: ToolEntry).returns(T.nilable(T::Array[String])) }
def package_with(package)
if package.key?(:with)
package[:with]
else
package[:options].fetch(:with, [])
end
end
private :package_with
end
sig { override.params(entries: T::Array[Object]).returns(T::Array[Object]) }
def format_checkable(entries)
checkable_entries(entries).map do |entry|
entry = T.cast(entry, Dsl::Entry)
with = if entry.options.is_a?(Hash)
value = entry.options[:with]
value.is_a?(Array) ? value : []
else
[]
end
self.class.package_record(entry.name, with:)
end
end
end
# TODO: Remove these compatibility aliases once bundle callers and tests
# stop requiring separate uv dumper/installer/checker constants.
UvDumper = Uv
UvInstaller = Uv
module Checker
# TODO: Remove this compatibility alias once bundle callers and tests stop
# requiring a separate uv checker constant.
UvChecker = Homebrew::Bundle::Uv
end
end
end
+6 -3
View File
@@ -11,17 +11,20 @@ module Homebrew
PACKAGE_TYPE_NAME = "Flatpak"
sig {
params(entries: T::Array[Homebrew::Bundle::Dsl::Entry], exit_on_first_error: T::Boolean,
no_upgrade: T::Boolean, verbose: T::Boolean).returns(T::Array[String])
params(entries: T::Array[Object], exit_on_first_error: T::Boolean,
no_upgrade: T::Boolean, verbose: T::Boolean).returns(T::Array[Object])
}
def find_actionable(entries, exit_on_first_error: false, no_upgrade: false, verbose: false)
super
end
# Override to return entry hashes with options instead of just names
sig { params(entries: T::Array[Bundle::Dsl::Entry]).returns(T::Array[T::Hash[Symbol, T.untyped]]) }
# TODO: Replace these `T.untyped` flatpak hashes once flatpak entries are
# normalized into a typed package shape like the extension classes use.
sig { params(entries: T::Array[Object]).returns(T::Array[T::Hash[Symbol, T.untyped]]) }
def format_checkable(entries)
checkable_entries(entries).map do |entry|
entry = T.cast(entry, Dsl::Entry)
{ name: entry.name, options: entry.options || {} }
end
end
+4
View File
@@ -0,0 +1,4 @@
# typed: strict
# frozen_string_literal: true
require "bundle/extensions/go"
+1 -21
View File
@@ -1,24 +1,4 @@
# typed: strict
# frozen_string_literal: true
module Homebrew
module Bundle
module Checker
class GoChecker < Homebrew::Bundle::Checker::Base
PACKAGE_TYPE = :go
PACKAGE_TYPE_NAME = "Go Package"
sig { params(package: String, no_upgrade: T::Boolean).returns(String) }
def failure_reason(package, no_upgrade:)
"#{PACKAGE_TYPE_NAME} #{package} needs to be installed."
end
sig { params(package: String, no_upgrade: T::Boolean).returns(T::Boolean) }
def installed_and_up_to_date?(package, no_upgrade: false)
require "bundle/go_installer"
Homebrew::Bundle::GoInstaller.package_installed?(package)
end
end
end
end
end
require "bundle/go"
+1 -59
View File
@@ -1,62 +1,4 @@
# typed: strict
# frozen_string_literal: true
module Homebrew
module Bundle
module GoDumper
sig { void }
def self.reset!
@packages = nil
end
sig { returns(T::Array[String]) }
def self.packages
@packages ||= T.let(nil, T.nilable(T::Array[String]))
@packages ||= if Bundle.go_installed?
go = Bundle.which_go
ENV["GOBIN"] = ENV.fetch("HOMEBREW_GOBIN", nil)
ENV["GOPATH"] = ENV.fetch("HOMEBREW_GOPATH", nil)
gobin = `#{go} env GOBIN`.chomp
gopath = `#{go} env GOPATH`.chomp
bin_dir = gobin.empty? ? "#{gopath}/bin" : gobin
return [] unless File.directory?(bin_dir)
binaries = Dir.glob("#{bin_dir}/*").select do |f|
File.executable?(f) && !File.directory?(f) && !File.symlink?(f)
end
binaries.filter_map do |binary|
output = `#{go} version -m "#{binary}" 2>/dev/null`
next if output.empty?
# Parse the output to find the path line
# Format: "\tpath\tgithub.com/user/repo"
lines = output.split("\n")
path_line = lines.find { |line| line.strip.start_with?("path\t") }
next unless path_line
# Extract the package path (second field after splitting by tab)
# The line format is: "\tpath\tgithub.com/user/repo"
parts = path_line.split("\t")
path = parts[2]&.strip if parts.length >= 3
# `command-line-arguments` is a dummy package name for binaries built
# from a list of source files instead of a specific package name.
# https://github.com/golang/go/issues/36043
next if path == "command-line-arguments"
path
end.compact.uniq
else
[]
end
end
sig { returns(String) }
def self.dump
packages.map { |name| "go \"#{name}\"" }.join("\n")
end
end
end
end
require "bundle/go"
+1 -58
View File
@@ -1,61 +1,4 @@
# typed: strict
# frozen_string_literal: true
module Homebrew
module Bundle
module GoInstaller
sig { void }
def self.reset!
@installed_packages = nil
end
sig { params(name: String, verbose: T::Boolean, _options: T.anything).returns(T::Boolean) }
def self.preinstall!(name, verbose: false, **_options)
unless Bundle.go_installed?
puts "Installing go. It is not currently installed." if verbose
Bundle.brew("install", "--formula", "go", verbose:)
raise "Unable to install #{name} package. Go installation failed." unless Bundle.go_installed?
end
if package_installed?(name)
puts "Skipping install of #{name} Go package. It is already installed." if verbose
return false
end
true
end
sig {
params(
name: String,
preinstall: T::Boolean,
verbose: T::Boolean,
force: T::Boolean,
_options: T.anything,
).returns(T::Boolean)
}
def self.install!(name, preinstall: true, verbose: false, force: false, **_options)
return true unless preinstall
puts "Installing #{name} Go package. It is not currently installed." if verbose
go = Bundle.which_go
return false unless Bundle.system go.to_s, "install", "#{name}@latest", verbose: verbose
installed_packages << name
true
end
sig { params(package: String).returns(T::Boolean) }
def self.package_installed?(package)
installed_packages.include? package
end
sig { returns(T::Array[String]) }
def self.installed_packages
require "bundle/go_dumper"
@installed_packages ||= T.let(Homebrew::Bundle::GoDumper.packages, T.nilable(T::Array[String]))
end
end
end
end
require "bundle/go"
+7 -7
View File
@@ -6,11 +6,10 @@ require "bundle/formula_installer"
require "bundle/cask_installer"
require "bundle/mac_app_store_installer"
require "bundle/vscode_extension_installer"
require "bundle/go_installer"
require "bundle/cargo_installer"
require "bundle/uv_installer"
require "bundle/flatpak_installer"
require "bundle/tap_installer"
require "bundle/extensions"
require "bundle/skipper"
module Homebrew
@@ -55,13 +54,8 @@ module Homebrew
Homebrew::Bundle::MacAppStoreInstaller
when :vscode
Homebrew::Bundle::VscodeExtensionInstaller
when :go
Homebrew::Bundle::GoInstaller
when :cargo
Homebrew::Bundle::CargoInstaller
when :uv
options = entry.options
Homebrew::Bundle::UvInstaller
when :flatpak
options = entry.options
Homebrew::Bundle::FlatpakInstaller
@@ -69,6 +63,12 @@ module Homebrew
verb = "Tapping"
options = entry.options
Homebrew::Bundle::TapInstaller
else
extension = Homebrew::Bundle.extension(type)
next if extension.nil? || !extension.install_supported?
options = entry.options
extension
end
next if cls.nil?
+15 -12
View File
@@ -1,38 +1,41 @@
# typed: strict
# frozen_string_literal: true
require "bundle/dsl"
require "bundle/extensions"
module Homebrew
module Bundle
module Lister
sig {
params(entries: T::Array[Homebrew::Bundle::Dsl::Entry], formulae: T::Boolean, casks: T::Boolean,
taps: T::Boolean, mas: T::Boolean, vscode: T::Boolean, go: T::Boolean, cargo: T::Boolean,
uv: T::Boolean, flatpak: T::Boolean).void
params(entries: T::Array[Object], formulae: T::Boolean, casks: T::Boolean, taps: T::Boolean,
mas: T::Boolean, vscode: T::Boolean, cargo: T::Boolean, flatpak: T::Boolean,
extension_types: Homebrew::Bundle::ExtensionTypes).void
}
def self.list(entries, formulae:, casks:, taps:, mas:, vscode:, go:, cargo:, uv:, flatpak:)
def self.list(entries, formulae:, casks:, taps:, mas:, vscode:, cargo:, flatpak:, extension_types: {})
entries.each do |entry|
puts entry.name if show?(entry.type, formulae:, casks:, taps:, mas:, vscode:, go:, cargo:,
uv:, flatpak:)
entry = T.cast(entry, Dsl::Entry)
puts entry.name if show?(entry.type, formulae:, casks:, taps:, mas:, vscode:, cargo:, flatpak:,
extension_types:)
end
end
sig {
params(type: Symbol, formulae: T::Boolean, casks: T::Boolean, taps: T::Boolean, mas: T::Boolean,
vscode: T::Boolean, go: T::Boolean, cargo: T::Boolean, uv: T::Boolean,
flatpak: T::Boolean)
vscode: T::Boolean, cargo: T::Boolean, flatpak: T::Boolean,
extension_types: Homebrew::Bundle::ExtensionTypes)
.returns(T::Boolean)
}
private_class_method def self.show?(type, formulae:, casks:, taps:, mas:, vscode:, go:, cargo:,
uv:, flatpak:)
private_class_method def self.show?(type, formulae:, casks:, taps:, mas:, vscode:, cargo:, flatpak:,
extension_types:)
return true if formulae && type == :brew
return true if casks && type == :cask
return true if taps && type == :tap
return true if mas && type == :mas
return true if vscode && type == :vscode
return true if go && type == :go
return true if cargo && type == :cargo
return true if uv && type == :uv
return true if flatpak && type == :flatpak
return true if extension_types.fetch(type, false)
false
end
@@ -15,7 +15,10 @@ module Homebrew
end
def format_checkable(entries)
checkable_entries(entries).to_h { |e| [e.options[:id], e.name] }
checkable_entries(entries).to_h do |entry|
entry = T.cast(entry, Dsl::Entry)
[entry.options[:id], entry.name]
end
end
def exit_early_check(app_ids_with_names, no_upgrade:)
+1 -1
View File
@@ -9,7 +9,7 @@ module Homebrew
PACKAGE_TYPE_NAME = "Tap"
sig {
params(entries: T::Array[Homebrew::Bundle::Dsl::Entry], exit_on_first_error: T::Boolean,
params(entries: T::Array[Object], exit_on_first_error: T::Boolean,
no_upgrade: T::Boolean, verbose: T::Boolean).returns(T::Array[String])
}
def find_actionable(entries, exit_on_first_error: false, no_upgrade: false, verbose: false)
+4
View File
@@ -0,0 +1,4 @@
# typed: strict
# frozen_string_literal: true
require "bundle/extensions/uv"
+1 -40
View File
@@ -1,43 +1,4 @@
# typed: strict
# frozen_string_literal: true
require "bundle/checker"
module Homebrew
module Bundle
module Checker
class UvChecker < Homebrew::Bundle::Checker::Base
PACKAGE_TYPE = :uv
PACKAGE_TYPE_NAME = "uv Tool"
sig {
params(entries: T::Array[Bundle::Dsl::Entry]).returns(T::Array[T::Hash[Symbol, T.untyped]])
}
def format_checkable(entries)
checkable_entries(entries).map do |entry|
{ name: entry.name, options: entry.options || {} }
end
end
sig { params(package: T::Hash[Symbol, T.untyped], no_upgrade: T::Boolean).returns(String) }
def failure_reason(package, no_upgrade:)
name = T.cast(package[:name], String)
"#{PACKAGE_TYPE_NAME} #{name} needs to be installed."
end
sig {
params(package: T::Hash[Symbol, T.untyped], no_upgrade: T::Boolean).returns(T::Boolean)
}
def installed_and_up_to_date?(package, no_upgrade: false)
require "bundle/uv_installer"
options = T.cast(package[:options], T::Hash[Symbol, T.untyped])
Homebrew::Bundle::UvInstaller.package_installed?(
T.cast(package[:name], String),
with: T.cast(options[:with] || [], T::Array[String]),
)
end
end
end
end
end
require "bundle/uv"
+1 -113
View File
@@ -1,116 +1,4 @@
# typed: strict
# frozen_string_literal: true
module Homebrew
module Bundle
module UvDumper
sig { void }
def self.reset!
@packages = nil
end
sig { returns(T::Array[T::Hash[Symbol, T.untyped]]) }
def self.packages
@packages ||= T.let(nil, T.nilable(T::Array[T::Hash[Symbol, T.untyped]]))
@packages ||= if Bundle.uv_installed?
uv = Bundle.which_uv
output = `#{uv} tool list --show-with --show-extras 2>/dev/null`
parse_tool_list(output)
else
[]
end
end
sig { returns(String) }
def self.dump
packages.map do |package|
build_entry(package)
end.join("\n")
end
sig { params(output: String).returns(T::Array[T::Hash[Symbol, T.untyped]]) }
def self.parse_tool_list(output)
entries = T.let([], T::Array[T::Hash[Symbol, T.untyped]])
output.each_line do |line|
match = line.match(/\A(\S+)\s+v\S+/)
next unless match
name = T.must(match[1])
extras_raw = line[/\[extras:\s*([^\]]+)\]/, 1]
name = name_with_extras(name, extras_raw)
with_raw = line[/\[with:\s*([^\]]+)\]/, 1]
with = parse_with_requirements(with_raw)
entries << {
name: name,
with: with,
}
end
entries.sort_by { |entry| T.cast(entry[:name], String) }
end
sig { params(name: String, extras_raw: T.nilable(String)).returns(String) }
def self.name_with_extras(name, extras_raw)
return name if extras_raw.blank?
extras = extras_raw.split(",").map(&:strip).reject(&:empty?).uniq.sort
return name if extras.empty?
"#{name}[#{extras.join(",")}]"
end
sig { params(with_raw: T.nilable(String)).returns(T::Array[String]) }
def self.parse_with_requirements(with_raw)
return [] if with_raw.blank?
entries = T.let([], T::Array[String])
with_raw.split(", ").each do |token|
requirement = token.strip
next if requirement.empty?
if continuation_constraint?(requirement) && entries.any?
entries[-1] = "#{T.must(entries.last)}, #{normalize_constraint(requirement)}"
else
entries << requirement
end
end
entries.uniq.sort
end
sig { params(requirement: String).returns(T::Boolean) }
def self.continuation_constraint?(requirement)
requirement.match?(/\A(?:<=|>=|!=|==|~=|<|>)\s*\S/)
end
sig { params(requirement: String).returns(String) }
def self.normalize_constraint(requirement)
requirement.strip.sub(/\A(<=|>=|!=|==|~=|<|>)\s+/, "\\1")
end
sig { params(package: T::Hash[Symbol, T.untyped]).returns(String) }
def self.build_entry(package)
name = T.cast(package[:name], String)
with = T.cast(package[:with], T::Array[String])
line = "uv #{quote(name)}"
options = []
if with.present?
formatted_with = with.map { |requirement| quote(requirement) }.join(", ")
options << "with: [#{formatted_with}]"
end
return line if options.empty?
"#{line}, #{options.join(", ")}"
end
sig { params(value: String).returns(String) }
def self.quote(value)
value.inspect
end
end
end
end
require "bundle/uv"
+1 -123
View File
@@ -1,126 +1,4 @@
# typed: strict
# frozen_string_literal: true
module Homebrew
module Bundle
module UvInstaller
sig { void }
def self.reset!
@installed_packages = nil
end
sig {
params(
name: String,
verbose: T::Boolean,
with: T::Array[String],
_options: T.anything,
).returns(T::Boolean)
}
def self.preinstall!(name, verbose: false, with: [], **_options)
unless Bundle.uv_installed?
puts "Installing uv. It is not currently installed." if verbose
Bundle.brew("install", "--formula", "uv", verbose:)
Bundle.reset!
raise "Unable to install #{name} uv tool. uv installation failed." unless Bundle.uv_installed?
end
if package_installed?(name, with:)
puts "Skipping install of #{name} uv tool. It is already installed." if verbose
return false
end
true
end
sig {
params(
name: String,
preinstall: T::Boolean,
verbose: T::Boolean,
force: T::Boolean,
with: T::Array[String],
_options: T.anything,
).returns(T::Boolean)
}
def self.install!(name, preinstall: true, verbose: false, force: false, with: [], **_options)
return true unless preinstall
puts "Installing #{name} uv tool. It is not currently installed." if verbose
uv = T.must(Bundle.which_uv)
args = ["tool", "install", name]
normalized_with = normalize_with(with)
normalized_with.each do |requirement|
args << "--with"
args << requirement
end
success = Bundle.system uv.to_s, *args, verbose: verbose
return false unless success
installed_packages << normalized_options(name, with:)
true
end
sig {
params(
package: String,
with: T::Array[String],
).returns(T::Boolean)
}
def self.package_installed?(package, with: [])
desired = normalized_options(package, with:)
installed_packages.any? do |installed|
installed_name = T.cast(installed[:name], String)
installed_with = T.cast(installed[:with] || [], T::Array[String])
installed_name == desired[:name] &&
installed_with == desired[:with]
end
end
sig { returns(T::Array[T::Hash[Symbol, T.untyped]]) }
def self.installed_packages
require "bundle/uv_dumper"
@installed_packages ||= T.let(Homebrew::Bundle::UvDumper.packages,
T.nilable(T::Array[T::Hash[Symbol, T.untyped]]))
end
sig { params(with: T::Array[String]).returns(T::Array[String]) }
def self.normalize_with(with)
with.map(&:strip)
.reject(&:empty?)
.uniq
.sort
end
sig { params(name: String).returns(String) }
def self.normalize_name(name)
match = name.strip.match(/\A(?<base>[^\[\]]+)(?:\[(?<extras>[^\]]+)\])?\z/)
return name.strip unless match
base = T.must(match[:base]).strip
extras_raw = match[:extras]
return base if extras_raw.blank?
extras = extras_raw.split(",").map(&:strip).reject(&:empty?).uniq.sort
return base if extras.empty?
"#{base}[#{extras.join(",")}]"
end
sig {
params(
name: String,
with: T::Array[String],
).returns(T::Hash[Symbol, T.untyped])
}
def self.normalized_options(name, with:)
{
name: normalize_name(name),
with: normalize_with(with),
}
end
end
end
end
require "bundle/uv"
+86 -50
View File
@@ -2,15 +2,54 @@
# frozen_string_literal: true
require "abstract_command"
require "bundle/dsl"
require "bundle/extensions"
module Homebrew
module Cmd
class Bundle < AbstractCommand
sig { params(args: Args, extension: T.class_of(Homebrew::Bundle::Extension)).returns(T::Boolean) }
def self.extension_selected?(args, extension)
args.public_send(extension.predicate_method)
end
sig { params(args: Args, extension: T.class_of(Homebrew::Bundle::Extension)).returns(T::Boolean) }
def self.extension_dump_disabled?(args, extension)
args.public_send(extension.dump_disable_predicate_method)
end
BUNDLE_EXTENSIONS = T.let(Homebrew::Bundle.extensions.dup.freeze, T::Array[T.class_of(Homebrew::Bundle::Extension)])
BUNDLE_SOURCES_DESCRIPTION = T.let(
[
"Homebrew",
"Homebrew Cask",
"Mac App Store",
"Visual Studio Code (and forks/variants)",
*BUNDLE_EXTENSIONS.map(&:banner_name),
"Cargo packages",
"Flatpak",
].to_sentence.freeze,
String,
)
BUNDLE_ADD_FLAGS_DESCRIPTION = T.let(
["`--cask`", "`--tap`", "`--vscode`", *BUNDLE_EXTENSIONS.select(&:add_supported?).map do |extension|
"`--#{extension.flag}`"
end].to_sentence.freeze,
String,
)
BUNDLE_REMOVE_FLAGS_DESCRIPTION = T.let(
["`--formula`", "`--cask`", "`--tap`", "`--mas`", "`--vscode`", "`--cargo`", "`--flatpak`",
*BUNDLE_EXTENSIONS.select(&:remove_supported?).map do |extension|
"`--#{extension.flag}`"
end].to_sentence.freeze,
String,
)
cmd_args do
usage_banner <<~EOS
`bundle` [<subcommand>]
Bundler for non-Ruby dependencies from Homebrew, Homebrew Cask, Mac App Store, Visual Studio Code (and forks/variants), Go packages, Cargo packages, uv tools and Flatpak.
Bundler for non-Ruby dependencies from #{BUNDLE_SOURCES_DESCRIPTION}.
Note: Flatpak support is only available on Linux.
@@ -48,10 +87,10 @@ module Homebrew
Edit the `Brewfile` in your editor.
`brew bundle add` <name> [...]:
Add entries to your `Brewfile`. Adds formulae by default. Use `--cask`, `--tap`, `--vscode` or `--uv` to add the corresponding entry instead.
Add entries to your `Brewfile`. Adds formulae by default. Use #{BUNDLE_ADD_FLAGS_DESCRIPTION} to add the corresponding entry instead.
`brew bundle remove` <name> [...]:
Remove entries that match `name` from your `Brewfile`. Use `--formula`, `--cask`, `--tap`, `--mas`, `--vscode` or `--uv` to remove only entries of the corresponding type. Passing `--formula` also removes matches against formula aliases and old formula names.
Remove entries that match `name` from your `Brewfile`. Use #{BUNDLE_REMOVE_FLAGS_DESCRIPTION} to remove only entries of the corresponding type. Passing `--formula` also removes matches against formula aliases and old formula names.
`brew bundle exec` [`--check`] [`--no-secrets`] <command>:
Run an external command in an isolated build environment based on the `Brewfile` dependencies.
@@ -109,29 +148,28 @@ module Homebrew
description: "`list` or `dump` Mac App Store dependencies."
switch "--vscode",
description: "`list`, `dump` or `cleanup` VSCode (and forks/variants) extensions."
switch "--go",
description: "`list` or `dump` Go packages."
switch "--cargo",
description: "`list` or `dump` Cargo packages."
switch "--uv",
description: "`list` or `dump` uv tools."
switch "--flatpak",
description: "`list` or `dump` Flatpak packages. Note: Linux only."
BUNDLE_EXTENSIONS.each do |extension|
switch "--#{extension.flag}",
description: extension.switch_description
end
switch "--no-vscode",
description: "`dump` without VSCode (and forks/variants) extensions.",
env: :bundle_dump_no_vscode
switch "--no-go",
description: "`dump` without Go packages.",
env: :bundle_dump_no_go
switch "--no-cargo",
description: "`dump` without Cargo packages.",
env: :bundle_dump_no_cargo
switch "--no-uv",
description: "`dump` without uv tools.",
env: :bundle_dump_no_uv
switch "--no-flatpak",
description: "`dump` without Flatpak packages.",
env: :bundle_dump_no_flatpak
BUNDLE_EXTENSIONS.each do |extension|
switch "--no-#{extension.flag}",
description: extension.dump_disable_description,
env: extension.dump_disable_env
end
switch "--describe",
description: "`dump` adds a description comment above each line, unless the " \
"dependency does not have a description.",
@@ -150,14 +188,14 @@ module Homebrew
conflicts "--all", "--no-vscode"
conflicts "--vscode", "--no-vscode"
conflicts "--all", "--no-go"
conflicts "--go", "--no-go"
conflicts "--all", "--no-cargo"
conflicts "--cargo", "--no-cargo"
conflicts "--all", "--no-uv"
conflicts "--uv", "--no-uv"
conflicts "--all", "--no-flatpak"
conflicts "--flatpak", "--no-flatpak"
BUNDLE_EXTENSIONS.each do |extension|
conflicts "--all", "--no-#{extension.flag}"
conflicts "--#{extension.flag}", "--no-#{extension.flag}"
end
conflicts "--install", "--upgrade"
conflicts "--file", "--global"
@@ -199,8 +237,9 @@ module Homebrew
zap = args.zap?
Homebrew::Bundle.upgrade_formulae = args.upgrade_formulae
no_type_args = [args.formulae?, args.casks?, args.taps?, args.mas?, args.vscode?, args.go?,
args.cargo?, args.uv?, args.flatpak?].none?
no_type_args = ([args.formulae?, args.casks?, args.taps?, args.mas?, args.vscode?, args.cargo?,
args.flatpak?] +
BUNDLE_EXTENSIONS.map { |extension| self.class.extension_selected?(args, extension) }).none?
if args.install?
if [nil, "install", "upgrade"].include?(subcommand)
@@ -243,14 +282,6 @@ module Homebrew
no_type_args
end
go = if args.no_go?
false
elsif args.go?
true
else
no_type_args
end
cargo = if args.no_cargo?
false
elsif args.cargo?
@@ -259,14 +290,6 @@ module Homebrew
no_type_args
end
uv = if args.no_uv?
false
elsif args.uv?
true
else
no_type_args
end
flatpak = if args.no_flatpak?
false
elsif args.flatpak?
@@ -285,10 +308,13 @@ module Homebrew
casks: args.casks? || no_type_args,
mas: args.mas? || no_type_args,
vscode:,
go:,
cargo:,
uv:,
flatpak:
flatpak:,
extension_types: BUNDLE_EXTENSIONS.select(&:dump_supported?).to_h do |extension|
disabled = self.class.extension_dump_disabled?(args, extension)
enabled = !disabled && (self.class.extension_selected?(args, extension) || no_type_args)
[extension.type, enabled]
end
)
when "edit"
require "bundle/brewfile"
@@ -307,19 +333,22 @@ module Homebrew
require "bundle/commands/check"
Homebrew::Bundle::Commands::Check.run(global:, file:, no_upgrade:, verbose:)
when "list"
extension_list_options = BUNDLE_EXTENSIONS.to_h do |extension|
[extension.type, self.class.extension_selected?(args, extension) || args.all?]
end
require "bundle/commands/list"
Homebrew::Bundle::Commands::List.run(
global:,
file:,
formulae: args.formulae? || args.all? || no_type_args,
casks: args.casks? || args.all?,
taps: args.taps? || args.all?,
mas: args.mas? || args.all?,
vscode: args.vscode? || args.all?,
go: args.go? || args.all?,
cargo: args.cargo? || args.all?,
uv: args.uv? || args.all?,
flatpak: args.flatpak? || args.all?,
formulae: args.formulae? || args.all? || no_type_args,
casks: args.casks? || args.all?,
taps: args.taps? || args.all?,
mas: args.mas? || args.all?,
vscode: args.vscode? || args.all?,
cargo: args.cargo? || args.all?,
flatpak: args.flatpak? || args.all?,
extension_types: extension_list_options,
)
when "add", "remove"
# We intentionally omit the s from `brews`, `casks`, and `taps` for ease of handling later.
@@ -329,12 +358,13 @@ module Homebrew
tap: args.taps?,
mas: args.mas?,
vscode: args.vscode?,
go: args.go?,
cargo: args.cargo?,
uv: args.uv?,
flatpak: args.flatpak?,
none: no_type_args,
}
BUNDLE_EXTENSIONS.each do |extension|
type_hash[extension.type] = self.class.extension_selected?(args, extension)
end
type_hash[:none] = no_type_args
selected_types = type_hash.select { |_, v| v }.keys
raise UsageError, "`#{subcommand}` supports only one type of entry at a time." if selected_types.count != 1
@@ -346,6 +376,12 @@ module Homebrew
else t
end
extension = Homebrew::Bundle.extension(type)
if extension && !extension.add_supported?
raise UsageError,
"`add` does not support `--#{extension.flag}`."
end
require "bundle/commands/add"
Homebrew::Bundle::Commands::Add.run(*named_args, type:, global:, file:)
else
@@ -12,7 +12,8 @@ require "bundle/uv_dumper"
RSpec.describe Homebrew::Bundle::Commands::Dump do
subject(:dump) do
described_class.run(global:, file: nil, describe: false, force:, no_restart: false, taps: true, formulae: true,
casks: true, mas: true, vscode: true, go: true, cargo: true, uv: true, flatpak: false)
casks: true, mas: true, vscode: true, cargo: true, flatpak: false,
extension_types: { go: true, uv: true })
end
let(:force) { false }
@@ -24,17 +24,19 @@ end.freeze
RSpec.describe Homebrew::Bundle::Commands::List do
subject(:list) do
described_class.run(
global: false,
file: nil,
formulae: formulae,
casks: casks,
taps: taps,
mas: mas,
vscode: vscode,
go: go,
cargo: cargo,
uv: uv,
flatpak: false,
global: false,
file: nil,
formulae: formulae,
casks: casks,
taps: taps,
mas: mas,
vscode: vscode,
cargo: cargo,
flatpak: false,
extension_types: {
go: go,
uv: uv,
},
)
end
+26
View File
@@ -102,6 +102,14 @@ RSpec.describe Homebrew::Bundle::Dsl do
end
end
context "with extension entries" do
it "accepts positional option hashes for extensions" do
dsl = dsl_from_string 'uv "mkdocs", { with: ["mkdocs-material<10"] }'
expect(dsl.entries[0].name).to eql("mkdocs")
expect(dsl.entries[0].options).to eql(with: ["mkdocs-material<10"])
end
end
context "with invalid input" do
it "handles completely invalid code" do
expect { dsl_from_string "abcdef" }.to raise_error(RuntimeError)
@@ -120,6 +128,24 @@ RSpec.describe Homebrew::Bundle::Dsl do
expect { dsl_from_string "tap 'foo', ['bad_clone_target']" }.to raise_error(RuntimeError)
expect { dsl_from_string "flatpak 'foo', ['bad_option']" }.to raise_error(RuntimeError)
end
it "errors on unknown go options" do
expect do
dsl_from_string 'go "github.com/charmbracelet/crush", with: ["github.com/charmbracelet/gum"]'
end.to raise_error(RuntimeError, /unknown options\(\[:with\]\) for go/)
end
it "errors on invalid uv with options" do
expect do
dsl_from_string 'uv "mkdocs", with: "mkdocs-material<10"'
end.to raise_error(RuntimeError, /options\[:with\].*Array of String objects/)
expect do
dsl_from_string 'uv "mkdocs", with: [1]'
end.to raise_error(RuntimeError, /options\[:with\].*Array of String objects/)
expect do
dsl_from_string 'uv "mkdocs", with: false'
end.to raise_error(RuntimeError, /options\[:with\].*Array of String objects/)
end
end
it ".sanitize_brew_name" do
+19 -1
View File
@@ -10,6 +10,7 @@ require "bundle/vscode_extension_dumper"
require "bundle/brew_services"
require "bundle/go_dumper"
require "bundle/cargo_dumper"
require "bundle/flatpak_dumper"
require "bundle/uv_dumper"
require "cask"
@@ -55,11 +56,28 @@ RSpec.describe Homebrew::Bundle::Dumper do
it "generates output" do
expect(dumper.build_brewfile(
describe: false, no_restart: false, formulae: true, taps: true, casks: true, mas: true,
vscode: true, go: true, cargo: true, uv: true, flatpak: false
vscode: true, cargo: true, flatpak: false, extension_types: { go: true, uv: true }
)).to eql("cask \"google-chrome\"\ncask \"java\"\ncask \"homebrew/cask-versions/iterm2-beta\"\n")
end
it "determines the brewfile correctly" do
expect(dumper.brewfile_path).to eql(Pathname.new(Dir.pwd).join("Brewfile"))
end
it "preserves the legacy extension dump order" do
allow(Homebrew::Bundle::GoDumper).to receive(:dump).and_return('go "github.com/charmbracelet/crush"')
allow(Homebrew::Bundle::CargoDumper).to receive(:dump).and_return('cargo "ripgrep"')
allow(Homebrew::Bundle::UvDumper).to receive(:dump).and_return('uv "mkdocs"')
allow(Homebrew::Bundle::FlatpakDumper).to receive(:dump).and_return('flatpak "org.gnome.Calculator"')
expect(dumper.build_brewfile(
describe: false, no_restart: false, formulae: false, taps: false, casks: false, mas: false,
vscode: false, cargo: true, flatpak: true, extension_types: { go: true, uv: true }
)).to eql(<<~BREWFILE)
go "github.com/charmbracelet/crush"
cargo "ripgrep"
uv "mkdocs"
flatpak "org.gnome.Calculator"
BREWFILE
end
end
@@ -16,6 +16,16 @@ RSpec.describe Homebrew::Bundle::GoInstaller do
.and_return(true)
expect { described_class.preinstall!("github.com/charmbracelet/crush") }.to raise_error(RuntimeError)
end
it "preserves upgrade_formulae while bootstrapping Go" do
Homebrew::Bundle.upgrade_formulae = "foo,bar"
expect(Homebrew::Bundle).to \
receive(:system).with(HOMEBREW_BREW_FILE, "install", "--formula", "go", verbose: false)
.and_return(true)
expect { described_class.preinstall!("github.com/charmbracelet/crush") }.to raise_error(RuntimeError)
expect(Homebrew::Bundle.upgrade_formulae).to eql(["foo", "bar"])
end
end
context "when Go is installed" do
@@ -38,7 +48,7 @@ RSpec.describe Homebrew::Bundle::GoInstaller do
context "when package is not installed" do
before do
allow(Homebrew::Bundle).to receive(:which_go).and_return(Pathname.new("go"))
allow(described_class).to receive(:installed_packages).and_return([])
allow(described_class).to receive_messages(packages: [], installed_packages: [])
end
it "installs package" do
@@ -48,6 +58,16 @@ RSpec.describe Homebrew::Bundle::GoInstaller do
expect(described_class.preinstall!("github.com/charmbracelet/crush")).to be(true)
expect(described_class.install!("github.com/charmbracelet/crush")).to be(true)
end
it "updates dump output after install in the same process" do
expect(Homebrew::Bundle).to \
receive(:system).with("go", "install", "github.com/charmbracelet/crush@latest", verbose: false)
.and_return(true)
described_class.install!("github.com/charmbracelet/crush")
expect(Homebrew::Bundle::GoDumper.dump).to eql('go "github.com/charmbracelet/crush"')
end
end
end
end
@@ -100,7 +100,7 @@ RSpec.describe Homebrew::Bundle::UvInstaller do
context "when package is not installed" do
before do
allow(Homebrew::Bundle).to receive(:which_uv).and_return(Pathname.new("/tmp/uv/bin/uv"))
allow(described_class).to receive(:installed_packages).and_return([])
allow(described_class).to receive_messages(packages: [], installed_packages: [])
end
it "installs package with no options" do
@@ -120,6 +120,17 @@ RSpec.describe Homebrew::Bundle::UvInstaller do
expect(described_class.preinstall!("mkdocs", with: ["mkdocs-material<10"])).to be(true)
expect(described_class.install!("mkdocs", with: ["mkdocs-material<10"])).to be(true)
end
it "updates dump output after install in the same process" do
expect(Homebrew::Bundle).to receive(:system)
.with("/tmp/uv/bin/uv", "tool", "install", "mkdocs",
"--with", "mkdocs-material<10",
verbose: false).and_return(true)
described_class.install!("mkdocs", with: ["mkdocs-material<10"])
expect(Homebrew::Bundle::UvDumper.dump).to eql('uv "mkdocs", with: ["mkdocs-material<10"]')
end
end
end
end