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:
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() {