Repository Public
1#!/bin/zsh2# Nightly encrypted backup of Claude Remote metadata (database + configuration, NOT project files, NOT secrets in clear).3# Output: $BACKUP_DIR/$APP-YYYYmmdd-HHMMSS.tar.gz.enc (AES-256, PBKDF2) ; keeps the last $KEEP archives.4# Requires: DATABASE_URL, CR_DATA_DIR. Passphrase: ~/.config/$APP/backup.pass (0600, created on first run).5#6# One script for both instances. CR_APP_NAME (default claude-remote) names the instance and every path derives7# from it: ~/backups/$APP, ~/.config/$APP/backup.pass, archive prefix $APP-. CR_AGENT (claude | codex) selects8# which agent configuration is saved; unset, it is inferred from the name (codex-remote ⇒ codex, else claude).9# With nothing set this is the Claude Remote backup, unchanged: claude-remote paths and claude-config.tar.10set -euo pipefail11export PATH=/opt/homebrew/bin:/opt/homebrew/opt/postgresql@17/bin:/usr/bin:/bin:/usr/sbin:/sbin12: "${DATABASE_URL:?DATABASE_URL required}"13: "${CR_DATA_DIR:?CR_DATA_DIR required}"14APP="${CR_APP_NAME:-claude-remote}"15[[ "$APP" =~ '^[a-z0-9][a-z0-9-]*$' ]] || { echo "invalid CR_APP_NAME: $APP" >&2; exit 2; }16AGENT="${CR_AGENT:-}"17if [[ -z "$AGENT" ]]; then18 if [[ "$APP" == "codex-remote" ]]; then AGENT=codex; else AGENT=claude; fi19fi20[[ "$AGENT" == "claude" || "$AGENT" == "codex" ]] || { echo "invalid CR_AGENT: $AGENT (claude | codex)" >&2; exit 2; }21BACKUP_DIR="${BACKUP_DIR:-$HOME/backups/$APP}"22KEEP="${KEEP:-14}"23PASS_FILE="$HOME/.config/$APP/backup.pass"24umask 07725mkdir -p "$BACKUP_DIR" "$(dirname "$PASS_FILE")"26if [[ ! -s "$PASS_FILE" ]]; then27 openssl rand -base64 48 > "$PASS_FILE"28 chmod 600 "$PASS_FILE"29 echo "created backup passphrase at $PASS_FILE — copy it somewhere safe; without it backups cannot be restored"30fi31STAMP=$(date +%Y%m%d-%H%M%S)32WORK=$(mktemp -d)33trap 'rm -rf "$WORK"' EXIT34pg_dump --format=custom --no-owner --no-privileges "$DATABASE_URL" > "$WORK/db.dump"35# Task metadata (not logs), artifact index. Runtime tokens, setup token and sockets are excluded on purpose.36( cd "$CR_DATA_DIR" && find tasks -name meta.json -maxdepth 2 2>/dev/null | tar -cf "$WORK/task-meta.tar" -T - 2>/dev/null ) || : > "$WORK/task-meta.tar"37if [[ "$AGENT" == "codex" ]]; then38 # Codex CLI configuration that is safe to restore: config.toml, AGENTS.md, prompts/ — an explicit allow-list,39 # relative to the Codex home ($CODEX_HOME, default ~/.codex). NEVER auth.json (credentials), nor the session40 # rollouts, history, sqlite state or logs that live in the same directory; the excludes also keep such files41 # out if one was dropped inside prompts/.42 CODEX_DIR="${CODEX_HOME:-$HOME/.codex}"43 members=()44 [[ -f "$CODEX_DIR/config.toml" ]] && members+=(config.toml)45 [[ -f "$CODEX_DIR/AGENTS.md" ]] && members+=(AGENTS.md)46 [[ -d "$CODEX_DIR/prompts" ]] && members+=(prompts)47 if (( ${#members} )); then48 tar -cf "$WORK/codex-config.tar" -C "$CODEX_DIR" \49 --exclude 'auth.json' --exclude '*.sqlite*' --exclude '*.db' --exclude '*.jsonl' --exclude '*.log' \50 "${members[@]}" 2>/dev/null || : > "$WORK/codex-config.tar"51 else52 : > "$WORK/codex-config.tar"53 fi54else55 # Claude Code configuration that is safe to restore (no credentials): settings, skills, commands, agents.56 ( cd "$HOME" && tar -cf "$WORK/claude-config.tar" \57 $( [[ -f .claude/settings.json ]] && echo .claude/settings.json ) \58 $( [[ -d .claude/skills ]] && echo .claude/skills ) \59 $( [[ -d .claude/commands ]] && echo .claude/commands ) \60 $( [[ -d .claude/agents ]] && echo .claude/agents ) 2>/dev/null ) || : > "$WORK/claude-config.tar"61fi62date -u +%Y-%m-%dT%H:%M:%SZ > "$WORK/created_at"63OUT="$BACKUP_DIR/$APP-$STAMP.tar.gz.enc"64tar -czf - -C "$WORK" . | openssl enc -aes-256-cbc -pbkdf2 -iter 200000 -salt -pass "file:$PASS_FILE" -out "$OUT"65chmod 600 "$OUT"66ls -1t "$BACKUP_DIR"/$APP-*.tar.gz.enc 2>/dev/null | tail -n +$((KEEP + 1)) | while read -r f; do rm -f "$f"; done67echo "backup ok: $OUT ($(stat -f %z "$OUT") bytes)"68