Rewrite bin/hey in ruby

Because bash is a pain at this (and planned) levels of complexity.

Also, better auto-documentation on 'hey {h,help,-h,--help}'
This commit is contained in:
Henrik Lissner 2021-02-13 12:19:03 -05:00
parent 66868d48ac
commit b155537219

554
bin/hey
View file

@ -1,230 +1,368 @@
#!/usr/bin/env zsh #!/usr/bin/env cached-nix-shell
#! nix-shell -p ruby -i "ruby -S" --quiet
# I don't like the user story for Nix's binaries, unlike Guix's. So I wrote DESC = <<~DESC
# this to abstract away the unneeded complexity for everyday maintenance of my Nix's CLI has a poor user story, in my opinion. It could be better; Guix is a
# NixOS config. good role model here. I wrote this script to abstract the more common
# workflows, like rebuilding (nixos-rebuild), cleaning up (nix-collect-garbage),
# hey cd|check package/env management (nix-env), updating, rolling back, etc. As well as some
# nix flake check $DOTFILES utility operations (like managing generations, building remotely, or swapping
# hey sh|show themes on the fly).
# nix flake show $DOTFILES
# hey u|update [INPUT...]
# nix flake update --recreate-lock-file $DOTFILES
# hey re|rebuild [ARGS...]
# nixos-rebuild --flake $DOTFILES#$HOST ${@:-build}
# hey t|test -> nixos-rebuild --flake $DOTFILES#$HOST --fast test
# hey theme $THEME -> THEME=$THEME hey test (quick theme swapping)
# hey rollback -> hey rebuild --rollback switch
# hey s|search -> nix search nixpkgs $@
# hey swap $PATH -> swaps all specified nix-store symlinks (or recursively
# in a specified directory) with copies so they can be
# modified in place (while tweaking). Run again to
# restore the old link (careful, you'll lose work if you
# don't save your changes elsewhere!)
# hey vm -> hey rebuild vm
# hey up|upgrade -> hey update && hey rebuild switch
# hey gc -> GC user profile, optimise nix store
# sudo hey gc -> GC, optimise nix store, & delete old boot entries
# hey push REMOTE -> 1. Build machine locally, 2. Copy result to remote, 3. Activate config on remote
# hey gen|generations -> explore, manage, diff across generations (WIP)
#
# hey (with no sub-command) can also be used as an alternative to nix-env:
#
# hey -q
# hey -iA nixos.htop
# hey -e htop
set -e Because I can go months without shaving yaks in NixOS, exact flags or details
if [[ -z $FLAKE ]]; then are often and easily forgotten. This spares me that fumbling phase and also
for dir in /etc/nixos /etc/dotfiles; do serves as a reference.
if [[ -f "$dir/flake.nix" ]]; then
FLAKE=$dir
break
fi
done
fi
# Note: `hey` can also be used as a shortcut for nix-env:
function main {
function _do { [[ $DRYRUN ]] && echo "$@" || "$@" }
### Helpers hey -q
function _usage { hey -iA nixos.htop
echo "Usage: hey COMMAND [FLAGS...] [ARGS...]" hey -e htop
echo DESC
echo "Available commands:"
echo " check (ch) Same as 'nix flake check \$DOTFILES'"
echo " gc Garbage collect and optimize store"
echo " generations (gen) TODO"
echo " push Push thie config to a remote NixOS system and rebuild it"
echo " rebuild (re) Rebuild current system flake"
echo " rollback Roll back to last generation"
echo " search (s) Searches nixpkgs for a package"
echo " show (sh) Same as 'nix flake show \$DOTFILES'"
echo " swap Swap nix-store link (or directory thereof) for tweaking"
echo " test (t) Quickly rebuild, for quick iteration"
echo " theme Quickly swap to another theme"
echo " upgrade (up) Update flake lockfile and rebuild"
echo " vm Build flake into a VM image"
}
function _rebuild { require 'shellwords'
# Move to $DOTFILES to prevent polluting the system with result links require 'optparse'
pushd $FLAKE >/dev/null
# REVIEW Use --impure instead when it's in a stable release of nix
_do ${SUDO:+sudo} nixos-rebuild \
--flake "${FLAKE}#${HOSTNAME:-$HOST}" \
--option pure-eval no \
${@:-build}
popd >/dev/null
}
function _update { module Hey
echo "Updating NixOS flake" extend self
if [[ "$@" ]]; then Command = Struct.new(:name, :desc, :args, :opts, :fn)
for flake in $@; do
_do nix flake update --update-input "$flake" "$FLAKE" @@opts = { flake: File.expand_path('../', File.dirname(__FILE__)) }
done @@alias = {}
@@commands = {}
@@cli = nil
def self.defcmd names, &block
names = (names.kind_of?(Array) ? names : [names]).map(&:to_s)
names.drop(1).each { |n| @@alias[n] = names.first }
cli = Command.new names.first, [], []
cli.opts = OptionParser.new do |opt|
opt.banner = "Usage: #{opt.program_name} #{cli.name} [options]"
opt.separator "\nOptions:"
opt.on_tail('-h', '--help', 'Prints this help') { puts opt; exit }
end
@@cli = cli
fn = block.call cli
cli.fn = lambda do |args|
begin
cli.opts.banner +=
(cli.args ? " #{cli.args.join ' '}" : "") +
(cli.desc ? "\n\n#{cli.desc[0]}\n\n#{cli.desc[1..].join "\n"}" : "")
cli.opts.order!(args, into: @@opts)
fn.call args
ensure @@cli = nil end
end
@@commands[cli.name] = @@cli
end
def self.opts(*args) @@cli.opts.on(*args) end
def self.args(*args) @@cli.args = args end
def self.desc(*args) @@cli.desc = args end
def self.sh(args, sudo: false, noerror: false)
raise ArgumentError unless args.kind_of?(Array)
args.unshift('sudo') if sudo
if @@opts[:dryrun]
print args.compact
return true
else else
_do nix flake update --recreate-lock-file "$FLAKE" result = system "#{args.compact.shelljoin} 2>&1"
fi noerror ? result : exit($?.exitstatus)
} end
end
def self.rebuild(*args, sudo: false)
sh ["nixos-rebuild",
"--flake", @@opts[:flake],
"--option", "pure-eval", "no",
*args], sudo: sudo
end
function _gc { #
if [[ $EUID == 0 ]]; then defcmd [:shell, :sh] do
echo "Cleaning up your system..." desc "Open a nix-shell with the dotfiles loaded"
_do nix-collect-garbage ${@:--d} lambda do |args|
_do nix-store --optimise sh ["nix", "develop", args.first || @@opts[:flake]]
end
end
defcmd :repl do
desc "Open a nix-repl with nixpkgs and dotfiles preloaded"
lambda do |args|
sh ["nix", "repl", @@opts[:flake], "<nixpkgs>", *args]
end
end
defcmd [:check, :ch] do
desc "Run 'nix flake check' on your dotfiles"
lambda do |args|
sh ["nix", "flake", "check", @@opts[:flake]]
end
end
defcmd :show do
desc "[ARGS...]", "Run 'nix flake show' on your dotfiles"
lambda do |args|
sh ["nix", "flake", "show", @@opts[:flake]]
end
end
defcmd :theme do
desc 'Quickly swap to another theme module'
args "THEME_NAME"
lambda do |args|
ENV["THEME"] = args.shift
rebuild "--fast", "test"
end
end
defcmd [:test, :t] do
desc "Quickly rebuild, for quick iteration"
lambda do |args|
rebuild "--fast", "test", *args
end
end
defcmd [:rebuild, :re] do
desc "Rebuild the current system's flake"
opts '--build-host HOST', "TODO"
opts '--target-host HOST', "TODO"
opts '--host HOST', "TODO"
opts '--user', "TODO"
opts '--fast', "TODO"
lambda do |args|
Dir.chdir(@@opts[:flake]) do
@@opts[:host] ||= ENV["HOST"] || `hostname`.rstrip
args = ["switch"] unless args.length > 0
rebuild(*(@@opts[:"build-host"] ? ["--build-host", @@opts[:"build-host"]] : []),
*(@@opts[:"target-host"] ? ["--target-host", @@opts[:"target-host"]] : []),
@@opts[:fast] ? "--fast" : nil,
@@opts[:debug] ? "--show-trace" : nil,
*args,
sudo: !@@opts[:user])
end
end
end
defcmd :rollback do
desc "Roll back to last generation"
lambda do |_args|
rebuild "--rollback", "switch"
end
end
defcmd [:update, :u] do
desc "Update specific flakes or all of them"
args "[INPUT...]"
lambda do |args|
args.each do |flake|
puts "Trying to update #{flake}..."
sh ["nix", "flake", "update", "--update-input", flake, @@opts[:flake]]
end.empty? and begin
puts "Updating all flakes.."
sh ["nix", "flake", "update", "--recreate-lock-file", @@opts[:flake]]
end
end
end
defcmd [:upgrade, :up] do
desc "Update all flakes and rebuild system"
lambda do |_args|
update
rebuild :switch
end
end
defcmd [:search, :s] do
desc "Search nixpkgs for a package"
lambda do |args|
sh ["nix", "search", "nixpkgs", *args]
end
end
defcmd :swap do
desc "Recursively swap nix-store links (or folders thereof) with copies.",
"The purpose is so you can have copies of your dotfiles that can be modified in",
"place (so you can iterate on them quickly instead of 'hey re'ing between",
"changes). Run again to restore the old link(s).\n",
"Warning: backup your copies, or they will be destroyed when restored!"
args "PATH", "[PATHS...]"
lambda do |args|
for target in args
if File.directory? target
files = Dir.glob("#{target}/**/*.nix-store-backup")
if files.length > 0
puts "There are swapped files here. Unswapping!"
call :swap, *files
else
puts "Swapping files in #{target}"
call :swap, *(Dir.glob("#{target}/**/*")
.select { |f| File.symlink? f }
.select { |l| File.readlink(l).match '^/nix/store/' })
end
elsif File.file? "#{target}.nix-store-backup"
puts "Unswapping #{target}"
sh [:mv, '-i', "#{target}.nix-store-backup", target]
elsif File.file? target
puts "Swapping #{target}"
sh [:mv, target, "#{target}.nix-store-backup"]
sh [:cp, "#{target}.nix-store-backup", target]
else
puts "Not a valid target: #{target}"
exit 1
end
end
end
end
defcmd :gc do
desc "Garbage collect & optimize nix store"
opts "-a", "--all", "GC both the user and system profiles"
opts "-s", "--system", "Only GC the system profile"
lambda do |_args|
if @@opts[:all] or not @@opts[:root]
puts "Cleaning up your user profile..."
sh %w{nix-collect-garbage -d}
end
if @@opts[:all] or @@opts[:system]
puts "Cleaning up your system..."
sh %w{nix-collect-garbage -d}, sudo: true
sh %w{nix-store --optimise}, sudo: true
# Clear out left-over secrets. The switch-to-configuration call below will # Clear out left-over secrets. The switch-to-configuration call below will
# regenerate them. # regenerate them.
[[ -d /run/secrets ]] && _do rm -f /run/secrets/* if File.exists?("/run/secrets/*")
sh %w{rm -rf /run/secrets/*}, sudo: true
end
# nix-collect-garbage is a Nix tool, not a NixOS tool. It won't delete old # nix-collect-garbage is a Nix tool, not a NixOS tool. It won't delete old
# boot entries until you do a nixos-rebuild (which means we'll always have # boot entries until you do a nixos-rebuild (which means we'll always have
# 2 boot entries at any time). Instead, we properly delete them by # 2 boot entries at any time). Instead, we properly delete them by
# reloading the current environment. # reloading the current environment.
_do nix-env --delete-generations old --profile /nix/var/nix/profiles/system sh %w{nix-env --delete-generations old --profile /nix/var/nix/profiles/system}, sudo: true
_do /nix/var/nix/profiles/system/bin/switch-to-configuration switch sh %w{/nix/var/nix/profiles/system/bin/switch-to-configuration switch}, sudo: true
else end
echo "Cleaning up your user profile..." end
_do nix-collect-garbage ${@:--d} end
fi
}
function _swap { defcmd :ssh do
for target in $@; do desc "Run a Hey command on a remote NixOS system",
if [ -d "$target" ]; then "This command assumes that the remote system is built on these dotfiles, it is",
files=( "$target"/**/*.nix-store-backup ) "dangerous to do this if that isn't the case."
links=( $(find "$target" -type l) ) args "HOST", "[COMMAND]"
if [[ "$files" ]]; then opts '--host HOST', "The nixos config to build and deploy to HOST"
echo "There are swapped files here. Unswapping!" lambda do |args|
for file in $files[@]; do target, command = args.take(2)
_swap "$link" if command.match /switch|test|boot/
done puts "Enabling root ssh access on #{target}. Enter root password for remote system:"
if sh ["ssh", "-t", target,
"sudo mkdir -p /root/.ssh && sudo cp -f ~/.ssh/authorized_keys /root/.ssh/"],
noerror: true
begin
puts "Building #{target}"
call "rebuild", ["--user",
"--host", @@opts[:host] || target,
"--build-host", "localhost",
"--target-host", "root@#{target}",
command || "test"]
ensure
puts "Undoing root ssh access on #{target}"
sh ["ssh", "-t", "root@#{target}", "rm -f /root/.ssh/authorized_keys"]
end
end
else else
echo "Swapping files in $target" command = "nix run github:hlissner/dotfiles -- #{command}"
for link in $links[@]; do puts "Running '#{command}' on #{target}"
if [[ "$(readlink $link)" == /nix/store/* ]]; then sh ["ssh", "-t", target, command]
_swap "$link" end
fi end
done end
fi
elif [ -f "$target.nix-store-backup" ]; then # TODO Generalize this to nix profiles
echo "Unswapping $target" defcmd [:generations, :gen] do
_do mv -i "$target.nix-store-backup" "$target" desc "Explore, manage, diff across generations"
elif [ -f "$target" ]; then opts '--diff FROM TO', "TODO"
echo "Swapping $target" opts '-r', '--remove PROFILE', "TODO"
_do mv "$target" "$target.nix-store-backup" lambda do |args|
_do cp "$target.nix-store-backup" "$target" profile = "/nix/var/nix/profiles/system"
else case args.shift
>&2 echo "Not a valid target: $target" when "diff"
from, to = *args
if from == to
puts "Comparing the same profile! Aborting..."
exit 1 exit 1
fi elsif [from, to].compact.select do |gen|
done File.exists? "#{profile}-#{gen}-link" or
} puts "Generation #{gen} does not exist!"
end.length != [from, to].compact.length
### exit 2
eval set -- $(getopt -n hey -o df:h:ieAqp -l dryrun,flake:,host: -- "$@") end
while : from = from ? "#{profile}-#{from}-link" : profile
do to = to ? "#{profile}-#{to}-link" : profile
case "$1" in fromDevs = `nix-store -q --references #{from}`.split
-d|--dryrun) DRYRUN=1; shift ;; toDevs = `nix-store -q --references #{to}`.split
-f|--flake) FLAKE=$2; shift 2 ;; puts (fromDevs - toDevs).map { |d| "\e[31m-#{d}\e[0m" }
-h|--host) HOSTNAME=$2; shift 2 ;; puts (toDevs - fromDevs).map { |d| "\e[32m+#{d}\e[0m" }
-i|-A|-q|-e|-p) _do nix-env "$@"; exit 0 ;; when /ls|list/
--) shift; break ;; sh ["nix-env", "--list-generations", "--profile", profile], sudo: true
esac when "remove"
done sh ["nix-env", "--delete-generations", "--profile", profile, *args], sudo: true
if [[ ! -d $FLAKE ]]; then when "switch"
>&2 echo "Couldn't find flake.nix in $FLAKE" puts "Targeted rollback not supported yet" # TODO Is this true?
exit 1 exit 1
fi end
end
end
cmd="$1" class UsageError < StandardError; end
[[ $# > 0 ]] && shift defcmd [:help, :h] do
case "$cmd" in desc "Show usage information for this script or a subcommand"
sh|shell) _do nix develop "$FLAKE" "$@" ;; args "[SUBCOMMAND]"
repl) _do nix repl "$FLAKE/flake.nix" "<nixpkgs>" "$@" ;; lambda do |args|
raise UsageError unless args.first
call args.first, ["-h", *args.drop(1)]
end
end
ch|check) _do nix flake check "$@" "$FLAKE" ;; #
sh|show) _do nix flake show "$@" "$FLAKE" ;; def self.call command, args
cmd = command.to_s
theme) THEME="$1" SUDO=1 _rebuild --fast test ;; cmd = @@alias[cmd] while cmd and not @@commands.key?(cmd)
t|test) SUDO=1 _rebuild --fast test "$@" ;; if cmd
re|rebuild) SUDO=1 _rebuild "${@:-switch}" ;; @@commands[cmd].fn.call args
rollback) SUDO=1 _rebuild --rollback switch ;; elsif command
vm) SUDO=1 _rebuild "$@" vm ;; puts "Couldn't recognize command '#{command}'"
exit 1
u|update) _update "$@" ;;
up|upgrade) _update && SUDO=1 _rebuild switch ;;
s|search) nix search nixpkgs "$@" ;;
swap) _swap "$@" ;;
gc) _gc "$@" ;;
push)
target=$1
cmd=${2:-test}
echo "Enabling root ssh access on $target"
if _do ssh -t "$target" "sudo mkdir -p /root/.ssh && sudo cp -f ~/.ssh/authorized_keys /root/.ssh/authorized_keys"; then
trap '_do ssh -t "root@$target" "rm -f /root/.ssh/authorized_keys"' EXIT
echo "Building $target ($cmd)"
HOSTNAME="${HOSTNAME:-$target}" _rebuild \
--build-host localhost \
--target-host "root@$target" \
$cmd
echo "Undoing root ssh access on $target"
fi
;;
gen|generations)
profile=/nix/var/nix/profiles/system
case "$1" in
switch) >&2 echo "Targeted rollback not supported yet" ;; # TODO Is this true?
diff)
to=/nix/var/nix/profiles/system-${2}-link
diff \
<(nix-store -q --references $profile) \
<(nix-store -q --references $to)
;;
remove)
shift
_do sudo nix-env --delete-generations --profile $profile $@
;;
"")
_do sudo nix-env --list-generations --profile $profile
;;
esac
;;
h|help) _usage ;;
"") _usage ;;
*) if command -v hey-$cmd >/dev/null; then
hey-$cmd $@
else else
echo "Couldn't find command: $cmd" 1>&2 raise UsageError
exit 1 end
fi end
;;
esac
}
main "$@" def self.run args=[]
opts = OptionParser.new do |opt|
opt.banner = "Usage: hey [global-options] [command] [sub-options]\n\n" + DESC
opt.separator "\n\nAvailable Commands:"
opt.separator (@@commands.values
.reject { |c| c.desc.empty? }
.sort { |a,b| a.name <=> b.name }
.map { |c| " %-18s %s\n" % ["#{c.name} #{@@alias.key(c.name)}",
c.desc[0]]})
opt.separator "\nOptions:"
opt.on('-d', '--dryrun', "Don't change anything; preform dry run")
opt.on('-D', '--debug', "Show trace on nix errors")
opt.on('-f', '--flake URI', "Change target flake to URI")
opt.on(*%w{-h --help}, "Display this help, or help for a specific command")
opt.on(*%w{-i -A -q -e -p}, "Forward to nix-env") { sh ["nix-env", *ARGV]; exit }
end
opts.order!(args, into: @@opts)
begin
args.unshift("help") if @@opts[:help]
if args.length == 0
puts "Error: No command specified!"
puts
raise UsageError
else
call args.first, args.drop(1)
end
rescue UsageError
puts opts
end
end
end
Hey.run ARGV.dup