#!/bin/sh
# shellcheck shell=sh
#
# gitbackup CLI. Exit codes, shared with every module: 0 ok, 1 general error,
# 2 invalid configuration, 3 network or authentication, 4 refused for safety.

set -u

GB_SHARE="${GB_SHARE:-/usr/share/gitbackup}"
GB_STATE_DIR="${GB_STATE_DIR:-/var/run/gitbackup}"

# shellcheck disable=SC1091  # GB_SHARE is a runtime path, not resolvable statically
. "$GB_SHARE/lib.sh"
# shellcheck disable=SC1091
. "$GB_SHARE/device.sh"
# shellcheck disable=SC1091
. "$GB_SHARE/remoteurl.sh"
# shellcheck disable=SC1091
. "$GB_SHARE/visibility.sh"
# shellcheck disable=SC1091
. "$GB_SHARE/auth.sh"
# shellcheck disable=SC1091
. "$GB_SHARE/collect.sh"
# shellcheck disable=SC1091
. "$GB_SHARE/scrub.sh"
# shellcheck disable=SC1091
. "$GB_SHARE/gitio.sh"
# shellcheck disable=SC1091
. "$GB_SHARE/restore.sh"
# shellcheck disable=SC1091
. "$GB_SHARE/paths.sh"
# shellcheck disable=SC1091
. "$GB_SHARE/schedule.sh"
# shellcheck disable=SC1091
. "$GB_SHARE/card.sh"

usage() {
	cat <<'EOF'
Usage: gitbackup <command> [options]

Commands:
  run        run a backup now
  status     print current status as JSON
  collect    build a backup set and manifest without pushing
  diff       show what changed since the last successful run, or [from to]
             (two full commit shas) for the diff between two backups
  history    list this device's own backup commits as JSON
  keygen     generate the SSH deploy key ([--force [--confirm <fingerprint>]]
             to replace an existing one -- --force alone only shows its
             fingerprint and refuses; naming it back with --confirm proceeds)
  pubkey     print the deploy key's public key
  test       verify connectivity and remote configuration
  hostkey    show|accept <fingerprint> -- confirm the remote's SSH host key
             without an interactive terminal (what the web UI uses)
  paths      list [--json]|add|del|audit -- edit /etc/sysupgrade.conf
  restore    restore configuration from a commit
  card       print a recovery card
  log        show recent gitbackup log entries
EOF
}

# Guards every caller-supplied commit before it reaches a bare git argument:
# an option-shaped value ("--upload-pack=...") must never get there.
_gb_valid_full_sha() {
	case "$1" in
		''|*[!0-9a-f]*) return 1 ;;
	esac
	# The charset check alone passes abbreviated or overlong shas.
	[ "${#1}" -eq 40 ]
}

# gb_remote -- sets GB_SCHEME, GB_HOST, GB_PORT; exit 2 on an unsupported URL.
gb_remote() {
	_gb_parsed=$(gb_parse_url "$GB_URL") ||
		gb_die 2 "gitbackup.origin.url: '$GB_URL' does not match any supported remote URL form"
	# shellcheck disable=SC2086  # word-splitting is the point: five fields from one line
	set -- $_gb_parsed
	GB_SCHEME="$1" GB_HOST="$2" GB_PORT="$3"
}

gb_branch() {
	gb_expand "$(gb_uci_get gitbackup.origin.branch 'device/{device}')"
}

# gb_validate_config -- strict config check for commands that touch the
# remote: exit 2 on the first unusable field. Sets GB_DEVICE, GB_URL, GB_SCRUB.
gb_validate_config() {
	gb_status_config
	case " $GB_MISSING " in
		*' device_id '*) GB_DEVICE=$(gb_device_id) || exit $? ;;
	esac
	case " $GB_MISSING " in
		*' cron_expr '*) gb_die 2 "gitbackup.main.cron_expr: $(gb_cron_valid "$(gb_uci_get gitbackup.main.cron_expr)" 2>&1)" ;;
	esac
	[ -n "$GB_URL" ] || gb_die 2 'gitbackup.origin.url: empty, a repository url is required'
}

# _gb_effective_scrub -- a public remote is always scrubbed. Overrides only
# this run's decision, never the saved gitbackup.security.scrub.
_gb_effective_scrub() {
	_gb_vis=$(gb_uci_get gitbackup.origin.visibility private)
	_gb_scrub=$(gb_uci_get gitbackup.security.scrub 0)
	if [ "$_gb_vis" = public ] && [ "$(gb_json_bool "$_gb_scrub")" != true ]; then
		gb_log notice 'gitbackup.origin.visibility=public forces security.scrub on for this run'
		_gb_scrub=1
	fi
	printf '%s\n' "$_gb_scrub"
}

# gb_status_config -- never exits: unusable fields go to GB_MISSING and
# GB_CONFIGURED is false.
gb_status_config() {
	GB_MISSING=''

	if ! GB_DEVICE=$(gb_device_id 2>/dev/null); then
		GB_DEVICE=''
		GB_MISSING="$GB_MISSING device_id"
	fi

	_gb_sched=$(gb_uci_get gitbackup.main.schedule daily)
	if [ "$_gb_sched" = cron ]; then
		_gb_cron=$(gb_uci_get gitbackup.main.cron_expr)
		gb_cron_valid "$_gb_cron" >/dev/null 2>&1 || GB_MISSING="$GB_MISSING cron_expr"
	fi

	GB_URL=$(gb_uci_get gitbackup.origin.url)
	[ -n "$GB_URL" ] || GB_MISSING="$GB_MISSING url"

	GB_SCRUB=$(_gb_effective_scrub)

	GB_CONFIGURED=true
	[ -z "$GB_MISSING" ] || GB_CONFIGURED=false
}

cmd_status() {
	_gb_enabled=$(gb_uci_get gitbackup.main.enabled 0)
	_gb_token_file=$(gb_uci_get gitbackup.origin.token_file /etc/gitbackup/token)
	_gb_token_set=false
	[ -s "$_gb_token_file" ] && _gb_token_set=true

	_gb_last_run='null'
	[ -f "$GB_STATE_DIR/last_run" ] && _gb_last_run=$(cat "$GB_STATE_DIR/last_run")

	printf '{\n'
	printf '  "enabled": %s,\n' "$(gb_json_bool "$_gb_enabled")"
	printf '  "configured": %s,\n' "$GB_CONFIGURED"
	printf '  "device": %s,\n' "$(gb_json_str "$GB_DEVICE")"
	printf '  "scrub": %s,\n' "$(gb_json_bool "$GB_SCRUB")"
	printf '  "token_set": %s,\n' "$_gb_token_set"
	printf '  "last_run": %s,\n' "$_gb_last_run"
	printf '  "missing": ['
	_gb_first=1
	for _gb_m in $GB_MISSING; do
		[ "$_gb_first" -eq 1 ] || printf ','
		printf '%s' "$(gb_json_str "$_gb_m")"
		_gb_first=0
	done
	printf ']\n'
	printf '}\n'
}

