Skill: vidarbrekke/claw-backup (version 1.0.7)

--- name: claw-backup version: 1.0.15 description: Back up OpenClaw customizations (memory, config, skills, workspace) to cloud storage via rclone, with scheduling + retention. Works on macOS, Linux, and Windows (Git Bash/WSL). --- # Claw Backup Version: 1.0.15 **OpenClaw backup skill** — schedules backups of your OpenClaw data (memory, config, skills, workspace) to an `rclone` destination (e.g. Google Drive). Works on macOS, Linux, and Windows (via Git Bash or WSL). ## What it does - Backs up **clawd memory**, **~/.openclaw** (config, skills, modules, workspace, cron), **clawd/scripts**, and **Dev/CursorApps/clawd** (excluding `node_modules`) - Runs on a schedule (macOS LaunchAgent, Linux cron, or Windows Task Scheduler) - Applies local and remote retention so old backups are pruned - Supports `rclone` (cloud) or `local-only` upload mode ## Before you install - **Do not** run `curl ... | node` (or any one-liner) without first inspecting the script. Prefer **git clone** and open `setup.js` and `install-launchagent.sh` (or the cron/Task Scheduler scripts) to review what they will do. - **Verify the code** in `setup.js` and the scheduler installer: confirm only the expected paths are archived and that there is no unexpected network use or commands. If you are uncomfortable reviewing the scripts, do not install. - **Confirm repository identity**: official source is [github.com/vidarbrekke/ClawBackup](https://github.com/vidarbrekke/ClawBackup). The version in this SKILL.md (frontmatter and "Version" line) is the source of truth; the registry may show a different revision number. - **Use encryption** for sensitive backups: an encrypted rclone remote (e.g. `rclone crypt`) or encrypt archives yourself before offsite storage. - **After installation**: inspect your LaunchAgent plist, crontab, or Task Scheduler entries, and run a **test backup to a local destination first** before using a cloud remote. ## Restore notes Each archive includes `RESTORE_NOTES.txt` with the correct restore targets based on the configured paths. In general: - `openclaw_config/skills` → `~/.openclaw/skills` - `cursorapps_clawd/skills` → `~/Dev/CursorApps/clawd/skills` (or your chosen path) ## Quick start 1. **Prerequisites:** Node.js, [rclone](https://rclone.org/install/) configured for Google Drive, and Bash (or Git Bash on Windows). - `rclone` is only required when upload mode is `rclone`. 2. **Recommended install (review first):** ```bash git clone https://github.com/vidarbrekke/ClawBackup.git cd ClawBackup node setup.js ``` 3. **Quick install (not recommended):** Only use if you have already inspected the script. Do **not** run without review: ```bash curl -fsSL https://raw.githubusercontent.com/vidarbrekke/ClawBackup/main/setup.js | node ``` 4. Follow the prompts (or use `node setup.js --defaults` for default paths). Then run the printed test command and install the scheduler as shown. ## Security notes - Backups may contain sensitive data (OpenClaw config, memory, and skills). - Setup can install recurring scheduler entries (LaunchAgent/cron/Task Scheduler); inspect these after install. - In cloud mode, credentials come from your existing `rclone config`; no extra env vars are required by this repo. - Prefer encrypted destinations (e.g. `rclone crypt`) or encrypt archives before offsite storage. - Archives include checksum files (`.sha256`) for integrity checks. ## Repo and contributions - **Code and full docs:** [github.com/vidarbrekke/ClawBackup](https://github.com/vidarbrekke/ClawBackup) - **Improvements welcome:** Open issues or pull requests on the GitHub repo. The project is open for refinements, fixes, and features from any OpenClaw user. ## License MIT

path: README.md
content: # ClawBackup
Version: 1.0.15

Backs up your OpenClaw customizations (memory, config, skills, workspace) to an `rclone` destination on a schedule (Google Drive by default). Works on macOS, Linux, and Windows (via Git Bash or WSL).

GitHub repository: https://github.com/vidarbrekke/ClawBackup

## What gets backed up

- **Clawd memory** — your project memory folder
- **OpenClaw config** — `openclaw.json`, skills, modules, workspace, cron jobs
- **Clawd scripts** — backup script, LaunchAgent plist (macOS)
- **CursorApps/clawd** — includes active skills and related files (excluding `node_modules`)

## Prerequisites

