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 | The preference-based terminal apps (iTerm2, Apple Terminal) keep their settings in macOS-native preferences, so those domains are macOS-only. Config-file terminals (Alacritty, kitty, WezTerm) and the cross-platform core (dotfiles, dependencies, backup/restore) run anywhere, and are CI-tested on Linux. | iTerm2 and Apple 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, sync, bundle export, bundle import, and the work verbs |
| 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. (The
one privileged surface is the apt rail on apt-based Linux — apply --deps to
install, and restore --packages to uninstall what --deps recorded — which
delegates to apt-get and must itself run under sudo; Homebrew needs none.)
Linux scope. The core (dotfiles, dependencies, backup/restore) is cross-platform and CI-tested on Linux, as are the config-file terminal emulators (Alacritty, kitty, WezTerm). The preference-based terminal apps — iTerm2 and Apple Terminal — are macOS-only and skip cleanly on Linux. The
keybindingsanditerm2-profilesdomains deploy into~/Library/…paths only macOS reads — on a Linux machine, leave them out of scope inferry.local.toml(e.g.keybindings = false).
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 or
edit your shell, and by default it does not run ferry init: it runs the freshly
installed binary once to print ferry's banner, and nothing else. Pass --init
(curl … | bash -s -- --init) to chain ferry init straight after the install.
To build from source instead:
git clone https://github.com/REPPL/ferry.git && cd ferry
make build
mkdir -p ~/.local/bin
cp "bin/ferry-$(go env GOOS)-$(go env GOARCH)" ~/.local/bin/ferry
# If ~/.local/bin isn't on your PATH, add this to your shell config:
# export PATH="$HOME/.local/bin:$PATH"make build cross-compiles every target to bin/ferry-<goos>-<arch>, so the copy
names the one for this host: there is no plain bin/ferry.
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> add -A
git -C <your-ferry-repo> commit -m "Initial capture"
git -C <your-ferry-repo> remote add origin <your-empty-repo-url>
git -C <your-ferry-repo> push -u origin HEADStage with add -A before committing: capture writes files git has never seen —
a newly declared terminal, agents, or dependency source lands at a fresh path —
and commit -a stages only tracked files, so a bare commit -am would push a
repo missing exactly the configuration just captured. ferry init has already
committed the seeded tree, so commit on its own reports nothing to commit until
capture has written something.
A 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. A bare init also wires
no remote — create an empty repo with your git host and add it as origin
yourself (as above), or let ferry do both with
ferry init --github.
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 — the schema is in
the configuration reference).
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 zsh (~/.zshrc).
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 defence 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 — except for a plain local path that is already a
git working tree, which ferry uses where it stands rather than re-cloning. Either way it
prints the repo path it settles on. (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 <your-repo-url> # 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 declared dependencies (needs brew and/or npm-globals under [manage])ferry apply is idempotent and safe to re-run: run it after every git pull. It
never overwrites local edits you haven't captured: a managed file with uncaptured
changes is left alone rather than clobbered — it is a capture candidate. If the repo
has changed that same file too, apply reports a conflict and refuses it until you
pick a winner. 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.
ferry manages a single-branch config repo: ferry sync integrates and pushes main
and refuses any other checked-out branch. A repo whose default branch is named
something else needs renaming on the remote side as well — git branch -M main,
push that branch, and set it as the remote's default branch — because a local-only
rename leaves every other machine's clone on the old default.
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