# cmd_test -- exit 3 covers network and auth (one slot in the contract); the
# message tells them apart. No gb_have_net precheck: git's error classifies.
cmd_test() {
	gb_remote
	_gb_scheme="$GB_SCHEME" _gb_host="$GB_HOST" _gb_port="$GB_PORT"

	# rc 3 means "could not verify", never "safe".
	gb_visibility_ok "$GB_URL"
	case $? in
		4) gb_die 4 "$GB_URL is publicly visible to an anonymous request; push refused" ;;
		3) gb_die 3 "could not verify whether $GB_URL is public (network or provider API unreachable); try again once connectivity is confirmed" ;;
	esac

	# Host keys are accepted only here, never in `run`.
	if [ "$_gb_scheme" = ssh ]; then
		# gb_accept_hostkey sets _gb_port to the effective port (0 -> 22).
		gb_accept_hostkey "$_gb_host" "$_gb_port"
		case $? in
			0) ;;
			1) gb_die 3 "$_gb_host:$_gb_port host key was not accepted" ;;
			3)
				# No stdin to ask on (e.g. rpcd): not the same as a typed decline.
				gb_die 3 "$_gb_host:$_gb_port host key needs confirmation before this can proceed -- run 'gitbackup hostkey show' (or use the web UI) to see its fingerprint, then 'gitbackup hostkey accept <fingerprint>' to trust it"
				;;
			*) gb_die 3 "$_gb_host:$_gb_port host key could not be obtained -- is the network reachable?" ;;
		esac
	fi

	# gb_git_env quotes its output for eval.
	eval "$(gb_git_env)"
	if _gb_err=$(git ls-remote "$GB_URL" 2>&1 >/dev/null); then
		gb_log notice "gitbackup test: $GB_URL is reachable and authenticated"
		# ls-remote proved the current key works: drop the `keygen --force` backup.
		gb_keygen_forget_old
		printf 'ok: %s is reachable and authenticated\n' "$GB_URL"
		return 0
	fi

	_gb_test_classify "$_gb_host" "$_gb_err"
}

_gb_test_classify() {
	_gb_h="$1"
	_gb_e="$2"
	case "$_gb_e" in
		*'Could not resolve host'*|*'Failed to connect'*|*'Connection timed out'*| \
		*'Connection refused'*|*'Network is unreachable'*|*'ssh: connect to host'*|*'No route to host'*)
			gb_die 3 "cannot reach $_gb_h: $_gb_e"
			;;
		*)
			gb_die 3 "authentication to $GB_URL failed: $_gb_e"
			;;
	esac
}

# cmd_hostkey show|accept <fingerprint> -- host key confirmation for the web
# UI (rpcd runs `test` with no stdin). ssh:// only.
cmd_hostkey() {
	_gb_hk_sub="${1:-}"
	[ -n "$_gb_hk_sub" ] && shift
	_gb_hk_fp="${1:-}"

	gb_remote
	_gb_hk_scheme="$GB_SCHEME" _gb_hk_host="$GB_HOST" _gb_hk_port="$GB_PORT"
	[ "$_gb_hk_port" != 0 ] || _gb_hk_port=22

	if [ "$_gb_hk_scheme" != ssh ]; then
		gb_die 2 "gitbackup.origin.url uses $_gb_hk_scheme://, not ssh:// -- there is no host key to show or accept"
	fi

	case "$_gb_hk_sub" in
		show)
			_gb_hk_out=$(gb_hostkey_show "$_gb_hk_host" "$_gb_hk_port")
			_gb_hk_rc=$?
			[ "$_gb_hk_rc" -eq 0 ] ||
				gb_die 3 "could not reach $_gb_hk_host:$_gb_hk_port to obtain its host key -- is the network reachable?"

			# shellcheck disable=SC2086  # word-splitting is the point
			set -- $_gb_hk_out
			_gb_hk_state="$1"
			if [ "$_gb_hk_state" = trusted ]; then
				printf '{ "host": %s, "port": %s, "trusted": true }\n' \
					"$(gb_json_str "$_gb_hk_host")" "$_gb_hk_port"
			else
				shift 3
				_gb_hk_fp_text="$*"
				printf '{ "host": %s, "port": %s, "trusted": false, "fingerprint": %s }\n' \
					"$(gb_json_str "$_gb_hk_host")" "$_gb_hk_port" "$(gb_json_str "$_gb_hk_fp_text")"
			fi
			;;
		accept)
			[ -n "$_gb_hk_fp" ] ||
				gb_die 2 'gitbackup hostkey accept: a fingerprint argument is required (the exact text "hostkey show" printed)'
			gb_hostkey_accept "$_gb_hk_host" "$_gb_hk_port" "$_gb_hk_fp"
			case $? in
				0) printf '{ "ok": true }\n' ;;
				4) gb_die 4 "fingerprint for $_gb_hk_host:$_gb_hk_port does not match the pending host key; refusing to record it" ;;
				*) gb_die 1 "no pending host key for $_gb_hk_host:$_gb_hk_port -- run 'gitbackup hostkey show' first" ;;
			esac
			;;
		*)
			gb_die 2 "gitbackup hostkey: unknown subcommand '$_gb_hk_sub' (expected show or accept)"
			;;
	esac
}

# ---------------------------------------------------------------------------
# run -- cmd_run does the gates; _gb_run_backup the git half, treating GB_URL
# as opaque so it can be tested against a local bare repository.
# ---------------------------------------------------------------------------

# Basenames of /etc/config/* entries differing in any field. sort | uniq -u:
# the image ships no comm/diff.
_gb_run_changed_configs() {
	_gb_rcc_old="$1"
	_gb_rcc_new="$2"
	{
		[ -r "$_gb_rcc_old" ] && grep '"path":"/etc/config/' "$_gb_rcc_old"
		grep '"path":"/etc/config/' "$_gb_rcc_new"
	} 2>/dev/null | sort | uniq -u | sed -n 's#.*"path":"/etc/config/\([^"]*\)".*#\1#p' | sort -u
}

