mirror of
https://github.com/Homebrew/brew.git
synced 2026-10-28 17:44:43 +08:00
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:
32 files changed
+1123
-651
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# typed: strict
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "bundle/extensions/extension"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# typed: strict
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "bundle/extensions/go"
|
||||
@@ -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,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,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"
|
||||
@@ -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?
|
||||
|
||||
|
||||
@@ -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:)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# typed: strict
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "bundle/extensions/uv"
|
||||
@@ -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,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,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"
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user