commit 6feee7d6ad31b0b2185ed57342a88d3e570bb981 from: mtmn date: Mon Sep 14 23:10:37 2026 UTC move backup to tools commit - 07461bbc2e0322a12f588a41d54805de6a87aae4 commit + 6feee7d6ad31b0b2185ed57342a88d3e570bb981 blob - 013f5b458bb09b1c1a60cf170fb3db0e8b02e326 blob + f681cda2dec4d8571d7993a81d215c31c35f77eb Binary files hosts/impish/overlays/config/magdalena/config.json.nix and hosts/impish/overlays/config/magdalena/config.json.nix differ blob - beb932be04cc8905c5dad7002ad382e10f4ab557 blob + 3b32bbbdb771a9eadec88ce3d169ddf74a59b07e --- justfile +++ justfile @@ -8,15 +8,15 @@ check: nix flake check --no-update-lock-file deploy host: - @backup git:nix + @sursum git:nix NH_ELEVATION_STRATEGY=none nh os switch . --hostname {{ host }} --target-host root@{{ host }} --build-host root@{{ host }} home: - -@backup git:nix + -@sursum git:nix nh home switch . diff host: - -@backup git:nix + -@sursum git:nix NH_ELEVATION_STRATEGY=none nh os switch -n -d always . \ --hostname {{ host }} \ --target-host root@{{ host }} \ blob - f9f473e401e40749c33df98ed8639622a8105461 (mode 644) blob + /dev/null --- modules/mixins/dotfiles/bin/backup +++ /dev/null @@ -1,401 +0,0 @@ -#!/usr/bin/env ruby -# vim: ft=ruby -# frozen_string_literal: true - -require "English" -require "optparse" -require "open3" -require "timeout" -require "yaml" - -module Backup - Result = Struct.new(:status, :out, :err) do - def success? = status&.success? - def exitcode = status&.exitstatus || 127 - end - - Backend = Struct.new(:list, :run) - - @failures = [] - @current_pid = nil - - def self.ts = Time.now.strftime("%Y-%m-%dT%H:%M:%S%z") - def self.log(msg) = puts("[#{ts}]: #{msg}") - def self.err(msg) = warn("[#{ts}]: #{msg}") - - def self.fatal(msg) - err(msg) - exit(1) - end - - def self.record_failure(msg) - @failures << msg - err("ERROR: #{msg}") - end - - 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") } - PLAKAR_CONFIG_DIR = "#{HOME}/.config/plakar".freeze - - GIT_DIRS = { - "bandcamp" => "#{HOME}/misc/music/backlog/_todo", - "mpd" => "#{HOME}/.config/mpd", - "nix" => "#{HOME}/src/sr.ht/nix", - "notes" => "#{HOME}/notes", - "pass" => "#{HOME}/.password-store", - "releases" => "#{HOME}/src/mtmn.name/releases" - }.freeze - - TARSNAP_DIRS = { - "mpd" => "#{HOME}/.config/mpd", - "notes" => "#{HOME}/notes", - "password-store" => "#{HOME}/.password-store", - "releases" => "#{HOME}/src/mtmn.name/releases" - }.freeze - - RCLONE_REMOTE = "backup" - - def self.run_cmd(verbose, *args) - if verbose - $stdout.flush - $stderr.flush - @current_pid = Process.spawn(*args, in: :close) - _, status = Process.wait2(@current_pid) - @current_pid = nil - Result.new(status, "", status.nil? ? "command not found: #{args.first}" : "") - else - out, err, status = Open3.capture3(*args) - Result.new(status, out, err) - end - rescue Errno::ENOENT => e - @current_pid = nil - Result.new(nil, "", e.message) - end - - def self.git(dir, *args, verbose:) - run_cmd(verbose, "git", "--git-dir=#{dir}/.git", "--work-tree=#{dir}", *args) - end - - def self.commit_changes(dir, verbose:) - r = git(dir, "diff", "--cached", "--name-only", verbose: false) - return false 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 false unless check("Failed to commit changes in #{dir}", r2) - - log("Committed changes in #{dir}") - true - end - - def self.git_remote(dir, action, verb) - log("#{verb.capitalize} changes from #{dir}") - r = git(dir, action, verbose: false) - if r.success? - log("Done #{verb}ing #{dir}") - true - else - record_failure("#{verb.capitalize} failed for #{dir} (exit #{r.exitcode}): #{r.err.strip}") - false - end - end - - def self.push_changes(dir) = git_remote(dir, "push", "push") - def self.fetch_changes(dir) = git_remote(dir, "pull", "fetch") - - def self.sync_repo(verbose:, push:, fetch:, name:, dir:) - log("git/#{name}") - 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) - committed = if diff.success? - log("Nothing to commit in #{dir}") - true - else - commit_changes(dir, verbose: verbose) - end - - return unless committed - return if fetch && !fetch_changes(dir) - - push_changes(dir) if push && committed - end - - def self.tarsnap_archive(verbose:, name:, dir:) - stamp = Time.now.strftime("%Y-%m-%d_%H:%M:%S") - log("Archiving tarsnap/#{name} from #{dir}") - 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.rclone_backup(verbose:, name:, dir:) - remote_path = "#{RCLONE_REMOTE}:#{name}" - log("Syncing rclone/#{name} from #{dir} to #{remote_path}") - args = ["rclone", "copy", dir, remote_path] - if verbose - args.concat(["--progress", "--verbose"]) - else - args.concat(["--stats-one-line"]) - end - args.concat([ - "--transfers", "4", - "--checkers", "8", - "--checksum" - ]) - r = run_cmd(verbose, *args) - return unless check("rclone failed for #{name}", r) - - log("Done rclone/#{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 => e - fatal("Failed to parse #{path}: #{e.message}") - end - - def self.plakar_stores - @plakar_stores ||= (load_plakar_yml("stores") || plakar_query("store")).freeze - end - - def self.plakar_sources - @plakar_sources ||= (load_plakar_yml("sources") || plakar_query("source")).freeze - end - - def self.plakar_stores_hash = plakar_stores - def self.plakar_stores_list = plakar_stores.keys - def self.plakar_sources_list = plakar_sources.keys - def self.plakar_source_dirs = plakar_sources.transform_values { |cfg| cfg["location"] }.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:) - 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:, exclude: []) - fatal("Unknown plakar store: #{store}") unless plakar_stores_list.include?(store) - (plakar_sources_list - exclude).each { |src| plakar_source(verbose: verbose, store: store, source: src) } - end - - def self.backup_plakar(verbose:, exclude: []) - plakar_stores_list.each { |store| plakar_store(verbose: verbose, store: store, exclude: exclude) } - end - - def self.list_flat(name, dirs) - puts(name) - dirs.keys.sort.each { |k| puts(" #{k}") } - end - - def self.list_plakar - puts("plakar") - puts(" sources") - plakar_sources_list.sort.each { |s| puts(" #{s}") } - plakar_stores_list.sort.each do |store| - loc = plakar_stores_hash.dig(store, "location") || "?" - puts(" #{store} (#{loc})") - end - end - - def self.list_rclone - puts("rclone") - puts(" sources") - plakar_source_dirs.keys.sort.each { |s| puts(" #{s}") } - puts(" #{RCLONE_REMOTE}") - end - - def self.git_run(verbose:, push:, fetch:, parts:, exclude:) - if parts.empty? - GIT_DIRS.except(*exclude).each { |n, d| sync_repo(verbose: verbose, push: push, fetch: fetch, name: n, dir: d) } - else - name = parts.first - return if exclude.include?(name) - sync_repo( - verbose: verbose, - push: push, - fetch: fetch, - name: name, - dir: GIT_DIRS.fetch(name) { fatal("Unknown git target: #{name}") } - ) - end - end - - def self.tarsnap_run(verbose:, parts:, exclude:) - if parts.empty? - TARSNAP_DIRS.except(*exclude).each { |n, d| tarsnap_archive(verbose: verbose, name: n, dir: d) } - else - name = parts.first - return if exclude.include?(name) - tarsnap_archive( - verbose: verbose, - name: name, - dir: TARSNAP_DIRS.fetch(name) { fatal("Unknown tarsnap target: #{name}") } - ) - end - end - - def self.rclone_run(verbose:, parts:, exclude:) - if parts.empty? - plakar_source_dirs.except(*exclude).each { |n, d| rclone_backup(verbose: verbose, name: n, dir: d) } - else - name = parts.first - return if exclude.include?(name) - rclone_backup( - verbose: verbose, - name: name, - dir: plakar_source_dirs.fetch(name) { fatal("Unknown rclone target: #{name}") } - ) - end - end - - def self.plakar_run(verbose:, parts:, exclude:) - if parts.empty? - backup_plakar(verbose: verbose, exclude: exclude) - elsif parts.size == 1 - plakar_store(verbose: verbose, store: parts.first, exclude: exclude) - else - store = parts[0] - selected = parts[1].split(",") - exclude - selected.each do |source| - plakar_source(verbose: verbose, store: store, source: source) - end - end - end - - BACKENDS = { - "git" => Backend.new( - list: -> { list_flat("git", GIT_DIRS) }, - run: ->(verbose:, push:, fetch:, exclude:, parts:) { git_run(verbose: verbose, push: push, fetch: fetch, exclude: exclude, parts: parts) } - ), - "tarsnap" => Backend.new( - list: -> { list_flat("tarsnap", TARSNAP_DIRS) }, - run: ->(verbose:, push:, fetch:, exclude:, parts:) { tarsnap_run(verbose: verbose, exclude: exclude, parts: parts) } - ), - "plakar" => Backend.new( - list: -> { list_plakar }, - run: ->(verbose:, push:, fetch:, exclude:, parts:) { plakar_run(verbose: verbose, exclude: exclude, parts: parts) } - ), - "rclone" => Backend.new( - list: -> { list_rclone }, - run: ->(verbose:, push:, fetch:, exclude:, parts:) { rclone_run(verbose: verbose, exclude: exclude, parts: parts) } - ) - }.freeze - - def self.list_targets - BACKENDS.each_value { |b| b.list.call } - end - - def self.backup_target(verbose:, push:, fetch:, exclude:, target:) - parts = target.split(":") - bname = parts.shift - backend = BACKENDS[bname] || fatal("Unknown target: #{target}") - backend.run.call(verbose: verbose, push: push, fetch: fetch, exclude: exclude, parts: parts) - end - - def self.main(args) - Signal.trap("INT") do - warn("[#{Time.now.strftime("%Y-%m-%dT%H:%M:%S%z")}]: Interrupted") - if @current_pid - begin - Process.kill("TERM", @current_pid) - begin - Timeout.timeout(60) { Process.wait(@current_pid) } - rescue Timeout::Error - begin - Process.kill("KILL", @current_pid) - rescue - Errno::ESRCH - end - end - rescue Errno::ESRCH - end - end - exit(130) - end - - options = {verbose: true, push: false, fetch: false, list: false, exclude: []} - parser = OptionParser.new do |o| - o.banner = "Usage: backup [options] [target]\n" \ - " target: git, plakar, tarsnap, rclone, :\n" \ - " plakar sources may be comma-separated: plakar::\n" \ - " -e may exclude sources: backup plakar: -e " - 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("-e", "--exclude LIST", "Comma-separated list of sources/targets to skip") { |v| options[:exclude].concat(v.split(",").map(&:strip)) } - o.on("-h", "--help") do - puts(o) - exit(0) - end - end - - parser.parse!(args) - target = args.shift - unless options[:list] || target - list_targets - exit(0) - end - - options[:fetch] = true if options[:push] - - list_targets if options[:list] - if target - backup_target(verbose: options[:verbose], push: options[:push], fetch: options[:fetch], exclude: options[:exclude], target: target) - end - - fatal("#{@failures.size} backup target(s) failed") unless @failures.empty? - end -end - -Backup.main(ARGV) blob - e3d5063e4cfc82737d397f7678b74fe4bfa4017c blob + 6f0dfddcdbe41ffd8105e2c99aca3f39d1fd688b --- modules/mixins/dotfiles/bin/tuped +++ modules/mixins/dotfiles/bin/tuped @@ -97,7 +97,7 @@ (print usage) (os/exit 0)) (def opts (parse-args argv)) - (unless (have-spiped) (die "spiped not found on PATH (install net/spiped)")) + (unless (have-spiped) (die "spiped not found")) (unless (opts :key) (die "--key is required")) (when (zero? (opts :local-port)) (die "--local-port is required")) (when (zero? (opts :remote-port)) (die "--remote-port is required")) blob - 418d45818ead0246b5592bfb08545518c6c1f72d blob + 1c0c36295391d53d53f269f7236ad97bd49cc2f4 --- modules/mixins/dotfiles/config/nvim/fnl/init.fnl +++ modules/mixins/dotfiles/config/nvim/fnl/init.fnl @@ -38,6 +38,7 @@ (gh :jpalardy/vim-slime) (gh :gpanders/nvim-parinfer) (gh :L3MON4D3/LuaSnip) + (gh :janet-lang/janet.vim) {:src (gh :neovim/nvim-lspconfig) :version :master} {:src (gh :saghen/blink.cmp) :version (vim.version.range "^1")}] {:load true}) blob - 16b6538f40b07b3db232d246fd8717463ee5b686 blob + 4fcc19d5c21f4103b3bf86df9b3d954ee6c9def5 --- modules/mixins/dotfiles/config/shell/rc/command_overrides +++ modules/mixins/dotfiles/config/shell/rc/command_overrides @@ -69,3 +69,4 @@ alias gdb='gdb -q' alias dmesg='dmesg -T --color=always | less -R +G' alias scc='scc --no-cocomo' +alias backup='sursum'