diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b6a6b1..5369df3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,32 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +## [0.2.0] - 2026-06-14 + +### Added +- **Integrity**: every backup now writes a `.sha256` sidecar, and a new + `claude-backup verify [--from ]` re-checks the gzip stream, the + checksum and the archive's path safety (`--json` supported). +- **Off-site copy**: `--remote 'cmd:'` (and a `claude-backup push` + subcommand) push a finished backup through any command — `{}` is replaced by + the archive path, otherwise it is appended. Honours `CLAUDE_BACKUP_REMOTE`. + Pushes the `.sha256` too; a failed push never deletes the local backup. +- **Scheduling**: `claude-backup schedule [--daily|--weekly|--hourly] [--at HH:MM]` + installs a recurring backup via launchd (macOS) or a systemd user timer with a + crontab fallback (Linux); `--status` and `claude-backup unschedule` manage it. + Options after `--` are passed to each scheduled run. +- **Selective restore**: `claude-restore --only ` restores just the + chosen categories (`settings agents commands hooks skills mcp claude-md + history plugins`), and `--list-contents` shows what an archive holds. +- **curl | bash install**: `install.sh` now bootstraps its sources when piped + (downloads the repo tarball); overridable via `CCB_REPO` / `CCB_REF`. + +### Changed +- `_chk` status helper moved into `lib/common.sh` (shared by doctor, verify, + schedule status and the selective-restore listing). +- Updated bash/zsh completions and `--help` for the new subcommands and flags. +- Test suite expanded with verify, remote, selective-restore and schedule suites. + ## [0.1.1] - 2026-06-05 ### Added @@ -56,6 +82,7 @@ First release. - Documentation: architecture, restore safety, banner, plugin; plus `SECURITY.md`, `CONTRIBUTING.md`. -[Unreleased]: https://github.com/dberuben/claude-code-backup/compare/v0.1.1...HEAD +[Unreleased]: https://github.com/dberuben/claude-code-backup/compare/v0.2.0...HEAD +[0.2.0]: https://github.com/dberuben/claude-code-backup/compare/v0.1.1...v0.2.0 [0.1.1]: https://github.com/dberuben/claude-code-backup/compare/v0.1.0...v0.1.1 [0.1.0]: https://github.com/dberuben/claude-code-backup/releases/tag/v0.1.0 diff --git a/README.md b/README.md index e7ace71..f82725b 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,14 @@ control, and restores it on the same or a new machine. ## Install +One-liner (downloads sources, installs to `~/.local`): + +```bash +curl -fsSL https://raw.githubusercontent.com/dberuben/claude-code-backup/main/install.sh | bash +``` + +Or from a checkout: + ```bash git clone https://github.com/dberuben/claude-code-backup.git cd claude-code-backup @@ -40,6 +48,7 @@ claude-backup doctor claude-backup # back up → ~/Backups/claude-code claude-backup --dry-run # preview only, writes nothing claude-backup list # list existing backups +claude-backup verify # check the latest archive (gzip + checksum) claude-restore # restore the latest backup (asks first) ``` @@ -47,6 +56,44 @@ Handy flags: `--no-history` (skip the large `projects/` history), `--full` (everything), `--no-project`, `--dest `, `--strict-secrets`, `--json`. Full reference: `claude-backup --help`. +Every backup writes a `.sha256` sidecar; `claude-backup verify` +re-checks the gzip stream, the checksum and the path safety of an archive. + +### Off-site copy (opt-in) + +Backups are local by default. Push them anywhere with a command of your choice — +`{}` is replaced by the archive path (no extra dependency required): + +```bash +claude-backup --remote 'cmd:rclone copy {} mydrive:claude/' +claude-backup --remote 'cmd:rsync -a {} nas:/backups/' +claude-backup push --remote 'cmd:aws s3 cp {} s3://bucket/claude/' # push the latest +``` + +Set `CLAUDE_BACKUP_REMOTE` to make it the default. A failed push never deletes +the local backup. + +### Schedule it + +```bash +claude-backup schedule --daily --at 02:30 # launchd / systemd timer / cron +claude-backup schedule --status +claude-backup unschedule +``` + +Pass options to each scheduled run after `--`, e.g. +`schedule --daily -- --no-history --remote 'cmd:rclone copy {} d:/'`. + +### Restore a subset + +```bash +claude-restore --list-contents # what's inside an archive +claude-restore --only agents,commands # restore just those +claude-restore --only settings --home-only +``` + +Categories: `settings agents commands hooks skills mcp claude-md history plugins`. + By default a backup includes your config **and** conversation history, but **prunes** big regenerable data (plugin code, caches, venvs). Plugin *code* is not stored — the install manifests are, so you know what to reinstall. diff --git a/bin/claude-backup b/bin/claude-backup index ee65750..ababd7f 100755 --- a/bin/claude-backup +++ b/bin/claude-backup @@ -27,12 +27,18 @@ for _cand in \ done [ -n "$CCB_LIB_DIR" ] || { echo "claude-backup: cannot locate lib dir (set CLAUDE_BACKUP_LIB)" >&2; exit 1; } export CCB_LIB_DIR +# Absolute path to this very binary, used by `schedule` to write the cron/timer +# command. _self is the dir of the fully symlink-resolved script. +CCB_SELF_BIN="$_self/$(basename "${BASH_SOURCE[0]}")" +export CCB_SELF_BIN # shellcheck source=../lib/common.sh . "$CCB_LIB_DIR/common.sh" # shellcheck source=../lib/archive.sh . "$CCB_LIB_DIR/archive.sh" # shellcheck source=../lib/restore.sh . "$CCB_LIB_DIR/restore.sh" +# shellcheck source=../lib/verify.sh +. "$CCB_LIB_DIR/verify.sh" usage() { cat <<'EOF' @@ -41,6 +47,10 @@ claude-backup - back up Claude Code configuration USAGE: claude-backup [options] Create a backup (default) claude-backup list List existing backups + claude-backup verify [opts] Check an archive's integrity (gzip + checksum) + claude-backup push [opts] Push an existing backup to a remote + claude-backup schedule [opts] Install a recurring backup (launchd/systemd/cron) + claude-backup unschedule Remove the recurring backup claude-backup doctor Diagnose the environment claude-backup banner Print the status banner @@ -52,12 +62,29 @@ OPTIONS (backup): --full Include everything (no pruning of caches/plugins/etc.) --include-env Include .env.claude and .envrc (may contain secrets) --strict-secrets Abort if likely secrets are detected + --remote After backup, push the archive to a remote (see REMOTE) --dry-run Show what would be backed up --json Machine-readable JSON output --quiet Minimal output --help Show this help --version Show version +OPTIONS (verify / push): + --from Operate on a specific archive (default: latest) + --remote Remote spec for `push` (see REMOTE) + +REMOTE: + A remote is a user command, so any transport works with no extra dependency: + --remote 'cmd:rclone copy {} mydrive:claude/' # {} = archive path + --remote 'cmd:rsync -a {} nas:/backups/' # appended if no {} + Or set CLAUDE_BACKUP_REMOTE in the environment. + +SCHEDULE: + claude-backup schedule [--daily|--weekly|--hourly] [--at HH:MM] [-- ] + claude-backup schedule --status + Extra options after `--` are passed to each scheduled backup, e.g.: + claude-backup schedule --daily --at 02:30 -- --no-history --remote 'cmd:rclone copy {} d:/' + DESTINATION ORDER: --dest > $CLAUDE_BACKUP_DIR > $HOME/Backups/claude-code EOF @@ -66,14 +93,6 @@ EOF # --------------------------------------------------------------------------- # doctor # --------------------------------------------------------------------------- -_chk() { # _chk - case "$1" in - ok) printf ' %s✓%s %s\n' "$C_GREEN" "$C_RESET" "$2" ;; - warn) printf ' %s⚠%s %s\n' "$C_YELLOW" "$C_RESET" "$2" ;; - fail) printf ' %s✗%s %s\n' "$C_RED" "$C_RESET" "$2" ;; - esac -} - do_doctor() { local backup_dir; backup_dir="$(resolve_backup_dir "${OPT_DEST:-}")" log_step "claude-code-backup doctor (v$CCB_VERSION)" @@ -126,12 +145,34 @@ do_doctor() { # --------------------------------------------------------------------------- OPT_DEST=""; OPT_INCLUDE_PROJECT=1; OPT_INCLUDE_ENV=0 OPT_STRICT=0; OPT_DRY_RUN=0; OPT_FULL=0; OPT_NO_HISTORY=0 +OPT_REMOTE=""; OPT_FROM="" # Subcommand dispatch on the first argument. case "${1:-}" in list) shift while [ $# -gt 0 ]; do case "$1" in --json) CCB_JSON=1;; --dest) OPT_DEST="$2"; shift;; *) ;; esac; shift; done do_list; exit 0 ;; + verify) shift + while [ $# -gt 0 ]; do case "$1" in + --from) OPT_FROM="${2:-}"; shift;; --dest) OPT_DEST="${2:-}"; shift;; + --json) CCB_JSON=1;; --quiet) CCB_QUIET=1;; + -h|--help) usage; exit 0;; *) die "unknown option for verify: $1";; + esac; shift; done + do_verify; exit $? ;; + push) shift + while [ $# -gt 0 ]; do case "$1" in + --from) OPT_FROM="${2:-}"; shift;; --dest) OPT_DEST="${2:-}"; shift;; + --remote) OPT_REMOTE="${2:-}"; shift;; + --json) CCB_JSON=1;; --quiet) CCB_QUIET=1;; + -h|--help) usage; exit 0;; *) die "unknown option for push: $1";; + esac; shift; done + do_push; exit $? ;; + schedule|unschedule) + sub="$1"; shift + # shellcheck source=../lib/schedule.sh + . "$CCB_LIB_DIR/schedule.sh" + if [ "$sub" = "unschedule" ]; then do_unschedule "$@"; else do_schedule "$@"; fi + exit $? ;; doctor) shift; do_doctor; exit 0 ;; banner) shift # shellcheck source=../lib/banner.sh @@ -150,6 +191,7 @@ while [ $# -gt 0 ]; do --full) OPT_FULL=1 ;; --include-env) OPT_INCLUDE_ENV=1 ;; --strict-secrets) OPT_STRICT=1 ;; + --remote) OPT_REMOTE="${2:-}"; shift ;; --dry-run) OPT_DRY_RUN=1 ;; --json) CCB_JSON=1 ;; --quiet) CCB_QUIET=1 ;; @@ -160,5 +202,8 @@ while [ $# -gt 0 ]; do shift done +# Fall back to the environment-configured remote when no --remote was given. +OPT_REMOTE="${OPT_REMOTE:-${CLAUDE_BACKUP_REMOTE:-}}" + platform_supported || log_warn "Unsupported platform ($(uname -s)); proceeding best-effort." do_backup diff --git a/bin/claude-restore b/bin/claude-restore index 2dd222b..ad18c30 100755 --- a/bin/claude-restore +++ b/bin/claude-restore @@ -43,6 +43,8 @@ USAGE: OPTIONS: --from Restore from a specific archive (default: latest) --list List available backups and exit + --list-contents Show which categories an archive contains, then exit + --only Restore only these categories (see CATEGORIES) --dry-run Show the restore plan only --force Do not ask for confirmation --home-only Restore only global (~) config @@ -53,18 +55,28 @@ OPTIONS: --json Machine-readable JSON output --help Show this help --version Show version + +CATEGORIES (for --only): + settings agents commands hooks skills mcp claude-md history plugins + e.g. claude-restore --only agents,commands + claude-restore --only settings --home-only + Note: home-level `settings` and `mcp` both live in ~/.claude.json, so + restoring either reverts that whole file. EOF } OPT_FROM=""; OPT_DEST=""; OPT_DRY_RUN=0; OPT_FORCE=0 OPT_HOME_ONLY=0; OPT_PROJECT_ONLY=0; OPT_BACKUP_EXISTING=1 -DO_LIST=0 +OPT_ONLY="" +DO_LIST=0; DO_LIST_CONTENTS=0 while [ $# -gt 0 ]; do case "$1" in --from) OPT_FROM="${2:-}"; shift ;; --dest) OPT_DEST="${2:-}"; shift ;; --list) DO_LIST=1 ;; + --list-contents) DO_LIST_CONTENTS=1 ;; + --only) OPT_ONLY="${2:-}"; shift ;; --dry-run) OPT_DRY_RUN=1 ;; --force) OPT_FORCE=1 ;; --home-only) OPT_HOME_ONLY=1 ;; @@ -89,4 +101,9 @@ if [ "$DO_LIST" = "1" ]; then exit 0 fi +if [ "$DO_LIST_CONTENTS" = "1" ]; then + do_list_contents + exit 0 +fi + do_restore diff --git a/completions/claude-backup.bash b/completions/claude-backup.bash index 6d222b5..d6a2cd0 100644 --- a/completions/claude-backup.bash +++ b/completions/claude-backup.bash @@ -10,20 +10,24 @@ _claude_backup() { cur="${COMP_WORDS[COMP_CWORD]}" prev="${COMP_WORDS[COMP_CWORD-1]}" - local subcommands="list doctor banner" + local subcommands="list verify push schedule unschedule doctor banner" local opts="--dest --include-project --no-project --no-history --full --include-env \ - --strict-secrets --dry-run --json --quiet --help --version" + --strict-secrets --remote --from --dry-run --json --quiet --help --version" - # Complete a directory after --dest. + # Complete a directory after --dest, a file after --from. if [ "$prev" = "--dest" ]; then COMPREPLY=( $(compgen -d -- "$cur") ) return 0 fi + if [ "$prev" = "--from" ]; then + COMPREPLY=( $(compgen -f -- "$cur") ) + return 0 + fi if [ "$COMP_CWORD" -eq 1 ]; then COMPREPLY=( $(compgen -W "$subcommands $opts" -- "$cur") ) else - COMPREPLY=( $(compgen -W "$opts" -- "$cur") ) + COMPREPLY=( $(compgen -W "$opts --daily --weekly --hourly --at --status" -- "$cur") ) fi } complete -F _claude_backup claude-backup @@ -32,12 +36,17 @@ _claude_restore() { local cur prev cur="${COMP_WORDS[COMP_CWORD]}" prev="${COMP_WORDS[COMP_CWORD-1]}" - local opts="--from --list --dry-run --force --home-only --project-only \ + local opts="--from --list --list-contents --only --dry-run --force --home-only --project-only \ --backup-existing --no-backup-existing --dest --json --help --version" + local cats="settings agents commands hooks skills mcp claude-md history plugins" if [ "$prev" = "--from" ]; then COMPREPLY=( $(compgen -f -- "$cur") ) return 0 fi + if [ "$prev" = "--only" ]; then + COMPREPLY=( $(compgen -W "$cats" -- "$cur") ) + return 0 + fi COMPREPLY=( $(compgen -W "$opts" -- "$cur") ) } complete -F _claude_restore claude-restore diff --git a/completions/claude-backup.zsh b/completions/claude-backup.zsh index 0711dfd..88bee2f 100644 --- a/completions/claude-backup.zsh +++ b/completions/claude-backup.zsh @@ -7,6 +7,10 @@ _claude-backup() { local -a subcommands opts subcommands=( 'list:List existing backups' + 'verify:Check an archive (gzip + checksum)' + 'push:Push an existing backup to a remote' + 'schedule:Install a recurring backup' + 'unschedule:Remove the recurring backup' 'doctor:Diagnose the environment' 'banner:Print the status banner' ) @@ -18,6 +22,13 @@ _claude-backup() { '--full[Include everything (no pruning)]' '--include-env[Include .env.claude/.envrc]' '--strict-secrets[Abort if secrets detected]' + "--remote[Push the archive to a remote]:spec:" + '--from[Operate on a specific archive]:archive:_files' + '--daily[Schedule daily]' + '--weekly[Schedule weekly]' + '--hourly[Schedule hourly]' + '--at[Time HH:MM for schedule]:time:' + '--status[Show schedule status]' '--dry-run[Show what would be backed up]' '--json[Machine-readable output]' '--quiet[Minimal output]' @@ -34,6 +45,8 @@ _claude-restore() { _arguments \ '--from[Restore from archive]:archive:_files' \ '--list[List available backups]' \ + '--list-contents[Show categories in an archive]' \ + '--only[Restore only these categories]:categories:(settings agents commands hooks skills mcp claude-md history plugins)' \ '--dry-run[Show restore plan only]' \ '--force[Do not ask confirmation]' \ '--home-only[Restore only global config]' \ diff --git a/install.sh b/install.sh index d376759..f3b4e1c 100755 --- a/install.sh +++ b/install.sh @@ -17,7 +17,21 @@ INSTALL_COMPLETIONS=1 INSTALL_BANNER_CONF=1 ASSUME_YES=0 -SRC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# Where this script's sources live. Empty/invalid when piped via `curl | bash`, +# in which case we bootstrap by downloading the repo below. +SRC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" 2>/dev/null && pwd || true)" + +# Repo coordinates for the curl|bash bootstrap (override via env). +CCB_REPO="${CCB_REPO:-dberuben/claude-code-backup}" +CCB_REF="${CCB_REF:-main}" + +BOOTSTRAP_TMP="" +# Note the trailing `return 0`: without it the handler's last command is the +# `[ -n "$BOOTSTRAP_TMP" ]` test, which is false (status 1) on the normal local +# install, and an EXIT trap's last status overrides the script's exit code — +# making a successful install exit 1 and failing CI. +cleanup_bootstrap() { [ -n "$BOOTSTRAP_TMP" ] && rm -rf "$BOOTSTRAP_TMP"; return 0; } +trap cleanup_bootstrap EXIT usage() { cat <<'EOF' @@ -25,6 +39,7 @@ install.sh - install claude-code-backup USAGE: ./install.sh [options] + curl -fsSL https://raw.githubusercontent.com/dberuben/claude-code-backup/main/install.sh | bash OPTIONS: --prefix Install prefix (default: ~/.local) @@ -32,9 +47,39 @@ OPTIONS: --no-banner-conf Do not install the example banner config --yes Do not prompt (assume yes for optional steps) --help Show this help + +ENV (curl|bash bootstrap): + CCB_REPO owner/name to download from (default: dberuben/claude-code-backup) + CCB_REF branch or tag to install (default: main) EOF } +# bootstrap_sources - when running without a local checkout (piped via curl), +# download the repo tarball for CCB_REF and point SRC_DIR at the extracted tree. +bootstrap_sources() { + local url dl + url="https://github.com/$CCB_REPO/archive/$CCB_REF.tar.gz" # works for branch or tag + echo "==> No local sources found; downloading $CCB_REPO@$CCB_REF…" + BOOTSTRAP_TMP="$(mktemp -d "${TMPDIR:-/tmp}/ccb-install.XXXXXX")" || { echo "install.sh: mktemp failed" >&2; exit 1; } + + if command -v curl >/dev/null 2>&1; then + dl="curl -fsSL" + elif command -v wget >/dev/null 2>&1; then + dl="wget -qO-" + else + echo "install.sh: need curl or wget to bootstrap (or run from a git checkout)" >&2 + exit 1 + fi + + if ! $dl "$url" | tar xz -C "$BOOTSTRAP_TMP"; then + echo "install.sh: download/extract failed: $url" >&2 + exit 1 + fi + SRC_DIR="$(find "$BOOTSTRAP_TMP" -maxdepth 1 -type d -name 'claude-code-backup-*' 2>/dev/null | head -n1)" + [ -n "$SRC_DIR" ] && [ -f "$SRC_DIR/lib/common.sh" ] \ + || { echo "install.sh: unexpected archive layout from $url" >&2; exit 1; } +} + while [ $# -gt 0 ]; do case "$1" in --prefix) PREFIX="${2:?--prefix needs a path}"; shift ;; @@ -47,6 +92,11 @@ while [ $# -gt 0 ]; do shift done +# If we are not sitting in a checkout (e.g. piped via curl|bash), fetch sources. +if [ -z "$SRC_DIR" ] || [ ! -f "$SRC_DIR/lib/common.sh" ] || [ ! -f "$SRC_DIR/bin/claude-backup" ]; then + bootstrap_sources +fi + BIN_DIR="$PREFIX/bin" LIB_DIR="$PREFIX/share/claude-code-backup/lib" diff --git a/lib/archive.sh b/lib/archive.sh index de9da01..3fa8f7b 100644 --- a/lib/archive.sh +++ b/lib/archive.sh @@ -277,10 +277,28 @@ do_backup() { ccb_backup_cleanup trap - INT TERM EXIT + # --- Checksum sidecar ----------------------------------------------------- + # Write " " next to the archive so integrity can be checked + # later (claude-backup verify) and by standard `shasum -c` / `sha256sum -c`. + local sum="" + if ccb_have_sha256; then + sum="$(ccb_sha256 "$archive")" + if [ -n "$sum" ]; then + printf '%s %s\n' "$sum" "$(basename "$archive")" >"$archive.sha256" + fi + fi + + # --- Optional push to a remote ------------------------------------------- + # Never fatal: a failed upload must not invalidate a good local backup. + if [ -n "${OPT_REMOTE:-}" ]; then + ccb_push_remote "$archive" "$OPT_REMOTE" || log_warn "remote push failed; local backup kept" + fi + if [ "$CCB_JSON" = "1" ]; then - emit_backup_json "$archive" "$secret_hits" + emit_backup_json "$archive" "$secret_hits" "$sum" else log_ok "Backup created: $archive ($(human_size "$(file_size "$archive")"))" + [ -n "$sum" ] && log_info " checksum: $archive.sha256" [ "${#excludes[@]}" -gt 0 ] && \ log_info " pruned large/ephemeral dirs (caches, plugin code, venvs…); use --full to keep them" printf '%s\n' "$archive" @@ -327,13 +345,14 @@ print_backup_plan() { return 0 } -# emit_backup_json +# emit_backup_json [sha256] emit_backup_json() { - local archive="$1" secret_hits="$2" has_secrets="false" + local archive="$1" secret_hits="$2" sum="${3:-}" has_secrets="false" [ -n "$secret_hits" ] && has_secrets="true" - printf '{"status":"ok","archive":"%s","size_bytes":%s,"secrets_detected":%s,"version":"%s"}\n' \ + printf '{"status":"ok","archive":"%s","size_bytes":%s,"sha256":"%s","secrets_detected":%s,"version":"%s"}\n' \ "$(json_escape "$archive")" \ "$(file_size "$archive")" \ + "$(json_escape "$sum")" \ "$has_secrets" \ "$CCB_VERSION" } diff --git a/lib/common.sh b/lib/common.sh index ad10b8c..ded3ed8 100644 --- a/lib/common.sh +++ b/lib/common.sh @@ -11,7 +11,7 @@ # --------------------------------------------------------------------------- # Version # --------------------------------------------------------------------------- -CCB_VERSION="0.1.1" +CCB_VERSION="0.2.0" # --------------------------------------------------------------------------- # Library loading @@ -23,6 +23,8 @@ CCB_VERSION="0.1.1" . "$CCB_LIB_DIR/platform.sh" # shellcheck source=security.sh . "$CCB_LIB_DIR/security.sh" +# shellcheck source=remote.sh +. "$CCB_LIB_DIR/remote.sh" detect_platform @@ -106,6 +108,38 @@ human_size() { fi } +# --------------------------------------------------------------------------- +# Checksums (no external dependency beyond the system's sha256 tool) +# --------------------------------------------------------------------------- +# ccb_have_sha256 - return 0 if a SHA-256 tool is available on this system. +ccb_have_sha256() { + command -v shasum >/dev/null 2>&1 || command -v sha256sum >/dev/null 2>&1 +} + +# ccb_sha256 - print the lowercase hex SHA-256 of , or nothing when +# no tool is available. Handles both BSD (shasum, macOS) and GNU (sha256sum). +ccb_sha256() { + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" 2>/dev/null | awk '{print $1}' + elif command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" 2>/dev/null | awk '{print $1}' + fi +} + +# --------------------------------------------------------------------------- +# Archive path → restore destination +# --------------------------------------------------------------------------- +# ccb_relpath_to_dest - map an in-archive path (home/… or project/…) +# to its on-disk restore destination. home/ → $HOME/, project/ → $PWD/. Prints +# nothing for paths outside those two trees (caller treats that as "skip"). +ccb_relpath_to_dest() { + case "$1" in + home/*) printf '%s/%s\n' "$HOME" "${1#home/}" ;; + project/*) printf '%s/%s\n' "$PWD" "${1#project/}" ;; + *) : ;; + esac +} + # --------------------------------------------------------------------------- # Backup scope / exclusions # --------------------------------------------------------------------------- @@ -199,3 +233,13 @@ confirm() { require_cmd() { command -v "$1" >/dev/null 2>&1 || die "required command not found: $1" } + +# _chk - print a checkmark/warning/cross status line. +# Shared by doctor, verify, schedule status and selective-restore listing. +_chk() { + case "$1" in + ok) printf ' %s✓%s %s\n' "$C_GREEN" "$C_RESET" "$2" >&2 ;; + warn) printf ' %s⚠%s %s\n' "$C_YELLOW" "$C_RESET" "$2" >&2 ;; + fail) printf ' %s✗%s %s\n' "$C_RED" "$C_RESET" "$2" >&2 ;; + esac +} diff --git a/lib/remote.sh b/lib/remote.sh new file mode 100644 index 0000000..6814016 --- /dev/null +++ b/lib/remote.sh @@ -0,0 +1,86 @@ +# shellcheck shell=bash +# +# remote.sh - Push a finished backup to an off-machine destination. +# +# Transport is a user-supplied command, so claude-code-backup stays zero- +# dependency and can target anything (rclone, rsync/scp, aws s3, git, …) without +# bundling integrations. The spec format is: +# +# cmd: Run via `sh -c`. If it contains the token "{}", +# every "{}" is replaced by the archive path; otherwise the +# archive path is appended as the final argument. +# +# A bare spec with no "cmd:" prefix is also accepted and treated as "cmd:". +# +# Examples: +# --remote 'cmd:rclone copy {} mydrive:claude-backups/' +# --remote 'cmd:rsync -a {} nas:/backups/claude/' +# --remote 'cmd:aws s3 cp {} s3://my-bucket/claude/' +# CLAUDE_BACKUP_REMOTE='cmd:scp {} host:/backups/' claude-backup +# +# Sourced by common.sh. Not run directly. + +# ccb_remote_run - run one transport command for one +# file, substituting "{}" or appending the path. Returns the command's status. +# +# The file path is passed to `sh -c` as a positional argument ($1), never spliced +# into the command string. We only substitute the literal token "$1" for "{}", +# so a path containing $(…), backticks, quotes or spaces stays inert DATA rather +# than becoming shell CODE (the archive name embeds the host's `hostname`, which +# the running user may not fully control). The command template itself is the +# user's own intentional input, so executing it is by design. +ccb_remote_run() { + local tmpl="$1" file="$2" cmd + if printf '%s' "$tmpl" | grep -q '{}'; then + cmd="${tmpl//\{\}/\"\$1\"}" # {} -> "$1" + else + cmd="$tmpl \"\$1\"" # append "$1" + fi + # $0 is set to "ccb-remote" (cosmetic); $1 carries the path safely. + sh -c "$cmd" ccb-remote "$file" +} + +# ccb_push_remote - push the archive (and its .sha256 sidecar, +# if present) using . Returns non-zero if the archive push fails. A failed +# sidecar push is only a warning. Never deletes or alters the local backup. +ccb_push_remote() { + local archive="$1" spec="$2" tmpl rc + case "$spec" in + cmd:*) tmpl="${spec#cmd:}" ;; + *) tmpl="$spec" ;; # tolerate a bare command with no prefix + esac + [ -n "$tmpl" ] || { log_warn "empty --remote spec; nothing to push"; return 1; } + + log_step "Pushing backup to remote…" + if ccb_remote_run "$tmpl" "$archive"; then + rc=0 + log_ok "Remote push complete: $(basename "$archive")" + else + rc=$? + log_err "remote push failed (exit $rc): $(basename "$archive")" + fi + + # Best-effort: also ship the checksum sidecar so the remote copy is verifiable. + if [ "$rc" = "0" ] && [ -f "$archive.sha256" ]; then + ccb_remote_run "$tmpl" "$archive.sha256" \ + || log_warn "remote push of checksum sidecar failed (archive itself was pushed)" + fi + return "$rc" +} + +# do_push - implements `claude-backup push [--from ] --remote `. +# Pushes an already-created backup; defaults to the latest one. Reads OPT_*. +do_push() { + local spec="${OPT_REMOTE:-${CLAUDE_BACKUP_REMOTE:-}}" + [ -n "$spec" ] || die "no remote specified; use --remote or set CLAUDE_BACKUP_REMOTE" + + local archive="${OPT_FROM:-}" + if [ -z "$archive" ]; then + archive="$(latest_backup "$(resolve_backup_dir "${OPT_DEST:-}")")" + [ -n "$archive" ] || die "no backup found; use --from or run 'claude-backup' first" + log_info "Selected latest backup: $archive" + fi + [ -f "$archive" ] || die "archive not found: $archive" + + ccb_push_remote "$archive" "$spec" +} diff --git a/lib/restore.sh b/lib/restore.sh index 81dcfc2..2042f97 100644 --- a/lib/restore.sh +++ b/lib/restore.sh @@ -69,6 +69,65 @@ do_list() { return 0 } +# --------------------------------------------------------------------------- +# Selective restore: categories +# --------------------------------------------------------------------------- +# The categories a user can pass to `--only`. Kept here so help/listing and the +# planner share one source of truth. +CCB_CATEGORIES="settings agents commands hooks skills mcp claude-md history plugins" + +# ccb_category_relpaths - print the archive-relative paths that make +# up a category (one per line). Returns non-zero for an unknown category. +# Note: home-level `settings` and `mcp` both map to ~/.claude.json, which is a +# single file holding both — restoring either reverts the whole file. +ccb_category_relpaths() { + case "$1" in + settings) printf '%s\n' home/.claude/settings.json home/.claude/settings.local.json \ + home/.claude.json \ + project/.claude/settings.json project/.claude/settings.local.json ;; + agents) printf '%s\n' home/.claude/agents project/.claude/agents ;; + commands) printf '%s\n' home/.claude/commands project/.claude/commands ;; + hooks) printf '%s\n' home/.claude/hooks project/.claude/hooks ;; + skills) printf '%s\n' home/.claude/skills project/.claude/skills ;; + mcp) printf '%s\n' home/.claude.json project/.mcp.json ;; + claude-md|claudemd) printf '%s\n' home/.claude/CLAUDE.md project/CLAUDE.md project/CLAUDE.local.md ;; + history) printf '%s\n' home/.claude/projects ;; + plugins) printf '%s\n' home/.claude/plugins ;; + *) return 1 ;; + esac +} + +# do_list_contents - implement `claude-restore --list-contents`. Show which +# categories an archive contains. Reads OPT_FROM / OPT_DEST. +do_list_contents() { + require_cmd tar + local archive="${OPT_FROM:-}" + if [ -z "$archive" ]; then + archive="$(latest_backup "$(resolve_backup_dir "${OPT_DEST:-}")")" + [ -n "$archive" ] || die "no backup found; use --from " + fi + [ -f "$archive" ] || die "archive not found: $archive" + validate_archive_listing "$archive" >/dev/null 2>&1 || die "refusing to inspect an unsafe archive" + + local listing; listing="$(tar -tzf "$archive" 2>/dev/null)" + log_step "Categories in $(basename "$archive")" + local cat rel relre present + for cat in $CCB_CATEGORIES; do + present="" + while IFS= read -r rel; do + [ -n "$rel" ] || continue + # Anchor on a path-component boundary so e.g. "agents-archive/" does not + # count as the "agents" category. Escape the dots in rel for ERE. + relre="${rel//./\\.}" + printf '%s\n' "$listing" | grep -Eq "^(\./)?${relre}"'(/|$)' \ + && present="$present $rel" + done < <(ccb_category_relpaths "$cat") + if [ -n "$present" ]; then _chk ok "$cat:$present"; else _chk warn "$cat: (absent)"; fi + done + log_info "Restore a subset with: claude-restore --only [,…]" + return 0 +} + # --------------------------------------------------------------------------- # Validation # --------------------------------------------------------------------------- @@ -274,15 +333,47 @@ do_restore() { [ "${OPT_HOME_ONLY:-0}" = "1" ] && do_project=0 local plan=() # "src|dest" - if [ "$do_home" = "1" ] && [ -d "$tmp/home" ]; then - [ -e "$tmp/home/.claude" ] && plan+=("$tmp/home/.claude|$HOME/.claude") - [ -e "$tmp/home/.claude.json" ] && plan+=("$tmp/home/.claude.json|$HOME/.claude.json") - fi - if [ "$do_project" = "1" ] && [ -d "$tmp/project" ]; then - local pf - for pf in .claude .mcp.json CLAUDE.md CLAUDE.local.md .env.claude .envrc; do - [ -e "$tmp/project/$pf" ] && plan+=("$tmp/project/$pf|$PWD/$pf") + if [ -n "${OPT_ONLY:-}" ]; then + # --- Selective restore by category ------------------------------------- + local cats=() cat rel dest seen=" " + local IFS_save="$IFS"; IFS=','; set -f + # shellcheck disable=SC2206 + cats=($OPT_ONLY); set +f; IFS="$IFS_save" + # Validate every category up front so a typo fails before any work. + for cat in "${cats[@]}"; do + cat="$(printf '%s' "$cat" | tr 'A-Z' 'a-z' | tr -d ' ')" + [ -n "$cat" ] || continue + ccb_category_relpaths "$cat" >/dev/null 2>&1 \ + || die "unknown category: $cat (known: $CCB_CATEGORIES)" done + for cat in "${cats[@]}"; do + cat="$(printf '%s' "$cat" | tr 'A-Z' 'a-z' | tr -d ' ')" + [ -n "$cat" ] || continue + while IFS= read -r rel; do + [ -n "$rel" ] || continue + case "$rel" in + home/*) [ "$do_home" = "1" ] || continue ;; + project/*) [ "$do_project" = "1" ] || continue ;; + esac + [ -e "$tmp/$rel" ] || continue + case "$seen" in *" $rel "*) continue ;; esac + seen="$seen$rel " + dest="$(ccb_relpath_to_dest "$rel")" + [ -n "$dest" ] && plan+=("$tmp/$rel|$dest") + done < <(ccb_category_relpaths "$cat") + done + else + # --- Coarse restore (whole home / project trees) ----------------------- + if [ "$do_home" = "1" ] && [ -d "$tmp/home" ]; then + [ -e "$tmp/home/.claude" ] && plan+=("$tmp/home/.claude|$HOME/.claude") + [ -e "$tmp/home/.claude.json" ] && plan+=("$tmp/home/.claude.json|$HOME/.claude.json") + fi + if [ "$do_project" = "1" ] && [ -d "$tmp/project" ]; then + local pf + for pf in .claude .mcp.json CLAUDE.md CLAUDE.local.md .env.claude .envrc; do + [ -e "$tmp/project/$pf" ] && plan+=("$tmp/project/$pf|$PWD/$pf") + done + fi fi [ "${#plan[@]}" -gt 0 ] || die "nothing to restore for the selected scope" diff --git a/lib/schedule.sh b/lib/schedule.sh new file mode 100644 index 0000000..240fbc5 --- /dev/null +++ b/lib/schedule.sh @@ -0,0 +1,309 @@ +# shellcheck shell=bash +# +# schedule.sh - Install/remove a recurring backup using the OS scheduler. +# +# macOS -> launchd user agent (~/Library/LaunchAgents) +# Linux -> systemd user timer (~/.config/systemd/user), else crontab +# +# The scheduled job runs a global backup (--no-project: a daemon has no +# meaningful working directory). Extra options after `--` are appended verbatim, +# so a remote push or --no-history can be scheduled too. +# +# Sourced by bin/claude-backup for the `schedule` / `unschedule` subcommands. + +CCB_SCHED_LABEL="com.claude-code-backup.backup" # launchd label / systemd unit base +CCB_SCHED_UNIT="claude-code-backup" # systemd unit name (.service/.timer) +CCB_SCHED_CONF_DIR="$HOME/.claude-backup" +CCB_SCHED_LOG="$CCB_SCHED_CONF_DIR/schedule.log" +CCB_CRON_BEGIN="# >>> claude-code-backup >>>" +CCB_CRON_END="# <<< claude-code-backup <<<" + +# --- small quoting helpers ------------------------------------------------- +# ccb_shquote - single-quote a string for a POSIX shell / crontab line. +ccb_shquote() { local s="$1"; printf "'%s'" "${s//\'/\'\\\'\'}"; } +# ccb_sdquote - double-quote a string for a systemd ExecStart= value. +ccb_sdquote() { local s="$1"; s="${s//\\/\\\\}"; s="${s//\"/\\\"}"; printf '"%s"' "$s"; } + +# ccb_sched_usage - help for the schedule subcommand. +ccb_sched_usage() { + cat <<'EOF' +claude-backup schedule - install a recurring backup + +USAGE: + claude-backup schedule [--daily|--weekly|--hourly] [--at HH:MM] [--dest DIR] [-- ] + claude-backup schedule --status + claude-backup unschedule + + Default interval is --daily at 02:00. Anything after `--` is passed to each + backup, e.g.: schedule --daily --at 02:30 -- --no-history --remote 'cmd:rclone copy {} d:/' +EOF +} + +# do_schedule [args] - parse options and install the recurring job. +do_schedule() { + local interval="daily" at="" dest="" want_status=0 + local extra=() + while [ $# -gt 0 ]; do + case "$1" in + --daily) interval="daily" ;; + --weekly) interval="weekly" ;; + --hourly) interval="hourly" ;; + --at) at="${2:-}"; shift ;; + --dest) dest="${2:-}"; shift ;; + --status) want_status=1 ;; + --) shift; while [ $# -gt 0 ]; do extra+=("$1"); shift; done; break ;; + -h|--help) ccb_sched_usage; return 0 ;; + *) die "unknown schedule option: $1 (see 'claude-backup schedule --help')" ;; + esac + shift + done + + [ "$want_status" = "1" ] && { ccb_sched_status; return $?; } + + # Parse --at HH:MM into zero-padded hour/minute. + local hh="02" mm="00" + if [ -n "$at" ]; then + case "$at" in + [0-9]:[0-9][0-9]) hh="0${at%%:*}"; mm="${at##*:}" ;; + [0-9][0-9]:[0-9][0-9]) hh="${at%%:*}"; mm="${at##*:}" ;; + *) die "invalid --at time, expected HH:MM: $at" ;; + esac + [ "$hh" -le 23 ] 2>/dev/null && [ "$mm" -le 59 ] 2>/dev/null \ + || die "invalid --at time, hour 00-23 minute 00-59: $at" + fi + + # Resolve the backup binary and assemble its argument list. + local bin="${CCB_SELF_BIN:-}" + [ -n "$bin" ] && [ -x "$bin" ] || bin="$(command -v claude-backup 2>/dev/null || echo claude-backup)" + local args=(--quiet --no-project) + [ -n "$dest" ] && args+=(--dest "$dest") + args+=(${extra[@]+"${extra[@]}"}) + + # Refuse newlines in any scheduled argument: a newline would terminate an + # ExecStart= line (systemd) or a crontab line and let extra directives/jobs be + # injected. Normal options never contain newlines, so this only blocks abuse. + local _a _nl + _nl=$'\n' + for _a in "${args[@]}"; do + case "$_a" in + *"$_nl"*) die "scheduled arguments must not contain newlines" ;; + esac + done + + mkdir -p "$CCB_SCHED_CONF_DIR" 2>/dev/null || true + + case "$CCB_PLATFORM" in + macos) ccb_sched_launchd "$interval" "$hh" "$mm" "$bin" "${args[@]}" ;; + linux) + if ccb_systemd_available; then + ccb_sched_systemd "$interval" "$hh" "$mm" "$bin" "${args[@]}" + else + log_info "systemd user instance not available; using crontab." + ccb_sched_cron "$interval" "$hh" "$mm" "$bin" "${args[@]}" + fi ;; + *) die "scheduling is not supported on this platform ($(uname -s))" ;; + esac +} + +# do_unschedule [args] - remove whatever recurring job we installed. +do_unschedule() { + case "$CCB_PLATFORM" in + macos) ccb_unsched_launchd ;; + linux) ccb_systemd_available && ccb_unsched_systemd; ccb_unsched_cron ;; + *) die "scheduling is not supported on this platform ($(uname -s))" ;; + esac +} + +# --------------------------------------------------------------------------- +# macOS: launchd +# --------------------------------------------------------------------------- +ccb_launchd_plist() { printf '%s/Library/LaunchAgents/%s.plist\n' "$HOME" "$CCB_SCHED_LABEL"; } + +# ccb_sched_launchd +ccb_sched_launchd() { + local interval="$1" hh="$2" mm="$3"; shift 3 + local plist; plist="$(ccb_launchd_plist)" + mkdir -p "$(dirname "$plist")" || die "cannot create LaunchAgents dir" + + # Calendar dict varies by interval (launchd fires hourly when only Minute is set). + local cal="" + case "$interval" in + hourly) cal=" Minute$((10#$mm))" ;; + daily) cal=" Hour$((10#$hh)) + Minute$((10#$mm))" ;; + weekly) cal=" Weekday0 + Hour$((10#$hh)) + Minute$((10#$mm))" ;; + esac + + { + printf '\n' + printf '\n' + printf '\n\n' + printf ' Label%s\n' "$CCB_SCHED_LABEL" + printf ' ProgramArguments\n \n' + printf ' /bin/bash\n' + local a + for a in "$@"; do printf ' %s\n' "$(ccb_xml_escape "$a")"; done + printf ' \n' + printf ' StartCalendarInterval\n \n%s\n \n' "$cal" + printf ' StandardOutPath%s\n' "$CCB_SCHED_LOG" + printf ' StandardErrorPath%s\n' "$CCB_SCHED_LOG" + printf '\n\n' + } >"$plist" || die "cannot write plist: $plist" + + if command -v launchctl >/dev/null 2>&1; then + launchctl unload "$plist" >/dev/null 2>&1 || true + if launchctl load -w "$plist" 2>/dev/null; then + log_ok "Scheduled ($interval) via launchd: $plist" + else + log_warn "wrote plist but 'launchctl load' failed; load it manually: launchctl load -w $plist" + fi + else + log_warn "launchctl not found; plist written to $plist (load it when available)" + fi + log_info " logs: $CCB_SCHED_LOG" +} + +ccb_unsched_launchd() { + local plist; plist="$(ccb_launchd_plist)" + if [ -f "$plist" ]; then + command -v launchctl >/dev/null 2>&1 && launchctl unload "$plist" >/dev/null 2>&1 || true + rm -f "$plist" && log_ok "Removed launchd schedule: $plist" + else + log_info "No launchd schedule installed." + fi +} + +# ccb_xml_escape - escape &, <, > for an XML value. +ccb_xml_escape() { + local s="$1"; s="${s//&/&}"; s="${s///>}"; printf '%s' "$s" +} + +# --------------------------------------------------------------------------- +# Linux: systemd user timer +# --------------------------------------------------------------------------- +ccb_systemd_available() { + command -v systemctl >/dev/null 2>&1 && systemctl --user show-environment >/dev/null 2>&1 +} + +ccb_systemd_dir() { printf '%s/systemd/user\n' "${XDG_CONFIG_HOME:-$HOME/.config}"; } + +# ccb_sched_systemd +ccb_sched_systemd() { + local interval="$1" hh="$2" mm="$3"; shift 3 + local dir; dir="$(ccb_systemd_dir)" + mkdir -p "$dir" || die "cannot create systemd user dir: $dir" + + # OnCalendar expression per interval. + local oncal + case "$interval" in + hourly) oncal="*-*-* *:$mm:00" ;; + daily) oncal="*-*-* $hh:$mm:00" ;; + weekly) oncal="Sun *-*-* $hh:$mm:00" ;; + esac + + # Build a quoted ExecStart line. + local exec_line="/bin/bash" a + for a in "$@"; do exec_line="$exec_line $(ccb_sdquote "$a")"; done + + { + printf '[Unit]\nDescription=claude-code-backup scheduled backup\n\n' + printf '[Service]\nType=oneshot\nExecStart=%s\n' "$exec_line" + } >"$dir/$CCB_SCHED_UNIT.service" || die "cannot write service unit" + + { + printf '[Unit]\nDescription=claude-code-backup timer (%s)\n\n' "$interval" + printf '[Timer]\nOnCalendar=%s\nPersistent=true\n\n' "$oncal" + printf '[Install]\nWantedBy=timers.target\n' + } >"$dir/$CCB_SCHED_UNIT.timer" || die "cannot write timer unit" + + systemctl --user daemon-reload >/dev/null 2>&1 || true + if systemctl --user enable --now "$CCB_SCHED_UNIT.timer" >/dev/null 2>&1; then + log_ok "Scheduled ($interval) via systemd user timer: $CCB_SCHED_UNIT.timer" + log_info " status: systemctl --user list-timers '$CCB_SCHED_UNIT*'" + else + log_warn "wrote units but enabling failed; try: systemctl --user enable --now $CCB_SCHED_UNIT.timer" + fi +} + +ccb_unsched_systemd() { + local dir; dir="$(ccb_systemd_dir)" + [ -f "$dir/$CCB_SCHED_UNIT.timer" ] || return 0 + systemctl --user disable --now "$CCB_SCHED_UNIT.timer" >/dev/null 2>&1 || true + rm -f "$dir/$CCB_SCHED_UNIT.timer" "$dir/$CCB_SCHED_UNIT.service" + systemctl --user daemon-reload >/dev/null 2>&1 || true + log_ok "Removed systemd schedule: $CCB_SCHED_UNIT.timer" +} + +# --------------------------------------------------------------------------- +# Linux fallback: crontab +# --------------------------------------------------------------------------- +# ccb_sched_cron +ccb_sched_cron() { + local interval="$1" hh="$2" mm="$3"; shift 3 + command -v crontab >/dev/null 2>&1 || die "neither systemd --user nor crontab is available" + + local sched + case "$interval" in + hourly) sched="$((10#$mm)) * * * *" ;; + daily) sched="$((10#$mm)) $((10#$hh)) * * *" ;; + weekly) sched="$((10#$mm)) $((10#$hh)) * * 0" ;; + esac + + local cmd="/bin/bash" a + for a in "$@"; do cmd="$cmd $(ccb_shquote "$a")"; done + cmd="$cmd >> $(ccb_shquote "$CCB_SCHED_LOG") 2>&1" + + # Rewrite the crontab, replacing any prior claude-code-backup block. + local current; current="$(crontab -l 2>/dev/null || true)" + local filtered; filtered="$(printf '%s\n' "$current" | sed "/$CCB_CRON_BEGIN/,/$CCB_CRON_END/d")" + { + printf '%s\n' "$filtered" | sed '/^$/d' + printf '%s\n' "$CCB_CRON_BEGIN" + printf '%s %s\n' "$sched" "$cmd" + printf '%s\n' "$CCB_CRON_END" + } | crontab - && log_ok "Scheduled ($interval) via crontab" || die "failed to update crontab" + log_info " logs: $CCB_SCHED_LOG" +} + +ccb_unsched_cron() { + command -v crontab >/dev/null 2>&1 || return 0 + local current; current="$(crontab -l 2>/dev/null || true)" + case "$current" in + *"$CCB_CRON_BEGIN"*) + printf '%s\n' "$current" | sed "/$CCB_CRON_BEGIN/,/$CCB_CRON_END/d" | sed '/^$/d' | crontab - \ + && log_ok "Removed crontab schedule" ;; + *) : ;; + esac +} + +# --------------------------------------------------------------------------- +# Status +# --------------------------------------------------------------------------- +ccb_sched_status() { + log_step "Scheduled backup status" + local found=0 + case "$CCB_PLATFORM" in + macos) + local plist; plist="$(ccb_launchd_plist)" + if [ -f "$plist" ]; then + found=1; _chk ok "launchd agent installed: $plist" + command -v launchctl >/dev/null 2>&1 && launchctl list 2>/dev/null | grep -q "$CCB_SCHED_LABEL" \ + && _chk ok "agent is loaded" || _chk warn "agent not currently loaded" + fi ;; + linux) + local dir; dir="$(ccb_systemd_dir)" + if [ -f "$dir/$CCB_SCHED_UNIT.timer" ]; then + found=1; _chk ok "systemd timer installed: $dir/$CCB_SCHED_UNIT.timer" + ccb_systemd_available && systemctl --user is-enabled "$CCB_SCHED_UNIT.timer" >/dev/null 2>&1 \ + && _chk ok "timer is enabled" || _chk warn "timer not enabled" + fi + if command -v crontab >/dev/null 2>&1 && crontab -l 2>/dev/null | grep -q "$CCB_CRON_BEGIN"; then + found=1; _chk ok "crontab entry installed" + fi ;; + esac + [ "$found" = "1" ] || _chk warn "no recurring backup is scheduled" + [ -f "$CCB_SCHED_LOG" ] && _chk ok "log: $CCB_SCHED_LOG" + return 0 +} diff --git a/lib/verify.sh b/lib/verify.sh new file mode 100644 index 0000000..54fedae --- /dev/null +++ b/lib/verify.sh @@ -0,0 +1,75 @@ +# shellcheck shell=bash +# +# verify.sh - Check the integrity and safety of a backup archive. +# +# Three independent checks, all non-destructive: +# 1. gzip integrity - `gzip -t` proves the compressed stream is intact. +# 2. checksum - compare against the `.sha256` sidecar. +# 3. safe listing - reuse validate_archive_listing (no absolute / ".." paths). +# +# Sourced by bin/claude-backup (needs validate_archive_listing from restore.sh). +# Not run directly. + +# do_verify - implements `claude-backup verify [--from ]`. Reads OPT_*. +# Exit status is 0 only when every applicable check passes. +do_verify() { + require_cmd tar + require_cmd gzip + + # --- Select archive ------------------------------------------------------- + local archive="${OPT_FROM:-}" + if [ -z "$archive" ]; then + archive="$(latest_backup "$(resolve_backup_dir "${OPT_DEST:-}")")" + [ -n "$archive" ] || die "no backup found; use --from or run 'claude-backup' first" + fi + [ -f "$archive" ] || die "archive not found: $archive" + + local ok=1 + local gz="fail" sumc="absent" listc="fail" + + # --- 1. gzip integrity ---------------------------------------------------- + if gzip -t "$archive" 2>/dev/null; then gz="ok"; else gz="fail"; ok=0; fi + + # --- 2. checksum ---------------------------------------------------------- + if [ -f "$archive.sha256" ]; then + if ccb_have_sha256; then + local want got + want="$(awk 'NR==1{print $1}' "$archive.sha256" 2>/dev/null)" + got="$(ccb_sha256 "$archive")" + if [ -n "$want" ] && [ "$want" = "$got" ]; then sumc="ok"; else sumc="mismatch"; ok=0; fi + else + sumc="no-tool" # sidecar present but we cannot verify it here + fi + else + sumc="absent" # not an error: older backups have no sidecar + fi + + # --- 3. safe listing ------------------------------------------------------ + if validate_archive_listing "$archive" >/dev/null 2>&1; then listc="ok"; else listc="fail"; ok=0; fi + + # --- Report --------------------------------------------------------------- + if [ "$CCB_JSON" = "1" ]; then + local status="ok"; [ "$ok" = "1" ] || status="fail" + printf '{"status":"%s","archive":"%s","gzip":"%s","checksum":"%s","listing":"%s","version":"%s"}\n' \ + "$status" "$(json_escape "$archive")" "$gz" "$sumc" "$listc" "$CCB_VERSION" + [ "$ok" = "1" ] + return + fi + + log_step "Verifying $(basename "$archive")" + case "$gz" in ok) _chk ok "gzip stream intact" ;; *) _chk fail "gzip integrity check FAILED (archive corrupt)" ;; esac + case "$sumc" in + ok) _chk ok "checksum matches $archive.sha256" ;; + mismatch) _chk fail "checksum MISMATCH — archive does not match its .sha256" ;; + no-tool) _chk warn "checksum present but no sha256 tool to verify it" ;; + absent) _chk warn "no .sha256 sidecar (not checked)" ;; + esac + case "$listc" in ok) _chk ok "archive listing is safe (no absolute / parent paths)" ;; *) _chk fail "archive listing contains unsafe paths" ;; esac + + if [ "$ok" = "1" ]; then + log_ok "Archive verified." + else + log_err "Archive verification FAILED." + fi + [ "$ok" = "1" ] +} diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index 7393aa0..2f5a15d 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "claude-code-backup", "description": "Back up and restore Claude Code configuration (global + project) via the claude-backup CLI.", - "version": "0.1.1", + "version": "0.2.0", "author": { "name": "claude-code-backup contributors" }, diff --git a/tests/run.sh b/tests/run.sh index 57e5302..f2ef2fd 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -5,7 +5,7 @@ set -uo pipefail DIR="$(cd "$(dirname "$0")" && pwd)" fail=0 -for t in test-backup.sh test-restore.sh test-linux-paths.sh test-macos-paths.sh test-banner.sh test-compat.sh; do +for t in test-backup.sh test-restore.sh test-verify.sh test-remote.sh test-restore-selective.sh test-schedule.sh test-linux-paths.sh test-macos-paths.sh test-banner.sh test-compat.sh; do echo "-------------------------------------------------------------------" bash "$DIR/$t" || fail=1 done diff --git a/tests/test-remote.sh b/tests/test-remote.sh new file mode 100755 index 0000000..6c1f837 --- /dev/null +++ b/tests/test-remote.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# Tests for the custom-command remote (`--remote cmd:…` and `push`). +. "$(dirname "$0")/helper.sh" + +echo "== test-remote ==" + +# --- --remote during backup runs the command with {} substitution -------- +setup_sandbox +mkdir -p "$SANDBOX/remote" +in_project "$BIN/claude-backup" --no-project --quiet \ + --remote "cmd:cp {} $SANDBOX/remote/" >/dev/null 2>&1 +n="$(find "$SANDBOX/remote" -name 'claude-code-backup-*.tar.gz' | wc -l | tr -d ' ')" +ASSERT_MSG="--remote pushed the archive to the remote dir"; assert_true test "$n" = "1" +ns="$(find "$SANDBOX/remote" -name '*.sha256' | wc -l | tr -d ' ')" +ASSERT_MSG="--remote also pushed the .sha256 sidecar"; assert_true test "$ns" = "1" +teardown_sandbox + +# --- append mode: no {} means the path is appended as last arg ------------ +setup_sandbox +mkdir -p "$SANDBOX/remote" +in_project "$BIN/claude-backup" --no-project --quiet \ + --remote "cmd:cp -t $SANDBOX/remote" >/dev/null 2>&1 || true +# `cp -t` is GNU-only; on BSD this push fails but must NOT delete the local backup. +archive="$(find "$CLAUDE_BACKUP_DIR" -name 'claude-code-backup-*.tar.gz' | head -n1)" +ASSERT_MSG="a failed remote push keeps the local backup"; assert_true test -n "$archive" +teardown_sandbox + +# --- `push` of an existing archive ---------------------------------------- +setup_sandbox +mkdir -p "$SANDBOX/remote" +in_project "$BIN/claude-backup" --no-project --quiet >/dev/null 2>&1 +archive="$(find "$CLAUDE_BACKUP_DIR" -name 'claude-code-backup-*.tar.gz' | head -n1)" +"$BIN/claude-backup" push --from "$archive" --remote "cmd:cp {} $SANDBOX/remote/" >/dev/null 2>&1 +ASSERT_MSG="push copied the chosen archive" +assert_file "$SANDBOX/remote/$(basename "$archive")" + +# --- push with no remote configured fails --------------------------------- +ASSERT_MSG="push without a remote exits non-zero" +assert_status 1 env -u CLAUDE_BACKUP_REMOTE "$BIN/claude-backup" push --from "$archive" +teardown_sandbox + +# --- a path containing $(…) is treated as data, not code (no injection) ---- +setup_sandbox +export CCB_LIB_DIR="$CLAUDE_BACKUP_LIB" +. "$CLAUDE_BACKUP_LIB/platform.sh"; detect_platform +. "$CLAUDE_BACKUP_LIB/common.sh" +rm -f "$SANDBOX/PWNED" +ccb_push_remote "$SANDBOX/arch_\$(touch $SANDBOX/PWNED).tar.gz" "cmd:echo {}" >/dev/null 2>&1 || true +ASSERT_MSG="command substitution in the archive path is NOT executed" +assert_nofile "$SANDBOX/PWNED" +teardown_sandbox + +# --- CLAUDE_BACKUP_REMOTE env var is honoured ----------------------------- +setup_sandbox +mkdir -p "$SANDBOX/remote" +CLAUDE_BACKUP_REMOTE="cmd:cp {} $SANDBOX/remote/" \ + in_project "$BIN/claude-backup" --no-project --quiet >/dev/null 2>&1 +n="$(find "$SANDBOX/remote" -name 'claude-code-backup-*.tar.gz' | wc -l | tr -d ' ')" +ASSERT_MSG="CLAUDE_BACKUP_REMOTE triggers a push"; assert_true test "$n" = "1" +teardown_sandbox + +finish diff --git a/tests/test-restore-selective.sh b/tests/test-restore-selective.sh new file mode 100755 index 0000000..225acda --- /dev/null +++ b/tests/test-restore-selective.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# Tests for selective restore (`--only`) and `--list-contents`. +. "$(dirname "$0")/helper.sh" + +echo "== test-restore-selective ==" + +# Build an archive whose home config has agents + commands. +make_archive() { + setup_sandbox + mkdir -p "$HOME/.claude/agents" "$HOME/.claude/commands" + printf 'AGENT\n' > "$HOME/.claude/agents/a.md" + printf 'COMMAND\n' > "$HOME/.claude/commands/c.md" + in_project "$BIN/claude-backup" --no-project --quiet >/dev/null 2>&1 + ARCHIVE="$(find "$CLAUDE_BACKUP_DIR" -name 'claude-code-backup-*.tar.gz' | head -n1)" +} + +# --- --list-contents reports the categories present ----------------------- +make_archive +out="$("$BIN/claude-restore" --list-contents --from "$ARCHIVE" 2>&1)" +ASSERT_MSG="--list-contents shows agents present"; assert_true grep -q 'agents:.*agents' <<<"$out" +ASSERT_MSG="--list-contents shows commands present"; assert_true grep -q 'commands:.*commands' <<<"$out" +ASSERT_MSG="--list-contents shows skills absent"; assert_true grep -q 'skills: (absent)' <<<"$out" +teardown_sandbox + +# --- --only agents,commands restores just those, not history -------------- +make_archive +rm -rf "$HOME/.claude/agents" "$HOME/.claude/commands" +"$BIN/claude-restore" --only agents,commands --from "$ARCHIVE" \ + --home-only --force --no-backup-existing >/dev/null 2>&1 +assert_file "$HOME/.claude/agents/a.md" +assert_file "$HOME/.claude/commands/c.md" +ASSERT_MSG="history was NOT restored (not requested)" +assert_nofile "$HOME/.claude/projects/marker" +teardown_sandbox + +# --- an unknown category is rejected -------------------------------------- +make_archive +ASSERT_MSG="unknown category exits non-zero" +assert_status 1 "$BIN/claude-restore" --only nonsense --from "$ARCHIVE" --force +teardown_sandbox + +# --- --only with a dry-run writes nothing --------------------------------- +make_archive +rm -rf "$HOME/.claude/agents" +"$BIN/claude-restore" --only agents --from "$ARCHIVE" --home-only --dry-run --force >/dev/null 2>&1 +ASSERT_MSG="--dry-run with --only restores nothing" +assert_nofile "$HOME/.claude/agents/a.md" +teardown_sandbox + +finish diff --git a/tests/test-schedule.sh b/tests/test-schedule.sh new file mode 100755 index 0000000..8e09014 --- /dev/null +++ b/tests/test-schedule.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# Tests for `claude-backup schedule` / `unschedule`. +# +# We never touch the real scheduler: a fake launchctl/systemctl/crontab is put +# first on PATH and HOME points at the sandbox, so plists/units/cron entries +# land inside the sandbox and are torn down with it. +. "$(dirname "$0")/helper.sh" + +echo "== test-schedule ==" + +# --- portable: an invalid --at is rejected -------------------------------- +setup_sandbox +ASSERT_MSG="invalid --at time exits non-zero" +assert_status 1 "$BIN/claude-backup" schedule --at 99:99 +teardown_sandbox + +# --- portable: status with nothing scheduled exits 0 ---------------------- +setup_sandbox +ASSERT_MSG="schedule --status exits 0 when nothing is scheduled" +assert_status 0 "$BIN/claude-backup" schedule --status +teardown_sandbox + +# --- platform round-trip with fakes --------------------------------------- +setup_sandbox +FAKEBIN="$SANDBOX/fakebin"; mkdir -p "$FAKEBIN" +printf '#!/bin/sh\nexit 0\n' > "$FAKEBIN/launchctl"; chmod +x "$FAKEBIN/launchctl" +# Fake crontab backed by a file, for the Linux fallback path. +export CRONFILE="$SANDBOX/cronfile" +cat > "$FAKEBIN/crontab" <<'EOF' +#!/bin/sh +if [ "$1" = "-l" ]; then cat "$CRONFILE" 2>/dev/null; exit 0; fi +cat > "$CRONFILE"; exit 0 +EOF +chmod +x "$FAKEBIN/crontab" +# Fake systemctl that reports "no user instance", forcing the cron fallback. +printf '#!/bin/sh\nexit 1\n' > "$FAKEBIN/systemctl"; chmod +x "$FAKEBIN/systemctl" + +case "$(uname -s)" in + Darwin) + PATH="$FAKEBIN:$PATH" "$BIN/claude-backup" schedule --daily --at 03:15 -- --no-history >/dev/null 2>&1 + plist="$HOME/Library/LaunchAgents/com.claude-code-backup.backup.plist" + assert_file "$plist" + ASSERT_MSG="plist embeds the scheduled time (Hour 3)"; assert_grep '3' "$plist" + ASSERT_MSG="plist passes through extra opts (--no-history)"; assert_grep 'no-history' "$plist" + PATH="$FAKEBIN:$PATH" "$BIN/claude-backup" unschedule >/dev/null 2>&1 + ASSERT_MSG="unschedule removes the plist"; assert_nofile "$plist" + ;; + Linux) + PATH="$FAKEBIN:$PATH" "$BIN/claude-backup" schedule --daily --at 03:15 -- --no-history >/dev/null 2>&1 + ASSERT_MSG="crontab gained a claude-code-backup block" + assert_grep 'claude-code-backup' "$CRONFILE" + ASSERT_MSG="cron line carries the extra opts"; assert_grep 'no-history' "$CRONFILE" + PATH="$FAKEBIN:$PATH" "$BIN/claude-backup" unschedule >/dev/null 2>&1 + ASSERT_MSG="unschedule clears the cron block" + assert_false grep -q 'claude-code-backup' "$CRONFILE" + ;; +esac +teardown_sandbox + +finish diff --git a/tests/test-verify.sh b/tests/test-verify.sh new file mode 100755 index 0000000..60cffe4 --- /dev/null +++ b/tests/test-verify.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Tests for `claude-backup verify` and the .sha256 sidecar. +. "$(dirname "$0")/helper.sh" + +echo "== test-verify ==" + +# --- a backup writes a .sha256 sidecar ------------------------------------ +setup_sandbox +in_project "$BIN/claude-backup" --no-project --quiet >/dev/null 2>&1 +archive="$(find "$CLAUDE_BACKUP_DIR" -name 'claude-code-backup-*.tar.gz' | head -n1)" +ASSERT_MSG="backup created an archive"; assert_true test -n "$archive" +assert_file "$archive.sha256" +ASSERT_MSG="sidecar names the archive" +assert_grep "$(basename "$archive")" "$archive.sha256" + +# --- verify passes on a good archive -------------------------------------- +ASSERT_MSG="verify exits 0 on a valid archive" +assert_status 0 "$BIN/claude-backup" verify --from "$archive" + +# --- verify --json reports ok --------------------------------------------- +out="$("$BIN/claude-backup" verify --from "$archive" --json 2>/dev/null)" +ASSERT_MSG="verify --json says status ok" +assert_true grep -q '"status":"ok"' <<<"$out" +ASSERT_MSG="verify --json says checksum ok" +assert_true grep -q '"checksum":"ok"' <<<"$out" +teardown_sandbox + +# --- verify fails on a corrupted archive ---------------------------------- +setup_sandbox +in_project "$BIN/claude-backup" --no-project --quiet >/dev/null 2>&1 +archive="$(find "$CLAUDE_BACKUP_DIR" -name 'claude-code-backup-*.tar.gz' | head -n1)" +printf 'CORRUPTION' >> "$archive" # break the gzip stream +ASSERT_MSG="verify exits non-zero on a corrupted archive" +assert_status 1 "$BIN/claude-backup" verify --from "$archive" +teardown_sandbox + +# --- verify detects a checksum mismatch (archive changed, sidecar stale) --- +setup_sandbox +in_project "$BIN/claude-backup" --no-project --quiet >/dev/null 2>&1 +archive="$(find "$CLAUDE_BACKUP_DIR" -name 'claude-code-backup-*.tar.gz' | head -n1)" +# Replace the archive with a different-but-valid gzip; sidecar now mismatches. +printf 'different' | gzip -c > "$archive" +ASSERT_MSG="verify exits non-zero on a checksum mismatch" +assert_status 1 "$BIN/claude-backup" verify --from "$archive" +teardown_sandbox + +finish