#!/usr/bin/env ruby # frozen_string_literal: true # rubocop:disable Layout/LineLength # Mise Updates # v1.1.0 # Jim Myhrberg # jimeh # List and manage outdated tools installed with mise # https://raw.githubusercontent.com/jimeh/dotfiles/70e616332e9cc196d365e5375156b27ce8c9451e/xbar/img/mise-updates.png # ruby # https://github.com/jimeh/dotfiles/tree/main/xbar # # string(VAR_MISE_PATH=""): Path to "mise" executable. # string(VAR_ENVIRONMENT_ROOTS=""): Comma-separated list of extra paths to process in addition to $HOME. # string(VAR_UPGRADE_ALL_EXCLUDE=""): Comma-separated list formulas/casks to exclude from upgrade all operations. # 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 || ''}") 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 || ''}") 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 << '' 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