747 lines
34 KiB
Bash
Executable File
747 lines
34 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# homehub — lean home-drive sync: rsync against a Synology hub, mtime-arbitrated,
|
|
# silent unless something has been broken for a day.
|
|
#
|
|
# sync [--exec] one pull+push cycle (DRY unless --exec)
|
|
# status what would move, and who synced when
|
|
# fresh [--dry] the only command allowed to complain (24 h rule)
|
|
# git <sub> class B: repos.tsv manifest, clone on the far side
|
|
# bookmarks [--write] class C: union-merge the link bar
|
|
# forget <path>... drop tombstones so a path can come back
|
|
# filter dump the generated rsync filter
|
|
# setup | setup-key | install one-time plumbing
|
|
#
|
|
# Rationale for every odd choice: ../DESIGN.md
|
|
set -uo pipefail
|
|
|
|
HH_ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
|
|
[ -r "$HH_ROOT/config" ] || { printf 'homehub: no config at %s\n' "$HH_ROOT/config" >&2; exit 1; }
|
|
# shellcheck disable=SC1091
|
|
. "$HH_ROOT/config"
|
|
|
|
# HH_HOST lets the deletion/convergence semantics be tested with two throwaway
|
|
# "machines" on one box; leave unset in normal use.
|
|
HOST=${HH_HOST:-$(hostname -s)}
|
|
HUB_URL="$HUB_HOST:$HUB_PATH"
|
|
RULES=$HH_ROOT/rules
|
|
STATE=${STATE_ROOT:?config: STATE_ROOT unset}
|
|
FILTER=$STATE/effective.filter
|
|
mkdir -p "$STATE/tmp"
|
|
|
|
# systemd sets HH_QUIET=1: silence on success is a feature, not an omission (§10)
|
|
v() { [ "${HH_QUIET:-0}" = 1 ] || printf ' %s\n' "$*"; }
|
|
warn() { printf 'homehub: %s\n' "$*" >&2; }
|
|
die() { printf 'homehub: %s\n' "$*" >&2; exit 1; }
|
|
now() { date +%s; }
|
|
hr() { [ "${HH_QUIET:-0}" = 1 ] || printf '\n== %s ==\n' "$*"; }
|
|
|
|
# ------------------------------------------------------------- ssh plumbing --
|
|
SSH_OPTS=(-o BatchMode=yes -o ConnectTimeout="${SSH_TIMEOUT:-10}" -o LogLevel=ERROR)
|
|
[ -f "$HH_KEY" ] && SSH_OPTS+=(-i "$HH_KEY" -o IdentitiesOnly=yes)
|
|
hub() { ssh "${SSH_OPTS[@]}" "$HUB_HOST" "$@"; }
|
|
hub_ok() { hub true >/dev/null 2>&1; }
|
|
|
|
# Telling "off the LAN" apart from "our own config is broken" is the whole point of
|
|
# §10: the first must stay silent for weeks, the second must become loud after a
|
|
# day. 0 = usable, 1 = network/unreachable (stays silent), 2 = auth/config (loud).
|
|
hub_probe() {
|
|
local out
|
|
if ! out=$(ssh "${SSH_OPTS[@]}" "$HUB_HOST" true 2>&1); then
|
|
case $out in
|
|
*"Permission denied"*|*"Host key verification"*|*"no matching host key"*|*"invalid format"*|*"no such identity"*)
|
|
return 2 ;;
|
|
esac
|
|
return 1
|
|
fi
|
|
return 0
|
|
}
|
|
|
|
# Local breakage markers — readable with the hub down, so a machine whose own key
|
|
# stopped working can still alert about itself. First-failure epoch is preserved.
|
|
mark_broken() { # kind message
|
|
local f=$STATE/broken.$1 e
|
|
e=$(awk -F'\t' 'NF{print $1; exit}' "$f" 2>/dev/null)
|
|
[ -n "${e:-}" ] || e=$(now)
|
|
printf '%s\t%s\n' "$e" "$2" > "$f"
|
|
}
|
|
clear_broken() { rm -f "$STATE/broken.$1" 2>/dev/null || true; }
|
|
# Remote script with positional args, no quoting hell:
|
|
# hub_script "'$HUB_PATH' '$HOST'" <<'EOS' ... EOS (POSIX sh on DSM!)
|
|
hub_script() { ssh "${SSH_OPTS[@]}" "$HUB_HOST" "sh -s -- $1"; }
|
|
|
|
touch_marker() {
|
|
hub "mkdir -p '$HUB_PATH/.hub/meta/$1' && touch '$HUB_PATH/.hub/meta/$1/$HOST'" || true
|
|
}
|
|
log_line() { # rc note
|
|
printf '%s\t%s\t%s\t%s\n' "$(now)" "$HOST" "$1" "$2" |
|
|
hub "mkdir -p '$HUB_PATH/.hub' && cat >> '$HUB_PATH/.hub/log.tsv'" 2>/dev/null || true
|
|
}
|
|
hub_setup_dirs() {
|
|
hub "mkdir -p '$HUB_PATH/.hub/meta/markers' '$HUB_PATH/.hub/meta/alive' \
|
|
'$HUB_PATH/.hub/meta/alerted' '$HUB_PATH/.hub/tombstones' '$HUB_PATH/.hub/prev' \
|
|
'$HUB_PATH/.hub/gitman' '$HUB_PATH/.hub/bookmarks' && chmod 700 '$HUB_PATH'" || true
|
|
}
|
|
|
|
# ---------------------------------------------------- filter (default deny) --
|
|
sets_for_host() {
|
|
local f=$RULES/hosts/$HOST
|
|
[ -r "$f" ] || { warn "no rules/hosts/$HOST — this host syncs nothing (see rules/hosts/_laptop-template)"; return 1; }
|
|
awk '!/^[[:space:]]*#/ && NF' "$f"
|
|
}
|
|
# one path per line -> rsync rule; trailing slash means "this dir and its subtree"
|
|
rules_from() { # file sign
|
|
awk -v s="$2" '!/^[[:space:]]*#/ && NF {
|
|
if ($0 ~ /\/$/) printf "%s %s***\n", s, $0; else printf "%s %s\n", s, $0 }' "$1"
|
|
}
|
|
# ancestors must be explicitly traversable, otherwise a blanket "- *" can prune
|
|
# the branch before rsync ever reaches the include below it
|
|
ancestors_of() { # /a/b/c/ -> "+ /a/", "+ /a/b/"
|
|
local p=${1%/}
|
|
while [ -n "$p" ] && [ "${p#*/}" != "$p" ]; do
|
|
p=${p%/*}
|
|
[ -n "$p" ] && printf '+ %s/\n' "$p"
|
|
done
|
|
}
|
|
check_shadowing() { # $1 set name
|
|
local f=$RULES/sets.d/$1.list d inc nm
|
|
[ -r "$f" ] || return 0
|
|
while read -r inc; do
|
|
while read -r d; do
|
|
case $d in
|
|
"$inc") warn "shadowed: include '$inc' is also denied verbatim (set $1)"; continue ;;
|
|
esac
|
|
case $d in
|
|
/*/) case $inc in "$d"*|"${d%/}") warn "shadowed: include '$inc' sits under deny rule '$d' (set $1)" ;; esac ;;
|
|
*/) nm=${d%/}; case "/$inc" in *"/$nm"/*|*"/$nm") warn "shadowed: include '$inc' matches unanchored deny '$d' (set $1)" ;; esac ;;
|
|
esac
|
|
done < <(awk '!/^[[:space:]]*#/ && NF' "$RULES/denylist" 2>/dev/null)
|
|
done < <(awk '!/^[[:space:]]*#/ && NF' "$f")
|
|
}
|
|
# Filter composition. Order is load-bearing: rsync applies FIRST MATCH WINS, so
|
|
# 1. denylist (default-deny trees, wins over everything)
|
|
# 2. tombstone excludes (pull only — must precede the includes or they never fire)
|
|
# 3. includes (this host's sets + their traversable ancestors)
|
|
# 4. "- *" (default deny)
|
|
# Two variants: the push filter must NOT exclude tombstones, or a file you re-create
|
|
# after deleting it could never travel again (edits win, §3).
|
|
gen_filter() {
|
|
local sets s
|
|
sets=$(sets_for_host) || return 1
|
|
|
|
{ echo '- /.hub/***' # hub metadata can never leak into a home
|
|
[ -r "$RULES/denylist" ] && rules_from "$RULES/denylist" '-'
|
|
} | awk '!seen[$0]++' > "$FILTER.deny" || return 1
|
|
|
|
{ for s in $sets; do
|
|
[ -r "$RULES/sets.d/$s.list" ] || { warn "unknown set '$s' (no rules/sets.d/$s.list)"; continue; }
|
|
{ rules_from "$RULES/sets.d/$s.list" '+'
|
|
awk '!/^[[:space:]]*#/ && NF' "$RULES/sets.d/$s.list" | while read -r l; do ancestors_of "$l"; done; }
|
|
check_shadowing "$s"
|
|
done
|
|
} | awk '!seen[$0]++' > "$FILTER.incl" || return 1
|
|
|
|
awk -F'\t' 'NF>=2 { p=$2; sub(/^\//, "", p)
|
|
if (p != "" && p !~ /^\.hub\// && p !~ /\.\./) { print "- /" p; print "- /" p "/***" } }' \
|
|
"$STATE/tombstones.all" 2>/dev/null | awk '!seen[$0]++' > "$FILTER.tomb"
|
|
|
|
cat "$FILTER.deny" "$FILTER.tomb" "$FILTER.incl" > "$FILTER.base"
|
|
{ cat "$FILTER.deny" "$FILTER.incl"; echo '- *'; } > "$FILTER.tmp" && mv "$FILTER.tmp" "$FILTER"
|
|
{ cat "$FILTER.deny" "$FILTER.tomb" "$FILTER.incl"; echo '- *'; } > "$FILTER.tmp.pull" && mv "$FILTER.tmp.pull" "$FILTER.pull"
|
|
}
|
|
|
|
# --------------------------------------------------------------- rsync bits --
|
|
# rsync must authenticate with the SAME identity as the rest of homehub, or it
|
|
# silently picks another key/agent and unreachable comes back from the wrong door.
|
|
RSH="ssh -o BatchMode=yes -o ConnectTimeout=${SSH_TIMEOUT:-10} -o LogLevel=ERROR"
|
|
[ -f "${HH_KEY:-}" ] && RSH="$RSH -i $HH_KEY -o IdentitiesOnly=yes"
|
|
RS=(-rltDz --partial --timeout=90 --out-format='%i|%n' -e "$RSH")
|
|
run_rsync() { # dry filter extra args...
|
|
local dry=$1 filt=$2; shift 2
|
|
if [ "$dry" = 1 ]; then
|
|
"$RSYNC_BIN" "${RS[@]}" --filter="merge $filt" -n "$@"
|
|
else
|
|
"$RSYNC_BIN" "${RS[@]}" --filter="merge $filt" "$@"
|
|
fi
|
|
}
|
|
pull_rsync() { # dry
|
|
run_rsync "$1" "$FILTER.pull" --update "$HUB_URL/" "$HOME/"
|
|
}
|
|
push_rsync() { # dry
|
|
if [ "$1" = 1 ]; then
|
|
run_rsync 1 "$FILTER" --update "$HOME/" "$HUB_URL/"
|
|
else
|
|
run_rsync 0 "$FILTER" --update --backup --backup-dir=".hub/prev/$(date +%Y-%m)" "$HOME/" "$HUB_URL/"
|
|
fi
|
|
}
|
|
count_items() { grep -cE '^[<>]' "$1" 2>/dev/null || true; }
|
|
show_head() { grep -E '^[<>]' "$1" 2>/dev/null | head -n "${2:-10}"; }
|
|
|
|
# ----------------------------------------------------- deletions (tombstones) --
|
|
# $STATE/seen = every path this machine has actually transferred (pushed or pulled).
|
|
# It is what makes "on the hub but not local" decidable: a file we transferred and
|
|
# then removed is OUR deletion and may propagate; a file only a peer ever uploaded is
|
|
# not. Without it, a newly added machine harvests every existing hub file as its own
|
|
# deletion and tombstones the whole hub on join. Built from rsync's own itemized
|
|
# output (no extra round trip); its failure mode is the safe one — a file that is not
|
|
# recognised is never deleted.
|
|
seen_update() {
|
|
{ [ -s "$STATE/seen" ] && cat "$STATE/seen"
|
|
awk '/^[<>]/ { sub(/^[^|]*\|/, ""); if (length($0)) print }' \
|
|
"$STATE/pull.out" "$STATE/push.out" 2>/dev/null
|
|
} | sed '/^\.$/d' | sort -u > "$STATE/seen.new" && mv "$STATE/seen.new" "$STATE/seen"
|
|
}
|
|
|
|
# Format on the hub: .hub/tombstones/<host> lines = epoch \t home-rel-path
|
|
hub_tombstones_all() {
|
|
hub_script "'$HUB_PATH'" <<'EOS'
|
|
root=$1
|
|
cd "$root/.hub/tombstones" 2>/dev/null || exit 0
|
|
for f in *; do
|
|
[ -f "$f" ] || continue
|
|
while IFS= read -r l; do printf '%s\t%s\n' "${f%%:*}" "$l"; done < "$f"
|
|
done
|
|
exit 0
|
|
EOS
|
|
}
|
|
# is this home-relative path inside one of this host's sets?
|
|
active_path() { # $1 = path without leading slash
|
|
local p="/$1"
|
|
while :; do
|
|
# NOTE the slash before ***: the include rules are "+ /dir/***", so probing
|
|
# "+ /dir***" would never match and every tombstone would look out-of-scope.
|
|
grep -qxF "+ $p/***" "$FILTER" && return 0 # inside an included subtree
|
|
grep -qxF "+ $p" "$FILTER" && return 0 # is itself an included file
|
|
grep -qxF "+ $p/" "$FILTER" && return 0 # an included ancestor dir
|
|
[ "${p%/*}" = "$p" ] && return 1
|
|
p=${p%/*}
|
|
[ -z "$p" ] && return 1
|
|
done
|
|
}
|
|
apply_tombstones() { # deletions recorded by any machine, edit-wins (§3)
|
|
local all=$STATE/tombstones.all n=0 kept=0 out=0 bad=0
|
|
[ -s "$all" ] || { v "tombstones: none recorded"; return 0; }
|
|
while IFS=$'\t' read -r src ep path; do
|
|
[ -n "${path:-}" ] || continue
|
|
path=${path#/}
|
|
case $path in
|
|
.hub/*|*..*|/*) warn "ignoring suspicious tombstone '$path'"; bad=$((bad+1)); continue ;;
|
|
esac
|
|
active_path "$path" || { out=$((out+1)); continue; }
|
|
[ -e "$HOME/$path" ] || continue
|
|
if [ -n "$ep" ] && find "$HOME/$path" -newermt "@$ep" -print -quit 2>/dev/null | grep -q .; then
|
|
kept=$((kept+1)); continue # touched after the delete: edits win, keep it
|
|
fi
|
|
n=$((n+1))
|
|
if [ "$n" -gt "${TOMB_LIMIT:-100}" ] && [ "${HH_FORCE:-0}" != 1 ]; then
|
|
die "tombstones ask for more than $TOMB_LIMIT deletions — refusing. Check $HUB_HOST:$HUB_PATH/.hub/tombstones/ then re-run with HH_FORCE=1"
|
|
fi
|
|
v "deleted (tombstone from $src): $path"
|
|
rm -rf --one-file-system -- "$HOME/$path"
|
|
done < "$all"
|
|
v "tombstones: applied=$n edited-since-so-kept=$kept outside-sets=$out malformed=$bad"
|
|
}
|
|
harvest_tombstones() { # record what this machine deleted — BEFORE any pull, else
|
|
# the pull re-downloads the file and the deletion is silently lost forever.
|
|
# A hub copy newer than our last successful sync was edited by someone else
|
|
# since we last looked, so it is an edit, not a deletion: leave it alone (§3).
|
|
local new=$STATE/tomb.new merged=$STATE/tomb.merged n ep last rc=0
|
|
# stderr is kept, never sent to /dev/null: an ssh / known_hosts / transport error
|
|
# here would otherwise look exactly like "nothing was deleted" and silently stop
|
|
# deletions propagating anywhere, forever.
|
|
run_rsync 1 "$FILTER" --delete "$HOME/" "$HUB_URL/" > "$STATE/harvest.out" 2> "$STATE/harvest.err" || rc=$?
|
|
if [ "$rc" != 0 ]; then
|
|
warn "deletion harvest failed (rsync rc=$rc): $(tr '\n' ' ' < "$STATE/harvest.err" | tail -c 200)"
|
|
return 1
|
|
fi
|
|
awk -F'|' '$1 ~ /^\*/ { sub(/^\//, "", $2); print $2 }' "$STATE/harvest.out" | sort -u > "$new.raw"
|
|
# intersect with what we saw at the hub on the previous sync (first sync: no cache,
|
|
# so nothing can be claimed as a deletion — correct, nothing was ever ours to delete)
|
|
if [ -s "$STATE/seen" ]; then
|
|
comm -12 "$new.raw" "$STATE/seen" > "$new"
|
|
else
|
|
: > "$new"
|
|
v "nothing transferred by this machine yet: no deletions are attributable to it"
|
|
fi
|
|
local skipped; skipped=$(($(grep -c . "$new.raw" 2>/dev/null || true) - $(grep -c . "$new" 2>/dev/null || true)))
|
|
[ "${skipped:-0}" -gt 0 ] && v "ignored $skipped hub file(s) this machine never had (not our deletions)"
|
|
n=$(grep -c . "$new" 2>/dev/null || true)
|
|
if [ "${n:-0}" = 0 ]; then v "no local deletions to record"; return 0; fi
|
|
|
|
last=$(hub "date -r '$HUB_PATH/.hub/meta/markers/$HOST' +%s 2>/dev/null || echo 0")
|
|
local kept=$STATE/tomb.kept edited=$STATE/tomb.edited
|
|
# Candidates exist on the hub by definition (hub has it, we do not), so spool the
|
|
# list there and let the remote loop stat it. A heredoc cannot share stdin with a
|
|
# pipe, which is why this is two ssh calls rather than one.
|
|
hub "mkdir -p '$HUB_PATH/.hub/tmp' && cat > '$HUB_PATH/.hub/tmp/cand.$HOST'" < "$new" || return 1
|
|
hub_script "'$HUB_PATH' '${last:-0}' '$HOST'" > "$kept" <<'EOS'
|
|
root=$1; last=$2; me=$3
|
|
cf="$root/.hub/tmp/cand.$me"
|
|
[ -f "$cf" ] || exit 0
|
|
while IFS= read -r p; do
|
|
[ -n "$p" ] || continue
|
|
m=$(date -r "$root/$p" +%s 2>/dev/null || echo 0)
|
|
if [ "$last" != 0 ] && [ "$m" -gt "$last" ]; then
|
|
printf '%s\tEDITED-ELSEWHERE\n' "$p"
|
|
else
|
|
printf '%s\n' "$p"
|
|
fi
|
|
done < "$cf"
|
|
rm -f "$cf"
|
|
exit 0
|
|
EOS
|
|
grep -c 'EDITED-ELSEWHERE' "$kept" 2>/dev/null | tr -d ' ' > "$edited" || echo 0 > "$edited"
|
|
grep -v 'EDITED-ELSEWHERE' "$kept" > "$kept.paths" || true
|
|
if [ "$(grep -c . "$kept.paths" 2>/dev/null || true)" = 0 ]; then
|
|
v "no deletions recorded ($(cat "$edited") path(s) were edited elsewhere -> kept as edits)"; return 0
|
|
fi
|
|
|
|
ep=$(now)
|
|
{ hub "cat '$HUB_PATH/.hub/tombstones/$HOST' 2>/dev/null"
|
|
awk -v e="$ep" 'NF { print e "\t" $0 }' "$kept.paths"
|
|
} | awk -F'\t' 'NF>=2 { p=$2; if (!(p in e) || $1+0 < e[p]) e[p]=$1+0 }
|
|
END { for (p in e) printf "%d\t%s\n", e[p], p }' | sort -t$'\t' -k2 > "$merged"
|
|
hub "mkdir -p '$HUB_PATH/.hub/tombstones' && cat > '$HUB_PATH/.hub/tombstones/$HOST'" < "$merged" ||
|
|
{ warn "could not write tombstones"; return 1; }
|
|
# fold into the local cache and refresh the pull filter so the pull cannot undo us
|
|
cat "$merged" >> "$STATE/tombstones.all"
|
|
sort -u -t$'\t' -k2,2 "$STATE/tombstones.all" -o "$STATE/tombstones.all"
|
|
gen_filter >/dev/null 2>&1 || true
|
|
v "recorded $(grep -c . "$kept.paths") deletion(s) as tombstones ($(cat "$edited") edited elsewhere, kept)"
|
|
}
|
|
reap() { # stop resurrecting files deleted more than GRACE_DAYS ago (§5)
|
|
hub_script "'$HUB_PATH' '${GRACE_DAYS:-30}'" <<'EOS' || true
|
|
root=$1; grace=$2
|
|
cd "$root/.hub/tombstones" 2>/dev/null || exit 0
|
|
cutoff=$(( $(date +%s) - grace * 86400 ))
|
|
for f in *; do
|
|
[ -f "$f" ] || continue
|
|
awk -F'\t' -v c="$cutoff" '$1+0 < c { print }' "$f" > "$f.gone"
|
|
awk -F'\t' -v c="$cutoff" '$1+0 >= c { print }' "$f" > "$f.keep"
|
|
while IFS=$'\t' read -r e p; do
|
|
[ -n "$p" ] || continue
|
|
t="$root/$p"
|
|
[ -e "$t" ] || continue
|
|
d="$root/.hub/prev/reaped/$(date -d "@$e" +%Y-%m 2>/dev/null || echo unknown)"
|
|
mkdir -p "$d/$(dirname "$p")" 2>/dev/null && mv "$t" "$d/$p" 2>/dev/null && echo "reaped $p"
|
|
done < "$f.gone"
|
|
if [ -s "$f.keep" ]; then mv "$f.keep" "$f"; else rm -f "$f.keep" "$f"; fi
|
|
rm -f "$f.gone"
|
|
done
|
|
exit 0
|
|
EOS
|
|
}
|
|
|
|
# The hub sync root must contain ONLY what homehub put there. Anything else means
|
|
# HUB_PATH points somewhere that already has data — which would be merged into
|
|
# $HOME on the first pull (and could be reaped by a tombstone). This guard exists
|
|
# because /volume1/homes/bl really did hold 178 G of unrelated legacy data.
|
|
check_hub_stray() {
|
|
[ -r "$FILTER.base" ] || return 1 # cannot judge -> do not block the sync
|
|
local allowed stray
|
|
allowed=$(printf '.hub\n@eaDir\n'; grep '^+ /' "$FILTER.base" | sed -E 's#^\+ /([^/]+).*#\1#; s#/$##' | sort -u)
|
|
stray=$(hub "cd '$HUB_PATH' 2>/dev/null && ls -A" | grep -vxF -f <(printf '%s\n' "$allowed") || true)
|
|
[ -z "$stray" ] && return 1
|
|
warn "unexpected content in hub sync root $HUB_PATH (pull would merge it into $HOME):"
|
|
printf ' %s\n' $stray
|
|
return 0
|
|
}
|
|
|
|
# ------------------------------------------------------------------ sync ----
|
|
cmd_sync() {
|
|
local exec=0; [ "${1:-}" = "--exec" ] && exec=1
|
|
local dry=1; [ $exec = 1 ] && dry=0
|
|
local sets rc=0
|
|
|
|
sets=$(sets_for_host) || return 1
|
|
|
|
case "$(hub_probe; echo $?)" in
|
|
1) # Off the LAN. Silent on purpose: the laptop-in-another-country case (§10).
|
|
v "hub $HUB_HOST unreachable — nothing done (silent by design; a live machine that stays broken alerts through 'fresh' after ${FRESH_MAX_AGE}s)"
|
|
return 0 ;;
|
|
2) warn "cannot authenticate to $HUB_HOST (key: ${HH_KEY:-default agent}). This is a CONFIGURATION failure, not an offline machine."
|
|
warn " fix: ssh-copy-id -i ${HH_KEY:-~/.ssh/id_ed25519}.pub $HUB_HOST"
|
|
mark_broken auth "ssh auth to $HUB_HOST failed (key ${HH_KEY:-default})"
|
|
return 64 ;;
|
|
esac
|
|
clear_broken auth
|
|
hub_setup_dirs
|
|
# tombstones must be known BEFORE the filter is built: they shape the pull filter
|
|
hub_tombstones_all > "$STATE/tombstones.all" || true
|
|
gen_filter || return 1
|
|
# ...and the filter must exist before the hub root can be checked against it
|
|
if check_hub_stray; then
|
|
[ $exec = 1 ] && [ "${HH_FORCE:-0}" != 1 ] &&
|
|
die "refusing to sync into a hub root that is not ours. Fix HUB_PATH in config, move that data away, or re-run with HH_FORCE=1 if you really mean it"
|
|
fi
|
|
hr "homehub sync ($([ $exec = 1 ] && echo EXECUTE || echo 'dry run')) on $HOST"
|
|
v "sets: $sets"
|
|
v "filter: $(grep -c . "$FILTER" || true) push, $(grep -c . "$FILTER.pull" || true) pull rules, $(grep -c . "$STATE/tombstones.all" 2>/dev/null || true) tombstoned"
|
|
[ $exec = 1 ] && touch_marker alive
|
|
|
|
# 0. harvest BEFORE the pull: a deletion the pull would otherwise undo
|
|
if [ $exec = 1 ]; then
|
|
hr "0. harvest this machine's deletions (before the pull can undo them)"
|
|
harvest_tombstones || rc=1
|
|
gen_filter || return 1 # pull filter must know the fresh tombstones
|
|
fi
|
|
|
|
hr "1. pull $HUB_URL -> $HOME/"
|
|
pull_rsync "$dry" > "$STATE/pull.out" 2> "$STATE/pull.err" || rc=$?
|
|
if [ $rc != 0 ]; then
|
|
warn "pull failed (rsync rc=$rc): $(tr '\n' ' ' < "$STATE/pull.err" | tail -c 300)"
|
|
else
|
|
v "$(count_items "$STATE/pull.out") change(s) inbound"
|
|
[ $dry = 1 ] && show_head "$STATE/pull.out"
|
|
fi
|
|
|
|
if [ $exec = 1 ]; then
|
|
hr "2. apply deletions recorded by any machine (edit-wins)"
|
|
apply_tombstones
|
|
else
|
|
hr "2. deletion handling skipped in dry run (harvest only runs with --exec)"
|
|
fi
|
|
|
|
hr "4. push $HOME/ -> $HUB_URL"
|
|
push_rsync "$dry" > "$STATE/push.out" 2> "$STATE/push.err" || rc=$?
|
|
if [ $rc != 0 ]; then
|
|
warn "push failed (rsync rc=$rc): $(tr '\n' ' ' < "$STATE/push.err" | tail -c 300)"
|
|
else
|
|
v "$(count_items "$STATE/push.out") change(s) outbound"
|
|
[ $dry = 1 ] && show_head "$STATE/push.out"
|
|
fi
|
|
|
|
if [ $exec = 1 ] && [ $rc = 0 ]; then
|
|
reap | sed 's/^/ /'
|
|
touch_marker markers
|
|
hub "rm -f '$HUB_PATH/.hub/meta/alerted/$HOST'" || true
|
|
clear_broken sync
|
|
seen_update
|
|
elif [ $exec = 1 ]; then
|
|
mark_broken sync "last sync failed with rsync rc=$rc (see $STATE/pull.err, $STATE/push.err)"
|
|
fi
|
|
[ $exec = 1 ] && log_line "$rc" "sync"
|
|
|
|
if [ $dry = 1 ]; then
|
|
local tot; tot=$(count_items "$STATE/pull.out"); tot=$((tot + $(count_items "$STATE/push.out")))
|
|
hr "summary"
|
|
v "$tot change(s) pending — nothing was written. Run: homehub sync --exec"
|
|
[ "${tot:-0}" -gt "${WARN_FILES:-20000}" ] &&
|
|
warn "that is a lot of files for a first run; review $STATE/{pull,push}.out first"
|
|
fi
|
|
return $rc
|
|
}
|
|
|
|
# ---------------------------------------------------------------- status ----
|
|
hub_meta() {
|
|
hub_script "'$HUB_PATH'" <<'EOS'
|
|
root=$1
|
|
cd "$root/.hub" 2>/dev/null || exit 0
|
|
for d in meta/markers meta/alive meta/alerted; do
|
|
for f in "$d"/*; do
|
|
[ -f "$f" ] || continue
|
|
printf '%s\t%s\t%s\n' "${d##*/}" "$(basename "$f")" "$(date -r "$f" +%s 2>/dev/null || echo 0)"
|
|
done
|
|
done
|
|
if [ -f log.tsv ]; then
|
|
tail -n 400 log.tsv | awk -F'\t' '{ r[$2] = $3 "\t" $4 } END { for (h in r) printf "lastlog\t%s\t%s\n", h, r[h] }'
|
|
fi
|
|
for f in tombstones/*; do [ -f "$f" ] && printf 'tomb\t%s\t%s\n' "$(basename "$f")" "$(grep -c . "$f")"; done
|
|
for f in gitman/repos.*; do [ -f "$f" ] && printf 'repos\t%s\t%s\n' "$(basename "$f" 2>/dev/null)" "$(grep -c . "$f")"; done
|
|
for f in bookmarks/*; do [ -f "$f" ] && printf 'bmarks\t%s\t%s\n' "$(basename "$f" .json)" "$(date -r "$f" +%s)"; done
|
|
exit 0
|
|
EOS
|
|
}
|
|
age() { local n=$(( $(now) - ${1:-0} )); [ "${1:-0}" = 0 ] && { printf 'never'; return; }
|
|
if [ $n -lt 3600 ]; then printf '%dm' $((n/60))
|
|
elif [ $n -lt 172800 ]; then printf '%.1fh' "$(awk -v n=$n 'BEGIN{print n/3600}')"
|
|
else printf '%.0fd' "$(awk -v n=$n 'BEGIN{print n/86400}')"; fi; }
|
|
|
|
cmd_status() {
|
|
hub_ok || die "hub $HUB_HOST unreachable"
|
|
sets_for_host >/dev/null || true
|
|
gen_filter 2>/dev/null || true
|
|
hub_meta > "$STATE/meta.tsv" || die "could not read hub metadata"
|
|
|
|
hr "machines"
|
|
printf ' %-14s %-9s %-9s %-6s %s\n' HOST synced heartbeat last-rc notes
|
|
local h
|
|
for h in $(awk -F'\t' '$1=="markers"||$1=="alive"{print $2}' "$STATE/meta.tsv" | sort -u); do
|
|
local m a rc note
|
|
m=$(awk -F'\t' -v h="$h" '$1=="markers"&&$2==h{print $3}' "$STATE/meta.tsv")
|
|
a=$(awk -F'\t' -v h="$h" '$1=="alive"&&$2==h{print $3}' "$STATE/meta.tsv")
|
|
rc=$(awk -F'\t' -v h="$h" '$1=="lastlog"{split($0,x,"\t"); if (x[2]==h) print x[3]}' "$STATE/meta.tsv")
|
|
note=$(awk -F'\t' -v h="$h" '$1=="lastlog"{split($0,x,"\t"); if (x[2]==h) print x[4]}' "$STATE/meta.tsv")
|
|
printf ' %-14s %-9s %-9s %-6s %s\n' "$h" "$(age "$m")" "$(age "$a")" "${rc:-?}" "${note:-}"
|
|
done
|
|
|
|
hr "this host ($HOST)"
|
|
v "sets: $(sets_for_host | tr '\n' ' ')"
|
|
v "included: $(grep -c '^+ ' "$FILTER" 2>/dev/null || echo 0) rules, denied: $(grep -c '^- ' "$FILTER" 2>/dev/null || echo 0)"
|
|
v "state dir: $STATE"
|
|
awk -F'\t' -v h="$HOST" '$1=="tomb"&&$2==h{print " tombstones: " $3 " paths"}' "$STATE/meta.tsv"
|
|
v "pending dry-run lists: $STATE/pull.out $STATE/push.out (run 'homehub sync' to refresh)"
|
|
|
|
hr "class B — repo manifest"
|
|
awk -F'\t' '$1=="repos"{printf " %-14s %s repos\n", $2, $3}' "$STATE/meta.tsv"
|
|
cmd_git missing 2>/dev/null | sed 's/^/ /' || true
|
|
|
|
hr "class C — bookmark bar"
|
|
awk -F'\t' '$1=="bmarks"{printf " %-14s exported %s\n", $2, ""}' "$STATE/meta.tsv"
|
|
[ -r "$BOOKMARK_SRC" ] && v "live file: $BOOKMARK_SRC"
|
|
}
|
|
|
|
# ----------------------------------------------------------------- fresh ----
|
|
alert_cmd() {
|
|
if [ -n "${ALERT_CMD:-}" ]; then printf '%s' "$ALERT_CMD"; return; fi
|
|
if command -v msmtp >/dev/null 2>&1; then printf 'msmtp -a homehub %s' "$ALERT_TO"
|
|
elif command -v sendmail >/dev/null 2>&1; then printf 'sendmail -t'
|
|
else printf 'cat >> "%s/alerts.out"' "$STATE"; fi
|
|
}
|
|
alert_send() { # subject body
|
|
{ printf 'From: %s\nTo: %s\nSubject: %s\n\n%s\n' "${ALERT_FROM:-homehub}" "${ALERT_TO:-root}" "$1" "$2" |
|
|
sh -c "$(alert_cmd)"; } 2>>"$STATE/alert.err" || warn "alert delivery failed (see $STATE/alert.err)"
|
|
if [ "${HH_QUIET:-0}" != 1 ] && [ -n "${DISPLAY:-}" ] && command -v notify-send >/dev/null 2>&1; then
|
|
notify-send -u critical "$1" "$2" || true
|
|
fi
|
|
}
|
|
cmd_fresh() { # the ONLY place that may complain (§10)
|
|
local dry=0; [ "${1:-}" = "--dry" ] && dry=1
|
|
local n=$(now) h msgs="" f
|
|
|
|
# 1) Self-checks that work with the hub down. Closes the hole where a dead key
|
|
# means no marker and no heartbeat, i.e. a machine that can never alert.
|
|
for f in "$STATE"/broken.*; do
|
|
[ -e "$f" ] || continue
|
|
local kind=${f##*.} ep msg age already=0
|
|
ep=$(awk -F'\t' 'NF{print $1; exit}' "$f"); msg=$(cut -f2- "$f")
|
|
age=$(( n - ${ep:-0} ))
|
|
[ -e "$STATE/alerted-local.$kind" ] &&
|
|
[ $(( n - $(date -r "$STATE/alerted-local.$kind" +%s) )) -lt "${REALERT_AGE:-604800}" ] && already=1
|
|
if [ "$age" -gt "${FRESH_MAX_AGE:-86400}" ] && [ $already = 0 ]; then
|
|
msgs="$msgs$HOST, local check '$kind': $msg (failing for $(age "$ep"))"$'\n'
|
|
touch "$STATE/alerted-local.$kind"
|
|
else
|
|
v "ok(local): $kind has been failing for $(age "$ep")$([ $already = 1 ] && echo ', already alerted')"
|
|
fi
|
|
done
|
|
|
|
# 2) Hub-side freshness of every machine
|
|
if ! hub_ok; then
|
|
v "hub unreachable — cannot judge other machines (the local checks above still ran)"
|
|
else
|
|
hub_meta > "$STATE/meta.tsv" || die "cannot read hub metadata"
|
|
for h in $(awk -F'\t' '$1=="markers"||$1=="alive"{print $2}' "$STATE/meta.tsv" | sort -u); do
|
|
local m a al rc mka maa
|
|
m=$(awk -F'\t' -v h="$h" '$1=="markers"&&$2==h{print $3}' "$STATE/meta.tsv")
|
|
a=$(awk -F'\t' -v h="$h" '$1=="alive"&&$2==h{print $3}' "$STATE/meta.tsv")
|
|
al=$(awk -F'\t' -v h="$h" '$1=="alerted"&&$2==h{print $3}' "$STATE/meta.tsv")
|
|
rc=$(awk -F'\t' -v h="$h" '$1=="lastlog"{split($0,x,"\t"); if (x[2]==h) print x[3]}' "$STATE/meta.tsv")
|
|
mka=$(( n - ${m:-0} )); maa=$(( n - ${a:-$n} ))
|
|
local alive_now=0; [ "${a:-0}" != 0 ] && [ $maa -lt "${ALIVE_FRESH:-10800}" ] && alive_now=1
|
|
local netrc=0 x
|
|
for x in ${NET_RC:-5 255}; do [ "$rc" = "$x" ] && netrc=1; done
|
|
local realert_ok=1
|
|
[ "${al:-0}" != 0 ] && [ $(( n - al )) -lt "${REALERT_AGE:-604800}" ] && realert_ok=0
|
|
|
|
if [ $alive_now = 1 ] && [ $mka -gt "${FRESH_MAX_AGE:-86400}" ] && [ $netrc = 0 ] && [ $realert_ok = 1 ]; then
|
|
msgs="$msgs$h (last success $(age "$m"), last rc=${rc:-?})"$'\n'
|
|
v "STALE: $h — on, but not synced for $(age "$m")"
|
|
hub "mkdir -p '$HUB_PATH/.hub/meta/alerted' && touch '$HUB_PATH/.hub/meta/alerted/$h'" || true
|
|
else
|
|
v "ok: $h (sync $(age "$m"), heartbeat $(age "$a"), rc=${rc:--}$([ $netrc = 1 ] && echo ', off-LAN: silent'))"
|
|
fi
|
|
done
|
|
fi
|
|
|
|
[ -n "$msgs" ] || return 0
|
|
if [ $dry = 1 ]; then
|
|
printf 'homehub: would alert for:\n%s' "$msgs" >&2
|
|
return 0
|
|
fi
|
|
alert_send "homehub: sync stale on $(hostname -f 2>/dev/null || hostname)" \
|
|
"These machines are running but have not completed a homehub sync in over $(( ${FRESH_MAX_AGE:-86400} / 3600 )) h:
|
|
|
|
$msgs
|
|
Check with: homehub status
|
|
Hub: $HUB_HOST:$HUB_PATH/.hub/log.tsv"
|
|
}
|
|
|
|
# ------------------------------------------------------- class B: the repos --
|
|
git_root_p() { [ -e "$1/.git" ]; }
|
|
git_row() { # relpath remote branch sha dirty unpushed (last two are ADVISORY ONLY)
|
|
local d=$1 rel=${1#"$HOME"/} url br sha dirty unpush cred=ok
|
|
url=$(git -C "$d" remote get-url origin 2>/dev/null || true); [ -n "$url" ] || url=-
|
|
# SECURITY: remotes can carry credentials (https://user:TOKEN@host/...). They
|
|
# must never be published to the hub. Strip userinfo and mark the row so
|
|
# `git clone` can tell you the URL needs re-adding on the far side.
|
|
case $url in
|
|
*://*:*@*) url=$(printf '%s' "$url" | sed -E 's#(://)[^/@]*@#\1#'); cred=stripped ;;
|
|
*://*@*) url=$(printf '%s' "$url" | sed -E 's#(://)[^/@]*@#\1#'); cred=stripped ;;
|
|
esac
|
|
br=$(git -C "$d" symbolic-ref --short HEAD 2>/dev/null || echo -)
|
|
sha=$(git -C "$d" rev-parse --short HEAD 2>/dev/null || echo -)
|
|
dirty=$(git -C "$d" status --porcelain 2>/dev/null | wc -l | tr -d ' ')
|
|
unpush=$(git -C "$d" rev-list --count '@{u}..HEAD' 2>/dev/null | tail -1); [ -n "$unpush" ] || unpush=0
|
|
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "$rel" "$url" "$br" "$sha" "${dirty:-0}" "${unpush:-0}" "$cred"
|
|
}
|
|
git_walk() { # dir depth — stops at the first .git, so submodules are not double counted
|
|
local d=$1 depth=$2 c
|
|
if git_root_p "$d"; then git_row "$d"; return; fi
|
|
[ "$depth" -le 0 ] && return
|
|
for c in "$d"/*/; do [ -d "$c" ] || continue; git_walk "${c%/}" $((depth - 1)); done
|
|
}
|
|
cmd_git() {
|
|
local sub=${1:-inventory}; shift || true
|
|
local lazy=0 exec=0 a
|
|
for a in "$@"; do case $a in --lazy) lazy=1;; --exec) exec=1;; esac; done
|
|
case $sub in
|
|
inventory)
|
|
local out=$STATE/repos.$HOST.tsv
|
|
: > "$out"
|
|
local r; for r in ${GIT_DIRS:-git src}; do [ -d "$HOME/$r" ] && git_walk "$HOME/$r" 3 >> "$out"; done
|
|
v "$(grep -c . "$out") repo(s) under ${GIT_DIRS:-git src}"
|
|
awk -F'\t' '$5+0>0 || $6+0>0 { printf " %-34s %s modified, %s unpushed\n", $1, $5, $6 }' "$out"
|
|
awk -F'\t' '$7=="stripped" { printf " CREDENTIALS STRIPPED from remote of %s — the far side must supply them\n", $1 }' "$out"
|
|
v "advisory only — neither the working tree nor unpushed commits ever leave this machine"
|
|
if [ $exec = 1 ] || [ "${HH_PUSH_INVENTORY:-1}" = 1 ]; then
|
|
hub "mkdir -p '$HUB_PATH/.hub/gitman' && cat > '$HUB_PATH/.hub/gitman/repos.$HOST'" < "$out" &&
|
|
v "manifest published to hub"
|
|
fi
|
|
;;
|
|
list)
|
|
hub "cat '$HUB_PATH/.hub/gitman/repos.'* 2>/dev/null" | sort -u -t$'\t' -k1,1
|
|
;;
|
|
missing)
|
|
hub "cat '$HUB_PATH/.hub/gitman/repos.'* 2>/dev/null" | sort -u -t$'\t' -k1,1 |
|
|
while IFS=$'\t' read -r rel url br sha dirty unpush cred; do
|
|
[ -n "${rel:-}" ] || continue
|
|
[ -e "$HOME/$rel/.git" ] && continue
|
|
printf '%-34s %s (%s%s%s)\n' "$rel" "$url" "$br" \
|
|
"$( [ "${dirty:-0}" != 0 ] && printf ', %s modified elsewhere' "$dirty")" \
|
|
"$( [ "${cred:-ok}" = stripped ] && printf ', creds stripped')"
|
|
done
|
|
;;
|
|
clone)
|
|
local n=0
|
|
hub "cat '$HUB_PATH/.hub/gitman/repos.'* 2>/dev/null" | sort -u -t$'\t' -k1,1 |
|
|
while IFS=$'\t' read -r rel url br sha dirty unpush cred; do
|
|
[ -n "${rel:-}" ] || continue
|
|
[ "$url" = - ] && continue
|
|
[ -e "$HOME/$rel/.git" ] && continue
|
|
if [ $exec != 1 ]; then
|
|
printf ' would clone %s <- %s%s%s\n' "$rel" "$url" \
|
|
"$( [ $lazy = 1 ] && echo ' (blobless)')" "$( [ "${cred:-ok}" = stripped ] && echo ' [creds stripped: set a credential helper or fix the remote]')"
|
|
else
|
|
printf ' cloning %s <- %s\n' "$rel" "$url"
|
|
mkdir -p "$(dirname "$HOME/$rel")"
|
|
local opts=(--origin origin)
|
|
[ $lazy = 1 ] && opts+=(--filter=blob:none)
|
|
[ "$br" != - ] && git clone "${opts[@]}" -b "$br" "$url" "$HOME/$rel" >/dev/null 2>&1 ||
|
|
git clone "${opts[@]}" "$url" "$HOME/$rel" >/dev/null 2>&1 ||
|
|
warn "clone failed: $rel ($url)"
|
|
fi
|
|
n=$((n+1))
|
|
done
|
|
;;
|
|
*) die "git: unknown subcommand '$sub' (inventory|list|missing|clone)";;
|
|
esac
|
|
}
|
|
|
|
# ------------------------------------------------- class C: the link bar -----
|
|
cmd_bookmarks() {
|
|
local write=0 a
|
|
for a in "$@"; do [ "$a" = --write ] && write=1; done
|
|
[ -r "$BOOKMARK_SRC" ] || die "no bookmark file at $BOOKMARK_SRC"
|
|
cp -- "$BOOKMARK_SRC" "$STATE/bookmarks.$HOST.json" || die "cannot read $BOOKMARK_SRC"
|
|
local nu; nu=$(jq '[.. | objects | select(.type=="url")] | length' "$STATE/bookmarks.$HOST.json" 2>/dev/null || echo '?')
|
|
hr "class C: bookmark bar ($HOST)"
|
|
v "exported $nu url(s) from $BOOKMARK_SRC (the union-merge below covers the bar only)"
|
|
if [ $write != 1 ]; then
|
|
v "dry: nothing pushed, nothing merged, live file untouched. Use --write."
|
|
return 0
|
|
fi
|
|
hub "mkdir -p '$HUB_PATH/.hub/bookmarks' && cat > '$HUB_PATH/.hub/bookmarks/$HOST.json'" < "$STATE/bookmarks.$HOST.json" ||
|
|
die "could not publish bookmark export"
|
|
hub "cd '$HUB_PATH/.hub/bookmarks' 2>/dev/null && for f in *.json; do printf '### %s\\n' \"\$f\"; cat \"\$f\"; done" > "$STATE/bookmarks.all.json" ||
|
|
die "could not fetch exports"
|
|
"$PY" "$HH_ROOT/lib/merge_bookmarks.py" "$STATE/bookmarks.all.json" > "$STATE/bookmarks.merged.json" ||
|
|
die "merge failed"
|
|
local mu; mu=$(jq '.urls' "$STATE/bookmarks.merged.json" 2>/dev/null || echo '?')
|
|
v "merged union: $mu urls (from $(jq -r '.hosts | join(",")' "$STATE/bookmarks.merged.json" 2>/dev/null))"
|
|
v "union result: $STATE/bookmarks.merged.json"
|
|
if pgrep -f "$BOOKMARK_PGREP" >/dev/null 2>&1; then
|
|
warn "browser is running — merged bar NOT written (it would be overwritten in memory). Close it and re-run."
|
|
v "merged result is ready at $STATE/bookmarks.merged.json"
|
|
return 0
|
|
fi
|
|
warn "writing the merged bar to $BOOKMARK_SRC is not enabled in v1 — inspect $STATE/bookmarks.merged.json first (see README)"
|
|
}
|
|
|
|
# -------------------------------------------------------------- housekeeping -
|
|
cmd_forget() {
|
|
[ $# -ge 1 ] || die "usage: homehub forget <home-relative-path> ..."
|
|
hub_ok || die "hub unreachable"
|
|
local p
|
|
for p in "$@"; do
|
|
p=${p#/}
|
|
hub_script "'$HUB_PATH' '$p'" <<'EOS'
|
|
root=$1; pat=$2
|
|
cd "$root/.hub/tombstones" 2>/dev/null || exit 0
|
|
for f in *; do
|
|
[ -f "$f" ] || continue
|
|
awk -F'\t' -v p="$pat" '$2 != p && $2 !~ ("^" p "/")' "$f" > "$f.t" && mv "$f.t" "$f"
|
|
done
|
|
exit 0
|
|
EOS
|
|
v "forget: $p"
|
|
done
|
|
}
|
|
cmd_filter() { gen_filter || return 1; cat "$FILTER"; }
|
|
|
|
cmd_setup_key() {
|
|
[ -f "$HH_KEY" ] && { warn "$HH_KEY already exists"; return 0; }
|
|
mkdir -p "$(dirname "$HH_KEY")"
|
|
ssh-keygen -q -t ed25519 -N '' -C "homehub@$HOST" -f "$HH_KEY" || die "keygen failed"
|
|
hr "deploy key created"
|
|
v "$HH_KEY (passphrase-less, by necessity: a systemd user unit has no agent)"
|
|
v "Install it on the hub, then make ~/.ssh 700 there:"
|
|
printf '\n ssh-copy-id -i %s.pub %s\n\n' "$HH_KEY" "$HUB_HOST"
|
|
warn "Until that is done, background sync cannot authenticate — and it fails silently by design."
|
|
}
|
|
cmd_setup() {
|
|
hub_ok || die "cannot reach $HUB_HOST with the current ssh setup"
|
|
hub_setup_dirs
|
|
mkdir -p "$SHARED_ROOT"
|
|
v "shared state root ready: $SHARED_ROOT (put app state here, symlink apps to it)"
|
|
hub "chmod 700 '$HUB_PATH' 2>/dev/null; ls -d '$HUB_PATH/.hub'/*" | sed 's/^/ /'
|
|
hr "published rules"
|
|
git -C "$HH_ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1 &&
|
|
v "rules live in git (https://gitea.lue.dk/druppy/homehub): $HH_ROOT — clone that repo on the other machines"
|
|
[ -f "$HH_KEY" ] || cmd_setup_key
|
|
}
|
|
cmd_install() {
|
|
local d=${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user
|
|
mkdir -p "$d"
|
|
cp -v "$HH_ROOT"/systemd/homehub-*.service "$HH_ROOT"/systemd/homehub-*.timer "$d/" || return 1
|
|
systemctl --user daemon-reload
|
|
systemctl --user enable --now homehub-sync.timer homehub-fresh.timer
|
|
systemctl --user --no-pager --full status homehub-sync.timer homehub-fresh.timer 2>&1 | grep -E 'Loaded:|Active:|→' || true
|
|
}
|
|
|
|
usage() { sed -n '2,14p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; }
|
|
|
|
case ${1:-help} in
|
|
sync) shift; cmd_sync "$@";;
|
|
status) shift; cmd_status "$@";;
|
|
fresh) shift; cmd_fresh "$@";;
|
|
git) shift; cmd_git "$@";;
|
|
bookmarks) shift; cmd_bookmarks "$@";;
|
|
forget) shift; cmd_forget "$@";;
|
|
filter) shift; cmd_filter "$@";;
|
|
setup) shift; cmd_setup "$@";;
|
|
setup-key) shift; cmd_setup_key "$@";;
|
|
install) shift; cmd_install "$@";;
|
|
help|-h|--help) usage;;
|
|
*) warn "unknown command '${1:-}'; try 'homehub help'"; exit 2;;
|
|
esac
|