Metadata-Version: 2.4
Name: rangler
Version: 0.2.0
Summary: macOS home-directory backup to an rclone remote, with versioning and scheduling.
Author: John Grimes
License-Expression: Apache-2.0
License-File: LICENSE
License-File: NOTICE
Requires-Python: >=3.13
Requires-Dist: click==8.4.1
Description-Content-Type: text/markdown

# rangler

rangler is a macOS command-line tool that backs up a single user's home
directory to an [rclone](https://rclone.org/) remote. It mirrors an explicit
list of configured paths (under `files/`) and auto-discovered project `.local`
directories (under `locals/`), namespaces everything by hostname, keeps prior
versions of changed and deleted files, and can run unattended on a schedule.

Backups are one-way (local to remote). rangler never writes to your local
filesystem except its own configuration, logs, runtime state, and its
LaunchAgent. It shells out to the `rclone` binary you have installed; it does not
install or configure rclone, and it adds no encryption of its own (use an rclone
crypt remote if you want that).

## Requirements

- macOS, single user.
- [rclone](https://rclone.org/) installed and at least one remote configured.
- Python 3.13+ and [uv](https://docs.astral.sh/uv/) for installation from source.

## Installation

```console
brew install rclone
rclone config          # create a remote, e.g. a `local` remote named `backup`
uv tool install --editable .   # or run ad hoc with: uv run rangler ...
```

## Configuration

rangler reads a single TOML file at `~/.config/rangler/config.toml`. Scaffold a
starter file with `rangler init`, then edit it. Every path must live under your
home directory.

| Key           | Type            | Default      | Notes                                                     |
| ------------- | --------------- | ------------ | --------------------------------------------------------- |
| `remote_base` | string          | (required)   | rclone remote in `name:path` form, e.g. `backup:rangler`. |
| `paths`       | array of string | `[]`         | Explicit paths under `$HOME` to back up. `~` is expanded. |
| `scan_roots`  | array of string | `["~/Code"]` | Roots scanned for `.local` directories.                   |
| `max_depth`   | integer         | `4`          | Maximum descent depth under each scan root.               |
| `excludes`    | array of string | see below    | rclone filter patterns applied to every target.           |
| `time`        | string or time  | `"12:00"`    | Daily local time backups run, as `"HH:MM"` (24-hour).     |
| `notify`      | string          | `"failure"`  | Desktop notifications: `failure`, `always`, or `never`.   |

Default `excludes`: `node_modules/**`, `.git/**`, `.DS_Store`, `*.log`,
`.cache/**`, `__pycache__/**`. At least one of `paths` or `scan_roots` must be
non-empty. Unknown keys are reported as warnings (typo protection) rather than
aborting.

## Usage

### `rangler init`

Scaffold a starter `config.toml`. Does nothing if one already exists unless you
pass `--force` to overwrite it.

```console
rangler init
```

### `rangler check`

Validate the configuration, confirm rclone is present and the remote is
reachable, and print the full resolved target list (each source and its computed
destination) without transferring anything. Exits `0` when valid, `2` when the
configuration is invalid, and `3` when rclone is missing or the remote is
unreachable.

```console
rangler check
```

### `rangler run`

Back up every resolved target. Each explicit path is mirrored to
`<remote_base>/<hostname>/files/<path-relative-to-home>`. Symlinks are followed
so symlinked dotfiles are stored as content. Prior copies of changed or deleted
files are moved into `<remote_base>/<hostname>/_versions/<run-timestamp>/...`
rather than being hard-deleted. Each run prints per-target progress and a final
summary, and writes a timestamped log to `~/Library/Logs/rangler/`.

```console
rangler run            # perform the backup
rangler run --dry-run  # report intended changes without writing to the remote
```

A configured path that does not exist on this machine is reported as skipped and
does not fail the run (paths legitimately differ across machines). A path that
exists but errors during transfer is recorded as a failed target; the run
continues with the others and exits non-zero. Overlapping runs are prevented by a
lock: a second concurrent run exits cleanly with `skipped: already running`.

### Auto-discovery of `.local` directories

Beyond the explicit `paths` list, rangler scans each entry in `scan_roots`
(default `~/Code`) for project `.local` directories down to `max_depth` (default
4). Each discovered `<root>/.../project/.local` is mirrored to
`<remote_base>/<hostname>/locals/<project-path-relative-to-home>` - the `.local`
segment itself is dropped. For example, `~/Code/pathling/.local` is backed up to
`locals/Code/pathling`.

Discovery deliberately skips:

- the XDG `~/.local` directory (it shares the name but is not a project scratch
  directory);
- `.Trash` and any directory named by an exclude pattern (such as `node_modules`
  or `.git`), which are pruned without being descended into.

If the same directory is both discovered and listed explicitly in `paths`, it is
backed up once, with the explicit `files/` mapping taking precedence. Run
`rangler check` to preview the full resolved target list - explicit and
discovered alike - before backing up.

### Scheduling (`schedule` / `unschedule` / `status`)

rangler can run unattended via a macOS LaunchAgent that fires once a day at a
fixed local time and survives reboot.

```console
rangler schedule --at 02:00    # install and load the agent (defaults to config time)
rangler status                 # report whether scheduling is active and the last run
rangler unschedule             # unload and remove the agent
```

`schedule` writes a LaunchAgent plist to
`~/Library/LaunchAgents/au.id.grimes.john.rangler.plist` and loads it with `launchctl
bootstrap`; re-running it replaces any existing agent. `--at` accepts a 24-hour
`HH:MM` local time and defaults to the `time` from the config (`12:00` if unset).
Because launchd starts agents with a minimal `PATH` that omits the Homebrew
directories, the plist sets an `EnvironmentVariables` `PATH` covering both
Homebrew prefixes (`/opt/homebrew/bin` and `/usr/local/bin`) so the scheduled
run can locate rclone.
The agent uses launchd `StartCalendarInterval`, so the schedule is anchored to a
wall-clock time rather than a relative countdown: reinstalling the agent never
delays the next run, and if the machine is asleep or off at the scheduled time
launchd runs the missed backup on the next wake. `unschedule` is idempotent: it
succeeds even if nothing was installed. `status` reports the scheduled daily time
and the time and result of the last run (read from
`~/.local/state/rangler/last-run.json`).

Breaking change: earlier versions scheduled on a repeating interval via an
`interval` config key and a `rangler schedule --every <duration>` option. Both
have been removed. A stale `interval` key is now reported as an unknown key
(warning) and ignored, and the daily `time` default applies. An agent installed
by an older version keeps running on its old interval until you next run `rangler
schedule`, which migrates it to the daily-time agent; `status` flags such a
legacy agent and points you at the reschedule.

You can confirm the loaded job directly with launchctl:

```console
launchctl print gui/$(id -u)/au.id.grimes.john.rangler
```

### Notifications

For unattended runs, rangler can raise a macOS desktop notification at run
completion. The `notify` config key controls when:

- `failure` (default): notify only when one or more targets failed.
- `always`: notify on every completed run, success or failure.
- `never`: stay silent.

Notifications fire for real runs (manual or scheduled); a `--dry-run` preview is
always silent.

## Remote layout

Given `remote_base = "backup:rangler"` and hostname `mymac`:

```text
backup:rangler/
└── mymac/                         # Hostname segment.
    ├── files/                     # Explicit configured paths.
    │   ├── .ssh/...               # from ~/.ssh
    │   └── Documents/notes/...    # from ~/Documents/notes
    ├── locals/                    # Auto-discovered .local directories.
    │   └── Code/pathling/...      # from ~/Code/pathling/.local
    └── _versions/                 # Versioned overwrites/deletes.
        └── 2026-06-19T03-21-55Z/  # One sub-tree per run that changed something.
            ├── files/...
            └── locals/...
```

This layout is a stable contract that manual restore relies on. rangler only
ever writes under `<remote_base>/<hostname>/`, so other machines' trees are never
touched.

## Restore (manual)

Restore is performed manually with rclone against the layout above. For example:

```console
rclone copy backup:rangler/mymac/files/.ssh ~/.ssh
rclone copy backup:rangler/mymac/locals/Code/pathling ~/Code/pathling/.local
```

Earlier versions of a file are found under
`backup:rangler/mymac/_versions/<timestamp>/...`.

## Troubleshooting

- **"rclone is not installed or not on your PATH"** - install rclone
  (`brew install rclone`) and confirm `rclone version` works in your shell.
- **"the remote ... is not reachable"** - the name before the `:` in
  `remote_base` must match a remote from `rclone listremotes`. Create or rename
  it with `rclone config`.
- **A configured path is reported as skipped** - the path does not exist on this
  machine. This is expected when paths differ across machines; it does not fail
  the run.
- **A target is reported as failed** - check the per-run log under
  `~/Library/Logs/rangler/` for the rclone error (for example a permission
  problem). The run still backs up every other target and exits non-zero.
- **"skipped: already running"** - another rangler run holds the lock at
  `~/.local/state/rangler/rangler.lock`. Wait for it to finish; the lock is
  released automatically even if that process is killed.
- **Scheduled runs are not happening** - confirm the agent is loaded with
  `launchctl print gui/$(id -u)/au.id.grimes.john.rangler` and inspect
  `~/Library/Logs/rangler/launchd.err.log`.

## Licence

rangler is licensed under the [Apache License, Version 2.0](LICENSE). See the
[NOTICE](NOTICE) file for attribution.

Copyright © 2026 John Grimes.
