nix

configuration files that power my machines
Log | Files | Refs | README | LICENSE

commit 1c0e812675c420029370e95819d4cf4b8c93a818
parent 9caa17625e1fb37023c3a2af111cddbc50e787a1
Author: mtmn <miro@haravara.org>
Date:   Sat,  4 Jul 2026 05:04:33 +0200

void: add xbps-otterb-build, update backup, etc.

Diffstat:
Mflake.lock | 12++++++------
Ahosts/void/overlays/bin/xbps-otterb-build | 212+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mhosts/void/overlays/config/shell/rc/void | 0
Mjustfile | 8++++----
Mmodules/mixins/dotfiles/bin/backup | 264+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Mmodules/mixins/dotfiles/config/shell/rc/aliased-short-names | 11++++++-----
Mmodules/mixins/dotfiles/config/shell/rc/functions | 6+-----
7 files changed, 447 insertions(+), 66 deletions(-)

diff --git a/flake.lock b/flake.lock @@ -162,11 +162,11 @@ ] }, "locked": { - "lastModified": 1783099620, - "narHash": "sha256-prQSM9PGnrfSaqknJOZ3DCQKbpMiXAWLVC5SHbjLcJ4=", + "lastModified": 1783134515, + "narHash": "sha256-qMoZazubXlXUD9k/syJ/aiWC4X4g73mwVmZ7Z4+rdpM=", "owner": "nix-community", "repo": "home-manager", - "rev": "b2e4390ff35319c52935d6a700b615da4c0f204d", + "rev": "b885baad531fa3d3beae2ba9a0712d22974d8016", "type": "github" }, "original": { @@ -248,11 +248,11 @@ }, "nixpkgs_3": { "locked": { - "lastModified": 1782918843, - "narHash": "sha256-ETYnV9U7Sr+A45dohzZdfCZKOss4qrTkO+wgNZNvEc0=", + "lastModified": 1782978903, + "narHash": "sha256-bG1LAS3YehMzp4RSXw99o3yMEO/Ktb0Tx29RCtEU0Tk=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "e8273b29fe1390ec8d4603f2477357555291432e", + "rev": "d33369954a67ae3322177dc9a3d564092912120c", "type": "github" }, "original": { diff --git a/hosts/void/overlays/bin/xbps-otterb-build b/hosts/void/overlays/bin/xbps-otterb-build @@ -0,0 +1,212 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "fileutils" +require "open3" +require "etc" + +class Builder + REPO = "https://repo-default.voidlinux.org/current" + DEPS = %w[ + cmake + make + gcc + qt5-devel + qt5-svg-devel + qt5-multimedia-devel + qt5-declarative-devel + qt5-webkit-devel + hunspell-devel + gstreamer1-devel + libxml2-devel + qt5-tools + ] + .freeze + + def initialize(argv) + @outdir = nil + @chroot = "/var/chroot/otter-build" + @keep = false + @quiet = false + parse(argv) + validate + end + + def run + bootstrap + mount_vfs + prep_chroot + install_deps + build + extract + ensure + unmount_vfs + remove_chroot unless @keep + msg("done.") + end + + private + + def parse(argv) + i = 0 + while i < argv.length + case argv[i] + when "-o" + @outdir = argv[i += 1] + when "-c" + @chroot = argv[i += 1] + when "-k" + @keep = true + when "-q" + @quiet = true + when "-h", "--help" + usage(code: 0) + else + usage + end + + i += 1 + end + end + + def usage_lines + [ + "Usage: #{File.basename($0)} [-o DIR] [-c DIR] [-k] [-q]", + " -o DIR output directory (default: invoking user home)", + " -c DIR chroot directory (default: /var/chroot/otter-build)", + " -k keep chroot after build", + " -q quiet" + ] + end + + def usage(code: 1) + usage_lines.each { |l| puts(l) } + exit(code) + end + + def validate + unless Process.uid.zero? + usage_lines.each { |l| puts(l) } + die("must run as root") + end + + @src = Dir.pwd + die("no CMakeLists.txt in #{@src}") unless File.file?(File.join(@src, "CMakeLists.txt")) + + inv = ENV["SUDO_USER"] || ENV["DOAS_USER"] + if inv + @pw = Etc.getpwnam(inv) rescue nil + die("cannot resolve user #{inv}") unless @pw + @outdir ||= @pw.dir + else + @outdir ||= File.expand_path("~") + end + end + + def msg(s) = puts(s) unless @quiet + + def die(s) + $stderr.puts("#{File.basename($0)}: #{s}") + exit(1) + end + + def run_cmd(cmd, fatal: true) + msg(cmd) + _o, e, st = Open3.capture3(cmd) + die("#{cmd}: #{e.strip}") if !st.success? && fatal + st.success? + end + + def chroot_cmd(script) + msg("chroot #{@chroot} /bin/bash -c '#{script}'") + die("chroot command failed") unless system("chroot #{@chroot} /bin/bash -c '#{script}'") + end + + def bootstrap + FileUtils.mkdir_p(@chroot) + return if File.executable?(File.join(@chroot, "bin/bash")) + + msg("bootstrapping chroot...") + kdir = File.join(@chroot, "var/db/xbps/keys") + FileUtils.mkdir_p(kdir) + Dir.glob("/var/db/xbps/keys/*").each { |f| FileUtils.cp(f, kdir) } + + run_cmd("xbps-install -Syu -R #{REPO} -r #{@chroot} base-voidstrap", fatal: false) + die("bootstrap failed") unless File.executable?(File.join(@chroot, "bin/bash")) + msg("bootstrap ok") + end + + def mount_vfs + mounts = { + File.join(@chroot, "proc") => ["-t", "proc", "proc"], + File.join(@chroot, "sys") => ["-t", "sysfs", "sys"], + File.join(@chroot, "dev") => ["--bind", "/dev"] + } + mounts.each do |dst, opts| + FileUtils.mkdir_p(dst) + next if system("mountpoint -q #{dst} 2>/dev/null") + system("mount #{opts.join(" ")} #{dst}") + end + end + + def unmount_vfs + %w[dev sys proc].each { |n| system("umount #{File.join(@chroot, n)} 2>/dev/null") } + end + + def prep_chroot + FileUtils.cp("/etc/resolv.conf", File.join(@chroot, "etc/resolv.conf")) + kdir = File.join(@chroot, "var/db/xbps/keys") + FileUtils.mkdir_p(kdir) + Dir.glob("/var/db/xbps/keys/*").each { |f| FileUtils.cp(f, kdir) } + end + + def install_deps + chroot_cmd("xbps-install -Syu") + chroot_cmd("xbps-install -S #{DEPS.join(" ")}") + end + + def build + dst = File.join(@chroot, "src/otter-browser") + FileUtils.rm_rf(dst) + FileUtils.mkdir_p(File.dirname(dst)) + system("cp -a #{@src} #{dst}") || die("copy failed") + + cm = File.join(dst, "CMakeLists.txt") + txt = File.read(cm) + unless txt.include?("FATAL_ON_MISSING_REQUIRED_PACKAGES") + die("CMakeLists.txt: FATAL_ON_MISSING_REQUIRED_PACKAGES not found") + end + + txt.gsub!("FATAL_ON_MISSING_REQUIRED_PACKAGES", "") + File.write(cm, txt) + + script = "set -e; cd /src/otter-browser; rm -rf build; mkdir build; cd build; cmake -DENABLE_QT5=ON ../; make -j$(nproc)" + chroot_cmd(script) + end + + def extract + bin = File.join(@chroot, "src/otter-browser/build/otter-browser") + die("binary not found") unless File.file?(bin) + + out = File.join(@outdir, "otter-browser") + system("cp #{bin} #{out}") || die("copy binary failed") + FileUtils.chmod("+x", out) + + if @pw + begin + File.chown(@pw.uid, @pw.gid, out) + rescue => e + msg("chown failed: #{e}") + end + end + + msg("binary: #{out}") + end + + def remove_chroot + msg("removing #{@chroot}...") + FileUtils.rm_rf(@chroot) + end +end + +Builder.new(ARGV).run diff --git a/hosts/void/overlays/config/shell/rc/void b/hosts/void/overlays/config/shell/rc/void Binary files differ. diff --git a/justfile b/justfile @@ -8,20 +8,20 @@ check: nix flake check --no-update-lock-file deploy host="nixaran": - @backup -t git:nix + @backup git:nix NH_ELEVATION_STRATEGY=none nh os switch . --hostname {{ host }} --target-host root@{{ host }} --build-host root@{{ host }} home: - -@backup -t git:nix + -@backup git:nix nh home switch . diff: - -@backup -t git:nix + -@backup git:nix home-manager build --flake ".#$(whoami)@$(hostname)" --no-update-lock-file nvd diff $HOME/.local/state/nix/profiles/home-manager ./result diffr host="nixaran": - -@backup -t git:nix + -@backup git:nix nix store diff-closures \ $(ssh {{ host }} readlink -f /run/current-system) \ $(nix build --no-link --print-out-paths .#nixosConfigurations.{{ host }}.config.system.build.toplevel) diff --git a/modules/mixins/dotfiles/bin/backup b/modules/mixins/dotfiles/bin/backup @@ -2,10 +2,19 @@ # frozen_string_literal: true # vim: ft=ruby +require "English" require "optparse" require "open3" +require "yaml" module Backup + Result = Struct.new(:status, :out, :err) do + def success? = status&.success? + def exitcode = status&.exitstatus || 127 + end + + Backend = Struct.new(:max_depth, :list, :run_all, :run_one, :run_leaf, keyword_init: true) + @failures = [] def self.ts = Time.now.strftime("%Y-%m-%dT%H:%M:%S%z") @@ -22,13 +31,16 @@ module Backup err("ERROR: #{msg}") end - def self.check(msg, status) - return true if status.success? - record_failure(msg) + def self.check(msg, result) + return true if result.success? + + detail = result.err.to_s.strip + record_failure(detail.empty? ? msg : "#{msg}: #{detail}") false end - HOME = ENV.fetch("HOME") { fatal "HOME not set" } + HOME = ENV.fetch("HOME") { fatal("HOME not set") } + PLAKAR_CONFIG_DIR = "#{HOME}/.config/plakar".freeze GIT_DIRS = { "mpd" => "#{HOME}/.config/mpd", @@ -48,12 +60,15 @@ module Backup def self.run_cmd(verbose, *args) if verbose - system(*args, out: $stdout, err: $stderr) - [$?, "", ""] + system(*args) + Result.new($CHILD_STATUS, "", $CHILD_STATUS.nil? ? "command not found: #{args.first}" : "") else out, err, status = Open3.capture3(*args) - [status, out, err] + Result.new(status, out, err) end + + rescue Errno::ENOENT => e + Result.new(nil, "", e.message) end def self.git(dir, *args, verbose:) @@ -61,95 +76,252 @@ module Backup end def self.commit_changes(dir, verbose:) - status, out, = git(dir, "diff", "--cached", "--name-only", verbose: false) - return unless check("Failed to list staged changes in #{dir}", status) - files = out.lines.map(&:strip).reject(&:empty?).join(" ") - status2, = git(dir, "commit", "-m", files, verbose: verbose) - return unless check("Failed to commit changes in #{dir}", status2) + r = git(dir, "diff", "--cached", "--name-only", verbose: false) + return unless check("Failed to list staged changes in #{dir}", r) + + files = r.out.lines.map(&:strip).reject(&:empty?).join(" ") + r2 = git(dir, "commit", "-m", files, verbose: verbose) + return unless check("Failed to commit changes in #{dir}", r2) + log("Committed changes in #{dir}") true end def self.push_changes(dir) log("Pushing changes from #{dir}") - status, _, err = git(dir, "push", verbose: false) - if status.success? + r = git(dir, "push", verbose: false) + case r.exitcode + when 0 log("Done pushing #{dir}") + when 128 + log("No push destination for #{dir} (skipped)") else - record_failure("Push failed for #{dir} (exit #{status.exitstatus}): #{err.strip}") + record_failure("Push failed for #{dir} (exit #{r.exitcode}): #{r.err.strip}") end end - def self.sync_repo(opts, name, dir) + def self.fetch_changes(dir) + log("Fetching changes for #{dir}") + r = git(dir, "pull", verbose: false) + case r.exitcode + when 0 + log("Done fetching #{dir}") + when 128 + log("No fetch destination for #{dir} (skipped)") + else + record_failure("Fetch failed for #{dir} (exit #{r.exitcode}): #{r.err.strip}") + end + end + + def self.sync_repo(verbose:, push:, fetch:, name:, dir:) log("git/#{name}") - status, = git(dir, "add", "-A", verbose: opts[:verbose]) - return unless check("Failed to stage changes in #{dir}", status) - diff, = git(dir, "diff", "--cached", "--quiet", verbose: false) + fetch_changes(dir) if fetch + r = git(dir, "add", "-A", verbose: verbose) + return unless check("Failed to stage changes in #{dir}", r) + + diff = git(dir, "diff", "--cached", "--quiet", verbose: false) ok = if diff.success? log("Nothing to commit in #{dir}") true else - commit_changes(dir, verbose: opts[:verbose]) + commit_changes(dir, verbose: verbose) end - push_changes(dir) if opts[:push] && ok + push_changes(dir) if push && ok end - def self.tarsnap_archive(opts, name, dir) + def self.tarsnap_archive(verbose:, name:, dir:) stamp = Time.now.strftime("%Y-%m-%d_%H:%M:%S") log("Archiving tarsnap/#{name} from #{dir}") - status, = run_cmd(opts[:verbose], "tarsnap", "-c", "-f", "#{name}-#{stamp}", "-C", dir, ".") - return unless check("tarsnap failed for #{name}", status) + r = run_cmd(verbose, "tarsnap", "-c", "-f", "#{name}-#{stamp}", "-C", dir, ".") + return unless check("tarsnap failed for #{name}", r) + log("Done tarsnap/#{name}") end + def self.parse_plakar_show(text) + records = {} + name = nil + text.each_line do |line| + stripped = line.strip + next if stripped.empty? + + if line.start_with?(" ") + key, val = stripped.split(":", 2) + records[name][key] = val&.strip if name + else + name = stripped.delete_suffix(":") + records[name] = {} + end + end + + records + end + + def self.plakar_query(subcommand) + r = run_cmd(false, "plakar", subcommand, "show") + return {} unless r.success? + + parse_plakar_show(r.out) + end + + def self.load_plakar_yml(name) + path = File.join(PLAKAR_CONFIG_DIR, "#{name}.yml") + return nil unless File.file?(path) + + YAML.load_file(path)[name.to_s] || {} + rescue StandardError => e + fatal("Failed to parse #{path}: #{e.message}") + end + + def self.plakar_stores + load_plakar_yml("stores") || plakar_query("store") + end + + def self.plakar_sources + load_plakar_yml("sources") || plakar_query("source") + end + + PLAKAR_STORES_HASH = plakar_stores.freeze + PLAKAR_STORES = PLAKAR_STORES_HASH.keys.freeze + PLAKAR_SOURCES = plakar_sources.keys.freeze + + def self.plakar_ignore_file(store) + path = File.join(PLAKAR_CONFIG_DIR, "#{store}.ignore") + File.file?(path) ? path : nil + end + + def self.plakar_source(verbose:, store:, source:) + fatal("Unknown plakar source: #{source}") unless PLAKAR_SOURCES.include?(source) + ignore = plakar_ignore_file(store) + args = ["plakar", "at", "@#{store}", "backup"] + args.push("-ignore-file", ignore) if ignore + args.push("@#{source}") + log("Syncing plakar/#{store}/#{source}") + r = run_cmd(verbose, *args) + return unless check("plakar @#{store} failed for #{source}", r) + + log("Done plakar/#{store}/#{source}") + end + + def self.plakar_store(verbose:, store:) + fatal("Unknown plakar store: #{store}") unless PLAKAR_STORES.include?(store) + PLAKAR_SOURCES.each { |src| plakar_source(verbose: verbose, store: store, source: src) } + end + + def self.backup_plakar(verbose:) + PLAKAR_STORES.each { |store| plakar_store(verbose: verbose, store: store) } + end + + def self.list_flat(name, dirs) + puts(name) + dirs.keys.sort.each { |k| puts(" #{k}") } + end + + def self.list_plakar + puts("plakar") + PLAKAR_STORES.sort.each do |store| + loc = PLAKAR_STORES_HASH.dig(store, "location") || "?" + puts(" #{store} (#{loc})") + PLAKAR_SOURCES.sort.each { |s| puts(" #{s}") } + end + end + BACKENDS = { - "git" => {dirs: GIT_DIRS, run: :sync_repo}, - "tarsnap" => {dirs: TARSNAP_DIRS, run: :tarsnap_archive} + "git" => Backend.new( + max_depth: 2, + list: -> { list_flat("git", GIT_DIRS) }, + run_all: -> (verbose:, push:, fetch:, **) { + GIT_DIRS.each { |n, d| sync_repo(verbose: verbose, push: push, fetch: fetch, name: n, dir: d) } + }, + run_one: -> (verbose:, push:, fetch:, name:, **) { + sync_repo( + verbose: verbose, + push: push, + fetch: fetch, + name: name, + dir: GIT_DIRS.fetch(name) { fatal("Unknown git target: #{name}") } + ) + }, + run_leaf: nil + ), + "tarsnap" => Backend.new( + max_depth: 2, + list: -> { list_flat("tarsnap", TARSNAP_DIRS) }, + run_all: -> (verbose:, push:, **) { + TARSNAP_DIRS.each { |n, d| tarsnap_archive(verbose: verbose, name: n, dir: d) } + }, + run_one: -> (verbose:, push:, name:, **) { + tarsnap_archive( + verbose: verbose, + name: name, + dir: TARSNAP_DIRS.fetch(name) { fatal("Unknown tarsnap target: #{name}") } + ) + }, + run_leaf: nil + ), + "plakar" => Backend.new( + max_depth: 3, + list: -> { list_plakar }, + run_all: -> (verbose:, push:, **) { backup_plakar(verbose: verbose) }, + run_one: -> (verbose:, push:, name:, **) { plakar_store(verbose: verbose, store: name) }, + run_leaf: -> (verbose:, push:, store:, source:, **) { + plakar_source(verbose: verbose, store: store, source: source) + } + ) }.freeze def self.list_targets - BACKENDS.keys.sort.each do |bname| - puts(bname) - BACKENDS[bname][:dirs].keys.sort.each { |k| puts(" #{k}") } - end + BACKENDS.each_value { |b| b.list.call } end - def self.backup_target(opts, target) - bname, name = target.split(":", 2) + def self.backup_target(verbose:, push:, fetch:, target:) + parts = target.split(":") + bname = parts.shift backend = BACKENDS[bname] || fatal("Unknown target: #{target}") - dirs = backend[:dirs] - if name - dir = dirs[name] || fatal("Unknown #{bname} target: #{name}") - send(backend[:run], opts, name, dir) + if parts.size > backend.max_depth - 1 + fatal("Unknown target: #{target}") + elsif parts.empty? + backend.run_all.call(verbose: verbose, push: push, fetch: fetch) + elsif parts.size == 1 + backend.run_one.call(verbose: verbose, push: push, fetch: fetch, name: parts[0]) else - dirs.each { |n, d| send(backend[:run], opts, n, d) } + backend.run_leaf.call(verbose: verbose, push: push, fetch: fetch, store: parts[0], source: parts[1]) end end def self.main(args) - options = {verbose: true, push: false, list: false} + Signal.trap("INT") do + warn("[#{Time.now.strftime("%Y-%m-%dT%H:%M:%S%z")}]: Interrupted") + exit(130) + end + + options = {verbose: true, push: false, fetch: false, list: false} parser = OptionParser.new do |o| - o.banner = "Usage: backup [options]" - o.on("-t", "--target TARGET", "git, tarsnap, <backend>:<name>") { |v| options[:target] = v } - o.on("-p", "--push", "Push changes after commit") { options[:push] = true } + o.banner = "Usage: backup [options] [target]\n" \ + " target: git, plakar, tarsnap, <backend>:<name>" + o.on("-p", "--push", "Fetch and push changes after commit") { options[:push] = true } + o.on("-f", "--fetch", "Run git pull for git targets before backup") { options[:fetch] = true } o.on("-q", "--quiet", "Suppress command output") { options[:verbose] = false } o.on("-l", "--list", "List all configured backup targets") { options[:list] = true } - o.on("-h", "--help") { + o.on("-h", "--help") do puts(o) exit(0) - } + end end parser.parse!(args) - unless options[:list] || options[:target] + target = args.shift + unless options[:list] || target puts(parser) exit(0) end + options[:fetch] = true if options[:push] + list_targets if options[:list] - if options[:target] - backup_target({verbose: options[:verbose], push: options[:push]}, options[:target]) + if target + backup_target(verbose: options[:verbose], push: options[:push], fetch: options[:fetch], target: target) end fatal("#{@failures.size} backup target(s) failed") unless @failures.empty? diff --git a/modules/mixins/dotfiles/config/shell/rc/aliased-short-names b/modules/mixins/dotfiles/config/shell/rc/aliased-short-names @@ -69,9 +69,9 @@ alias khi='khal interactive' alias khl='khal list' alias khlw='khal list now 7d' -alias bpp='backup -t git:pass -p' -alias bnp='backup -t git:nota -p' -alias brr='backup -t git:releases -p' +alias bpp='backup git:pass -p' +alias bnp='backup git:nota -p' +alias brr='backup git:releases -p' alias nr='newsraft' alias nre='vim ~/src/sr.ht/nix/modules/mixins/dotfiles/config/newsraft/feeds' @@ -129,8 +129,9 @@ alias brake='bexec rake' alias chav='cha --visual' -alias boli='boring list' +alias borl='boring list' alias boro='boring open' alias borc='boring close' + alias borog='boring open -g' -alias borcog='boring close -g' +alias borcg='boring close -g' diff --git a/modules/mixins/dotfiles/config/shell/rc/functions b/modules/mixins/dotfiles/config/shell/rc/functions @@ -7,10 +7,6 @@ nixpkgs_cache_index_update() { ln -f "$filename" files } -plakar_backup_all() { - yq '.sources | keys | .[]' ~/.config/plakar/sources.yml | tr -d '"' | sed 's/^/@/' | xargs -r plakar at "@${1:-puck}" backup -} - nvimrecomp() { nix_fennel_path="$HOME/src/sr.ht/nix/modules/mixins/dotfiles/config/nvim/fnl" config_path="$HOME/.config/nvim" @@ -37,7 +33,7 @@ bandsnatch() { -c "$HOME/misc/music/random/bandcamp-cookies.json" \ -f flac \ -o "$HOME/misc/music/backlog/_todo" \ - mtmnn && backup -t git:bandcamp + mtmnn && backup git:bandcamp } gotosleep() {