_gb_run_write_message() {
	_gb_rwm_file="$1"
	_gb_rwm_trigger="$2"
	_gb_rwm_list="$3"
	_gb_rwm_count="$4"

	_gb_rwm_when=$(date -u +'%Y-%m-%d %H:%M')

	_gb_rwm_short=$(printf '%s\n' "$_gb_rwm_list" | sed -n '1,3p' | gb_join_lines ', ')
	[ -n "$_gb_rwm_short" ] || _gb_rwm_short='config'
	if [ "$_gb_rwm_count" -gt 3 ]; then
		_gb_rwm_short="$_gb_rwm_short and $((_gb_rwm_count - 3)) more"
	fi

	_gb_rwm_paths=$(printf '%s\n' "$_gb_rwm_list" | sed -n '/./ s#^#/etc/config/#p' | gb_join_lines ', ')
	[ -n "$_gb_rwm_paths" ] || _gb_rwm_paths='(no /etc/config/* entry changed)'

	# The router's own file (not a backup's), so sourcing it is safe.
	_gb_rwm_release=$(
		DISTRIB_RELEASE='' DISTRIB_REVISION=''
		# shellcheck disable=SC1090,SC1091
		[ -r "${GB_ROOT:-}/etc/openwrt_release" ] && . "${GB_ROOT:-}/etc/openwrt_release"
		printf '%s %s' "$DISTRIB_RELEASE" "$DISTRIB_REVISION"
	)

	{
		printf '%s %s: %s\n' "$_gb_rwm_when" "$GB_DEVICE" "$_gb_rwm_short"
		printf '\n'
		printf 'Changed: %s\n' "$_gb_rwm_paths"
		printf 'Trigger: %s\n' "$_gb_rwm_trigger"
		printf 'OpenWrt: %s\n' "$_gb_rwm_release"
		printf 'Device: %s\n' "$GB_DEVICE"
	} >"$_gb_rwm_file"
}

# last_run and state.json live on tmpfs; git remains the source of truth.
_gb_run_write_state() {
	_gb_rws_result="$1"
	_gb_rws_reason="$2"
	_gb_rws_commit="${3:-}"
	_gb_rws_timings="${4:-{\"collect_s\":0,\"compare_s\":0,\"push_s\":0}}"

	mkdir -p "$GB_STATE_DIR" 2>/dev/null
	_gb_rws_now=$(date -u +%Y-%m-%dT%H:%M:%SZ)
	printf '%s\n' "$(gb_json_str "$_gb_rws_now")" >"$GB_STATE_DIR/last_run" 2>/dev/null
	{
		printf '{\n'
		printf '  "generated": %s,\n' "$(gb_json_str "$_gb_rws_now")"
		printf '  "result": %s,\n' "$(gb_json_str "$_gb_rws_result")"
		printf '  "reason": %s,\n' "$(gb_json_str "$_gb_rws_reason")"
		printf '  "branch": %s,\n' "$(gb_json_str "${_gb_branch:-}")"
		printf '  "commit": %s,\n' "$(gb_json_str "$_gb_rws_commit")"
		printf '  "timings": %s\n' "$_gb_rws_timings"
		printf '}\n'
	} >"$GB_STATE_DIR/state.json" 2>/dev/null
}

# Pushes an index README to "main" if it does not exist yet. Only on a
# device's first push (saves an ls-remote per run); never fails the backup.
_gb_run_ensure_main_index() {
	_gb_rmi_repodir="$1"
	_gb_rmi_branch="$2"

	# Clear the device branch's parent: main gets its own root commit.
	GB_PARENT=''

	_gb_rmi_head=$(gb_remote_head main 2>/dev/null)
	_gb_rmi_rc=$?
	[ "$_gb_rmi_rc" -eq 0 ] || return 0
	[ -z "$_gb_rmi_head" ] || return 0

	_gb_rmi_readme="$WORK/main-readme.md"
	{
		printf '# gitbackup\n\n'
		printf 'This repository holds automatic router configuration backups. Each\n'
		printf 'router pushes to its own branch here -- by default named\n'
		# shellcheck disable=SC2016  # backticks are markdown code spans in
		# the generated document, not attempted command substitution
		printf '`device/<id>`, e.g. `%s` for the router that just created this page.\n\n' "$_gb_rmi_branch"
		# shellcheck disable=SC2016
		printf 'Open one of the `device/*` branches above: its own README.md carries\n'
		printf 'that router'\''s ready-made recovery instructions, including a one-line\n'
		printf 'command to restore a freshly flashed replacement.\n'
	} >"$_gb_rmi_readme" 2>/dev/null
	[ -s "$_gb_rmi_readme" ] || return 0

	_gb_rmi_msg="$WORK/main-readme-message"
	printf 'gitbackup: initialize repository index\n\nSee device/* branches for each router'\''s own backup and recovery\ninstructions.\n' \
		>"$_gb_rmi_msg" 2>/dev/null

	# gb_build_tree still requires a non-empty GB_PREFIX; the README goes in via
	# the extra-file pair.
	_gb_rmi_emptydir="$WORK/main-readme-empty"
	rm -rf "$_gb_rmi_emptydir"
	mkdir -p "$_gb_rmi_emptydir" 2>/dev/null

	_gb_rmi_tree=$(GB_PREFIX='.' gb_build_tree "$_gb_rmi_repodir" "$_gb_rmi_emptydir" "$_gb_rmi_readme" 'README.md' 2>/dev/null)
	[ -n "$_gb_rmi_tree" ] || return 0

	gb_commit_push "$_gb_rmi_repodir" "$_gb_rmi_tree" '' "$_gb_rmi_msg" main >/dev/null 2>&1
	return 0
}

