From f50e41b3bbb56adc2ed77b865a599a8c9f9c9ec5 Mon Sep 17 00:00:00 2001 From: Jim Myhrberg Date: Sun, 28 Apr 2024 22:59:46 +0100 Subject: [PATCH] feat(xbar): add experimental mise-updates script --- xbar/mise-updates.1h.rb | 782 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 782 insertions(+) create mode 100755 xbar/mise-updates.1h.rb diff --git a/xbar/mise-updates.1h.rb b/xbar/mise-updates.1h.rb new file mode 100755 index 0000000..8673a62 --- /dev/null +++ b/xbar/mise-updates.1h.rb @@ -0,0 +1,782 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# rubocop:disable Layout/LineLength + +# Mise Updates +# v0.0.1 +# Jim Myhrberg +# jimeh +# List and manage outdated tools installed with mise +# ruby +# https://github.com/jimeh/dotfiles/tree/main/xbar +# +# string(VAR_MISE_PATH=""): Path to "mise" executable. +# string(VAR_EXTRA_ROOTS=""): Comma-separated list of extra paths to process in addition to globals. +# string(VAR_UPGRADE_ALL_EXCLUDE=""): Comma-separated list formulas/casks to exclude from upgrade all operations. + +# rubocop:enable Layout/LineLength + +# rubocop:disable Lint/ShadowingOuterLocalVariable +# 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 'open3' +require 'json' +require 'set' + +module Xbar + class CommandError < StandardError; end + class RPCError < StandardError; end + class DependencyError < StandardError; end + + module Service + 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 + + class Config < Hash + 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__}.vars.json" + end + + def save + File.write(filename, JSON.pretty_generate(self)) + end + end + + class Printer + 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) + print_item(label, **props) if !label.nil? && !label.empty? + + yield(sub_printer) if block_given? + end + + def separator + print_item(SEP_STR) + end + alias sep separator + + private + + def print_item(text, **props) + props = props.dup + alt = props.delete(:alt) + + output = [text] + unless props.empty? + props = normalize_props(props) + output << PARAM_SEP + output += props.map { |k, v| "#{k}=\"#{v}\"" } + end + + $stdout.print(SUB_STR * nested_level, output.join(' ')) + $stdout.puts + + return if alt.nil? || alt.empty? + + print_item(alt, **props.merge(alternate: true)) + end + + def plugin_refresh_uri + @plugin_refresh_uri ||= 'xbar://app.xbarapp.com/refreshPlugin' \ + "?path=#{File.basename(__FILE__)}" + end + + def normalize_props(props = {}) + props = props.dup + + 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 + + # Refresh Xbar after shell command has run in terminal + if props[:terminal] && props[:refresh] && props[:shell] + 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 + +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) + if !active_in_other?(env, active_version(env)) + :upgrade + else + :install + end + end + end + + def install_name(env) + [name, latest_version(env)].compact.join('@') + end + + def latest_version(env) + return outdated(env)&.latest if outdated?(env) + + source_version(env).version if missing?(env) + 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) + @active ||= versions(env).find(&:active) + 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 + + 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 ||= tools.sum(&:outdated_count) + 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 print_settings(printer) + printer.item('Settings') + printer.sep + + printer.item('Extra Root Paths:') + extra_roots = config.as_set('VAR_EXTRA_ROOTS') + if extra_roots.empty? + printer.item('') + else + extra_roots.each do |root| + printer.item(root) + end + 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? + + printer.sep + printer.item(env.name, alt: env.full_name) + + all_tools = env_tools.reject { |f| upgrade_all_exclude?(f.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_path, 'install'] + to_install.map do |t| + t.install_name(env) + end + end + if to_upgrade.size.positive? + cmds << '&&' if cmds.size.positive? + cmds += [mise_path, 'upgrade'] + to_upgrade.map do |t| + t.install_name(env) + end + end + + if all_tools.size.positive? + + printer.item( + '⬆️ Upgrade', + 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| + if tool.missing?(env) + text = "➡️ Install (#{tool.latest_version(env)})" + else + text = '⬆️ Upgrade' + alt_text = "#{text} " \ + "(#{tool.active_version(env)} → #{tool.latest_version(env)})" + end + + printer.item( + text, + alt: alt_text || text, + terminal: true, refresh: true, + shell: [mise_path, tool.upgrade_operation(env).to_s, tool.name] + ) + printer.sep + unless tool.missing?(env) + printer.item("→ Active: #{tool.active_version(env)}") + end + if tool.latest_version(env) + printer.item("↑ Required: #{tool.latest_version(env)}") + end + printer.sep + printer.item('Installed:') + tool.versions(env).each do |v| + next unless v.installed + + icon = v.active ? '✔️' : '➖' + printer.item("#{icon} #{v.version}") do |printer| + printer.item("Active: #{v.active ? 'Yes' : 'No'}") + if v.source&.path + printer.item("Set by: #{relative_path(v.source.path)}") + end + if v.active + printer.item("Requested: #{v.requested_version}") + end + printer.item("Path: #{v.install_path}") + printer.sep + printer.item('🚫 Uninstall') do |printer| + printer.item('Are you sure?') + printer.item( + 'Yes', + terminal: true, refresh: true, + shell: [ + mise_path, '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_EXTRA_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| + memo[env.path] = JSON.parse( + cmd(mise_path, 'outdated', '--json', dir: env.path) + ) + 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 +# rubocop:enable Lint/ShadowingOuterLocalVariable