├── .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 | 
124 |
125 | Log:
126 |
127 | 
128 |
129 | Rollback:
130 |
131 | 
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>*5> Hey mom! I am <9>SO9> colored right now.10>".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 |
--------------------------------------------------------------------------------