_gb_run_backup() {
	_gb_trigger="$1"

	_gb_branch=$(gb_branch)
	_gb_prefix=$(gb_expand "$(gb_uci_get gitbackup.main.path_prefix 'devices/{device}')")
	# shellcheck disable=SC2034  # read by gb_build_tree (gitio.sh) as an environment seam, not used in this file
	GB_PREFIX="$_gb_prefix"

	_gb_outdir="$WORK/tree"
	_gb_repodir="$WORK/repo"
	# A brand-new branch has no parent to fetch, but the plumbing still
	# needs a .git here.
	[ -d "$_gb_repodir/.git" ] || git init -q "$_gb_repodir" ||
		gb_die 1 "gitbackup run: git init $_gb_repodir failed"

	_gb_t_collect_start=$(date +%s)
	gb_collect "$_gb_outdir" || gb_die 1 'gitbackup run: gb_collect failed'
	if [ "$(gb_json_bool "$GB_SCRUB")" = true ]; then
		gb_scrub "$_gb_outdir" 2>/dev/null | while IFS= read -r _gb_sl || [ -n "$_gb_sl" ]; do
			[ -n "$_gb_sl" ] && gb_log info "gitbackup run: scrubbed $_gb_sl"
		done
	fi
	_gb_t_collect_end=$(date +%s)

	# GB_PARENT is an environment input to gb_build_tree.
	_gb_t_compare_start=$(date +%s)
	GB_PARENT=$(gb_remote_head "$_gb_branch")
	_gb_head_rc=$?
	[ "$_gb_head_rc" -eq 0 ] || gb_die 3 "gitbackup run: could not reach $GB_URL to resolve $_gb_branch"
	# Captured before the retry loop can reassign GB_PARENT.
	_gb_branch_was_new=""
	[ -z "$GB_PARENT" ] && _gb_branch_was_new=1

	_gb_old_manifest="$WORK/old_manifest.json"
	_gb_changed=1
	if [ -n "$GB_PARENT" ]; then
		gb_fetch_meta "$_gb_branch" "$_gb_repodir" ||
			gb_die 3 "gitbackup run: could not fetch $_gb_branch metadata from $GB_URL"
		if git --git-dir="$_gb_repodir/.git" cat-file -p "$GB_PARENT:$_gb_prefix/manifest.json" \
			>"$_gb_old_manifest" 2>/dev/null; then
			if gb_manifest_equal "$_gb_old_manifest" "$(gb_manifest_path "$_gb_outdir")"; then
				_gb_changed=0
			fi
		fi
	fi
	_gb_t_compare_end=$(date +%s)
	_gb_t_collect_s=$((_gb_t_collect_end - _gb_t_collect_start))
	_gb_t_compare_s=$((_gb_t_compare_end - _gb_t_compare_start))

	if [ "$_gb_changed" -eq 0 ]; then
		gb_log notice "gitbackup run: no changes since the last backup on $_gb_branch"
		printf 'no changes\n'
		_gb_run_write_state ok no-changes "$GB_PARENT" \
			"{\"collect_s\":$_gb_t_collect_s,\"compare_s\":$_gb_t_compare_s,\"push_s\":0}"
		gb_log notice "gitbackup run: timings -- collect ${_gb_t_collect_s}s, compare ${_gb_t_compare_s}s, push 0s"
		exit 0
	fi

	# Archive only after the compare: sysupgrade -b's gzip is not
	# byte-deterministic. Never when scrubbing: it reads the LIVE filesystem and
	# would carry every value the scrub just removed.
	if [ "$(gb_json_bool "$(gb_uci_get gitbackup.main.archive 1)")" = true ]; then
		if [ "$(gb_json_bool "$GB_SCRUB")" = true ]; then
			gb_log notice 'gitbackup run: scrubbing is on, so no backup.tar.gz is pushed -- sysupgrade -b reads the live filesystem and would carry exactly the values just scrubbed out of the tree'
		else
			sysupgrade -b "$_gb_outdir/backup.tar.gz" >/dev/null 2>&1 ||
				gb_log warning 'gitbackup run: sysupgrade -b failed, continuing without backup.tar.gz'
		fi
	fi

	# One gb_card call for both RECOVERY.md and the branch-root README.md, so
	# the copies cannot drift.
	_gb_readme_root=''
	gb_card "$_gb_outdir/RECOVERY.md" 2>/dev/null
	if [ -s "$_gb_outdir/RECOVERY.md" ]; then
		_gb_readme_root="$WORK/branch-readme.md"
		cp -f "$_gb_outdir/RECOVERY.md" "$_gb_readme_root" 2>/dev/null || _gb_readme_root=''
	fi

	_gb_changed_list=$(_gb_run_changed_configs "$_gb_old_manifest" "$(gb_manifest_path "$_gb_outdir")")
	_gb_changed_count=$(printf '%s\n' "$_gb_changed_list" | grep -c .)
	_gb_msgfile="$WORK/commit-message"
	_gb_run_write_message "$_gb_msgfile" "$_gb_trigger" "$_gb_changed_list" "$_gb_changed_count"

	# On non-fast-forward retry once on the new parent, reusing the collected set.
	_gb_t_push_start=$(date +%s)
	_gb_attempt=1
	while :; do
		_gb_tree=$(gb_build_tree "$_gb_repodir" "$_gb_outdir" "$_gb_readme_root" 'README.md')
		[ -n "$_gb_tree" ] || gb_die 1 'gitbackup run: gb_build_tree failed'

		_gb_commit=$(gb_commit_push "$_gb_repodir" "$_gb_tree" "$GB_PARENT" "$_gb_msgfile" "$_gb_branch")
		_gb_push_rc=$?
		case "$_gb_push_rc" in
			0) break ;;
			2)
				if [ "$_gb_attempt" -ge 2 ]; then
					gb_die 1 "gitbackup run: $_gb_branch changed again during the retry, giving up after one retry"
				fi
				gb_log notice "gitbackup run: $_gb_branch moved under us, retrying once"
				_gb_attempt=2
				GB_PARENT=$(gb_remote_head "$_gb_branch")
				_gb_head_rc=$?
				[ "$_gb_head_rc" -eq 0 ] || gb_die 3 "gitbackup run: could not re-resolve $_gb_branch for the retry"
				gb_fetch_meta "$_gb_branch" "$_gb_repodir" ||
					gb_die 3 "gitbackup run: could not re-fetch $_gb_branch metadata for the retry"
				;;
			1) gb_die 1 'gitbackup run: gb_commit_push could not build the commit, see log' ;;
			*) gb_die 3 'gitbackup run: push failed, see log' ;;
		esac
	done
	_gb_t_push_end=$(date +%s)
	_gb_t_push_s=$((_gb_t_push_end - _gb_t_push_start))

	gb_log notice "gitbackup run: pushed $_gb_commit to $_gb_branch"
	printf 'pushed %s to %s\n' "$_gb_commit" "$_gb_branch"
	_gb_run_write_state ok pushed "$_gb_commit" \
		"{\"collect_s\":$_gb_t_collect_s,\"compare_s\":$_gb_t_compare_s,\"push_s\":$_gb_t_push_s}"
	gb_log notice "gitbackup run: timings -- collect ${_gb_t_collect_s}s, compare ${_gb_t_compare_s}s, push ${_gb_t_push_s}s"

	if [ -n "$_gb_branch_was_new" ]; then
		_gb_run_ensure_main_index "$_gb_repodir" "$_gb_branch"
	fi
}

