├── .gitignore ├── VERSION ├── spec ├── spec_helper.rb └── core_ext_spec.rb ├── design ├── Favorite Packages.md ├── Updates Grouped Together for Readability.md └── Fuzzy Installer.md ├── lib ├── upm.rb └── upm │ ├── tools │ ├── opkg.rb │ ├── pkg_add.rb │ ├── apk.rb │ ├── yum.rb │ ├── pkgman.rb │ ├── pkgin.rb │ ├── guix.rb │ ├── apt.rb │ ├── xbps.rb │ ├── pkg.rb │ └── pacman.rb │ ├── freshports_search.rb │ ├── log_parser.rb │ ├── tool_class_methods.rb │ ├── lesspipe.rb │ ├── tool.rb │ ├── pacman_verifier.rb │ ├── core_ext │ └── file.rb │ ├── core_ext.rb │ ├── tool_dsl.rb │ └── colored.rb ├── Rakefile ├── LICENSE ├── bin └── upm ├── .gemspec ├── TODO.md └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | pkg/ -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 0.1.19 2 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | $:.unshift File.join(__dir__, "../lib") -------------------------------------------------------------------------------- /design/Favorite Packages.md: -------------------------------------------------------------------------------- 1 | # Favorite Packages 2 | 3 | ## Concept 4 | 5 | The user can create their own package groups manually, or dump of their current desktop's package selecitons and organize them. 6 | 7 | 8 | -------------------------------------------------------------------------------- /lib/upm.rb: -------------------------------------------------------------------------------- 1 | require 'upm/colored' 2 | require 'upm/lesspipe' 3 | require 'upm/core_ext' 4 | require 'upm/log_parser' 5 | 6 | module UPM 7 | 8 | require 'upm/tool' 9 | 10 | Tool.register_tools! 11 | 12 | end -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | pkgname = "upm" 2 | gem_version = File.read("VERSION").strip 3 | 4 | gemfile = "#{pkgname}-#{gem_version}.gem" 5 | 6 | task :build do 7 | system "gem build .gemspec" 8 | system "mkdir pkg/" unless File.directory? "pkg" 9 | system "mv #{gemfile} pkg/" 10 | end 11 | 12 | task :release => :build do 13 | system "gem push pkg/#{gemfile}" 14 | end 15 | 16 | task :gem => :build 17 | 18 | task :install => :build do 19 | system "gem install pkg/#{gemfile}" 20 | end 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE 2 | Version 2, December 2004 3 | 4 | Copyright (C) 2004 Sam Hocevar 5 | 14 rue de Plaisance, 75014 Paris, France 6 | Everyone is permitted to copy and distribute verbatim or modified 7 | copies of this license document, and changing it is allowed as long 8 | as the name is changed. 9 | 10 | DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE 11 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 12 | 13 | 0. You just DO WHAT THE FUCK YOU WANT TO. 14 | -------------------------------------------------------------------------------- /spec/core_ext_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative "spec_helper" 2 | require "upm/core_ext" 3 | 4 | describe File do 5 | 6 | it "whiches" do 7 | File.which("ls").should == "/usr/bin/ls" 8 | File.which("ls", "rm").should == ["/usr/bin/ls", "/usr/bin/rm"] 9 | File.which("zzzzzzzzzzzzzzzzzzzzzzzzzzzz").should == nil 10 | end 11 | 12 | it "which_is_best?s" do 13 | File.which_is_best?("ls", "rm", "sudo").should == "/usr/bin/ls" 14 | File.which_is_best?("sudo").should == "/usr/bin/sudo" 15 | File.which_is_best?("zzzzzzzzzzzzzzzzzz").should == nil 16 | end 17 | 18 | end -------------------------------------------------------------------------------- /bin/upm: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | bin_dir = File.dirname(File.realpath(__FILE__)) 3 | $LOAD_PATH.unshift(File.expand_path(File.join('..', 'lib'), bin_dir)) 4 | 5 | require 'upm' 6 | 7 | unless tool = UPM::Tool.for_os 8 | $stderr.puts "Error: I don't recognize this OS, or its package manager." 9 | exit 1 10 | end 11 | 12 | if ARGV.any? { |arg| ["help", "version", "--help", "--version", "-h", "-v"].include? arg } 13 | tool.help 14 | else 15 | command, *args = ARGV 16 | if command.nil? 17 | tool.help 18 | else 19 | begin 20 | tool.call_command command, *args 21 | rescue Interrupt 22 | end 23 | end 24 | end -------------------------------------------------------------------------------- /.gemspec: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # -*- coding: utf-8 -*- 3 | 4 | Gem::Specification.new do |s| 5 | s.name = "upm" 6 | s.version = File.read "VERSION" 7 | s.date = File.mtime("VERSION").strftime("%Y-%m-%d") 8 | s.summary = "Universal Package Manager" 9 | s.description = "Wrap all known command-line package tools with a consistent and pretty interface." 10 | s.homepage = "http://github.com/epitron/upm/" 11 | s.licenses = ["WTFPL"] 12 | s.email = "chris@ill-logic.com" 13 | s.authors = ["epitron"] 14 | s.executables = ["upm"] 15 | 16 | s.files = `git ls`.lines.map(&:strip) 17 | s.extra_rdoc_files = ["README.md", "LICENSE"] 18 | end 19 | -------------------------------------------------------------------------------- /design/Updates Grouped Together for Readability.md: -------------------------------------------------------------------------------- 1 | # Updates Grouped Together for Readability 2 | 3 | ## Concept 4 | 5 | The design of package update screens is pretty abysmal -- usually just a big blob of package names and versions. This can be improved. 6 | 7 | ## Groups 8 | 9 | Packages can be grouped by any of the following: 10 | 11 | - semantic version (MAJOR.MINOR.PATCH): 12 | - MAJOR: incompatible API changes 13 | - MINOR: backwards-compatible features added 14 | - PATCH: bugfixes 15 | - package's category/tags 16 | - which repository the package came from 17 | - who packaged it 18 | - a tree-view of package dependencies (useful for seeing dependency relationships during package updates) 19 | -------------------------------------------------------------------------------- /lib/upm/tools/opkg.rb: -------------------------------------------------------------------------------- 1 | UPM::Tool.new "opkg" do 2 | 3 | os "openwrt", "lede" 4 | 5 | command "install", "opkg install", root: true 6 | command "update", "opkg update", root: true 7 | command "upgrade", root: true do |args| 8 | pkgs = `opkg list-upgradable`.each_line.map { |line| line.split.first } 9 | run "opkg", "upgrade", *pkgs 10 | end 11 | 12 | command "search" do |args| 13 | query = args.join 14 | run "opkg", "list", grep: query, paged: true 15 | end 16 | 17 | command "list" do |args| 18 | if args.any? 19 | query = args.join 20 | run "opkg", "list-installed", grep: query, paged: true 21 | else 22 | run "opkg", "list-installed", paged: true 23 | end 24 | end 25 | 26 | end 27 | -------------------------------------------------------------------------------- /lib/upm/tools/pkg_add.rb: -------------------------------------------------------------------------------- 1 | UPM::Tool.new "pkg_add" do 2 | 3 | os "OpenBSD" 4 | 5 | command "install", "pkg_add", root: true 6 | command "remove", "pkg_delete", root: true 7 | command "upgrade", "pkg_add -u", root: true 8 | command "clean", "yum clean", root: true 9 | command "info", "pkg_info", paged: true 10 | command "files", "pkg_info -L", paged: true 11 | command "search", "pkg_info -Q", paged: true 12 | command "verify", "pkg_check" 13 | 14 | command "list" do |args| 15 | if args.any? 16 | highlight_query = args.join(".+") 17 | grep_query = /#{highlight_query}/ 18 | run "pkg_info", grep: grep_query, highlight: highlight_query, paged: true 19 | else 20 | run "pkg_info", paged: true 21 | end 22 | end 23 | 24 | end 25 | -------------------------------------------------------------------------------- /lib/upm/tools/apk.rb: -------------------------------------------------------------------------------- 1 | UPM::Tool.new "apk" do 2 | 3 | os "alpine" 4 | 5 | command "install", "apk add", root: true 6 | command "remove", "apk del", root: true 7 | command "update", "apk update", root: true 8 | command "upgrade", "apk upgrade", root: true 9 | command "clean", "apk clean", root: true 10 | 11 | command "files", "apk info -L", paged: true 12 | command "search" do |args| 13 | query = args.join(".+") 14 | run "apk", "search", *args, sort: true, paged: true, highlight: query 15 | end 16 | 17 | command "list" do |args| 18 | if args.any? 19 | highlight_query = args.join(".+") 20 | grep_query = /#{highlight_query}/ 21 | run "apk", "info", grep: grep_query, highlight: highlight_query, paged: true 22 | else 23 | run "apk", "info", paged: true 24 | end 25 | end 26 | 27 | end 28 | -------------------------------------------------------------------------------- /lib/upm/tools/yum.rb: -------------------------------------------------------------------------------- 1 | UPM::Tool.new "yum" do 2 | 3 | os "centos", "fedora", "rhel" 4 | 5 | command "install", "yum install", root: true 6 | command "remove", "yum remove", root: true 7 | command "update", "yum updateinfo", root: true 8 | command "upgrade", "yum update", root: true 9 | command "clean", "yum clean", root: true 10 | 11 | command "files", "rpm -ql", paged: true 12 | command "search" do |args| 13 | query = args.join(".+") 14 | run "yum", "search", *args, sort: true, paged: true, highlight: query 15 | end 16 | 17 | command "list" do |args| 18 | if args.any? 19 | highlight_query = args.join(".+") 20 | grep_query = /#{highlight_query}/ 21 | run "yum", "list", "installed", grep: grep_query, highlight: highlight_query, paged: true 22 | else 23 | run "yum", "list", "installed", paged: true 24 | end 25 | end 26 | 27 | end 28 | -------------------------------------------------------------------------------- /lib/upm/freshports_search.rb: -------------------------------------------------------------------------------- 1 | require 'open-uri' 2 | require 'upm/colored' 3 | 4 | class FreshportsSearch 5 | NUM_RESULTS = 20 6 | SEARCH_URL = "https://www.freshports.org/search.php?query=%s&num=#{NUM_RESULTS}&stype=name&method=match&deleted=excludedeleted&start=1&casesensitivity=caseinsensitive" 7 | SVN_URL = "svn://svn.FreeBSD.org/ports/head/%s/%s" 8 | 9 | def print(results) 10 | results.each do |path, desc, version| 11 | _, category, package = path.split("/") 12 | puts "<9>#{category}<8>/<11>#{package} <8>(<7>#{version}<8>)".colorize 13 | puts " #{desc}" 14 | puts " #{SVN_URL % [category, package]}".light_green 15 | end 16 | end 17 | 18 | def search!(query) 19 | puts "<8>* <7>Searching for <15>#{query}<7>...".colorize 20 | html = open(SEARCH_URL % query, &:read) 21 | puts 22 | results = html.scan(%r{
\s*.+?\s*([^<]+)
\s*\s*([^<]+)\s*}im) 23 | print(results) 24 | end 25 | end 26 | 27 | if __FILE__ == $0 28 | FreshportsSearch.new.search!("silver_searcher") 29 | end 30 | -------------------------------------------------------------------------------- /design/Fuzzy Installer.md: -------------------------------------------------------------------------------- 1 | # Fuzzy Installer 2 | 3 | ## Concept 4 | 5 | 'upm install ' performs a search 6 | 7 | If there's one match, install it; if there are multiple matches, show ranked results and let the user pick one (fzf-like picker, but with a hotkey that can expand descriptions). 8 | 9 | Ranking of packages by source use statistics and heuristics 10 | - Which version is newest? 11 | - Which version is more stable? 12 | - What has the user picked in the past? (eg: a distro's global ruby- package vs a globally installed gem vs a locally installed gem) 13 | - What tools are installed? (docker? gem?) 14 | - Which package is less likely to break stuff? (some package managers are better than others (*cough*pip)) 15 | - Is it a service that should be instaled in a container? 16 | 17 | ## Depends on 18 | 19 | - A database of package aliases 20 | - A ranking algorithm 21 | - Package type prioritization (manually specified or machine learned, per-package rules based on all users who installed it) 22 | - 23 | - Distributed statistics 24 | - Users can anonymously donate their stats 25 | - Stats are all public 26 | - Ranking algorithm can run locally or on a distributed system 27 | 28 | # -------------------------------------------------------------------------------- /lib/upm/tools/pkgman.rb: -------------------------------------------------------------------------------- 1 | UPM::Tool.new "pkgman" do 2 | 3 | os "Haiku" 4 | 5 | def installed_packages 6 | Pathname.new("/packages").each_child.map { |dir| dir.basename.to_s } 7 | end 8 | 9 | def hpkg_dir 10 | Pathname.new("/system/packages") 11 | end 12 | 13 | def hpkg_file(name) 14 | r = hpkg_dir.glob("#{name}-*.hpkg").sort 15 | # p r 16 | r.last 17 | end 18 | 19 | command "install", "pkgman install", root: true 20 | command "remove", "pkgman uninstall", root: true 21 | command "upgrade", "pkgman update", root: true 22 | command "repos", "pkgman list-repos", paged: true 23 | 24 | #command "search", "pkgman search", paged: true, highlight: true 25 | 26 | command "search" do |args| 27 | #query = args.join(".*") 28 | #p query: query 29 | run "pkgman", "search", args.join(" "), highlight: "(#{ args.join("|") })", paged: true 30 | end 31 | 32 | command "info" do |args| 33 | args.each { |arg| run("package", "list", "-i", hpkg_file(arg).to_s, paged: true) } 34 | end 35 | 36 | command "files" do |args| 37 | args.each { |arg| run("package", "list", "-p", hpkg_file(arg).to_s, paged: true) } 38 | end 39 | 40 | command "list" do |args| 41 | lesspipe(search: "(#{args.join("|")})") do |less| 42 | installed_packages.sort.each do |pkg| 43 | less.puts pkg 44 | end 45 | end 46 | end 47 | 48 | 49 | #command "clean", "", root: true 50 | #command "verify", "" 51 | 52 | end 53 | -------------------------------------------------------------------------------- /lib/upm/tools/pkgin.rb: -------------------------------------------------------------------------------- 1 | UPM::Tool.new "pkgin" do 2 | 3 | os "MINIX", "NetBSD" 4 | 5 | command "install", "pkgin install", root: true 6 | command "update", "pkgin update", root: true 7 | command "upgrade", "pkgin upgrade", root: true 8 | command "info", "pkgin clean", root: true 9 | command "audit", "pkgin audit", root: true 10 | command "verify", "pkgin check --checksums", root: true 11 | 12 | command "files", "pkgin list", paged: true 13 | command "search", "pkgin search", paged: true, highlight: true 14 | command "search-sources" do |*args| 15 | query = args.join(" ") 16 | FreshportsSearch.new.search!(query) 17 | end 18 | 19 | command "log", "grep pkg: /var/log/messages", paged: true 20 | 21 | command "build" do |*args| 22 | # svn checkout --depth empty svn://svn.freebsd.org/ports/head /usr/ports 23 | # cd /usr/ports 24 | # svn update --set-depth files 25 | # svn update Mk 26 | # svn update Templates 27 | # svn update Tools 28 | # svn update --set-depth files $category 29 | # cd $category 30 | # svn update $port 31 | puts "Not implemented" 32 | end 33 | 34 | command "info", "pkg info", paged: true 35 | 36 | command "list" do |args| 37 | if args.any? 38 | query = args.join 39 | run "pkg", "info", grep: query, highlight: query, paged: true 40 | else 41 | run "pkg", "info", paged: true 42 | end 43 | end 44 | 45 | command "mirrors" do 46 | print_files("/etc/pkg/FreeBSD.conf", exclude: /^(#|$)/) 47 | end 48 | 49 | end 50 | -------------------------------------------------------------------------------- /lib/upm/log_parser.rb: -------------------------------------------------------------------------------- 1 | module UPM 2 | class LogParser 3 | 4 | def initialize(klass, log_glob) 5 | @klass = klass 6 | @log_glob = log_glob 7 | end 8 | 9 | def log_events 10 | return to_enum(:log_events) unless block_given? 11 | 12 | yielder = proc do |io| 13 | io.each_line do |line| 14 | if e = @klass.from_line(line.strip) 15 | yield e 16 | end 17 | end 18 | end 19 | 20 | logs = Dir[@log_glob].sort_by { |path| File.mtime(path) } 21 | 22 | logs.each do |log| 23 | if log =~ /\.gz$/ 24 | IO.popen(["zcat", log], &yielder) 25 | else 26 | open(log, &yielder) 27 | end 28 | end 29 | end 30 | 31 | def display 32 | lesspipe(tail: true) do |less| 33 | groups = log_events.split_between { |a,b| (b.date.to_i - a.date.to_i) > 60 } 34 | 35 | groups.each do |group| 36 | first, last = group.first.date, group.last.date 37 | elapsed = (last.to_i - first.to_i) / 60 38 | 39 | empty_group = true 40 | 41 | group.each do |ev| 42 | # Print the header only if the query matched something in this group 43 | if empty_group 44 | empty_group = false 45 | less.puts 46 | less.puts "<8>== <11>#{first.strftime("<10>%Y-%m-%d <7>at <2>%l:%M %p")} <7>(<9>#{elapsed} <7>minute session) <8>========".colorize 47 | end 48 | 49 | less.puts ev 50 | end 51 | end 52 | end # lesspipe 53 | end 54 | 55 | end # LogParser 56 | end -------------------------------------------------------------------------------- /lib/upm/tool_class_methods.rb: -------------------------------------------------------------------------------- 1 | module UPM 2 | class Tool 3 | class << self 4 | 5 | def error(message) 6 | $stderr.puts message 7 | exit 1 8 | end 9 | 10 | def tools; @@tools; end 11 | 12 | def register_tools! 13 | Dir["#{__dir__}/tools/*.rb"].each { |lib| require_relative(lib) } 14 | end 15 | 16 | def os_release 17 | @os_release ||= begin 18 | pairs = open("/etc/os-release") do |io| 19 | io.read.scan(/^(\w+)="?(.+?)"?$/) 20 | end 21 | Hash[pairs] 22 | rescue Errno::ENOENT 23 | nil 24 | end 25 | end 26 | 27 | def current_os_names 28 | # eg: ID=ubuntu, ID_LIKE=debian 29 | if os_release 30 | os_release.values_at("ID", "ID_LIKE").compact 31 | else 32 | # `uname -s` => Darwin|FreeBSD|OpenBSD 33 | # `uname -o` => Android|Cygwin 34 | names = [`uname -s`] 35 | names << `uname -o` unless names.first =~ /OpenBSD/ 36 | names.map(&:chomp).uniq 37 | end 38 | end 39 | 40 | def nice_os_name 41 | if os_release 42 | os_release.values_at("PRETTY_NAME", "NAME", "ID", "ID_LIKE").first 43 | else 44 | (`uname -o 2> /dev/null`.chomp rescue nil) 45 | end 46 | end 47 | 48 | def installed 49 | @@tools.select { |tool| File.which(tool.identifying_binary) } 50 | end 51 | 52 | def for_os(os_names=nil) 53 | os_names = os_names ? [os_names].flatten : current_os_names 54 | 55 | tool = nil 56 | 57 | if os_names.any? 58 | tool = @@tools.find { |name, tool| os_names.any? { |osname| tool.os&.include? osname } } 59 | end 60 | 61 | if tool.nil? 62 | tool = @@tools.find { |name, tool| File.which(tool.identifying_binary) } 63 | end 64 | 65 | tool&.last 66 | end 67 | 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/upm/tools/guix.rb: -------------------------------------------------------------------------------- 1 | class GUIXPackage < Struct.new(:name, :version, :out, :path) 2 | def self.from_line(line) 3 | new(*line.chomp.split) 4 | end 5 | 6 | def self.installed 7 | @installed_packages ||= IO.popen(["guix", "package", "--list-installed"]) { |io| io.each_line.map { |line| GUIXPackage.from_line(line) } } 8 | end 9 | 10 | def self.avaialble 11 | @available_packages ||= IO.popen(["guix", "package", "--list-available"]) { |io| io.each_line.map { |line| GUIXPackage.from_line(line) } } 12 | end 13 | 14 | def installed? 15 | !!path[%r{^/gnu/store/}] 16 | end 17 | end 18 | 19 | 20 | UPM::Tool.new "guix" do 21 | 22 | identifying_binary "guix" 23 | 24 | command "install", "guix install" 25 | command "remove", "guix remove" 26 | command "info", "guix show" 27 | command "rollback", "guix package –roll-back" 28 | command "selfupdate", "guix pull" 29 | command "upgrade", "guix package --upgrade" 30 | 31 | command "files", paged: true do |args| 32 | error "Ope, you forgot the package name!" if args.empty? 33 | 34 | args.each do |arg| 35 | if pkg = GUIXPackage.installed.find { |pkg| pkg.name == arg } 36 | run "find", pkg.path, paged: true 37 | else 38 | error "#{arg.inspect} not found" 39 | end 40 | end 41 | end 42 | 43 | command "search" do |args| 44 | query = args.join(" ") 45 | highlight_query = args.join(".+") 46 | grep_query = args.join(".+") 47 | run "guix", "package", "--list-available", sort: true, grep: grep_query, paged: true, highlight: highlight_query 48 | end 49 | 50 | command "list" do |args| 51 | if args.any? 52 | highlight_query = args.join(".+") 53 | grep_query = /#{highlight_query}/ 54 | run "guix", "package", "--list-installed", grep: grep_query, highlight: highlight_query, paged: true 55 | else 56 | run "guix", "package", "--list-installed", paged: true 57 | end 58 | end 59 | 60 | end 61 | -------------------------------------------------------------------------------- /lib/upm/tools/apt.rb: -------------------------------------------------------------------------------- 1 | UPM::Tool.new "apt" do 2 | 3 | os "debian", "ubuntu" 4 | 5 | command "install", "apt install", root: true 6 | command "update", "apt update", root: true 7 | 8 | command "upgrade" do |args| 9 | if call_command("update") 10 | run("apt", "upgrade", root: true) 11 | else 12 | puts "Error: couldn't 'apt update' package lists" 13 | end 14 | end 15 | 16 | command "remove", "apt remove", root: true 17 | 18 | command "files", "dpkg-query -L", paged: true 19 | command "search", "apt search", paged: true 20 | command "info", "apt show", paged: true 21 | 22 | command "list" do |args| 23 | if args.any? 24 | query = args.join 25 | run("dpkg", "-l", grep: query, paged: true) 26 | else 27 | run("dpkg", "-l", paged: true) 28 | end 29 | end 30 | 31 | command "mirrors" do 32 | print_files("/etc/apt/sources.list", *Dir["/etc/apt/sources.list.d/*"], exclude: /^(#|$)/) 33 | end 34 | 35 | command "log" do 36 | UPM::LogParser.new(DpkgEvent, "/var/log/dpkg*").display 37 | end 38 | 39 | class DpkgEvent < Struct.new(:datestr, :date, :cmd, :name, :v1, :v2) 40 | 41 | # 2010-12-03 01:36:56 remove gir1.0-mutter-2.31 2.31.5-0ubuntu9 2.31.5-0ubuntu9 42 | # 2010-12-03 01:36:58 install gir1.0-mutter-2.91 2.91.2+git20101114.982a10ac-0ubuntu1~11.04~ricotz0 43 | #LINE_RE = /^(.+ .+) (status \w+|\w+) (.+) (.+)$/ 44 | LINE_RE = /^(.+ .+) (remove|install|upgrade) (.+) (.+) (.+)$/ 45 | 46 | CMD_COLORS = { 47 | 'remove' => :light_red, 48 | 'install' => :light_yellow, 49 | 'upgrade' => :light_green, 50 | nil => :white, 51 | } 52 | 53 | def self.parse_date(date) 54 | DateTime.strptime(date, "%Y-%m-%d %H:%M:%S") 55 | end 56 | 57 | def self.from_line(line) 58 | if line =~ LINE_RE 59 | new($1, parse_date($1), $2, $3, $4, $5) 60 | else 61 | nil 62 | end 63 | end 64 | 65 | def cmd_color 66 | CMD_COLORS[cmd] 67 | end 68 | 69 | def to_s 70 | date, time = datestr.split 71 | "[#{date} #{time}] <#{cmd_color}>#{cmd} #{name} #{v2} (#{v1})".colorize 72 | end 73 | 74 | end 75 | 76 | end 77 | -------------------------------------------------------------------------------- /lib/upm/tools/xbps.rb: -------------------------------------------------------------------------------- 1 | UPM::Tool.new "xbps" do 2 | 3 | os "void" 4 | 5 | identifying_binary "xbps-install" 6 | 7 | command "install", "xbps-install", root: true 8 | command "remove", "xbps-remove", root: true 9 | command "update", "xbps-install -S", root: true 10 | command "upgrade", "xbps-install -Su", root: true 11 | command "files", "xbps-query -f", paged: true 12 | command "locate", "xlocate", paged: true 13 | command "selection", "xbps-query -m", paged: true 14 | command "rdeps", "xbps-query -R -X", paged: true 15 | command "orphans", "xbps-query -O", paged: true 16 | command "repos", "xbps-query -L", paged: true 17 | 18 | command "info" do |args| 19 | args.each do |arg| 20 | unless run("xbps-query", "-S", arg) 21 | run("xbps-query", "-R", "-S", arg) 22 | end 23 | puts 24 | end 25 | end 26 | 27 | command "search" do |args| 28 | query = args.join(".*") 29 | run "xbps-query", "--regex", "-Rs", query, highlight: /(#{ args.join("|") })/i, paged: true 30 | end 31 | 32 | command "list" do |args| 33 | if args.any? 34 | query = args.join 35 | run "xbps-query", "-l", grep: query, paged: true 36 | else 37 | run "xbps-query", "-l", paged: true 38 | end 39 | end 40 | 41 | 42 | 43 | class XBPSPackage < Struct.new(:name, :version, :date) 44 | def self.from_line(line) 45 | # zd1211-firmware-1.5_3: 2021-09-01 15:22 UTC 46 | if line =~ /^([\w\-]+)-([\d\.]+_\d+): (.+)$/ 47 | name, version, date = $1, $2, $3 48 | date = DateTime.parse($3) 49 | new(name, version, date) 50 | else 51 | nil 52 | end 53 | end 54 | 55 | def to_s 56 | "[#{date.strftime("%Y-%m-%d %H:%M:%S")}] #{name} #{version}" 57 | end 58 | end 59 | 60 | command "log" do |args| 61 | fakedata = %{ 62 | xset-1.2.4_1: 2021-11-05 15:03 UTC 63 | xsetroot-1.1.2_1: 2021-11-05 15:03 UTC 64 | xtools-0.63_1: 2021-11-05 16:36 UTC 65 | xtrans-1.4.0_2: 2021-11-05 15:26 UTC 66 | xvidcore-1.3.7_1: 2021-11-05 16:45 UTC 67 | xvinfo-1.1.4_2: 2021-11-05 15:03 UTC 68 | xwd-1.0.8_1: 2021-11-05 15:03 UTC 69 | xwininfo-1.1.5_1: 2021-11-05 15:03 UTC 70 | xwud-1.0.5_1: 2021-11-05 15:03 UTC 71 | xz-5.2.5_2: 2021-11-05 15:12 UTC 72 | zd1211-firmware-1.5_3: 2021-09-01 15:22 UTC 73 | zip-3.0_6: 2021-11-05 17:45 UTC 74 | zlib-1.2.11_4: 2021-09-01 14:14 UTC 75 | zlib-devel-1.2.11_4: 2021-11-05 15:26 UTC 76 | } 77 | 78 | data = IO.popen(["xbps-query", "-p", "install-date", "-s", ""], &:read) 79 | packages = data.each_line.map do |line| 80 | XBPSPackage.from_line(line.strip) 81 | end.compact 82 | packages.sort_by!(&:date) 83 | lesspipe(tail: true) { |less| packages.each { |pkg| less.puts pkg } } 84 | end 85 | end 86 | 87 | -------------------------------------------------------------------------------- /lib/upm/lesspipe.rb: -------------------------------------------------------------------------------- 1 | # 2 | # Create scrollable output via less! 3 | # 4 | # This command runs `less` in a subprocess, and gives you the IO to its STDIN pipe 5 | # so that you can communicate with it. 6 | # 7 | # Example: 8 | # 9 | # lesspipe do |less| 10 | # 50.times { less.puts "Hi mom!" } 11 | # end 12 | # 13 | # The default less parameters are: 14 | # * Allow colour 15 | # * Don't wrap lines longer than the screen 16 | # * Quit immediately (without paging) if there's less than one screen of text. 17 | # 18 | # You can change these options by passing a hash to `lesspipe`, like so: 19 | # 20 | # lesspipe(:wrap=>false) { |less| less.puts essay.to_s } 21 | # 22 | # It accepts the following boolean options: 23 | # :color => Allow ANSI colour codes? 24 | # :wrap => Wrap long lines? 25 | # :always => Always page, even if there's less than one page of text? 26 | # :tail => Seek to the end of the stream 27 | # :search => searches the output using the "/" operator 28 | # 29 | def lesspipe(*args) 30 | if args.any? and args.last.is_a?(Hash) 31 | options = args.pop 32 | else 33 | options = {} 34 | end 35 | 36 | output = args.first if args.any? 37 | 38 | # Don't page, just output to STDOUT 39 | if options[:disabled] 40 | if output 41 | puts output 42 | else 43 | yield STDOUT 44 | end 45 | return 46 | end 47 | 48 | params = [] 49 | 50 | less_bin = File.which("less") 51 | 52 | if File.symlink?(less_bin) and File.readlink(less_bin)[/busybox$/] 53 | # busybox less only supports one option! 54 | params << "-S" unless options[:wrap] == true 55 | else 56 | # authentic less 57 | params << "-R" unless options[:color] == false 58 | params << "-S" unless options[:wrap] == true 59 | params << "-F" unless options[:always] == true 60 | params << "-X" 61 | params << "-I" 62 | 63 | if regexp = options[:search] 64 | params << "+/#{regexp}" 65 | elsif options[:tail] == true 66 | params << "+\\>" 67 | $stderr.puts "Seeking to end of stream..." 68 | end 69 | end 70 | 71 | env = { 72 | "LESS_TERMCAP_mb" => "\e[01;31m", 73 | "LESS_TERMCAP_md" => "\e[01;37m", 74 | "LESS_TERMCAP_me" => "\e[0m", 75 | "LESS_TERMCAP_se" => "\e[0m", 76 | # "LESS_TERMCAP_so" => "\e[30;44m", # highlight: black on blue 77 | "LESS_TERMCAP_so" => "\e[01;44;33m", # highlight: bright yellow on blue 78 | # "LESS_TERMCAP_so" => "\e[30;43m", # highlight: black on yellow 79 | "LESS_TERMCAP_ue" => "\e[0m", 80 | "LESS_TERMCAP_us" => "\e[01;32m", 81 | } 82 | 83 | IO.popen(env, [less_bin, *params], "w") do |less| 84 | # less.puts params.inspect 85 | if output 86 | less.puts output 87 | else 88 | yield less 89 | end 90 | end 91 | 92 | rescue Errno::EPIPE, Interrupt 93 | # less just quit -- eat the exception. 94 | end 95 | 96 | -------------------------------------------------------------------------------- /lib/upm/tools/pkg.rb: -------------------------------------------------------------------------------- 1 | UPM::Tool.new "pkg" do 2 | 3 | os "FreeBSD" 4 | 5 | command "update", root: true do 6 | if database_needs_updating? 7 | run "pkg", "update" 8 | database_updated! 9 | else 10 | puts "Database has already been updated recently. Skipping." 11 | end 12 | end 13 | 14 | command "install", root: true do |args| 15 | call_command "update" 16 | run "pkg", "install", "--no-repo-update", *args 17 | end 18 | 19 | command "upgrade", root: true do 20 | call_command "update" 21 | run "pkg", "upgrade", "--no-repo-update" 22 | end 23 | 24 | command "remove", "pkg remove", root: true 25 | command "audit", "pkg audit", root: true 26 | command "clean", "pkg clean -a",root: true 27 | command "verify", "pkg check --checksums", root: true 28 | command "which", "pkg which" 29 | 30 | command "files" do |args| 31 | if args.empty? 32 | run "pkg", "info", "--list-files", "--all", paged: true 33 | else 34 | run "pkg", "list", *args, paged: true 35 | end 36 | end 37 | 38 | # the "pkg-provides" plugin is similar to arch's "pkgfile" (requires updates), and needs to be added to the plugins section of pkg's config ("pkg plugins" shows loaded plugins) 39 | command "provides" do |args| 40 | run "pkg", "info", "--list-files", "--all", grep: /#{args.join(".+")}/, highlight: true 41 | end 42 | 43 | command "search", "pkg search", paged: true, highlight: true 44 | command "search-sources" do |*args| 45 | require 'upm/freshports_search' 46 | query = args.join(" ") 47 | FreshportsSearch.new.search!(query) 48 | end 49 | 50 | # command "log", "grep -E 'pkg.+installed' /var/log/messages", paged: true 51 | command "log" do 52 | require 'upm/core_ext/file' 53 | lesspipe do |less| 54 | open("/var/log/messages").reverse_each_line do |line| 55 | # Jan 19 18:25:21 freebsd pkg[815]: pcre-8.43_2 installed 56 | # Apr 1 16:55:58 freebsd pkg[73957]: irssi-1.2.2,1 installed 57 | if line =~ /^(\S+\s+\S+\s+\S+) (\S+) pkg(?:\[\d+\])?: (\S+)-(\S+) installed/ 58 | timestamp = DateTime.parse($1) 59 | host = $2 60 | pkgname = $3 61 | pkgver = $4 62 | less.puts "#{timestamp} | #{pkgname} #{pkgver}" 63 | end 64 | end 65 | end 66 | end 67 | 68 | command "build" do |*args| 69 | # svn checkout --depth empty svn://svn.freebsd.org/ports/head /usr/ports 70 | # cd /usr/ports 71 | # svn update --set-depth files 72 | # svn update Mk 73 | # svn update Templates 74 | # svn update Tools 75 | # svn update --set-depth files $category 76 | # cd $category 77 | # svn update $port 78 | puts "Not implemented" 79 | end 80 | 81 | command "info", "pkg info", paged: true 82 | 83 | command "list" do |args| 84 | if args.any? 85 | query = args.join 86 | run "pkg", "info", grep: query, highlight: query, paged: true 87 | else 88 | run "pkg", "info", paged: true 89 | end 90 | end 91 | 92 | command "mirrors" do 93 | print_files("/etc/pkg/FreeBSD.conf", exclude: /^(#|$)/) 94 | end 95 | 96 | # pkg clean # cleans /var/cache/pkg/ 97 | # rm -rf /var/cache/pkg/* # just remove it all 98 | # pkg update -f # forces update of repository catalog 99 | # rm /var/db/pkg/repo-*.sqlite # removes all remote repository catalogs 100 | # pkg bootstrap -f # forces reinstall of pkg 101 | 102 | end 103 | -------------------------------------------------------------------------------- /lib/upm/tool.rb: -------------------------------------------------------------------------------- 1 | require 'upm/tool_dsl' 2 | require 'upm/tool_class_methods' 3 | 4 | # os: -- automatically select the package manager for the current unix distribution 5 | # deb: (or d: u:) 6 | # rpm: (or yum: y:) 7 | # bsd: (or b:) 8 | # ruby: (or r: gem:) 9 | # python:, (or py: p: pip:) 10 | 11 | module UPM 12 | 13 | class Tool 14 | 15 | @@tools = {} 16 | 17 | include UPM::Tool::DSL 18 | 19 | # TODO: Show unlisted commands 20 | 21 | COMMAND_HELP = { 22 | "install" => "install a package", 23 | "remove/uninstall" => "remove a package", 24 | "search" => "search packages", 25 | "update/sync" => "retrieve the latest package list or manifest", 26 | "upgrade" => "update package list and install updates", 27 | "search-sources" => "search package source (for use with 'build' command)", 28 | "list" => "list installed packages (or search their names if extra arguments are supplied)", 29 | "files" => "list files in a package", 30 | "info/show" => "show metadata about a package", 31 | "rdeps/depends" => "reverse dependencies (which packages depend on this package?)", 32 | "locate" => "search contents of packages (local or remote)", 33 | "selfupdate" => "update the package manager", 34 | "download" => "download package list and updates, but don't insatall them", 35 | "build" => "build a package from source and install it", 36 | "selection/manual" => "list manually installed packages", # this should probably be a `list` option ("upm list --manually-added" or smth (would be nice: rewrite in go and use ipfs' arg parsing library)) 37 | "pin" => "pinning a package means it won't be automatically upgraded", 38 | "rollback" => "revert to an earlier version of a package (including its dependencies)", 39 | "verify/check" => "verify the integrity of packages' files on the filesystem", 40 | "repair" => "fix corrupted packages", 41 | "audit/vulns" => "show known vulnerabilities in installed packages", 42 | "log" => "show history of package installs", 43 | "packagers" => "detect installed package managers, and pick which ones upm should wrap", 44 | "clean" => "clear out the local package cache", 45 | "orphans" => "dependencies which are no longer needed", 46 | "monitor" => "ad-hoc package manager for custom installations (like instmon)", 47 | "keys" => "keyrings and package authentication", 48 | "default" => "configure the action to take when no arguments are passed to 'upm' (defaults to 'os:update')", 49 | "stats" => "show statistics about package database(s)", 50 | "rosetta" => "show a table translations between all upm commands and equivalent the package manager commands", 51 | "repos/mirrors/sources/channels" => "manage subscriptions to remote repositories/mirrors/channels", 52 | } 53 | 54 | ALIASES = { 55 | "u" => "upgrade", 56 | "i" => "install", 57 | "d" => "download", 58 | "s" => "search", 59 | "f" => "files", 60 | "r" => "remove", 61 | "m" => "mirrors", 62 | "file" => "files", 63 | "vuln" => "audit", 64 | "source-search" => "search-sources", 65 | } 66 | 67 | COMMAND_HELP.keys.each do |key| 68 | cmd, *alts = key.split("/") 69 | alts.each do |alt| 70 | ALIASES[alt] = cmd 71 | end 72 | end 73 | 74 | def initialize(name, &block) 75 | @name = name 76 | 77 | set_default :cache_dir, "~/.cache/upm" 78 | set_default :config_dir, "~/.cache/upm" 79 | set_default :max_database_age, 15*60 # 15 minutes 80 | 81 | instance_eval(&block) 82 | 83 | @@tools[name] = self 84 | end 85 | 86 | end # class Tool 87 | 88 | end # module UPM 89 | 90 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | # TODO 2 | 3 | ## UI 4 | * fzf/pick 5 | * scrapers for web-based search engines (especially for "files in packages") 6 | 7 | ## Options 8 | * Proper option/command parser 9 | * Verbose mode (prints `run` commands) 10 | 11 | ## DSL 12 | * Call commands from within other commands, or specify dependencies (eg: command "install", "pkg install", depends: "update" ) 13 | * DSL setting defaults (eg: cache_dir "\~/.cache/upm") 14 | * Some commands require special packages (eg: "command 'locate', depends: 'pkgfile'") which have their own syncable databases 15 | |_ offer to install these dependencies (and sync them (periodically)) 16 | |_ web-based search is even nicer 17 | * Page multi-commands (eg: 'args.each { run ..., paged: true }' should all output to the same pager) 18 | |_ 'run ..., pager: IO'? will that break grep? 19 | 20 | ## Performance 21 | * RPi2 is very clunky 22 | |_ rewrite in... C? rust? go? lua? 23 | 24 | ## A language for letting programs specify and auto-install dependencies 25 | * A DSL for specifying dependencies (eg: `upm py:spotdl rb:epitools pkg:wget docker:aria2c`) 26 | * Language bindings (Ruby, Python, etc.) 27 | * Allow the CLI tool to do the same (for bash and unsupported languages) 28 | 29 | ## Custom help for command 30 | eg: command "something", help: "does stuff", root: true do ... end 31 | 32 | ## Pipes and filters 33 | * `Tool::DSL#run` is currently somewhat awkward; it would be simpler if returned an `Enumerator`, which could then be filtered (ie: highlight/grep), or concatenated to other `Enumerator`s. 34 | 35 | ## Streaming pipes with colours 36 | * Make the `run` command able to grep the output while streaming the results to the screen. 37 | * Make run pretend to be a tty, so I don't need `--color=always`. 38 | * Use spawn, like so: 39 | ``` 40 | r,w = IO.pipe 41 | spawn(*%w[echo hello world], out: w) 42 | spawn(*%w[tr a-z A-Z], in: r) 43 | ``` 44 | 45 | ## More package managers 46 | Currently missing: 47 | * RedHat/Fedora/CentOS 48 | * OSX 49 | * FreeBSD 50 | * OpenBSD 51 | * SuSE 52 | 53 | ## Dependency-fetching features 54 | * Use upm to fetch dependencies for any library or script, across any language. 55 | * Some kind of manifest file (or personifest, to be politically correct) 56 | * This is a little like bundler, npm, etc., but for any type of package. 57 | 58 | ## Ability to install any package from any OS to the user's home directory 59 | Slurps up the packages and their dependencies, then unpacks them into ~/.upm/{bin,lib} or something. 60 | (Like nix?) 61 | 62 | Related tool: intoli/exodus 63 | 64 | ## fzf 65 | Use fzf for "list" output (or other commands that require selecting, like "remove") 66 | 67 | ## Commandline argument parser 68 | * Add options to commands: 69 | * upm upgrade --download-only 70 | * upm install --help 71 | * upm help install 72 | 73 | ## Figure out how to integrate language package managers 74 | * The packages that you can get through gem/pip/luarocks/etc. are often duplicated in the OS-level package managers. Should there be a preference? 75 | * Should the search command show matches from all available package tools? (There could be a configure step where the user says which package managers should be included, and which have preference) 76 | * Possibilites: 77 | * upm install --ruby 78 | * upm install ruby:, 79 | * upm --ruby search 80 | * upm ruby:search 81 | * upm search os: 82 | * Separate tool: `lpm search ` searches only language packages 83 | * Add detectors for language-specific package-managers 84 | * Help screen needs to display language-specific package managers 85 | * `upm help --ruby` should show available ruby commands 86 | 87 | ## Give identical output on every platform 88 | * Requires parsing the output of every command into a canonical format, or reading the package databases directly. 89 | 90 | ## apt: Integrate 'acs' wrapper script 91 | 92 | ## Evaluate UPM::Tool.new block in an instance of an anonymous subclass of UPM::Tool 93 | This will allow tools to create classes and modules inside the UPM::Tool block without namespace collisions 94 | 95 | ## Themes? 96 | Why not! 97 | 98 | ## Mirror Selector 99 | Do a ping test on available mirrors, and use fzf to select. 100 | 101 | ## Interrupt catcher 102 | Don't print backtrace when ^C is pressed. 103 | 104 | ## Tests 105 | Create fake OS environments that you can chroot into and run upm to test it out. 106 | 107 | 108 | # DONE 109 | 110 | ## Abbrev cmds 111 | * eg: upm install => upm i => u i 112 | 113 | -------------------------------------------------------------------------------- /lib/upm/pacman_verifier.rb: -------------------------------------------------------------------------------- 1 | require 'zlib' 2 | require 'digest/sha2' 3 | require 'digest/md5' 4 | require 'pp' 5 | 6 | module UPM 7 | class PacmanVerifier 8 | 9 | SKIP_FILES = %w[ 10 | /.BUILDINFO 11 | /.INSTALL 12 | /.PKGINFO 13 | /.CHANGELOG 14 | ] 15 | 16 | PACKAGE_ROOT = "/var/lib/pacman/local/" 17 | 18 | def compare(key, a, b) 19 | a == b ? nil : [key, a, b] 20 | end 21 | 22 | def verify!(*included) 23 | $stderr.puts "Checking integrity of #{included.any? ? included.size : "installed"} packages..." 24 | 25 | report = [] 26 | 27 | Dir.entries(PACKAGE_ROOT).each do |package_dir| 28 | mtree_path = File.join(PACKAGE_ROOT, package_dir, "mtree") 29 | next unless File.exists?(mtree_path) 30 | 31 | chunks = package_dir.split("-") 32 | version = chunks[-2..-1].join("-") 33 | package = chunks[0...-2].join("-") 34 | 35 | if included.any? 36 | next if not included.include?(package) 37 | end 38 | 39 | puts "<8>[<7>+<8>] <10>#{package} <2>#{version}".colorize 40 | 41 | result = [] 42 | defaults = {} 43 | 44 | Zlib::GzipReader.open(mtree_path) do |io| 45 | lines = io.each_line.drop(1) 46 | 47 | lines.each do |line| 48 | path, *expected = line.split 49 | expected = expected.map { |opt| opt.split("=") }.to_h 50 | 51 | if path == "/set" 52 | defaults = expected 53 | next 54 | end 55 | 56 | path = path[1..-1] if path[0] == "." 57 | path = path.gsub(/\\(\d{3})/) { |m| $1.to_i(8).chr } # unescape \### codes 58 | 59 | # next if expected["type"] == "dir" 60 | next if SKIP_FILES.include?(path) 61 | 62 | expected = defaults.merge(expected) 63 | lstat = File.lstat(path) 64 | 65 | errors = expected.map do |key, val| 66 | case key 67 | when "type" 68 | compare("type", lstat.ftype[0...val.size], val) 69 | when "link" 70 | next if val == "/dev/null" 71 | compare("link", File.readlink(path), val) 72 | when "gid" 73 | compare("gid", lstat.gid, val.to_i) 74 | when "uid" 75 | compare("uid", lstat.uid, val.to_i) 76 | when "mode" 77 | compare("mode", "%o" % (lstat.mode & 0xFFF), val) 78 | when "size" 79 | compare("size", lstat.size, val.to_i) 80 | when "time" 81 | next if expected["type"] == "dir" 82 | next if expected["link"] == "/dev/null" 83 | compare("time", lstat.mtime.to_i, val.to_i) 84 | when "sha256digest" 85 | compare("sha256digest", Digest::SHA256.file(path).hexdigest, val) 86 | when "md5digest" 87 | next if expected["sha256digest"] 88 | compare("md5digest", Digest::MD5.file(path).hexdigest, val) 89 | else 90 | raise "Unknown key: #{key}=#{val}" 91 | end 92 | end.compact 93 | 94 | if errors.any? 95 | puts " <4>[<12>*<4>] <11>#{path}".colorize 96 | errors.each do |key, a, e| # a=actual, e=expected 97 | puts " <7>expected <14>#{key} <7>to be <2>#{e} <7>but was <4>#{a}".colorize 98 | result << [path, "expected #{key.inspect} to be #{e.inspect} but was #{a.inspect}"] 99 | end 100 | end 101 | rescue Errno::EACCES 102 | puts " <1>[<9>!<1>] <11>Can't read <7>#{path} <8>(<9>permission denied<8>)".colorize 103 | result << [path, "permission denied"] 104 | rescue Errno::ENOENT 105 | puts " <4>[<12>?<4>] <12>Missing file <15>#{path}".colorize 106 | result << [path, "missing"] 107 | end 108 | end # gzip 109 | 110 | report << [package, result] if result.any? 111 | end # mtree 112 | 113 | puts 114 | puts "#{report.size} packages with errors (#{report.map { |result| result.size }.sum} errors total)" 115 | puts 116 | 117 | if report.any? 118 | puts "Packages with problems:" 119 | report.each do |package, errors| 120 | puts " #{package} (#{errors.size} errors)" 121 | end 122 | end 123 | end # verify! 124 | 125 | end # PacmanAuditor 126 | end # UPM 127 | -------------------------------------------------------------------------------- /lib/upm/core_ext/file.rb: -------------------------------------------------------------------------------- 1 | # 2 | # Beginning of File reached! (Raised when reading a file backwards.) 3 | # 4 | class BOFError < Exception; end 5 | 6 | class File 7 | 8 | # 9 | # A streaming `reverse_each` implementation. (For large files, it's faster and uses less memory.) 10 | # 11 | def reverse_each(&block) 12 | return to_enum(:reverse_each) unless block_given? 13 | 14 | seek_end 15 | reverse_each_from_current_pos(&block) 16 | end 17 | alias_method :reverse_each_line, :reverse_each 18 | 19 | # 20 | # Read the previous `length` bytes. After the read, `pos` will be at the beginning of the region that you just read. 21 | # Returns `nil` when the beginning of the file is reached. 22 | # 23 | # If the `block_aligned` argument is `true`, reads will always be aligned to file positions which are multiples of 512 bytes. 24 | # (This should increase performance slightly.) 25 | # 26 | def reverse_read(length, block_aligned=false) 27 | raise "length must be a multiple of 512" if block_aligned and length % 512 != 0 28 | 29 | end_pos = pos 30 | return nil if end_pos == 0 31 | 32 | if block_aligned 33 | misalignment = end_pos % length 34 | length += misalignment 35 | end 36 | 37 | if length >= end_pos # this read will take us to the beginning of the file 38 | seek(0) 39 | else 40 | seek(-length, IO::SEEK_CUR) 41 | end 42 | 43 | start_pos = pos 44 | data = read(end_pos - start_pos) 45 | seek(start_pos) 46 | 47 | data 48 | end 49 | 50 | # 51 | # Read each line of file backwards (from the current position.) 52 | # 53 | def reverse_each_from_current_pos 54 | return to_enum(:reverse_each_from_current_pos) unless block_given? 55 | 56 | # read the rest of the current line, in case we started in the middle of a line 57 | start_pos = pos 58 | fragment = readline rescue "" 59 | seek(start_pos) 60 | 61 | while data = reverse_read(4096) 62 | lines = data.each_line.to_a 63 | lines.last << fragment unless lines.last[-1] == "\n" 64 | 65 | fragment = lines.first 66 | 67 | lines[1..-1].reverse_each { |line| yield line } 68 | end 69 | 70 | yield fragment 71 | end 72 | 73 | # 74 | # Seek to `EOF` 75 | # 76 | def seek_end 77 | seek(0, IO::SEEK_END) 78 | end 79 | 80 | # 81 | # Seek to `BOF` 82 | # 83 | def seek_start 84 | seek(0) 85 | end 86 | 87 | # 88 | # Read the previous line (leaving `pos` at the beginning of the string that was read.) 89 | # 90 | def reverse_readline 91 | raise BOFError.new("beginning of file reached") if pos == 0 92 | 93 | seek_backwards_to("\n", 512, -2) 94 | new_pos = pos 95 | data = readline 96 | seek(new_pos) 97 | data 98 | end 99 | 100 | # 101 | # Scan through the file until `string` is found, and set the IO's +pos+ to the first character of the matched string. 102 | # 103 | def seek_to(string, blocksize=512) 104 | raise "Error: blocksize must be at least as large as the string" if blocksize < string.size 105 | 106 | loop do 107 | data = read(blocksize) 108 | 109 | if index = data.index(string) 110 | seek(-(data.size - index), IO::SEEK_CUR) 111 | break 112 | elsif eof? 113 | return nil 114 | else 115 | seek(-(string.size - 1), IO::SEEK_CUR) 116 | end 117 | end 118 | 119 | pos 120 | end 121 | 122 | # 123 | # Scan backwards in the file until `string` is found, and set the IO's +pos+ to the first character after the matched string. 124 | # 125 | def seek_backwards_to(string, blocksize=512, rindex_end=-1) 126 | raise "Error: blocksize must be at least as large as the string" if blocksize < string.size 127 | 128 | loop do 129 | data = reverse_read(blocksize) 130 | 131 | if index = data.rindex(string, rindex_end) 132 | seek(index+string.size, IO::SEEK_CUR) 133 | break 134 | elsif pos == 0 135 | return nil 136 | else 137 | seek(string.size - 1, IO::SEEK_CUR) 138 | end 139 | end 140 | 141 | pos 142 | end 143 | alias_method :reverse_seek_to, :seek_backwards_to 144 | 145 | # 146 | # Iterate over each line of the file, yielding the line and the byte offset of the start of the line in the file 147 | # 148 | def each_line_with_offset 149 | return to_enum(:each_line_with_offset) unless block_given? 150 | 151 | offset = 0 152 | 153 | each_line do |line| 154 | yield line, offset 155 | offset = tell 156 | end 157 | end 158 | 159 | end 160 | -------------------------------------------------------------------------------- /lib/upm/tools/pacman.rb: -------------------------------------------------------------------------------- 1 | UPM::Tool.new "pacman" do 2 | os "arch" 3 | 4 | bin = ["pacman", "--color=always"] 5 | 6 | command "install", [*bin, "-S"], root: true 7 | command "update", [*bin, "-Sy"], root: true 8 | command "upgrade", [*bin, "-Syu", "--noconfirm"], root: true 9 | command "remove", [*bin, "-R"], root: true 10 | 11 | command "verify", root: true do |args| 12 | require 'upm/pacman_verifier' 13 | UPM::PacmanVerifier.new.verify!(*args) 14 | end 15 | 16 | command "audit", "arch-audit", paged: true 17 | command "files", [*bin, "-Ql"], paged: true 18 | command "search", [*bin, "-Ss"], paged: true, highlight: true 19 | command "locate", ["pkgfile", "-r"], paged: true 20 | 21 | command "info" do |args| 22 | run(*bin, "-Qi", *args, paged: true) || run(*bin, "-Si", *args, paged: true) 23 | end 24 | 25 | command "list" do |args| 26 | if args.any? 27 | query = args.join 28 | run(*bin, "-Q", grep: query, highlight: query, paged: true) 29 | else 30 | run(*bin, "-Q", paged: true) 31 | end 32 | end 33 | 34 | command "mirrors" do 35 | print_files("/etc/pacman.d/mirrorlist", exclude: /^(#|$)/) 36 | print_files("/etc/pacman.conf", include: /^Server\s*=/, exclude: /^(#|$)/) 37 | end 38 | 39 | command "depends" do |args| 40 | packages_that_depend_on = proc do |package| 41 | result = [] 42 | 43 | [`pacman -Sii #{package}`, `pacman -Qi #{package}`].each do |output| 44 | output.each_line do |l| 45 | if l =~ /Required By\s+: (.+)/ 46 | result += $1.strip.split unless $1["None"] 47 | break 48 | end 49 | end 50 | end 51 | 52 | result 53 | end 54 | 55 | args.each do |arg| 56 | puts "=== Packages which depend on: #{arg} ============" 57 | packages = packages_that_depend_on.call(arg) 58 | puts 59 | run *bin, "-Ss", "^(#{packages.join '|'})$" # upstream packages 60 | run *bin, "-Qs", "^(#{packages.join '|'})$" # packages that are only installed locally 61 | puts 62 | end 63 | end 64 | 65 | command "log" do 66 | UPM::LogParser.new(PacmanEvent, "/var/log/pacman.log*").display 67 | end 68 | 69 | class PacmanEvent < Struct.new(:datestr, :date, :cmd, :name, :v1, :v2) 70 | 71 | # [2015-01-04 04:21] [PACMAN] installed lib32-libidn (1.29-1) 72 | # [2015-01-04 04:21] [PACMAN] upgraded lib32-curl (7.38.0-1 -> 7.39.0-1) 73 | # [2015-01-07 04:39] [ALPM] upgraded intel-tbb (4.3_20141023-1 -> 4.3_20141204-1) 74 | # [2015-01-07 04:39] [ALPM] upgraded iso-codes (3.54-1 -> 3.57-1) 75 | 76 | DATE_RE = /[\d:-]+/ 77 | LINE_RE = /^\[(#{DATE_RE} #{DATE_RE})\](?: \[(?:PACMAN|ALPM)\])? (removed|installed|upgraded) (.+) \((.+)(?: -> (.+))?\)$/ 78 | 79 | CMD_COLORS = { 80 | 'removed' => :light_red, 81 | 'installed' => :light_yellow, 82 | 'upgraded' => :light_green, 83 | nil => :white, 84 | } 85 | 86 | def self.parse_date(date) 87 | DateTime.strptime(date, "%Y-%m-%d %H:%M") 88 | end 89 | 90 | def self.from_line(line) 91 | if line =~ LINE_RE 92 | new($1, parse_date($1), $2, $3, $4, $5) 93 | else 94 | nil 95 | end 96 | end 97 | 98 | def cmd_color 99 | CMD_COLORS[cmd] 100 | end 101 | 102 | def to_s 103 | date, time = datestr.split 104 | "[#{date} #{time}] <#{cmd_color}>#{cmd} #{name} #{"#{v2} " if v2}(#{v1})".colorize 105 | end 106 | 107 | end 108 | 109 | command "rdeps" do |args| 110 | packages_that_depend_on = proc do |package| 111 | result = [] 112 | 113 | # [`pacman -Sii #{package}`, `pacman -Qi #{package}`].each do |output| 114 | [`pacman -Sii #{package}`].each do |output| 115 | output.each_line do |l| 116 | if l =~ /Required By\s+: (.+)/ 117 | result += $1.strip.split unless $1["None"] 118 | break 119 | end 120 | end 121 | end 122 | 123 | result 124 | end 125 | 126 | args.each do |package| 127 | puts "<8>=== <14>#{package} <8>============".colorize 128 | puts 129 | 130 | packages = packages_that_depend_on.call package 131 | 132 | if packages.empty? 133 | puts " <12>None".colorize 134 | else 135 | run "pacman", "-Ss", "^(#{packages.join '|'})$" # upstream packages 136 | run "pacman", "-Qs", "^(#{packages.join '|'})$" # packages that are only installed locally 137 | end 138 | 139 | puts 140 | end 141 | end 142 | end 143 | 144 | 145 | -------------------------------------------------------------------------------- /lib/upm/core_ext.rb: -------------------------------------------------------------------------------- 1 | require 'date' 2 | 3 | class DateTime 4 | def to_i; to_time.to_i; end 5 | end 6 | 7 | class File 8 | 9 | # 10 | # Overly clever which(), which returns an array if more than one argument was supplied, 11 | # or string/nil if only one argument was supplied. 12 | # 13 | def self.which(*bins) 14 | results = [] 15 | bins = bins.flatten 16 | paths = ENV["PATH"].split(":").map { |path| File.realpath(path) rescue nil }.compact.uniq 17 | 18 | paths.each do |dir| 19 | bins.each do |bin| 20 | 21 | full_path = File.join(dir, bin) 22 | 23 | if File.exists?(full_path) 24 | if bins.size == 1 25 | return full_path 26 | else 27 | results << full_path 28 | end 29 | end 30 | 31 | end 32 | end 33 | 34 | bins.size == 1 ? nil : results 35 | end 36 | 37 | def self.which_is_best?(*bins) 38 | result = which(*bins.flatten) 39 | result.is_a?(Array) ? result.first : result 40 | end 41 | end 42 | 43 | module Enumerable 44 | # 45 | # Split this enumerable into chunks, given some boundary condition. (Returns an array of arrays.) 46 | # 47 | # Options: 48 | # :include_boundary => true #=> include the element that you're splitting at in the results 49 | # (default: false) 50 | # :after => true #=> split after the matched element (only has an effect when used with :include_boundary) 51 | # (default: false) 52 | # :once => flase #=> only perform one split (default: false) 53 | # 54 | # Examples: 55 | # [1,2,3,4,5].split{ |e| e == 3 } 56 | # #=> [ [1,2], [4,5] ] 57 | # 58 | # "hello\n\nthere\n".each_line.split_at("\n").to_a 59 | # #=> [ ["hello\n"], ["there\n"] ] 60 | # 61 | # [1,2,3,4,5].split(:include_boundary=>true) { |e| e == 3 } 62 | # #=> [ [1,2], [3,4,5] ] 63 | # 64 | # chapters = File.read("ebook.txt").split(/Chapter \d+/, :include_boundary=>true) 65 | # #=> [ ["Chapter 1", ...], ["Chapter 2", ...], etc. ] 66 | # 67 | def split_at(matcher=nil, options={}, &block) 68 | include_boundary = options[:include_boundary] || false 69 | 70 | if matcher.nil? 71 | boundary_test_proc = block 72 | else 73 | if matcher.is_a? Regexp 74 | boundary_test_proc = proc { |element| element =~ matcher } 75 | else 76 | boundary_test_proc = proc { |element| element == matcher } 77 | end 78 | end 79 | 80 | Enumerator.new do |yielder| 81 | current_chunk = [] 82 | splits = 0 83 | max_splits = options[:once] == true ? 1 : options[:max_splits] 84 | 85 | each do |e| 86 | 87 | if boundary_test_proc.call(e) and (max_splits == nil or splits < max_splits) 88 | 89 | if current_chunk.empty? and not include_boundary 90 | next # hit 2 boundaries in a row... just keep moving, people! 91 | end 92 | 93 | if options[:after] 94 | # split after boundary 95 | current_chunk << e if include_boundary # include the boundary, if necessary 96 | yielder << current_chunk # shift everything after the boundary into the resultset 97 | current_chunk = [] # start a new result 98 | else 99 | # split before boundary 100 | yielder << current_chunk # shift before the boundary into the resultset 101 | current_chunk = [] # start a new result 102 | current_chunk << e if include_boundary # include the boundary, if necessary 103 | end 104 | 105 | splits += 1 106 | 107 | else 108 | current_chunk << e 109 | end 110 | 111 | end 112 | 113 | yielder << current_chunk if current_chunk.any? 114 | 115 | end 116 | end 117 | 118 | # 119 | # Split the array into chunks, cutting between the matched element and the next element. 120 | # 121 | # Example: 122 | # [1,2,3,4].split_after{|e| e == 3 } #=> [ [1,2,3], [4] ] 123 | # 124 | def split_after(matcher=nil, options={}, &block) 125 | options[:after] ||= true 126 | options[:include_boundary] ||= true 127 | split_at(matcher, options, &block) 128 | end 129 | 130 | # 131 | # Split the array into chunks, cutting before each matched element. 132 | # 133 | # Example: 134 | # [1,2,3,4].split_before{|e| e == 3 } #=> [ [1,2], [3,4] ] 135 | # 136 | def split_before(matcher=nil, options={}, &block) 137 | options[:include_boundary] ||= true 138 | split_at(matcher, options, &block) 139 | end 140 | 141 | # 142 | # Split the array into chunks, cutting between two elements. 143 | # 144 | # Example: 145 | # [1,1,2,2].split_between{|a,b| a != b } #=> [ [1,1], [2,2] ] 146 | # 147 | def split_between(&block) 148 | Enumerator.new do |yielder| 149 | current = [] 150 | last = nil 151 | 152 | each_cons(2) do |a,b| 153 | current << a 154 | if yield(a,b) 155 | yielder << current 156 | current = [] 157 | end 158 | last = b 159 | end 160 | 161 | current << last unless last.nil? 162 | yielder << current 163 | end 164 | end 165 | 166 | alias_method :cut_between, :split_between 167 | end 168 | -------------------------------------------------------------------------------- /lib/upm/tool_dsl.rb: -------------------------------------------------------------------------------- 1 | require 'pathname' 2 | require 'fileutils' 3 | 4 | module UPM 5 | class Tool 6 | module DSL 7 | def identifying_binary(id_bin=nil) 8 | if id_bin 9 | @id_bin = id_bin 10 | else 11 | @id_bin || @name 12 | end 13 | end 14 | 15 | def prefix(name) 16 | @prefix = name 17 | end 18 | 19 | def os(*args) 20 | args.any? ? @os = args : @os 21 | end 22 | 23 | def max_database_age(age) 24 | @max_database_age = age.to_i 25 | end 26 | 27 | def cache_dir(dir) 28 | @cache_dir = Pathname.new(dir).expand_path 29 | @cache_dir.mkpath unless @cache_dir.exist? 30 | end 31 | 32 | def config_dir(dir) 33 | @config_dir = Pathname.new(dir).expand_path 34 | @config_dir.mkpath unless @config_dir.exist? 35 | end 36 | 37 | def set_default(key, value) 38 | send(key, value) 39 | end 40 | 41 | def command(name, shell_command=nil, root: false, paged: false, highlight: nil, &block) 42 | 43 | @cmds ||= {} 44 | 45 | if block_given? 46 | 47 | if root and Process.uid != 0 48 | @cmds[name] = proc { exec("sudo", $PROGRAM_NAME, *ARGV) } 49 | else 50 | @cmds[name] = block 51 | end 52 | 53 | elsif shell_command 54 | 55 | if shell_command.is_a? String 56 | shell_command = shell_command.split 57 | elsif not shell_command.is_a? Array 58 | raise "Error: command argument must be a String or an Array; it was a #{cmd.class}" 59 | end 60 | 61 | @cmds[name] = proc do |args| 62 | query = highlight ? args.join("\\s+") : nil 63 | run(*shell_command, *args, paged: paged, root: root, highlight: query) 64 | end 65 | 66 | else 67 | raise "Error: Must supply a block or shell command" 68 | end 69 | end 70 | 71 | ## Helpers 72 | 73 | def run(*args, root: false, paged: false, grep: nil, highlight: nil, sort: false) 74 | if root 75 | if Process.uid != 0 76 | if File.which("sudo") 77 | args.unshift "sudo" 78 | elsif File.which("su") 79 | args = ["su", "-c"] + args 80 | else 81 | raise "Error: You must be root to run this command. (And I couldn't find the 'sudo' *or* 'su' commands.)" 82 | end 83 | end 84 | end 85 | 86 | 87 | unless paged or grep or sort 88 | system(*args) 89 | else 90 | IO.popen(args, err: [:child, :out]) do |command_io| 91 | 92 | # if grep 93 | # pattern = grep.is_a?(Regexp) ? grep.source : grep.to_s 94 | # grep_io = IO.popen(["grep", "--color=always", "-Ei", pattern], "w+") 95 | # IO.copy_stream(command_io, grep_io) 96 | # grep_io.close_write 97 | # command_io = grep_io 98 | # end 99 | 100 | # if paged 101 | # lesspipe do |less| 102 | # IO.copy_stream(command_io, less) 103 | # end 104 | # else 105 | # IO.copy_stream(command_io, STDOUT) 106 | # end 107 | 108 | # highlight_proc = if highlight 109 | # proc { |line| line.gsub(highlight) { |m| "\e[33;1m#{m}\e[0m" } } 110 | # else 111 | # proc { |line| line } 112 | # end 113 | 114 | lesspipe(disabled: !paged, search: highlight, always: false) do |less| 115 | each_proc = if grep 116 | proc { |line| less.puts line if line[grep] } 117 | else 118 | proc { |line| less.puts line } 119 | end 120 | 121 | lines = command_io.each_line 122 | lines = lines.to_a.sort if sort 123 | lines.each(&each_proc) 124 | end 125 | 126 | end 127 | 128 | $?.to_i == 0 129 | end 130 | end 131 | 132 | def curl(url) 133 | IO.popen(["curl", "-Ss", url], &:read) 134 | rescue Errno::ENOENT 135 | puts "Error: 'curl' isn't installed. You need this!" 136 | exit 1 137 | end 138 | 139 | def print_files(*paths, include: nil, exclude: nil) 140 | lesspipe do |less| 141 | paths.each do |path| 142 | less.puts "<8>=== <11>#{path} <8>========".colorize 143 | open(path) do |io| 144 | enum = io.each_line 145 | enum = enum.grep(include) if include 146 | enum = enum.reject { |line| line[exclude] } if exclude 147 | enum.each { |line| less.puts line } 148 | end 149 | less.puts 150 | end 151 | end 152 | end 153 | 154 | def call_command(name, *args) 155 | if block = (@cmds[name] || @cmds[ALIASES[name]]) 156 | block.call args 157 | else 158 | puts "Command #{name.inspect} not supported by #{@name.inspect}" 159 | end 160 | end 161 | 162 | def database_lastupdate_file 163 | raise "Error: Tool 'name' is not set" unless @name 164 | raise "Error: 'cache_dir' is not set" unless @cache_dir 165 | @cache_dir/"#{@name}-last-update" 166 | end 167 | 168 | def database_updated! 169 | FileUtils.touch(database_lastupdate_file) 170 | end 171 | 172 | def database_lastupdate 173 | database_lastupdate_file.exist? ? File.mtime(database_lastupdate_file) : 0 174 | end 175 | 176 | def database_age 177 | Time.now.to_i - database_lastupdate.to_i 178 | end 179 | 180 | def database_needs_updating? 181 | database_age > @max_database_age 182 | end 183 | 184 | def help 185 | puts " UPM version: #{File.read("#{__dir__}/../../VERSION")}" 186 | if osname = Tool.nice_os_name 187 | puts " Detected OS: #{osname}" 188 | end 189 | 190 | puts "Package manager: #{@name}" 191 | puts 192 | puts "Available commands:" 193 | available = COMMAND_HELP.select do |name, desc| 194 | names = name.split("/") 195 | names.any? { |name| @cmds[name] } 196 | end 197 | 198 | max_width = available.map(&:first).map(&:size).max 199 | available.each do |name, desc| 200 | puts " #{name.rjust(max_width)} | #{desc}" 201 | end 202 | end 203 | 204 | end # DSL 205 | end # Tool 206 | end # UPM 207 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # upm: Universal Package Manager 2 | 3 | ## Concept: 4 | 5 | Wraps all known package managers to provide a consistent and pretty interface, along with advanced features not supported by all tools, such as: 6 | - install log 7 | - rollback 8 | - pinning 9 | - fuzzy search 10 | - containerization/sandboxing 11 | - learning what packages are cool (community statistics, user favorites) 12 | 13 | No matter what package manager you're using, you'll get a modern, pretty, colourful, output that's piped-to-less, and you only have to remember one consistent set of commands. It'll also prompt you with a text UI whenever faced with ambiguity. 14 | 15 | You can maintain lists of your favorite packages (and sync them to some remote server), so that you can automatically install them whenever you setup a new machine. (This can include git repos full of dotfiles/scripts, to give you a comfortable home environment, regardless of which OS you're using.) 16 | 17 | ## Reality: 18 | 19 | Currently, `upm` provides a consistent interface to a number of tools: apk (Alpine), apt (Debian/Ubuntu), guix, opkg (OpenWRT), pacman (Arch), pkg (FreeBSD), pkg_add (OpenBSD), pkgin (Solaris/NetBSD), xbps (Void), and yum (Fedora). 20 | 21 | All the tools support the basic operations (installing, searching, listing, removing), and some support more advanced features, like grabbing search results from the web and showing the installation logs, and the output is always paged to `less`. 22 | 23 | The more advanced features, like consistent output, colorization, fuzzy filtering, etc. are not yet implemented. 24 | 25 | ## Installation: 26 | 27 | First, install Ruby. Then: 28 | 29 | ``` 30 | gem install upm 31 | ``` 32 | 33 | ## Usage: 34 | 35 | ``` 36 | upm 37 | up 38 | u 39 | ``` 40 | 41 | ## Commands: 42 | 43 | * `install`/`add` - download and install a package 44 | * `remove`/`uninstall` - remove a previously installed package 45 | * `build` - compile a package from source and install it 46 | * `search` - using the fastest known API or service 47 | * `list` - show all packages, or the contents of a specific package 48 | * `info` - show metadata about a package 49 | * `sync`/`update` - retrieve the latest package list or manifest 50 | * `upgrade` - install new versions of all packages 51 | * `sysupgrade` - upgrade the kernel, bootloader, core system, etc. (on Linux it upgrades kernel packages and dependencies, on \*BSD it upgrades the core system (essentially anything requiring a reboot)) 52 | * `verify` - verify the integrity of installed files 53 | * `audit` - show known vulnerabilities for installed packages 54 | * `pin` - pinning a package means it won't be automatically upgraded 55 | * `rollback` - revert to an earlier version of a package (including its dependencies) 56 | * `log` - show history of package installs 57 | * `packagers` - detect installed package managers, and pick which ones upm should wrap 58 | * `sources`/`mirrors` - select remote repositories and mirrors 59 | * `clean` - clear out the local package cache 60 | * `monitor` - ad-hoc package manager for custom installations (like instmon) 61 | * `keys` - keyrings and package authentication 62 | * `default` - configure the action to take when no arguments are passed to "upm" (defaults to "os:update") 63 | * `switch` - set a default tool (eg: if you're on a system with both `apt-get` and `nix`, you can switch to `nix` so that you don't need to prefix every package with `nix:`) 64 | * `backup` - interactively backup package selections, system-wide configurations, and user configurations 65 | 66 | ### Any command that takes a package name can be prefixed with the package tool's namespace: 67 | 68 | ``` 69 | os: -- automatically select the package manager for the current unix distribution 70 | deb: (or d: u:) 71 | rpm: (or yum: y:) 72 | bsd: (or b:) 73 | ruby: (or r: gem:) 74 | python:, (or py: p: pip:) 75 | go:,, 76 | ``` 77 | 78 | ### ...or suffixed with its file extension: 79 | 80 | ``` 81 | .gem 82 | .deb 83 | .rpm 84 | .pip 85 | ``` 86 | 87 | ## Package tools to wrap: 88 | 89 | * Arch: `pacman`/`aur`/`abs` (svn mirror) 90 | * Debian/Ubuntu: `apt-get`/`dpkg` (+ curated list of ppa's) 91 | * RedHat/Fedora/Centos: `yum`/`rpm` 92 | * Mac OSX: `brew`/`fink`/`ports` 93 | * FreeBSD: `pkg`/`ports` 94 | * OpenBSD: `pkg_add`/`ports` 95 | * NetBSD: `pkgin`/`ports` 96 | * SmartOS/Illumos: `pkgin` 97 | * Windows: `apt-cyg`/`mingw-get`/`nuget`/`Windows Update`/(as-yet-not-created package manager, "winget") 98 | * Wine/Proton/Steam: `winetricks`/`steam` 99 | * Ruby: `rubygems` 100 | * Python: `pip`/`easy_install` 101 | * Javascript/NodeJS: `npm` 102 | * Rust: `cargo` 103 | * Dart: `pub` 104 | * go: `go-get` 105 | * R: `cran` 106 | * Qt: `qpm` 107 | * Lua: `rocks` 108 | * Julia: `Pkg` 109 | * Haskell: `cabal` 110 | * Clojure: `leiningen` 111 | * Java: `gradle` 112 | * Erlang: `rebar` 113 | * Scala: `sbt` 114 | * Perl: `cpan` 115 | 116 | ...[and many more!](https://en.wikipedia.org/wiki/List_of_software_package_management_systems) 117 | 118 | 119 | ## What it might look like: 120 | 121 | Info: 122 | 123 | ![acs](https://raw.githubusercontent.com/epitron/scripts/master/screenshots/acs.png) 124 | 125 | Log: 126 | 127 | ![paclog](https://raw.githubusercontent.com/epitron/scripts/master/screenshots/paclog.png) 128 | 129 | Rollback: 130 | 131 | ![pacman-rollback](https://raw.githubusercontent.com/epitron/scripts/master/screenshots/pacman-rollback.png) 132 | 133 | # Future Directions 134 | 135 | ## TODOs: 136 | 137 | * Use the pretty text-mode UI that passenger-install uses 138 | * Context-dependent operation 139 | * eg: if you're in a ruby project's directory, set the 'ruby' namespace to highest priority 140 | 141 | ## Dotfiles 142 | 143 | * Manage and version-control dotfiles 144 | * Sync sqlite databases 145 | * sqlitesync tool? 146 | 147 | ## Themes 148 | 149 | * Font packs 150 | * Theme browser/downloader for GTK{2,3}, Qt, XFCE4, and Compiz 151 | * Populate `~/.themes` and set ENVIRONMENT variables 152 | * Store/load from favorites 153 | 154 | ## Containers, VMs, and Virtual Environments: 155 | 156 | Containers, VMs, and Virtual Environments are another pile of tools which do roughly the same thing: they gather together the dependencies for a specific program, or small set of programs, into a bundle, and create an isolated environment in which it can run. 157 | 158 | In the future, these could be wrapped by `ucm` (Universal Container Manager), if I get around to it. 159 | 160 | ### Container tools to wrap: 161 | 162 | * Virtual Environments: 163 | * Python: `virtualenv` 164 | * Ruby: `bundler` 165 | * Java: `gradle` 166 | * NodeJS: `npm` 167 | * Containerized Applications/Systems: 168 | * AppImage 169 | * docker 170 | * rkt 171 | * snapd 172 | * systemd 173 | * podman 174 | * nanobox 175 | * SmartOS zones 176 | * BSD jails (iocage) 177 | * Wine environments: 178 | * wine prefixes 179 | * playonlinuxs 180 | * proton 181 | * Virtual Machines: 182 | * qemu 183 | * virtualbox 184 | * VMware 185 | * firecracker 186 | * Hypervisors: 187 | * ESXi 188 | * Xen 189 | * Nova 190 | 191 | 192 | ## Similar Projects 193 | 194 | * [PackageKit](https://en.wikipedia.org/wiki/PackageKit) 195 | * [libraries.io](https://libraries.io) 196 | * [pkgs.org](https://pkgs.org) 197 | * [Repology](https://repology.org) 198 | * [asdf](https://github.com/asdf-vm/asdf) (manages multiple language runtimes per-project (it's like gvm, nvm, rbenv & pyenv (and more) all in one (!))) 199 | -------------------------------------------------------------------------------- /lib/upm/colored.rb: -------------------------------------------------------------------------------- 1 | # 2 | # ANSI Colour-coding (for terminals that support it.) 3 | # 4 | # Originally by defunkt (Chris Wanstrath) 5 | # Enhanced by epitron (Chris Gahan) 6 | # 7 | # It adds methods to String to allow easy coloring. 8 | # 9 | # (Note: Colors are automatically disabled if your program is piped to another program, 10 | # ie: if STDOUT is not a TTY) 11 | # 12 | # Basic examples: 13 | # 14 | # >> "this is red".red 15 | # >> "this is red with a blue background (read: ugly)".red_on_blue 16 | # >> "this is light blue".light_blue 17 | # >> "this is red with an underline".red.underline 18 | # >> "this is really bold and really blue".bold.blue 19 | # 20 | # Color tags: 21 | # (Note: You don't *need* to close color tags, but you can!) 22 | # 23 | # >> "This is using color tags to colorize.".colorize 24 | # >> "<1>N<9>u<11>m<15>eric tags!".colorize 25 | # (For those who still remember the DOS color palette and want more terse tagged-colors.) 26 | # 27 | # Highlight search results: 28 | # 29 | # >> string.gsub(pattern) { |match| "#{match}" }.colorize 30 | # 31 | # Forcing colors: 32 | # 33 | # Since the presence of a terminal is detected automatically, the colors will be 34 | # disabled when you pipe your program to another program. However, if you want to 35 | # show colors when piped (eg: when you pipe to `less -R`), you can force it: 36 | # 37 | # >> Colored.enable! 38 | # >> Colored.disable! 39 | # >> Colored.enable_temporarily { puts "whee!".red } 40 | # 41 | 42 | require 'set' 43 | require 'rbconfig' 44 | require 'Win32/Console/ANSI' if RbConfig::CONFIG['host_os'] =~ /mswin|mingw/ 45 | #require 'Win32/Console/ANSI' if RUBY_PLATFORM =~ /win32/ 46 | 47 | module Colored 48 | extend self 49 | 50 | @@is_tty = STDOUT.isatty 51 | 52 | COLORS = { 53 | 'black' => 30, 54 | 'red' => 31, 55 | 'green' => 32, 56 | 'yellow' => 33, 57 | 'blue' => 34, 58 | 'magenta' => 35, 59 | 'purple' => 35, 60 | 'cyan' => 36, 61 | 'white' => 37 62 | } 63 | 64 | EXTRAS = { 65 | 'clear' => 0, 66 | 'bold' => 1, 67 | 'light' => 1, 68 | 'underline' => 4, 69 | 'reversed' => 7 70 | } 71 | 72 | # 73 | # BBS-style numeric color codes. 74 | # 75 | BBS_COLOR_TABLE = { 76 | 0 => :black, 77 | 1 => :blue, 78 | 2 => :green, 79 | 3 => :cyan, 80 | 4 => :red, 81 | 5 => :magenta, 82 | 6 => :yellow, 83 | 7 => :white, 84 | 8 => :light_black, 85 | 9 => :light_blue, 86 | 10 => :light_green, 87 | 11 => :light_cyan, 88 | 12 => :light_red, 89 | 13 => :light_magenta, 90 | 14 => :light_yellow, 91 | 15 => :light_white, 92 | } 93 | 94 | VALID_COLORS = begin 95 | normal = COLORS.keys 96 | lights = normal.map { |fore| "light_#{fore}" } 97 | brights = normal.map { |fore| "bright_#{fore}" } 98 | on_backgrounds = normal.map { |fore| normal.map { |back| "#{fore}_on_#{back}" } }.flatten 99 | 100 | Set.new(normal + lights + brights + on_backgrounds + ["grey", "gray"]) 101 | end 102 | 103 | COLORS.each do |color, value| 104 | define_method(color) do 105 | colorize(self, :foreground => color) 106 | end 107 | 108 | define_method("on_#{color}") do 109 | colorize(self, :background => color) 110 | end 111 | 112 | define_method("light_#{color}") do 113 | colorize(self, :foreground => color, :extra => 'bold') 114 | end 115 | 116 | define_method("bright_#{color}") do 117 | colorize(self, :foreground => color, :extra => 'bold') 118 | end 119 | 120 | COLORS.each do |highlight, value| 121 | next if color == highlight 122 | 123 | define_method("#{color}_on_#{highlight}") do 124 | colorize(self, :foreground => color, :background => highlight) 125 | end 126 | 127 | define_method("light_#{color}_on_#{highlight}") do 128 | colorize(self, :foreground => color, :background => highlight, :extra => 'bold') 129 | end 130 | 131 | end 132 | end 133 | 134 | alias_method :gray, :light_black 135 | alias_method :grey, :light_black 136 | 137 | EXTRAS.each do |extra, value| 138 | next if extra == 'clear' 139 | define_method(extra) do 140 | colorize(self, :extra => extra) 141 | end 142 | end 143 | 144 | define_method(:to_eol) do 145 | tmp = sub(/^(\e\[[\[\e0-9;m]+m)/, "\\1\e[2K") 146 | if tmp == self 147 | return "\e[2K" << self 148 | end 149 | tmp 150 | end 151 | 152 | # 153 | # Colorize a string (this method is called by #red, #blue, #red_on_green, etc.) 154 | # 155 | # Accepts options: 156 | # :foreground 157 | # The name of the foreground color as a string. 158 | # :background 159 | # The name of the background color as a string. 160 | # :extra 161 | # Extra styling, like 'bold', 'light', 'underline', 'reversed', or 'clear'. 162 | # 163 | # 164 | # With no options, it uses tagged colors: 165 | # 166 | # puts "* Hey mom! I am SO colored right now.".colorize 167 | # 168 | # Or numeric ANSI tagged colors (from the BBS days): 169 | # puts "<10><5>* Hey mom! I am <9>SO colored right now.".colorize 170 | # 171 | # 172 | def colorize(string=nil, options = {}) 173 | if string == nil 174 | return self.tagged_colors 175 | end 176 | 177 | if @@is_tty 178 | colored = [color(options[:foreground]), color("on_#{options[:background]}"), extra(options[:extra])].compact * '' 179 | colored << string 180 | colored << extra(:clear) 181 | else 182 | string 183 | end 184 | end 185 | 186 | # 187 | # Find all occurrences of "pattern" in the string and highlight them 188 | # with the specified color. (defaults to light_yellow) 189 | # 190 | # The pattern can be a string or a regular expression. 191 | # 192 | def highlight(pattern, color=:light_yellow, &block) 193 | pattern = Regexp.new(Regexp.escape(pattern)) if pattern.is_a? String 194 | 195 | if block_given? 196 | gsub(pattern, &block) 197 | else 198 | gsub(pattern) { |match| match.send(color) } 199 | end 200 | end 201 | 202 | # 203 | # An array of all possible colors. 204 | # 205 | def colors 206 | @@colors ||= COLORS.keys.sort 207 | end 208 | 209 | # 210 | # Returns the terminal code for one of the extra styling options. 211 | # 212 | def extra(extra_name) 213 | extra_name = extra_name.to_s 214 | "\e[#{EXTRAS[extra_name]}m" if EXTRAS[extra_name] 215 | end 216 | 217 | # 218 | # Returns the terminal code for a specified color. 219 | # 220 | def color(color_name) 221 | background = color_name.to_s =~ /on_/ 222 | color_name = color_name.to_s.sub('on_', '') 223 | return unless color_name && COLORS[color_name] 224 | "\e[#{COLORS[color_name] + (background ? 10 : 0)}m" 225 | end 226 | 227 | # 228 | # Will color commands actually modify the strings? 229 | # 230 | def enabled? 231 | @@is_tty 232 | end 233 | 234 | alias_method :is_tty?, :enabled? 235 | 236 | # 237 | # Color commands will always produce colored strings, regardless 238 | # of whether the script is being run in a terminal. 239 | # 240 | def enable! 241 | @@is_tty = true 242 | end 243 | 244 | alias_method :force!, :enable! 245 | 246 | # 247 | # Enable Colored just for this block. 248 | # 249 | def enable_temporarily(&block) 250 | last_state = @@is_tty 251 | 252 | @@is_tty = true 253 | block.call 254 | @@is_tty = last_state 255 | end 256 | 257 | # 258 | # Color commands will do nothing. 259 | # 260 | def disable! 261 | @@is_tty = false 262 | end 263 | 264 | # 265 | # Is this string legal? 266 | # 267 | def valid_tag?(tag) 268 | VALID_COLORS.include?(tag) or 269 | (tag =~ /^\d+$/ and BBS_COLOR_TABLE.include?(tag.to_i) ) 270 | end 271 | 272 | # 273 | # Colorize a string that has "color tags". 274 | # 275 | def tagged_colors 276 | stack = [] 277 | 278 | # split the string into tags and literal strings 279 | tokens = self.split(/(<\/?[\w\d_]+>)/) 280 | tokens.delete_if { |token| token.size == 0 } 281 | 282 | result = "" 283 | 284 | tokens.each do |token| 285 | 286 | # token is an opening tag! 287 | 288 | if /<([\w\d_]+)>/ =~ token and valid_tag?($1) 289 | stack.push $1 290 | 291 | # token is a closing tag! 292 | 293 | elsif /<\/([\w\d_]+)>/ =~ token and valid_tag?($1) 294 | 295 | # if this color is on the stack somwehere... 296 | if pos = stack.rindex($1) 297 | # close the tag by removing it from the stack 298 | stack.delete_at pos 299 | else 300 | raise "Error: tried to close an unopened color tag -- #{token}" 301 | end 302 | 303 | # token is a literal string! 304 | 305 | else 306 | 307 | color = (stack.last || "white") 308 | color = BBS_COLOR_TABLE[color.to_i] if color =~ /^\d+$/ 309 | result << token.send(color) 310 | 311 | end 312 | 313 | end 314 | 315 | result 316 | end 317 | 318 | end unless Object.const_defined? :Colored 319 | 320 | String.send(:include, Colored) 321 | --------------------------------------------------------------------------------