ferry has two starting points, mirroring git's own init / clone duality:
- Fresh: you have a machine whose setup you want to capture into a new repo.
- Existing: you already have a ferry repo and want to set up another machine.
ferry itself is a single self-contained binary, but it leans on a few host tools for
the work it deliberately does not reimplement:
| Prerequisite | Why ferry needs it | When |
|---|---|---|
| macOS | Terminal configuration (iTerm2, Apple Terminal) uses macOS-native preference mechanisms and is macOS-only. The cross-platform core (dotfiles, dependencies, backup/restore) is CI-tested on Linux. | terminal configuration |
git |
ferry does not embed git. It shells out to clone your config repo, and you commit/push your captured changes with git yourself. ferry preflights it and tells you how to install it if missing. | init, capture |
| A package manager (Homebrew on macOS) | Only for installing declared dependencies via ferry apply --deps. ferry never installs the package manager for you: it uses whatever is present and tells you if none is. |
apply --deps only |
You do not need admin/root, and you do not need to pre-install anything ferry manages: that's ferry's job. The above are the host tools ferry stands on.
Linux scope. The core (dotfiles, dependencies, backup/restore) is cross-platform and CI-tested on Linux; terminal-emulator configuration is macOS-only.
curl -fsSL https://raw.githubusercontent.com/REPPL/ferry/main/install.sh | bashNote: the
curl … | bashinstaller fetches the release'schecksums.txtand verifies each binary against it, failing closed if it is absent. Building from source (below) works today; see Cutting a release for how releases are cut.
This installs only the ferry binary to ~/.local/bin: no admin rights
required, so it works on any account, including locked-down or managed machines. If
~/.local/bin isn't already on your PATH, the installer prints the one line to add to
your shell config (it never edits your shell itself). It does not install Homebrew, edit
your shell, or run ferry init; it runs the freshly installed binary once to print
ferry's banner, and nothing else.
To build from source instead:
git clone https://github.com/REPPL/ferry.git && cd ferry
make build
mkdir -p ~/.local/bin
cp bin/ferry-$(uname -s | tr A-Z a-z)-* ~/.local/bin/ferry
# If ~/.local/bin isn't on your PATH, add this to your shell config:
# export PATH="$HOME/.local/bin:$PATH"ferry init # first-run setup; starts a new config repo at ~/.config/ferry/repo
ferry capture # review your config; approve each change, route shared/local
git -C <your-ferry-repo> commit -am "Initial capture"
git -C <your-ferry-repo> pushA bare ferry init creates the repo at ferry's own default location,
~/.config/ferry/repo: you do not need to pick a path. To place it somewhere
else, pass a directory: ferry init --fresh ~/somewhere.
On an interactive terminal (stdin and stdout both ttys), a fresh ferry init runs a
first-run wizard over your existing ~/.zshrc:
- Keep everything as-is (the default): adopt the file verbatim, except any detected secret-shaped lines, which still get the forced store/drop routing below (with no secrets present it is one keypress to a verbatim adopt).
- Choose per block: the wizard splits the file into paragraph blocks and lets you
route each one shared (committed, deploys everywhere), local (this machine's
gitignored
~/.zshrc.localsidecar), or drop (removed from the deployed config; it survives in the backup). - Start fresh: answer a few questions and seed a portable, commented starter instead (your original stays in the backup).
Two safety properties hold on every path:
- Secret routing is always on. A secret-shaped line (token, private key, credential
assignment) is never seeded: the wizard forces a choice between the out-of-repo
secret store (
~/.config/ferry/secrets-local, with a placeholder in the seed) and dropping the line. This is not optional and not part of--repair. - Nothing is written before you confirm. The wizard shows a full preview (seed
bytes plus a diff of your
~/.zshrcafter the first apply, secret values masked) and only then writes: a visible timestamped backup (~/.zshrc.ferry-<ts>.bak), the secret store entries, and the repo seed. Declining exits with nothing changed.
Opt-in repairs (ferry init --repair) additionally offer lint-style fixes — hardcoded
/Users/<name> paths to $HOME, duplicate PATH exports, dead source lines — each
accepted or declined individually.
Non-interactively (piped stdin/stdout, or --wizard=off) there is no TUI and
no prompt: ferry adopts the whole file shared, automatically extracts every detected
secret to the local store (the extracted ref names are listed on stderr; nothing is
ever dropped without you), and seeds placeholders in their place. A secret-free
~/.zshrc is adopted byte-identically, so the first ferry apply matches what is
already on disk and changes nothing. Your existing shell config is never zeroed.
Scripting the wizard itself is possible with ferry init --wizard=answers:<file>
(a TOML file carrying every decision).
If you have no ~/.zshrc (and skip the starter), ferry seeds no shell source at all —
.zshrc is still in scope, and your first ferry capture fills the repo from the
file once you have one. A symlinked or unreadable ~/.zshrc is left entirely alone:
ferry declares it but does not manage, back up, or replace it.
The wizard's plugin set is currently zsh (~/.zshrc); more config domains come
with later releases.
ferry capture is interactive and selective: it shows you each change and lets you
route it shared (synced to every machine) or local (this machine only). Things
outside the manifest's scope—a one-off font, an experimental colour scheme—are
never captured.
If you'd rather not create a GitHub repo, add a remote, and push by hand, let ferry do
it. ferry init --github [name] creates a new private repo through the GitHub CLI's
existing login and wires it as ferry's HTTPS remote, so ferry capture can push and
ferry apply on another machine can pull.
gh auth login # once: authenticate the GitHub CLI (if you haven't)
ferry init --github # creates a private repo named ferry-config
ferry init --github my-dotfiles # or pick your own name
ferry init --github my-dotfiles --yes # non-interactive (scripts, CI): skip the confirmWhat it guarantees:
- Needs
ghauthenticated. ferry uses your existingghlogin and stores no token — the credential stays ingh's own keyring. Rungh auth login(andgh auth setup-git) first. - Always private. ferry only ever creates a private repo and verifies it is private
before pushing; it never passes
--public. - Never touches an existing repo. If a repo with that name already exists, ferry aborts and asks you to pass a different name — it never reuses or overwrites one.
- Never pushes a secret. Detected secret-shaped content in your
~/.zshrc(a private key, a token) is routed by the first-run wizard — or extracted automatically on a non-interactive run — into the out-of-repo secret store before anything is committed, so the pushed repo carries only placeholders. The same secret scancaptureuses still runs before the first commit and again before the push as defense in depth: a raw secret somehow left in the planned commit blocks the push. - HTTPS only. The remote ferry sets is always
https://…; it never sets anssh://remote and never touches~/.ssh.
Non-interactive runs (a script or CI with no terminal) require --yes so ferry
never silently creates and pushes to an unexpected account.
ferry sync publishes your captured changes and pulls remote ones — the everyday
update for a managed repo. It never force-pushes and leaves your machine unchanged on
a conflict. Run ferry apply after to deploy pulled changes.
Point ferry init at your existing ferry repo as a positional argument: an HTTPS URL or
a local/file:// path. ferry clones it into its own space (~/.config/ferry/repo by
default), then writes ferry's config. (A bare ferry init, with no source, takes the
Fresh path above and sets up a new repo at the same default location.)
ferry init https://github.com/REPPL/ferry.git # clone your ferry repo over HTTPS, write ferry's config
ferry diff # preview what will change on this machine (optional)
ferry apply # reconcile this machine to the repo
ferry apply --deps # install dependencies (separate, explicit step)ferry apply is idempotent and safe to re-run: run it after every git pull. It
never overwrites local edits you haven't captured: if a managed file has uncaptured
changes, apply reports a conflict instead of clobbering it. It also refuses to
replace a substantial existing file with an empty or blank repo source — that would
erase your config, so apply stops and names the file instead. Pass --force to
override (it warns and backs the file up first, so ferry restore can recover it),
or run ferry capture to save the current file into the repo before applying.
When you can't clone the repo on the destination: a second user account on the same Mac,
or a machine with no network path to your repo: move a self-contained bundle instead.
ferry bundle export writes a secret-scanned .zip of the repo's tracked shared files;
ferry bundle import ingests it into a fresh config repo on the other side.
# On the source machine/account:
ferry bundle export --out /Users/Shared/ferry-bundle.zip # prints the bundle path + SHA256
# On the destination machine/account, after moving the zip there:
ferry bundle import --expect-sha256 <sha256> /Users/Shared/ferry-bundle.zip
ferry apply # reconcile the destinationSame-Mac, two accounts: account B usually cannot read account A's home directory, so
write the bundle somewhere both accounts can reach: --out /Users/Shared/ferry-bundle.zip,
or move it via a USB drive or AirDrop. Convey the SHA256 that bundle export printed
separately (a message, not the same channel as the file) and pass it to
bundle import --expect-sha256 so a tampered or corrupt bundle is refused. bundle import
writes into ~/.config/ferry/repo by default and refuses a non-empty target.
The bundle never contains secrets, anything under ~/.ssh/, or the per-machine local
layer (unless you pass --include-local on both bundle export and bundle import). SSH keys stay a
manual copy you make yourself: see Move SSH keys yourself.
ferry status # what has drifted on this machine?
ferry capture # pull chosen local changes back into the repo
ferry apply # pull repo changes onto this machine
ferry doctor # is this machine set up correctly?ferry restore # return managed files to their pre-ferry stateEvery change ferry makes is backed up first, so restore returns the machine to
exactly how it was before ferry touched it.
- Configuration: scope, the manifest, and the
.locallayer - SSH: ferry is hands-off with
~/.ssh/; here's how to move keys yourself