# cmd_run [--trigger cron|manual|procd|luci] -- the crontab line passes no
# flag, hence the cron default.
cmd_run() {
	_gb_trigger=cron
	while [ "$#" -gt 0 ]; do
		case "$1" in
			--trigger)
				shift
				[ "$#" -gt 0 ] && { _gb_trigger="$1"; shift; }
				;;
			--trigger=*) _gb_trigger="${1#--trigger=}"; shift ;;
			*) shift ;;
		esac
	done

	# Non-blocking flock on an fd. Busy -> skipped, exit 0: cron must not
	# report an overlap that resolved itself.
	_gb_lockfile="${GB_LOCK_FILE:-/var/run/gitbackup.lock}"
	mkdir -p "$(dirname "$_gb_lockfile")" 2>/dev/null
	exec 9>"$_gb_lockfile" || gb_die 1 "gitbackup run: cannot open lock file $_gb_lockfile"
	if ! flock -n 9; then
		gb_log notice 'gitbackup run: another run already holds the lock, skipped'
		printf 'skipped: another run is already in progress\n'
		exit 0
	fi

	gb_remote
	_gb_scheme="$GB_SCHEME" _gb_host="$GB_HOST" _gb_port="$GB_PORT"
	_gb_netport="$_gb_port"
	if [ "$_gb_netport" = 0 ]; then
		if [ "$_gb_scheme" = ssh ]; then _gb_netport=22; else _gb_netport=443; fi
	fi
	if ! gb_have_net "$_gb_host" "$_gb_netport"; then
		gb_log notice "gitbackup run: $_gb_host:$_gb_netport unreachable, skipped"
		printf 'skipped: %s is unreachable\n' "$_gb_host"
		exit 0
	fi

	# $WORK holds a plaintext backup set and briefly credentials: /tmp only
	# (never flash/USB), removed on any exit.
	WORK=$(mktemp -d "${TMPDIR:-/tmp}/gitbackup.XXXXXX") ||
		gb_die 1 'gitbackup run: cannot create a working directory under /tmp'
	trap 'rm -rf "$WORK"' EXIT INT TERM

	_gb_set_kb=$(gb_paths_size_kb)
	_gb_need_kb=$((_gb_set_kb * 2 + 1024))
	# TMPDIR may override /tmp: check the filesystem $WORK lives on.
	_gb_free_kb=$(gb_free_kb "${TMPDIR:-/tmp}")
	case "$_gb_free_kb" in '' | *[!0-9]*) _gb_free_kb=0 ;; esac
	if [ "$_gb_free_kb" -lt "$_gb_need_kb" ]; then
		gb_die 1 "gitbackup run: not enough space in /tmp -- need ~${_gb_need_kb} KB (backup set ~${_gb_set_kb} KB x2 + 1024 KB headroom), have ${_gb_free_kb} KB free; reduce the backup set (e.g. gitbackup.main.archive='0', or trim the sysupgrade path list) or free up /tmp"
	fi

	# Visibility gate: 3 (could not verify) is skipped like no network, never
	# treated as private; 4 (public) always fails loudly.
	gb_visibility_ok "$GB_URL"
	case $? in
		4) gb_die 4 "$GB_URL is publicly visible to an anonymous request; push refused" ;;
		3)
			gb_log notice "gitbackup run: could not verify whether $GB_URL is public, skipped"
			printf 'skipped: could not verify repository visibility\n'
			exit 0
			;;
	esac

	eval "$(gb_git_env)"

	_gb_run_backup "$_gb_trigger"
}

# cmd_restore -- no gb_validate_config: a bare router may not resolve its
# device id. Only the URL is required.
cmd_restore() {
	_gb_r_device=''
	_gb_r_commit=''
	_gb_r_flags=''
	while [ "$#" -gt 0 ]; do
		case "$1" in
			--device) shift; _gb_r_device="${1:-}"; shift ;;
			--device=*) _gb_r_device="${1#--device=}"; shift ;;
			--commit) shift; _gb_r_commit="${1:-}"; shift ;;
			--commit=*) _gb_r_commit="${1#--commit=}"; shift ;;
			--dry-run|--force|--yes|--with-packages) _gb_r_flags="$_gb_r_flags $1"; shift ;;
			*) shift ;;
		esac
	done

	# No --device: restore this router's own backup.
	[ -n "$_gb_r_device" ] || _gb_r_device=$(gb_device_id) || exit $?

	GB_URL=$(gb_uci_get gitbackup.origin.url)
	[ -n "$GB_URL" ] || gb_die 2 'gitbackup.origin.url: empty, a repository url is required'

	gb_remote

	# --commit reaches bare git arguments: allow only empty/HEAD or a full sha.
	if [ -n "$_gb_r_commit" ] && [ "$_gb_r_commit" != HEAD ]; then
		_gb_valid_full_sha "$_gb_r_commit" ||
			gb_die 2 "gitbackup restore: --commit '$_gb_r_commit' must be a full 40-character commit sha, or omitted/HEAD for the branch tip"
	fi

	eval "$(gb_git_env)"

	# shellcheck disable=SC2086  # word-splitting is the point: $_gb_r_flags is zero or more bare flag tokens
	gb_restore "$_gb_r_device" "$_gb_r_commit" $_gb_r_flags
	exit $?
}