- **Node.js** — [Download](https://nodejs.org/) (for running setup)
- **rclone** — [Download](https://rclone.org/install/) and configured for Google Drive (**only required in `rclone` upload mode**)
- **Bash** — included on macOS/Linux; use Git Bash on Windows. **If you use WSL**, run setup inside your WSL terminal (e.g. `node setup.js` from WSL), not from Windows PowerShell, so paths are generated for Linux.

## Install (recommended)

### Safer path (recommended)

Review the repository code before running setup:

```bash
git clone https://github.com/vidarbrekke/ClawBackup.git
cd ClawBackup
# optional: checkout a tag or pinned commit before running
node setup.js
```

### Quick path (advanced users)

This downloads and executes setup immediately. Use only if you trust the source:

```bash
curl -fsSL https://raw.githubusercontent.com/vidarbrekke/ClawBackup/main/setup.js | node
```

## Run setup

1. Run the setup (using Option A or B above).
2. Answer the prompts. Press Enter to accept the default in brackets. All paths use your system’s home directory and what you type — nothing is hardcoded to a specific username.
3. The setup writes a customized `backup_enhanced.sh` into your project’s `scripts/` folder (paths are resolved to absolute so cron/launchd work from any working directory).
4. Choose upload mode:
   - `rclone` (default): upload archives to your remote destination.
   - `local-only`: keep backups locally, skip cloud transfer.
5. If you chose `launchd` (macOS) or `cron` (Linux), follow the printed commands to enable scheduled backups. Setup now prompts for schedule hour/minute.

To regenerate the backup script and plist with default paths only (no prompts), run `node setup.js --defaults`.

## Before first backup

1. **Configure rclone for Google Drive:**
   ```bash
   rclone config
   ```
   Create a remote (e.g. `googleDrive`) and complete the OAuth flow.

2. **Test a backup:**
   ```bash
   ~/clawd/scripts/backup_enhanced.sh
   ```
   (Use the path the setup printed for your system.)

## Security notes (important)

- Backups can contain sensitive data from `~/.openclaw`, project config, memory, and skills.
- Setup and scheduling modify your local environment (LaunchAgent/cron/Task Scheduler) and create recurring jobs.
- In `rclone` mode, cloud credentials are provided by your existing `rclone config`; no new env vars are required by this repo.
- Prefer encrypted storage:
  - use an encrypted destination (e.g. `rclone crypt`), or
  - encrypt archives locally before offsite storage (e.g. age/gpg workflow).
- Each backup now writes a checksum file (`.sha256`) next to the archive for integrity verification.

## Disable / uninstall scheduler

- **macOS:** `launchctl unload ~/Library/LaunchAgents/com.openclaw.backup.plist`
- **Linux:** remove the backup line from `crontab -e`
- **Windows:** remove/disable the Task Scheduler task you created

## Restore notes (skills and user data)

Each archive includes a `RESTORE_NOTES.txt` file with the exact restore targets
based on your configured paths. In general:

- `openclaw_config/skills` → `~/.openclaw/skills`
- `cursorapps_clawd/skills` → `~/Dev/CursorApps/clawd/skills` (or your chosen path)
- `openclaw_config/*` → `~/.openclaw/`
- `clawd_scripts/*` → `~/clawd/scripts/`
- `root_md_files/*` → project root

## Schedule (optional)

- **macOS:** The setup generates a LaunchAgent plist. Install with the printed commands (copy to `~/Library/LaunchAgents/` and `launchctl load` — no sudo for the agent). Or run the universal installer: `./install-launchagent.sh` (it searches common locations; if your project is elsewhere, pass the plist path: `./install-launchagent.sh /path/to/com.openclaw.backup.plist`). If you had an old system-wide daemon, the installer may ask for your password once to remove it. Backup runs at the hour/minute configured during setup.
- **Linux:** Add the printed cron line to `crontab -e`.
- **Windows:** Use Task Scheduler to run the backup script daily via Git Bash or WSL.

## Troubleshooting

- **Backup fails or rclone “remote not found”:** Run `rclone config` and ensure the remote name matches what you entered at setup (e.g. `googleDrive`). On macOS, use the LaunchAgent (not LaunchDaemon) so the job runs as your user and sees `~/.config/rclone`.
- **Paths wrong after moving home/project:** Run `node setup.js` again with the new paths, then reinstall the scheduler (e.g. `./install-launchagent.sh` on macOS).
- **Check logs:** Local backup log: `$LOCAL_BACKUP_DIR/backup.log`; launchd stdout/stderr: `$LOCAL_BACKUP_DIR/launchd.log` and `launchd.err` (paths from your setup).

## Roadmap

### Now

- Add workspace include modes (`full|minimal|off`) so users can tune backup size and sensitivity for `~/.openclaw/workspace*`.

### Next

- Add optional remote verification mode (presence/size/checksum validation after upload).
- Add stricter remote retention safety checks (safe destination prefix by default, explicit override when needed).
- Enrich `manifest.json` with ClawBackup git commit hash and top-level staged sections.
- Add launchd polish options (`RunAtLoad`, `WorkingDirectory`) while keeping current defaults stable.
- Add destination examples/presets in setup/docs for common `rclone` remotes (Dropbox, OneDrive, Box, S3).

### Later

- Add a `restore.sh` helper (dry-run by default) to make restores safer and easier.
- Introduce a storage-provider abstraction (keep `rclone` as default, allow provider-skill adapters later).

## Test suite

Critical behavior tests are included for setup/runtime generation logic.

Run:

```bash
node --test tests/*.test.js
```

## For maintainers / AI-assisted edits

To reduce oversights when editing this repo (e.g. destructive ops, path edge cases), this repo includes a Cursor rule: [.cursor/rules/safety-destructive-ops.mdc](.cursor/rules/safety-destructive-ops.mdc). You can copy that file into your OpenClaw or clawd project’s `.cursor/rules/` so the same checks apply there.

## Versioning (auto-updated on commit)

This repo includes a `VERSION` file and an update script. To auto-update the
version on every commit, enable the local git hook:

```bash
git config core.hooksPath .githooks
```

This runs `scripts/update-version.sh --next` before each commit and updates
`VERSION`, `README.md`, and `SKILL.md`.

## License

MIT


path: install-launchagent.sh
content: #!/usr/bin/env bash
# Universal installer: probe for com.openclaw.backup.plist and existing
# LaunchDaemon/LaunchAgent, then install or update the user LaunchAgent.
# Usage: ./install-launchagent.sh [path-to-plist]
#        ./install-launchagent.sh   (auto-detect plist and state)

set -e

LABEL="com.openclaw.backup"
PLIST_NAME="${LABEL}.plist"
LAUNCH_AGENTS="$HOME/Library/LaunchAgents"
LAUNCH_DAEMONS="/Library/LaunchDaemons"
AGENT_PLIST="$LAUNCH_AGENTS/$PLIST_NAME"
DAEMON_PLIST="$LAUNCH_DAEMONS/$PLIST_NAME"

# ---- Platform ----
if [ "$(uname -s)" != "Darwin" ]; then
  echo "This script is for macOS (launchd). On Linux use cron; on Windows use Task Scheduler."
  exit 1
fi

# ---- Probe: find plist ----
search_plist_paths=(
  "$HOME/clawd/scripts/$PLIST_NAME"
  "$HOME/Dev/clawd/scripts/$PLIST_NAME"
  "$HOME/Projects/clawd/scripts/$PLIST_NAME"
  "$HOME/Dev/ClawBackup/scripts/$PLIST_NAME"
  "$(pwd)/scripts/$PLIST_NAME"
  "$(pwd)/$PLIST_NAME"
)

# Optional: search under home (max depth 5) for the plist name
find_plist_under_home() {
  local found
  found=$(find "$HOME" -maxdepth 5 -type f -name "$PLIST_NAME" 2>/dev/null | head -1)
  [ -n "$found" ] && echo "$found"
}

FOUND_PLIST=""
if [ -n "$1" ]; then
  if [ -f "$1" ]; then
    FOUND_PLIST="$1"
  else
    echo "Not a file: $1"
    exit 1
  fi
else
  for p in "${search_plist_paths[@]}"; do
    if [ -f "$p" ]; then
      FOUND_PLIST="$p"
      break
    fi
  done
  if [ -z "$FOUND_PLIST" ]; then
    FOUND_PLIST=$(find_plist_under_home)
  fi
fi

# ---- Report ----
echo "=== OpenClaw backup launchd installer ==="
echo ""
echo "Plist source:"
if [ -n "$FOUND_PLIST" ]; then
  echo "  Found: $FOUND_PLIST"
else
  echo "  None found."
  echo ""
  echo "Run 'node setup.js' first (from ClawBackup or your project) to generate the plist."
  echo "Or pass the plist path: $0 /path/to/$PLIST_NAME"
  exit 1
fi

echo ""
echo "Current state:"
echo "  LaunchDaemon (system, root): $([ -f "$DAEMON_PLIST" ] && echo "installed" || echo "not installed")"
echo "  LaunchAgent (user):          $([ -f "$AGENT_PLIST" ] && echo "installed" || echo "not installed")"
echo "  Loaded in launchctl:          $(launchctl list 2>/dev/null | grep -q "$LABEL" && echo "yes" || echo "no")"
echo ""

# ---- Unload old LaunchDaemon if present ----
if [ -f "$DAEMON_PLIST" ]; then
  echo "Removing old LaunchDaemon (runs as root; backup would not see your rclone config)..."
  if sudo launchctl unload "$DAEMON_PLIST" 2>/dev/null; then
    echo "  Unloaded."
  fi
  sudo rm -f "$DAEMON_PLIST"
  echo "  Removed $DAEMON_PLIST"
  echo ""
fi

# ---- Install LaunchAgent ----
mkdir -p "$LAUNCH_AGENTS"
cp "$FOUND_PLIST" "$AGENT_PLIST"
echo "Installed: $AGENT_PLIST"

# Load (idempotent: unload first if already loaded to pick up plist changes)
if launchctl list 2>/dev/null | grep -q "$LABEL"; then
  launchctl unload "$AGENT_PLIST" 2>/dev/null || true
fi
if ! launchctl load "$AGENT_PLIST"; then
  echo "Load failed. Check that the plist path inside the file points to a valid backup script."
  exit 1
fi
echo "Loaded."
echo ""
echo "Done. Backup will run daily at the time defined in the LaunchAgent plist under your user."
echo "Verify: launchctl list | grep $LABEL"
echo "Test:   run the backup script once manually to confirm rclone and paths work."


path: setup.js
content: #!/usr/bin/env node
"use strict";

const fs = require("fs");
const os = require("os");
const path = require("path");
const readline = require("readline");

let rl = null;
const ask = (q) =>
  new Promise((resolve, reject) => {
    if (!rl) {
      reject(new Error("Readline is not initialized."));
      return;
    }
    rl.question(q, resolve);
  });

const home = os.homedir();
const defaults = {
  projectDir: path.join(home, "clawd"),
  openclawDir: path.join(home, ".openclaw"),
  cursorappsClawd: path.join(home, "Dev", "CursorApps", "clawd"),
  localBackupDir: path.join(home, "clawd", "MoltBackups", "Memory"),
  rcloneRemote: "googleDrive:",
  rcloneDest: "MoltBackups/Memory/",
  retentionDays: "7",
  schedule: os.platform() === "darwin" ? "launchd" : "cron",
  scheduleHour: "11",
  scheduleMinute: "0",
  uploadMode: "rclone"
};

/** Resolve to absolute path; expand leading ~ to homedir so cron/launchd work from any CWD. */
function resolveDir(p) {
  if (typeof p !== "string" || !p.trim()) return p;
  const expanded = p.trim().replace(/^~(?=\/|$)/, home);
  return path.resolve(expanded);
}

/** Paths in generated Bash script must use forward slashes (Windows compatibility). */
const toPosix = (p) => (typeof p === "string" ? p.split(path.sep).join(path.posix.sep) : p);

/** Escape double quotes so injected values don't break Bash variable syntax. */
const escapeBash = (s) => (typeof s === "string" ? s.replace(/\\/g, "\\\\").replace(/"/g, '\\"') : String(s ?? ""));

/** Escape for XML plist string content. */
const escapePlist = (s) =>
  String(s ?? "")
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&apos;");

/** Warn if path does not exist; return path unchanged. */
function validatePath(label, p) {
  if (p && !fs.existsSync(p)) {
    console.warn(`Warning: "${label}" does not exist: ${p}`);
  }
  return p;
}

function safeWrite(filePath, content) {
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
  fs.writeFileSync(filePath, content, "utf8");
}

function writeScriptWithBackup(scriptPath, content) {
  if (fs.existsSync(scriptPath)) {
    const bak = scriptPath + ".bak";
    fs.renameSync(scriptPath, bak);
    console.warn(`Existing script backed up to: ${bak}`);
  }
  safeWrite(scriptPath, content);
}

function buildBackupScript(cfg) {
  const raw = (key, fallback) => (cfg[key] != null && String(cfg[key]).trim() !== "" ? cfg[key] : fallback);
  const rcloneRemote = raw("rcloneRemote", raw("gdriveRemote", defaults.rcloneRemote));
  const rcloneDest = raw("rcloneDest", raw("gdriveDest", defaults.rcloneDest));
  const p = (key, fallback) => escapeBash(toPosix(raw(key, fallback) ?? ""));
  const s = (key, fallback) => escapeBash(raw(key, fallback) ?? "");
  const retention = String(raw("retentionDays", "7")).replace(/[^0-9]/g, "") || "7";
  const uploadMode = String(raw("uploadMode", defaults.uploadMode)).trim().toLowerCase() === "local-only" ? "local-only" : "rclone";
  return `#!/bin/bash
set -euo pipefail

PROJECT_DIR="${p("projectDir", defaults.projectDir)}"
SOURCE_DIR="$PROJECT_DIR/memory"
OPENCLAW_DIR="${p("openclawDir", defaults.openclawDir)}"
CURSORAPPS_CLAWD="${p("cursorappsClawd", defaults.cursorappsClawd)}"
LOCAL_BACKUP_DIR="${p("localBackupDir", defaults.localBackupDir)}"
LOG_FILE="$LOCAL_BACKUP_DIR/backup.log"
RETENTION_DAYS=${retention}
RCLONE_REMOTE="${escapeBash(rcloneRemote)}"
RCLONE_DEST_DIR="${escapeBash(rcloneDest)}"
UPLOAD_MODE="${uploadMode}"

need_cmd() {
  command -v "$1" >/dev/null 2>&1 || { echo "Missing dependency: $1"; exit 1; }
}

need_cmd tar
need_cmd mktemp
need_cmd date
if [ "$UPLOAD_MODE" = "rclone" ]; then
  need_cmd rclone
fi

log_message() {
  local message="$1"
  echo "$(date +"%Y-%m-%d %H:%M:%S") - $message" | tee -a "$LOG_FILE"
}

if [ ! -d "$LOCAL_BACKUP_DIR" ]; then
  mkdir -p "$LOCAL_BACKUP_DIR"
  log_message "Created local backup directory '$LOCAL_BACKUP_DIR'."
fi

BACKUP_DATE=$(date +"%Y-%m-%d_%H-%M-%S")
ARCHIVE_NAME="clawd_memory_backup_\${BACKUP_DATE}.tar.gz"
FULL_ARCHIVE_PATH="$LOCAL_BACKUP_DIR/$ARCHIVE_NAME"
TEMP_DIR=$(mktemp -d)
LOCK_DIR="$LOCAL_BACKUP_DIR/.lock"
if ! mkdir "$LOCK_DIR" 2>/dev/null; then
  log_message "Another backup is already running (lock exists). Exiting."
  exit 0
fi
trap 'rm -rf "\${TEMP_DIR:-}"; rmdir "\${LOCK_DIR:-}" 2>/dev/null || true' EXIT
STAGING_DIR="$TEMP_DIR/clawd_backup_$BACKUP_DATE"

log_message "Preparing backup staging area..."
mkdir -p "$STAGING_DIR"

cat <<EOF > "$STAGING_DIR/RESTORE_NOTES.txt"
ClawBackup restore notes

This archive contains OpenClaw user data and active skills.

Restore targets for skills:
- openclaw_config/skills  -> $OPENCLAW_DIR/skills
- cursorapps_clawd/skills -> $CURSORAPPS_CLAWD/skills

Other restore targets:
- openclaw_config/*       -> $OPENCLAW_DIR/
- clawd_scripts/*         -> $PROJECT_DIR/scripts/
- root_md_files/*         -> $PROJECT_DIR/

Note: $OPENCLAW_DIR and $CURSORAPPS_CLAWD should match your local setup.
EOF

if [ -d "$SOURCE_DIR" ]; then
  cp -R "$SOURCE_DIR" "$STAGING_DIR/"
  log_message "Staged memory directory."
fi

MD_COUNT=$(find "$PROJECT_DIR" -maxdepth 1 -type f -name "*.md" 2>/dev/null | wc -l)
if [ "$MD_COUNT" -gt 0 ]; then
  mkdir -p "$STAGING_DIR/root_md_files"
  find "$PROJECT_DIR" -maxdepth 1 -type f -name "*.md" -exec cp {} "$STAGING_DIR/root_md_files/" \\; 2>/dev/null
  log_message "Staged $MD_COUNT .md files from project root."
fi

if [ -d "$PROJECT_DIR/scripts" ]; then
  mkdir -p "$STAGING_DIR/clawd_scripts"
  cp -R "$PROJECT_DIR/scripts"/* "$STAGING_DIR/clawd_scripts/" 2>/dev/null || true
  log_message "Staged clawd/scripts."
fi

OPENCLAW_STAGE="$STAGING_DIR/openclaw_config"
mkdir -p "$OPENCLAW_STAGE"
if [ -d "$OPENCLAW_DIR" ]; then
  [ -f "$OPENCLAW_DIR/openclaw.json" ] && cp "$OPENCLAW_DIR/openclaw.json" "$OPENCLAW_STAGE/"
  [ -d "$OPENCLAW_DIR/skills" ] && cp -R "$OPENCLAW_DIR/skills" "$OPENCLAW_STAGE/"
  [ -d "$OPENCLAW_DIR/modules" ] && cp -R "$OPENCLAW_DIR/modules" "$OPENCLAW_STAGE/"
  [ -f "$OPENCLAW_DIR/round-robin-models.json" ] && cp "$OPENCLAW_DIR/round-robin-models.json" "$OPENCLAW_STAGE/"
  [ -d "$OPENCLAW_DIR/workspace" ] && cp -R "$OPENCLAW_DIR/workspace" "$OPENCLAW_STAGE/"
  [ -d "$OPENCLAW_DIR/workspace-local-ops" ] && cp -R "$OPENCLAW_DIR/workspace-local-ops" "$OPENCLAW_STAGE/"
  [ -f "$OPENCLAW_DIR/cron/jobs.json" ] && mkdir -p "$OPENCLAW_STAGE/cron" && cp "$OPENCLAW_DIR/cron/jobs.json" "$OPENCLAW_STAGE/cron/"
  log_message "Staged ~/.openclaw custom config."
fi

if [ -d "$CURSORAPPS_CLAWD" ]; then
  CLAWD_STAGE="$STAGING_DIR/cursorapps_clawd"
  mkdir -p "$CLAWD_STAGE"
  if command -v rsync >/dev/null 2>&1; then
    rsync -a --exclude='node_modules' --exclude='test-results' --exclude='.last-run.json' "$CURSORAPPS_CLAWD/" "$CLAWD_STAGE/"
  else
    cp -R "$CURSORAPPS_CLAWD"/* "$CLAWD_STAGE/" 2>/dev/null || true
    rm -rf "$CLAWD_STAGE/node_modules" "$CLAWD_STAGE/test-results" 2>/dev/null
  fi
  log_message "Staged Dev/CursorApps/clawd."
fi

log_message "Writing manifest..."
MANIFEST="$STAGING_DIR/manifest.json"
cat > "$MANIFEST" <<EOF
{
  "createdAt": "$(date -u +"%Y-%m-%dT%H:%M:%SZ")",
  "hostname": "$(hostname 2>/dev/null || echo "unknown")",
  "os": "$(uname -a 2>/dev/null || echo "unknown")",
  "projectDir": "$PROJECT_DIR",
  "openclawDir": "$OPENCLAW_DIR",
  "cursorappsClawd": "$CURSORAPPS_CLAWD",
  "localBackupDir": "$LOCAL_BACKUP_DIR",
  "archiveName": "$ARCHIVE_NAME",
  "retentionDays": $RETENTION_DAYS,
  "uploadMode": "$UPLOAD_MODE"
}
EOF

log_message "Creating backup archive '$FULL_ARCHIVE_PATH'..."
tar -czf "$FULL_ARCHIVE_PATH" -C "$TEMP_DIR" "clawd_backup_$BACKUP_DATE"
log_message "Backup archive created successfully."

log_message "Writing checksum..."
if command -v shasum >/dev/null 2>&1; then
  shasum -a 256 "$FULL_ARCHIVE_PATH" > "$FULL_ARCHIVE_PATH.sha256"
elif command -v sha256sum >/dev/null 2>&1; then
  sha256sum "$FULL_ARCHIVE_PATH" > "$FULL_ARCHIVE_PATH.sha256"
else
  log_message "No sha256 tool found (shasum/sha256sum). Skipping checksum."
fi

if [ "$UPLOAD_MODE" = "local-only" ]; then
  log_message "Upload disabled (local-only). Skipping rclone transfer."
else
  log_message "Starting rclone transfer..."
  if [ -t 1 ]; then
    rclone copy "$FULL_ARCHIVE_PATH" "$RCLONE_REMOTE$RCLONE_DEST_DIR" --progress
  else
    rclone copy "$FULL_ARCHIVE_PATH" "$RCLONE_REMOTE$RCLONE_DEST_DIR" --stats-one-line --stats 10s
  fi
  [ -f "$FULL_ARCHIVE_PATH.sha256" ] && rclone copy "$FULL_ARCHIVE_PATH.sha256" "$RCLONE_REMOTE$RCLONE_DEST_DIR" >/dev/null 2>&1 || true
  log_message "Backup successfully transferred to Google Drive."
fi

log_message "Applying retention policy ($RETENTION_DAYS days)..."
find "$LOCAL_BACKUP_DIR" -maxdepth 1 -type f -name 'clawd_memory_backup_*.tar.gz' -mtime +"$RETENTION_DAYS" -print -delete | while read -r old_backup; do
  log_message "Deleted old local backup: '$old_backup'."
done
find "$LOCAL_BACKUP_DIR" -maxdepth 1 -type f -name 'clawd_memory_backup_*.tar.gz.sha256' -mtime +"$RETENTION_DAYS" -print -delete | while read -r old_checksum; do
  log_message "Deleted old local checksum: '$old_checksum'."
done
log_message "Cleaning up remote backups older than $RETENTION_DAYS days..."
if [ "$UPLOAD_MODE" = "rclone" ] && [ -n "$RCLONE_REMOTE" ] && [ -n "$RCLONE_DEST_DIR" ] && [ "$RCLONE_DEST_DIR" != "/" ]; then
  rclone delete "$RCLONE_REMOTE$RCLONE_DEST_DIR" --min-age \${RETENTION_DAYS}d --include 'clawd_memory_backup_*.tar.gz' --include 'clawd_memory_backup_*.tar.gz.sha256' 2>/dev/null || true
else
  log_message "Skipping remote cleanup: upload mode local-only or remote/dest invalid."
fi

log_message "Backup process completed successfully."
exit 0
`;
}

function buildLaunchdPlist(scriptPath, localBackupDir, hour, minute) {
  const logPath = escapePlist(toPosix(path.join(localBackupDir, "launchd.log")));
  const errPath = escapePlist(toPosix(path.join(localBackupDir, "launchd.err")));
  const scriptPathPosix = escapePlist(toPosix(scriptPath));
  const launchdPath = escapePlist("/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin");
  const h = Number(hour);
  const m = Number(minute);
  return `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
  <dict>
    <key>Label</key>
    <string>com.openclaw.backup</string>
    <key>ProgramArguments</key>
    <array>
      <string>${scriptPathPosix}</string>
    </array>
    <key>EnvironmentVariables</key>
    <dict>
      <key>PATH</key>
      <string>${launchdPath}</string>
    </dict>
    <key>StartCalendarInterval</key>
    <dict>
      <key>Hour</key>
      <integer>${h}</integer>
      <key>Minute</key>
      <integer>${m}</integer>
    </dict>
    <key>StandardOutPath</key>
    <string>${logPath}</string>
    <key>StandardErrorPath</key>
    <string>${errPath}</string>
  </dict>
</plist>
`;
}

function normalizeSchedule(input) {
  const v = (input || "").trim().toLowerCase();
  if (v === "launchd" || v === "cron" || v === "none") return v;
  if ((input || "").trim() !== "") console.warn(`Unknown schedule "${input}"; using "${defaults.schedule}".`);
  return defaults.schedule;
}

function normalizeRange(input, fallback, min, max, label) {
  const digits = String(input || "").replace(/[^0-9]/g, "");
  if (digits === "") return fallback;
  const n = Number(digits);
  if (Number.isNaN(n) || n < min || n > max) {
    console.warn(`Invalid ${label} "${input}"; using "${fallback}".`);
    return fallback;
  }
  return String(n);
}

function normalizeUploadMode(input) {
  const v = (input || "").trim().toLowerCase();
  if (v === "rclone" || v === "local-only") return v;
  if ((input || "").trim() !== "") console.warn(`Unknown upload mode "${input}"; using "${defaults.uploadMode}".`);
  return defaults.uploadMode;
}

async function main() {
  const useDefaults = process.argv.includes("--defaults");
  if (useDefaults) {
    const cfg = {
      projectDir: resolveDir(defaults.projectDir),
      openclawDir: resolveDir(defaults.openclawDir),
      cursorappsClawd: resolveDir(defaults.cursorappsClawd),
      localBackupDir: resolveDir(defaults.localBackupDir),
      rcloneRemote: defaults.rcloneRemote,
      rcloneDest: defaults.rcloneDest,
      retentionDays: defaults.retentionDays,
      uploadMode: defaults.uploadMode
    };
    const scriptsDir = path.join(cfg.projectDir, "scripts");
    const scriptPath = path.join(scriptsDir, "backup_enhanced.sh");
    writeScriptWithBackup(scriptPath, buildBackupScript(cfg));
    fs.chmodSync(scriptPath, 0o755);
    console.log(`Backup script written to: ${scriptPath}`);
    if (os.platform() === "darwin") {
      const plistPath = path.join(scriptsDir, "com.openclaw.backup.plist");
      safeWrite(plistPath, buildLaunchdPlist(scriptPath, cfg.localBackupDir, defaults.scheduleHour, defaults.scheduleMinute));
      console.log(`Plist written to: ${plistPath}`);
    }
    return;
  }
  rl = readline.createInterface({ input: process.stdin, output: process.stdout });
  try {
    console.log("ClawBackup Setup (interactive)\n");
    const projectDir = resolveDir((await ask(`Project dir [${defaults.projectDir}]: `)).trim() || defaults.projectDir);
    const openclawDir = resolveDir((await ask(`~/.openclaw dir [${defaults.openclawDir}]: `)).trim() || defaults.openclawDir);
    const cursorappsClawd = resolveDir((await ask(`Dev/CursorApps/clawd dir [${defaults.cursorappsClawd}]: `)).trim() || defaults.cursorappsClawd);
    const localBackupDir = resolveDir((await ask(`Local backup dir [${defaults.localBackupDir}]: `)).trim() || defaults.localBackupDir);
    validatePath("Project dir", projectDir);
    validatePath("~/.openclaw dir", openclawDir);
    validatePath("Dev/CursorApps/clawd dir", cursorappsClawd);
    validatePath("Local backup dir", localBackupDir);
    const rcloneRemote = (await ask(`rclone remote [${defaults.rcloneRemote}]: `)).trim() || defaults.rcloneRemote;
    const rcloneDest = (await ask(`rclone dest path [${defaults.rcloneDest}]: `)).trim() || defaults.rcloneDest;
    const retentionDays = (await ask(`Retention days [${defaults.retentionDays}]: `)).trim() || defaults.retentionDays;
    const uploadMode = normalizeUploadMode((await ask(`Upload mode (rclone|local-only) [${defaults.uploadMode}]: `)).trim() || defaults.uploadMode);
    const scheduleInput = (await ask(`Schedule (launchd|cron|none) [${defaults.schedule}]: `)).trim() || defaults.schedule;
    const schedule = normalizeSchedule(scheduleInput);
    let scheduleHour = defaults.scheduleHour;
    let scheduleMinute = defaults.scheduleMinute;
    if (schedule !== "none") {
      const hourInput = (await ask(`Schedule hour 0-23 [${defaults.scheduleHour}]: `)).trim() || defaults.scheduleHour;
      const minuteInput = (await ask(`Schedule minute 0-59 [${defaults.scheduleMinute}]: `)).trim() || defaults.scheduleMinute;
      scheduleHour = normalizeRange(hourInput, defaults.scheduleHour, 0, 23, "schedule hour");
      scheduleMinute = normalizeRange(minuteInput, defaults.scheduleMinute, 0, 59, "schedule minute");
    }

    const cfg = { projectDir, openclawDir, cursorappsClawd, localBackupDir, rcloneRemote, rcloneDest, retentionDays, uploadMode };
    const scriptsDir = path.join(projectDir, "scripts");
    const scriptPath = path.join(scriptsDir, "backup_enhanced.sh");
    writeScriptWithBackup(scriptPath, buildBackupScript(cfg));
    fs.chmodSync(scriptPath, 0o755);
    console.log(`\nBackup script written to: ${scriptPath}`);

    if (schedule === "launchd" && os.platform() === "darwin") {
      const plistPath = path.join(scriptsDir, "com.openclaw.backup.plist");
      const launchAgentsDir = path.join(home, "Library", "LaunchAgents");
      safeWrite(plistPath, buildLaunchdPlist(scriptPath, localBackupDir, scheduleHour, scheduleMinute));
      console.log(`Launchd plist written to: ${plistPath}`);
      console.log(`\nInstall the scheduler (run as your user, do not use sudo):`);
      console.log(`  mkdir -p "${launchAgentsDir}"`);
      console.log(`  cp "${plistPath}" "${path.join(launchAgentsDir, "com.openclaw.backup.plist")}"`);
      console.log(`  launchctl load "${path.join(launchAgentsDir, "com.openclaw.backup.plist")}"`);
    } else if (schedule === "cron") {
      console.log(`\nAdd this line to crontab (crontab -e):`);
      console.log(`  ${scheduleMinute} ${scheduleHour} * * * ${scriptPath}`);
    } else {
      console.log("\nScheduler not configured. Run the backup script manually or set up cron/launchd.");
    }

    console.log("\nNext steps:");
    console.log("  1. Ensure rclone is configured: rclone config");
    console.log(`  2. Test run: ${scriptPath}`);
  } finally {
    rl.close();
    rl = null;
  }
}

if (require.main === module) {
  main().catch((err) => { console.error(err); process.exit(1); });
}

module.exports = {
  defaults,
  resolveDir,
  toPosix,
  escapeBash,
  escapePlist,
  normalizeSchedule,
  normalizeRange,
  normalizeUploadMode,
  buildBackupScript,
  buildLaunchdPlist
};


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (HIGH)