Files
dotfiles/xbar/mise-updates.1h.rb

1045 lines
26 KiB
Ruby
Executable File
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env ruby
# frozen_string_literal: true
# rubocop:disable Layout/LineLength
# <xbar.title>Mise Updates</xbar.title>
# <xbar.version>v1.1.0</xbar.version>
# <xbar.author>Jim Myhrberg</xbar.author>
# <xbar.author.github>jimeh</xbar.author.github>
# <xbar.desc>List and manage outdated tools installed with mise</xbar.desc>
# <xbar.image>https://raw.githubusercontent.com/jimeh/dotfiles/70e616332e9cc196d365e5375156b27ce8c9451e/xbar/img/mise-updates.png</xbar.image>
# <xbar.dependencies>ruby</xbar.dependencies>
# <xbar.abouturl>https://github.com/jimeh/dotfiles/tree/main/xbar</xbar.abouturl>
#
# <xbar.var>string(VAR_MISE_PATH=""): Path to "mise" executable.</xbar.var>
# <xbar.var>string(VAR_ENVIRONMENT_ROOTS=""): Comma-separated list of extra paths to process in addition to $HOME.</xbar.var>
# <xbar.var>string(VAR_UPGRADE_ALL_EXCLUDE=""): Comma-separated list formulas/casks to exclude from upgrade all operations.</xbar.var>
# rubocop:enable Layout/LineLength
# rubocop:disable Metrics/AbcSize
# rubocop:disable Metrics/BlockLength
# rubocop:disable Metrics/ClassLength
# rubocop:disable Metrics/CyclomaticComplexity
# rubocop:disable Metrics/MethodLength
# rubocop:disable Metrics/PerceivedComplexity
# rubocop:disable Style/IfUnlessModifier
require 'fileutils'
require 'json'
require 'open3'
require 'set'
# Xbar is a tiny helper library for creating Xbar and SwiftBar plugins in Ruby.
module Xbar
class CommandError < StandardError; end
class RPCError < StandardError; end
module Helpers
def plugin_data_path
@plugin_data_path ||= swiftbar_data_path || File.dirname(__FILE__)
end
def plugin_file_path
@plugin_file_path ||= swiftbar_plugin_path || __FILE__
end
def plugin_filename
@plugin_filename ||= File.basename(plugin_file_path)
end
def plugin_name
@plugin_name ||= begin
parts = plugin_filename.split('.')
if parts.size < 3
raise "Invalid plugin name: #{plugin_filename}"
end
parts[0..-3].join('.')
end
end
def swiftbar?
ENV['SWIFTBAR'] == '1'
end
def swiftbar_cache_path
@swiftbar_cache_path ||= ENV['SWIFTBAR_PLUGIN_CACHE_PATH'] if swiftbar?
end
def swiftbar_data_path
@swiftbar_data_path ||= ENV['SWIFTBAR_PLUGIN_DATA_PATH'] if swiftbar?
end
def swiftbar_plugin_path
@swiftbar_plugin_path ||= ENV['SWIFTBAR_PLUGIN_PATH'] if swiftbar?
end
end
module Service
include Helpers
private
def config
@config ||= Xbar::Config.new
end
def printer
@printer ||= ::Xbar::Printer.new
end
def cmd(*args, dir: nil)
opts = {}
opts[:chdir] = File.expand_path(dir) if dir
out, err, s = Open3.capture3(*args, opts)
if s.exitstatus != 0
msg = "Command failed: #{args.join(' ')}"
msg += ": #{err}" unless err.empty?
raise CommandError, msg
end
out
end
end
class Runner
attr_reader :service
def initialize(service)
@service = service
end
def run(argv = [])
return service.run if argv.empty?
unless service.respond_to?(argv[0])
raise RPCError, "Unknown RPC method: #{argv[0]}"
end
service.public_send(*argv)
end
end
# Config is a simple wrapper around a JSON file that contains the plugin's
# configuration. It is compatible with Xbar's behavior of loading environment
# variables from it. We don't rely on that however and directly read the file
# ourselves.
#
# In SwiftBar, we use `SWIFTBAR_PLUGIN_DATA_PATH` to determine where to store
# the configuration in a `config.json` file.
class Config < Hash
include Helpers
def initialize
super
return unless File.exist?(filename)
merge!(JSON.parse(File.read(filename)))
end
def as_set(name)
values = self[name]&.to_s&.split(',')&.map(&:strip)&.reject(&:empty?)
::Set.new(values || [])
end
def filename
@filename ||= File.join(
plugin_data_path,
swiftbar? ? 'config.json' : "#{plugin_filename}.vars.json"
)
end
def dirname
@dirname ||= File.dirname(filename)
end
def save
FileUtils.mkdir_p(dirname)
File.write(filename, JSON.pretty_generate(self))
end
end
class Printer
include Helpers
attr_reader :nested_level
SUB_STR = '--'
SEP_STR = '---'
PARAM_SEP = '|'
def initialize(nested_level = 0)
@nested_level = nested_level
end
def item(label = nil, **props)
return if label.nil? || label.empty?
props = normalize_props(props.dup)
alt = props.delete(:alt)
nested_items = props.delete(:nested) || :both
yield_main = block_given? && [:main, :both].include?(nested_items)
yield_alt = block_given? && [:alt, :both].include?(nested_items)
print_item(label, **props)
yield(sub_printer) if yield_main
return if alt.nil? || alt.strip.empty?
print_item(alt, **props.merge(alternate: true))
yield(sub_printer) if yield_alt
end
def separator
print_item(SEP_STR)
end
alias sep separator
private
def print_item(text, **props)
output = [text]
unless props.empty?
output << PARAM_SEP
output += props.map { |k, v| "#{k}=\"#{v}\"" }
end
$stdout.print(SUB_STR * nested_level, output.join(' '))
$stdout.puts
end
def plugin_refresh_uri
return @plugin_refresh_uri if @plugin_refresh_uri
@plugin_refresh_uri = if swiftbar?
"swiftbar://refreshplugin?name=#{plugin_name}"
else
'xbar://app.xbarapp.com/refreshPlugin' \
"?path=#{plugin_filename}"
end
end
def normalize_props(props = {})
props = props.dup
# Explicitly set terminal to false for SwiftBar, as it seems to default
# to true when not specified. At least with SwiftBar 2.0.1.
if swiftbar? && !props.key?(:terminal)
props[:terminal] = false
end
if props[:rpc] && props[:shell].nil?
props[:shell] = [__FILE__] + props[:rpc]
props.delete(:rpc)
end
if props[:shell].is_a?(Array)
cmd = props[:shell]
props[:shell] = cmd[0]
cmd[1..].each_with_index do |c, i|
props["param#{i + 1}".to_sym] = c
end
end
# Always refresh SwiftBar via refresh plugin URI so as to temporarily
# disable the menu bar icon while refresh is running.
#
# For Xbar this does not work when terminal is false, so we only trigger
# refresh via the plugin refresh URI when terminal is true.
if props[:refresh] && props[:shell] && (swiftbar? || props[:terminal])
props[:refresh] = false
i = 1
i += 1 while props.key?("param#{i}".to_sym)
props["param#{i}".to_sym] = ';'
props["param#{i + 1}".to_sym] = 'open'
props["param#{i + 2}".to_sym] = '-jg'
props["param#{i + 3}".to_sym] = "'#{plugin_refresh_uri}'"
end
props
end
def sub_printer
@sub_printer || self.class.new(nested_level + 1)
end
end
end
#
# ------------------------------------------------------------------------------
#
# Mise module contains classes for managing Mise tools.
module Mise
module Helpers
def relative_path(path)
if path&.start_with?(ENV['HOME'])
path.sub(ENV['HOME'], '~')
else
path
end
end
end
class Common
include Xbar::Service
def self.prefix(value = nil)
return @prefix if value.nil? || value == ''
@prefix = value
end
private
def prefix
self.class.prefix
end
def truthy?(value)
%w[true yes 1 on y t].include?(value.to_s.downcase)
end
def print_rpc_toggle(printer, name, rpc, current_value)
if current_value
icon = '✅'
value = 'false'
else
icon = '☑️'
value = 'true'
end
printer.item("#{icon} #{name}", rpc: [rpc, value], refresh: true)
end
def mise_path
@mise_path ||= mise_path_from_env ||
mise_path_from_which ||
mise_path_from_fs_check ||
raise(Xbar::DependencyError)
end
def mise_path_from_env
env_value = config['VAR_MISE_PATH']&.to_s&.strip || ''
return if env_value == ''
return unless File.exist?(env_value)
env_value
end
def mise_path_from_which
detect = cmd('which', 'mise').strip
return if detect == ''
detect
rescue Xbar::CommandError
nil
end
def mise_path_from_fs_check
[
"#{ENV['HOME']}/.local/bin/mise",
"#{ENV['HOME']}/.local/share/mise/bin/mise",
'/usr/local/bin/mise',
'/opt/homebrew/bin/mise'
].each do |path|
return path if File.exist?(path)
end
nil
end
def mise_check(printer = nil)
printer ||= default_printer
begin
return if File.exist?(mise_path)
rescue Xbar::DependencyError
# do nothing
end
printer.item("#{prefix}↑⚠️", dropdown: false)
printer.sep
printer.item('Mise not found', color: 'red')
printer.item("Executable \"#{mise_path}\" does not exist.")
printer.sep
printer.item(
'Visit https://mise.jdx.dev for installation instructions',
href: 'https://mise.jdx.dev'
)
exit 0
end
end
class Env
include Helpers
attr_reader :path
def initialize(path)
@path = File.expand_path(path || ENV['HOME'])
end
def is?(other_path)
other_path = File.expand_path(other_path)
if global?
other_path == ENV['HOME'] ||
other_path.start_with?("#{ENV['HOME']}/.config/mise/")
elsif !other_path.nil? && !other_path.empty?
path == other_path || path == File.dirname(other_path)
end
end
def ==(other)
return false unless other.is_a?(Env)
path == other.path
end
def global?
path == ENV['HOME']
end
def name
if global?
'Global'
else
File.basename(path)
end
end
def full_name
if global?
'~ (Global)'
else
relative_path(path)
end
end
end
class Tool
attr_reader :name
def initialize(name:, versions: [], outdated: [])
@name = name
@versions = versions.each_with_object({}) do |v, memo|
ver = ToolVersion.new(v)
memo[ver.env_path] ||= []
memo[ver.env_path] << ver
end
@outdated = outdated&.each_with_object({}) do |od, memo|
o = ToolOutdated.new(od)
memo[o.env_path] = ToolOutdated.new(od)
end
end
def for_env?(env)
source_version(env)&.source&.for_env?(env)
end
def upgrade_operation(env)
if missing?(env)
:install
elsif outdated?(env)
active = active_version(env)
if !active.nil? && !desired_in_other?(env, active)
:upgrade
else
:install
end
end
end
# Install arg is the name of the tool followed by the exact version,
# separated by @.
def install_arg(env)
[name, latest_version(env)].compact.join('@')
end
# Upgrade arg is just the name of the tool. If a specific version is given,
# mise skips removing old versions.
def upgrade_arg(_env)
name
end
def latest_version(env)
return outdated(env)&.latest if outdated?(env)
source_version(env).version if missing?(env)
end
def requested_version(env)
source_version(env)&.requested_version
end
def source_version(env)
versions(env)&.find { |v| !v.source.nil? }
end
def versions(env)
@versions[env.path] || []
end
def outdated(env)
@outdated&.dig(env.path)
end
def outdated_count
@outdated&.map { |_, od| od.latest }&.uniq&.size || 0
end
def active(env)
versions(env).find(&:active)
end
def desired_in_other?(env, version)
@versions.any? do |env_path, vers|
next if env_path == env.path
outdated = @outdated&.dig(env_path)
if outdated
outdated.requested == version
else
vers.any? { |v| v.active && v.version == version }
end
end
end
def active_in_other?(env, version)
@versions.any? do |env_path, vers|
next if env_path == env.path
vers.any? { |v| v.active && v.version == version }
end
end
def active_version(env)
active(env)&.version
end
def installed_versions(env)
versions(env).select(&:installed)
end
def installed?(env)
installed_versions(env).any?
end
def outdated?(env)
od = outdated(env)
od.nil? || od.current != od.latest
end
def missing?(env)
sv = source_version(env)
sv.nil? || sv.installed == false
end
end
class ToolVersion
include Helpers
attr_reader :env, :active, :install_path, :installed, :requested_version,
:source, :version
def initialize(attributes = {})
@env = attributes['env']
@active = attributes['active']
@installed = attributes['installed']
@install_path = relative_path(attributes['install_path'])
@version = attributes['version']
@requested_version = attributes['requested_version']
@source = ToolSource.new(attributes['source']) if attributes['source']
end
def env_path
env&.path
end
end
class ToolSource
include Helpers
attr_reader :type, :path, :key, :value
def initialize(attributes = {})
@type = attributes['type']
@path = attributes['path']
@key = attributes['key']
@value = attributes['value']
end
def for_env?(env)
return false if path.nil? || path.empty?
env.is?(path)
end
def pretty
@pretty ||= case type
when 'environment'
"env #{key}=#{value}"
when !path.nil? && !path.empty?
relative_path(File.dirname(path))
end
end
end
class ToolOutdated
attr_reader :env, :name, :current, :latest, :requested
def initialize(attributes = {})
@env = attributes['env']
@name = attributes['name']
@current = attributes['current']
@latest = attributes['latest']
@requested = attributes['requested']
end
def env_path
env&.path
end
end
class ToolUpdates < Common
include Helpers
prefix '🍱'
def run
mise_check(printer)
printer.item("#{prefix}#{outdated_count}", dropdown: false)
printer.sep
printer.item('Mise Updates') do |printer|
printer.item('⏳ Refresh', alt: '⏳ Refresh (⌘R)', refresh: true)
printer.sep
print_settings(printer)
end
# Show mise version status and, if outdated, a self-update action.
if mise_outdated?
printer.item("mise (1)") do |printer|
printer.item(
"⬆️ Update (#{mise_current_version}#{mise_latest_version})",
terminal: true, refresh: true,
shell: [mise_path, 'self-update', '--yes']
)
printer.sep
printer.item(
"📝 Changelog (#{mise_latest_version})",
href: "https://github.com/jdx/mise/blob/v#{mise_latest_version}/CHANGELOG.md"
)
printer.item(
'📦 Releases',
href: "https://github.com/jdx/mise/releases"
)
end
end
print_tools(printer)
printer.sep
end
def exclude_upgrade_all(*args)
exclude = upgrade_all_exclude.clone
exclude += args.map(&:strip).reject(&:empty?)
config['VAR_UPGRADE_ALL_EXCLUDE'] = exclude.sort.join(',')
config.save
end
def include_upgrade_all(*args)
exclude = upgrade_all_exclude.clone
exclude -= args.map(&:strip).reject(&:empty?)
config['VAR_UPGRADE_ALL_EXCLUDE'] = exclude.sort.join(',')
config.save
end
private
def outdated_count
@outdated_count ||= begin
count = tools.sum(&:outdated_count)
count += 1 if mise_outdated?
count
end
end
def upgrade_all_exclude?(name)
upgrade_all_exclude.include?(name)
end
def upgrade_all_exclude
@upgrade_all_exclude ||= config.as_set('VAR_UPGRADE_ALL_EXCLUDE')
end
def status_label
label = []
label << "#{tools.size} tools" if tools.size.positive?
label = ['no updates available'] if label.empty?
label.join(', ')
end
def mise_version_info
return @mise_version_info if @mise_version_info
mise_force_check_latest_version
@mise_version_info = JSON.parse(
cmd(mise_path, 'version', '--json')
)
rescue StandardError
{}
end
# Force check for the latest mise version by running `self-update` and
# issuing a `n` response to the update prompt.
def mise_force_check_latest_version
return if @has_force_checked_version_info
Open3.popen3(mise_path, 'self-update') do |stdin, _, _, _|
stdin.puts('n')
end
@has_force_checked_version_info = true
end
def mise_current_version
version = mise_version_info['version'].to_s
version.split(' ').first
end
def mise_current_version_label
mise_version_info['version'].to_s
end
def mise_latest_version
mise_version_info['latest'].to_s
end
def mise_outdated?
current = mise_current_version
latest = mise_latest_version
return false if current.nil? || current == ''
return false if latest.nil? || latest == ''
version_greater?(latest, current)
end
def version_segments(version_str)
dotted = version_str.to_s[/\A\d+(?:\.\d+)*/]
return [] if dotted.nil? || dotted.empty?
dotted.split('.').map(&:to_i)
end
def version_greater?(a_str, b_str)
a = version_segments(a_str)
b = version_segments(b_str)
max = [a.size, b.size].max
(0...max).each do |i|
ai = a[i] || 0
bi = b[i] || 0
return true if ai > bi
return false if ai < bi
end
false
end
def print_settings(printer)
printer.item('Settings')
printer.sep
extra_roots = config.as_set('VAR_ENVIRONMENT_ROOTS')
printer.item('Environment Root Paths:')
printer.item('~ (Home Directory / Global)')
extra_roots.each do |root|
printer.item(" #{root}")
end
end
def print_tools(printer)
envs.each do |env|
print_env_tools(printer, env)
end
end
def print_env_tools(printer, env)
env_tools = tools.select do |tool|
tool.for_env?(env) && tool.latest_version(env)
end
return unless env_tools.size.positive?
mise_cmd_args = [mise_path]
mise_cmd_args += ['--cd', env.path] unless env.global?
printer.sep
printer.item(env.full_name)
all_tools = env_tools.reject { |tool| upgrade_all_exclude?(tool.name) }
excluded = (env_tools - all_tools)
printer.item("Upgrade All (#{all_tools.size})") do |printer|
to_install = []
to_upgrade = []
all_tools.each do |tool|
case tool.upgrade_operation(env)
when :install
to_install << tool
when :upgrade
to_upgrade << tool
end
end
cmds = []
if to_install.size.positive?
cmds += mise_cmd_args + ['install'] + to_install.map do |t|
t.install_arg(env)
end
end
if to_upgrade.size.positive?
cmds << '&&' if cmds.size.positive?
cmds += mise_cmd_args + ['upgrade'] + to_upgrade.map do |t|
t.upgrade_arg(env)
end
end
if all_tools.size.positive?
printer.item(
"⬆️ Upgrade (#{all_tools.size})",
terminal: true, refresh: true,
shell: cmds
)
end
if excluded.size.positive?
printer.sep
printer.item("Excluded (#{excluded.size}):")
excluded.sort_by(&:name).each do |item|
printer.item(item.name)
end
end
end
printer.sep
env_tools.each do |tool|
next if tool.latest_version(env).nil?
name = tool.name
name += ' ' if upgrade_all_exclude?(name)
printer.item(name) do |printer|
mise_operation = tool.upgrade_operation(env)
if mise_operation == :install
tool_arg = tool.install_arg(env)
text = "➡️ Install (→ #{tool.latest_version(env)})"
else
tool_arg = tool.upgrade_arg(env)
text = "⬆️ Upgrade (↑ #{tool.latest_version(env)})"
alt_text = '⬆️ Upgrade ' \
"(#{tool.active_version(env)}" \
"#{tool.latest_version(env)})"
end
printer.item(
text,
alt: alt_text || text,
terminal: true, refresh: true,
shell: mise_cmd_args + [mise_operation.to_s, tool_arg]
)
printer.sep
latest_version = tool.latest_version(env)
if latest_version
printer.item("↑ Latest: #{latest_version}")
end
active_version = tool.active_version(env)
printer.item("→ Active: #{active_version || '<Missing>'}")
requested_version = tool.requested_version(env)
if requested_version
printer.item("→ Requested: #{requested_version}")
end
other_envs = envs.select { |e| env != e && tool.for_env?(e) }
if other_envs.size.positive?
printer.sep
printer.item('Other Environments') do |printer|
other_envs.each_with_index do |other_env, index|
printer.item(other_env.full_name)
latest_version = tool.latest_version(other_env)
if latest_version
printer.item("↑ Latest: #{latest_version}")
end
active_version = tool.active_version(other_env)
printer.item("→ Active: #{active_version || '<Missing>'}")
requested_version = tool.requested_version(other_env)
if requested_version
printer.item("→ Requested: #{requested_version}")
end
printer.sep if index < other_envs.size - 1
end
end
end
printer.sep
printer.item('Installed:')
tool.versions(env).each do |v|
next unless v.installed
icon = if v.active
'✔️'
elsif tool.active_in_other?(env, v.version)
''
else
''
end
printer.item("#{icon} #{v.version}") do |printer|
printer.item("Active: #{v.active ? 'Yes' : 'No'}")
if v.requested_version
printer.item("Requested: #{v.requested_version}")
end
if v.source&.path
printer.item("Set by: #{relative_path(v.source.path)}")
end
printer.item("Path: #{v.install_path}")
active_envs = envs.select do |e|
active_version = tool.active_version(e)
!active_version.nil? && active_version == v.version
end
printer.sep
text = ['Active in Environments:']
text << '<None>' if active_envs.empty?
printer.item(text.join(' '))
active_envs.each do |e|
printer.item(e.full_name)
end
printer.sep
printer.item('🚫 Uninstall') do |printer|
printer.item('Are you sure?')
printer.item(
'Yes',
terminal: true, refresh: true,
shell: mise_cmd_args + [
'uninstall', "#{tool.name}@#{v.version}"
]
)
end
end
end
printer.sep
if upgrade_all_exclude?(tool.name)
printer.item(
'✅ Upgrade All: Exclude',
terminal: false, refresh: true,
rpc: ['include_upgrade_all', tool.name]
)
else
printer.item(
'☑️ Upgrade All: Exclude ',
terminal: false, refresh: true,
rpc: ['exclude_upgrade_all', tool.name]
)
end
end
end
end
def envs
@envs ||= ([ENV['HOME']] + config.as_set('VAR_ENVIRONMENT_ROOTS').to_a)
.uniq.map { |p| Env.new(p) }
end
def tools
return @tools if @tools
versions = envs.each_with_object({}) do |env, memo|
tool_list[env.path].each do |name, versions|
memo[name] ||= []
memo[name] += versions.map do |v|
v.merge('env' => env)
end
end
end
outdated = envs.each_with_object({}) do |env, memo|
outdated_list[env.path]&.each do |name, od|
memo[name] ||= []
memo[name] << od.merge('name' => name, 'env' => env)
end
end
versions.map do |name, vers|
Tool.new(
name: name,
versions: vers,
outdated: outdated[name]
)
end
end
def tool_list
@tool_list ||= envs.each_with_object({}) do |env, memo|
memo[env.path] = JSON.parse(
cmd(mise_path, 'list', '--json', dir: env.path)
)
end
end
def outdated_list
@outdated_list ||= envs.each_with_object({}) do |env, memo|
out = cmd(mise_path, 'outdated', '--json', dir: env.path)
out = '{}' if out.empty?
memo[env.path] = JSON.parse(out)
end
end
end
end
begin
service = Mise::ToolUpdates.new
Xbar::Runner.new(service).run(ARGV)
rescue StandardError => e
puts ":warning: #{File.basename(__FILE__)}"
puts '---'
puts 'exit status 1'
puts '---'
puts 'Error:'
puts e.message
e.backtrace.each do |line|
puts "--#{line}"
end
exit 0
end
# rubocop:enable Style/IfUnlessModifier
# rubocop:enable Metrics/PerceivedComplexity
# rubocop:enable Metrics/MethodLength
# rubocop:enable Metrics/CyclomaticComplexity
# rubocop:enable Metrics/ClassLength
# rubocop:enable Metrics/BlockLength
# rubocop:enable Metrics/AbcSize