# ---------------------------------------------------------------------------
# history -- the branch's commits as JSON.
# ---------------------------------------------------------------------------
cmd_history() {
	_gb_h_limit=20
	while [ "$#" -gt 0 ]; do
		case "$1" in
			--limit) shift; _gb_h_limit="${1:-20}"; shift ;;
			--limit=*) _gb_h_limit="${1#--limit=}"; shift ;;
			*) shift ;;
		esac
	done
	case "$_gb_h_limit" in ''|*[!0-9]*) _gb_h_limit=20 ;; esac

	gb_remote

	_gb_h_branch=$(gb_branch) ||
		gb_die 2 'gitbackup.origin.branch could not be expanded'

	eval "$(gb_git_env)"

	_gb_h_head=$(gb_remote_head "$_gb_h_branch")
	_gb_h_head_rc=$?
	[ "$_gb_h_head_rc" -eq 0 ] || gb_die 3 "gitbackup history: could not reach $GB_URL to resolve $_gb_h_branch"
	if [ -z "$_gb_h_head" ]; then
		printf '{ "branch": %s, "commits": [] }\n' "$(gb_json_str "$_gb_h_branch")"
		return 0
	fi

	_gb_h_dir=$(mktemp -d "${TMPDIR:-/tmp}/gitbackup-history.XXXXXX") ||
		gb_die 1 'gitbackup history: cannot create a scratch directory under /tmp'
	trap 'rm -rf "$_gb_h_dir"' EXIT INT TERM

	# Full history (empty depth), not gb_fetch_meta's --depth=1 default.
	gb_fetch_meta "$_gb_h_branch" "$_gb_h_dir" '' ||
		gb_die 3 "gitbackup history: could not fetch $_gb_h_branch from $GB_URL"

	_gb_h_tab=$(printf '\t')
	printf '{ "branch": %s, "commits": [' "$(gb_json_str "$_gb_h_branch")"
	_gb_h_first=1
	git -C "$_gb_h_dir" log -n "$_gb_h_limit" --format='%H%x09%s' FETCH_HEAD 2>/dev/null |
		while IFS= read -r _gb_h_line || [ -n "$_gb_h_line" ]; do
			_gb_h_sha="${_gb_h_line%%"$_gb_h_tab"*}"
			_gb_h_subject="${_gb_h_line#*"$_gb_h_tab"}"
			[ "$_gb_h_first" -eq 1 ] || printf ','
			_gb_h_first=0
			printf '{ "sha": %s, "subject": %s, "changed": [' \
				"$(gb_json_str "$_gb_h_sha")" "$(gb_json_str "$_gb_h_subject")"
			_gb_h_cfirst=1
			# --root: without it diff-tree prints nothing for a root commit.
			git -C "$_gb_h_dir" diff-tree --no-commit-id --name-only -r --root "$_gb_h_sha" 2>/dev/null |
				while IFS= read -r _gb_h_path || [ -n "$_gb_h_path" ]; do
					[ -n "$_gb_h_path" ] || continue
					[ "$_gb_h_cfirst" -eq 1 ] || printf ','
					_gb_h_cfirst=0
					printf '%s' "$(gb_json_str "$_gb_h_path")"
				done
			printf '] }'
		done
	printf '] }\n'

	rm -rf "$_gb_h_dir"
	trap - EXIT INT TERM
}

# ---------------------------------------------------------------------------
# collect -- gb_collect into --out for inspection; nothing pushed or scrubbed.
# ---------------------------------------------------------------------------
cmd_collect() {
	_gb_c_out=''
	while [ "$#" -gt 0 ]; do
		case "$1" in
			--out) shift; _gb_c_out="${1:-}"; shift ;;
			--out=*) _gb_c_out="${1#--out=}"; shift ;;
			*) shift ;;
		esac
	done
	[ -n "$_gb_c_out" ] || gb_die 2 'gitbackup collect: --out DIR is required'

	mkdir -p "$_gb_c_out" || gb_die 1 "gitbackup collect: cannot create $_gb_c_out"
	gb_collect "$_gb_c_out" || gb_die 1 'gitbackup collect: gb_collect failed'
	printf 'collected backup set into %s\n' "$_gb_c_out"
}

# ---------------------------------------------------------------------------
# diff -- live config vs the last commit by manifest.json, never git: git
# tracks only three file modes and misses a chmod.
# ---------------------------------------------------------------------------

# A manifest path never holds a raw tab.
_gb_diff_dump_line() {
	_gb_dd_path=$(gb_manifest_field "$1" path)
	[ -z "$_gb_dd_path" ] || printf '%s\t%s\n' "$_gb_dd_path" "$1"
}

_gb_diff_dump() {
	if [ -r "$1" ]; then gb_manifest_each "$1" entries _gb_diff_dump_line; fi | sort >"$2"
}

_gb_diff_describe_change() {
	_gb_ddc_old="$1"
	_gb_ddc_new="$2"
	_gb_ddc_path="$3"
	_gb_ddc_parts=''

	_gb_ddc_ot=$(gb_manifest_field "$_gb_ddc_old" type)
	_gb_ddc_nt=$(gb_manifest_field "$_gb_ddc_new" type)
	[ "$_gb_ddc_ot" = "$_gb_ddc_nt" ] || _gb_ddc_parts="$_gb_ddc_parts type $_gb_ddc_ot->$_gb_ddc_nt;"

	_gb_ddc_om=$(gb_manifest_field "$_gb_ddc_old" mode)
	_gb_ddc_nm=$(gb_manifest_field "$_gb_ddc_new" mode)
	[ "$_gb_ddc_om" = "$_gb_ddc_nm" ] || _gb_ddc_parts="$_gb_ddc_parts mode $_gb_ddc_om->$_gb_ddc_nm;"

	_gb_ddc_ou=$(gb_manifest_field "$_gb_ddc_old" uid)
	_gb_ddc_nu=$(gb_manifest_field "$_gb_ddc_new" uid)
	[ "$_gb_ddc_ou" = "$_gb_ddc_nu" ] || _gb_ddc_parts="$_gb_ddc_parts uid $_gb_ddc_ou->$_gb_ddc_nu;"

	_gb_ddc_og=$(gb_manifest_field "$_gb_ddc_old" gid)
	_gb_ddc_ng=$(gb_manifest_field "$_gb_ddc_new" gid)
	[ "$_gb_ddc_og" = "$_gb_ddc_ng" ] || _gb_ddc_parts="$_gb_ddc_parts gid $_gb_ddc_og->$_gb_ddc_ng;"

	_gb_ddc_osha=$(gb_manifest_field "$_gb_ddc_old" sha256)
	_gb_ddc_nsha=$(gb_manifest_field "$_gb_ddc_new" sha256)
	[ "$_gb_ddc_osha" = "$_gb_ddc_nsha" ] || _gb_ddc_parts="$_gb_ddc_parts content changed;"

	_gb_ddc_otgt=$(gb_manifest_field "$_gb_ddc_old" target)
	_gb_ddc_ntgt=$(gb_manifest_field "$_gb_ddc_new" target)
	[ "$_gb_ddc_otgt" = "$_gb_ddc_ntgt" ] || _gb_ddc_parts="$_gb_ddc_parts symlink target $_gb_ddc_otgt->$_gb_ddc_ntgt;"

	[ -n "$_gb_ddc_parts" ] && printf '~ %s:%s\n' "$_gb_ddc_path" "$_gb_ddc_parts"
}

# No join(1) on the image. No output = no changes.
_gb_diff_compare() {
	_gb_dc_old="$1"
	_gb_dc_new="$2"
	{ cut -f1 "$_gb_dc_old" 2>/dev/null; cut -f1 "$_gb_dc_new" 2>/dev/null; } | sort -u |
		while IFS= read -r _gb_dc_p || [ -n "$_gb_dc_p" ]; do
			[ -n "$_gb_dc_p" ] || continue
			_gb_dc_o=$(awk -F'\t' -v p="$_gb_dc_p" '$1==p{print $2; exit}' "$_gb_dc_old" 2>/dev/null)
			_gb_dc_n=$(awk -F'\t' -v p="$_gb_dc_p" '$1==p{print $2; exit}' "$_gb_dc_new" 2>/dev/null)
			if [ -z "$_gb_dc_o" ] && [ -n "$_gb_dc_n" ]; then
				printf '+ %s (%s)\n' "$_gb_dc_p" "$(gb_manifest_field "$_gb_dc_n" type)"
			elif [ -n "$_gb_dc_o" ] && [ -z "$_gb_dc_n" ]; then
				printf -- '- %s (%s)\n' "$_gb_dc_p" "$(gb_manifest_field "$_gb_dc_o" type)"
			elif [ "$_gb_dc_o" != "$_gb_dc_n" ]; then
				_gb_diff_describe_change "$_gb_dc_o" "$_gb_dc_n" "$_gb_dc_p"
			fi
		done
}

# cmd_diff [<from> <to>] -- no args: live vs last commit. Two full shas:
# `git diff` between two backups (History).
cmd_diff() {
	gb_remote

	if [ "$#" -eq 2 ]; then
		_gb_d_from="$1"
		_gb_d_to="$2"
		_gb_valid_full_sha "$_gb_d_from" ||
			gb_die 2 "gitbackup diff: 'from' must be a full 40-character commit sha, got '$_gb_d_from'"
		_gb_valid_full_sha "$_gb_d_to" ||
			gb_die 2 "gitbackup diff: 'to' must be a full 40-character commit sha, got '$_gb_d_to'"

		_gb_dc_branch=$(gb_branch) ||
			gb_die 2 'gitbackup.origin.branch could not be expanded'

		_gb_dc_work=$(mktemp -d "${TMPDIR:-/tmp}/gitbackup-diffc.XXXXXX") ||
			gb_die 1 'gitbackup diff: cannot create a working directory under /tmp'
		trap 'rm -rf "$_gb_dc_work"' EXIT INT TERM

		eval "$(gb_git_env)"

		# Full history: either commit may be far back on the branch.
		gb_fetch_meta "$_gb_dc_branch" "$_gb_dc_work" '' ||
			gb_die 3 "gitbackup diff: could not fetch $_gb_dc_branch from $GB_URL"

		# --end-of-options, not "--": for git diff "--" would turn the shas
		# into pathspecs. Needs git >= 2.24.
		_gb_dc_out=$(git -C "$_gb_dc_work" diff --end-of-options "$_gb_d_from" "$_gb_d_to" 2>&1)
		_gb_dc_rc=$?
		if [ "$_gb_dc_rc" -ne 0 ]; then
			gb_die 3 "gitbackup diff: $_gb_dc_out"
		fi
		printf '%s\n' "$_gb_dc_out"
		return 0
	fi

	if [ "$#" -ne 0 ]; then
		gb_die 2 'gitbackup diff: expected either no arguments (live config vs the last commit) or exactly two (from-sha to-sha)'
	fi

	_gb_d_branch=$(gb_branch)
	_gb_d_prefix=$(gb_expand "$(gb_uci_get gitbackup.main.path_prefix 'devices/{device}')")

	_gb_d_work=$(mktemp -d "${TMPDIR:-/tmp}/gitbackup-diff.XXXXXX") ||
		gb_die 1 'gitbackup diff: cannot create a working directory under /tmp'
	trap 'rm -rf "$_gb_d_work"' EXIT INT TERM

	eval "$(gb_git_env)"

	_gb_d_parent=$(gb_remote_head "$_gb_d_branch")
	_gb_d_head_rc=$?
	[ "$_gb_d_head_rc" -eq 0 ] || gb_die 3 "gitbackup diff: could not reach $GB_URL to resolve $_gb_d_branch"

	_gb_d_outdir="$_gb_d_work/tree"
	gb_collect "$_gb_d_outdir" || gb_die 1 'gitbackup diff: gb_collect failed'
	if [ "$(gb_json_bool "$GB_SCRUB")" = true ]; then
		# Scrub like `run` does, or every scrubbed field would show as changed.
		gb_scrub "$_gb_d_outdir" >/dev/null 2>&1
	fi

	_gb_d_old_manifest="$_gb_d_work/old_manifest.json"
	: >"$_gb_d_old_manifest"
	if [ -n "$_gb_d_parent" ]; then
		_gb_d_repodir="$_gb_d_work/repo"
		gb_fetch_meta "$_gb_d_branch" "$_gb_d_repodir" ||
			gb_die 3 "gitbackup diff: could not fetch $_gb_d_branch metadata from $GB_URL"
		git --git-dir="$_gb_d_repodir/.git" cat-file -p "$_gb_d_parent:$_gb_d_prefix/manifest.json" \
			>"$_gb_d_old_manifest" 2>/dev/null
	fi

	_gb_d_old_tsv="$_gb_d_work/old.tsv"
	_gb_d_new_tsv="$_gb_d_work/new.tsv"
	_gb_diff_dump "$_gb_d_old_manifest" "$_gb_d_old_tsv"
	_gb_diff_dump "$(gb_manifest_path "$_gb_d_outdir")" "$_gb_d_new_tsv"

	_gb_diff_compare "$_gb_d_old_tsv" "$_gb_d_new_tsv"
	return 0
}

_gb_paths_json_array() {
	_gb_pja_first=1
	printf '%s\n' "$1" | while IFS= read -r _gb_pja_p || [ -n "$_gb_pja_p" ]; do
		[ -n "$_gb_pja_p" ] || continue
		[ "$_gb_pja_first" -eq 1 ] || printf ','
		printf '%s' "$(gb_json_str "$_gb_pja_p")"
		_gb_pja_first=0
	done
}

# ---------------------------------------------------------------------------
# paths -- list|add|del|audit over /etc/sysupgrade.conf via paths.sh. Not
# gated by gb_validate_config: works before a remote is configured.
# ---------------------------------------------------------------------------
cmd_paths() {
	_gb_p_sub="${1:-list}"
	[ "$#" -gt 0 ] && shift

	case "$_gb_p_sub" in
		list)
			if [ "${1:-}" = '--json' ]; then
				# "paths": the file verbatim. "entries": what to list and send back.
				# "effective": read-only; saving it back would shrink a directory entry
				# to the files present at save time.
				_gb_p_raw=$(gb_paths_list)
				_gb_p_entries=$(gb_paths_entries)
				_gb_p_eff=$(sysupgrade -l 2>/dev/null)
				printf '{ "paths": [%s], "entries": [%s], "effective": [%s], "size_kb": %s }\n' \
					"$(_gb_paths_json_array "$_gb_p_raw")" "$(_gb_paths_json_array "$_gb_p_entries")" "$(_gb_paths_json_array "$_gb_p_eff")" "$(gb_paths_size_kb)"
			else
				gb_paths_list
				printf '# total: %s KB (effective sysupgrade set, sysupgrade -l)\n' "$(gb_paths_size_kb)"
			fi
			;;
		add)
			_gb_p_path="${1:-}"
			[ -n "$_gb_p_path" ] || gb_die 2 'gitbackup paths add: a path is required'
			_gb_p_err=$(gb_paths_add "$_gb_p_path" 2>&1)
			_gb_p_rc=$?
			if [ "$_gb_p_rc" -eq 0 ]; then
				printf 'added %s\n' "$_gb_p_path"
			else
				gb_die "$_gb_p_rc" "gitbackup paths add: $_gb_p_err"
			fi
			;;
		del)
			_gb_p_path="${1:-}"
			[ -n "$_gb_p_path" ] || gb_die 2 'gitbackup paths del: a path is required'
			gb_paths_del "$_gb_p_path"
			printf 'removed %s\n' "$_gb_p_path"
			;;
		audit)
			# Files changed since the base image (/overlay/upper) that are
			# neither in `sysupgrade -l` nor gitbackup's own secrets.
			# VERIFY: not checked against a real overlay mount; an absent
			# directory yields an empty list.
			_gb_p_upper="${GB_ROOT:-}/overlay/upper"
			if [ ! -d "$_gb_p_upper" ]; then
				printf '{ "paths": [] }\n'
				return 0
			fi
			_gb_p_backed=$(sysupgrade -l 2>/dev/null)
			printf '{ "paths": ['
			_gb_p_first=1
			( cd "$_gb_p_upper" 2>/dev/null && find . \( -type f -o -type l \) ) |
				while IFS= read -r _gb_p_rel || [ -n "$_gb_p_rel" ]; do
					_gb_p_abs="/${_gb_p_rel#./}"
					case "$_gb_p_abs" in
						/etc/gitbackup | /etc/gitbackup/*) continue ;;
					esac
					printf '%s\n' "$_gb_p_backed" | grep -qxF "$_gb_p_abs" && continue
					[ "$_gb_p_first" -eq 1 ] || printf ','
					_gb_p_first=0
					printf '%s' "$(gb_json_str "$_gb_p_abs")"
				done
			printf '] }\n'
			;;
		*)
			gb_die 2 "gitbackup paths: unknown subcommand '$_gb_p_sub' (expected list, add, del or audit)"
			;;
	esac
}

# ---------------------------------------------------------------------------
# card -- write the recovery card to --out; gb_card never fails.
# ---------------------------------------------------------------------------
cmd_card() {
	_gb_cd_out=''
	while [ "$#" -gt 0 ]; do
		case "$1" in
			--out) shift; _gb_cd_out="${1:-}"; shift ;;
			--out=*) _gb_cd_out="${1#--out=}"; shift ;;
			*) shift ;;
		esac
	done
	[ -n "$_gb_cd_out" ] || gb_die 2 'gitbackup card: --out FILE is required'
	gb_card "$_gb_cd_out"
	printf 'wrote recovery card to %s\n' "$_gb_cd_out"
}

# cmd_log [lines] -- tag-filtered syslog (logread survives a reboot).
cmd_log() {
	_gb_lines="${1:-50}"
	case "$_gb_lines" in '' | *[!0-9]*) _gb_lines=50 ;; esac
	logread -e gitbackup 2>/dev/null | tail -n "$_gb_lines"
}

[ "$#" -ge 1 ] || { usage; exit 0; }

cmd="$1"
shift

case "$cmd" in
	status)
		# Lenient: a fresh install's default config must not make status exit 2.
		gb_status_config
		cmd_status
		;;
	keygen)
		# No gb_validate_config: the key is created before a remote URL exists.
		_gb_force=''
		_gb_confirm=''
		while [ "$#" -gt 0 ]; do
			case "$1" in
				--force) _gb_force=1; shift ;;
				--confirm) shift; _gb_confirm="${1:-}"; shift ;;
				--confirm=*) _gb_confirm="${1#--confirm=}"; shift ;;
				*) shift ;;
			esac
		done
		_gb_kg_out=$(gb_keygen "$_gb_force" "$_gb_confirm")
		_gb_kg_rc=$?
		if [ "$_gb_kg_rc" -eq 4 ]; then
			# Relay the "confirm-required <fingerprint>" line on stdout (rpcd
			# parses it), then explain on stderr.
			printf '%s\n' "$_gb_kg_out"
			# shellcheck disable=SC2086  # word-splitting is the point: the fixed leading token, then the fingerprint text
			set -- $_gb_kg_out
			shift
			gb_die 4 "$(gb_uci_get gitbackup.origin.key_file /etc/gitbackup/id_ed25519) already exists (fingerprint: $*) -- keygen --force destroys the private half beyond recovery, and the deploy key already added at your git provider stops working the instant it does; re-run 'gitbackup keygen --force --confirm \"$*\"' to proceed, then add the NEW public key at the provider"
		fi
		[ "$_gb_kg_rc" -eq 0 ] || exit 1
		printf 'generated %s\n' "$(gb_uci_get gitbackup.origin.key_file /etc/gitbackup/id_ed25519)"
		;;
	pubkey)
		# Needs no remote configuration either.
		gb_pubkey || exit 1
		;;
	test)
		gb_validate_config
		cmd_test
		;;
	hostkey)
		gb_validate_config
		cmd_hostkey "$@"
		;;
	run)
		gb_validate_config
		cmd_run "$@"
		;;
	log)
		# No gb_validate_config: must work on a half-configured router.
		cmd_log "$@"
		;;
	restore)
		cmd_restore "$@"
		;;
	collect|diff|card)
		gb_validate_config
		case "$cmd" in
			collect) cmd_collect "$@" ;;
			diff) cmd_diff "$@" ;;
			card) cmd_card "$@" ;;
		esac
		;;
	history)
		gb_validate_config
		cmd_history "$@"
		;;
	paths)
		# No gb_validate_config: works before a remote is configured.
		cmd_paths "$@"
		;;
	*)
		usage >&2
		gb_die 1 "unknown command '$cmd'"
		;;
esac
