An interactive TUI for keeping the explicitly-installed packages of two Arch Linux machines in sync. It diffs this machine against another (live over SSH, or a committed package-list file), shows you what's missing / extra / version-skewed, and lets you tick exactly what to install, remove, or upgrade.
Built with ratatui. Everything runs through yay, so both
official-repo and AUR packages are handled, and sudo is requested only when a
command actually needs it.
pkgsync makes this machine look more like the thing you point it at.
It reads your local packages with pacman -Qe and compares them to a remote
source of truth:
| In the list | Meaning | Action |
|---|---|---|
🟢 install |
remote has it, you don't | yay -S --needed <pkg> |
🔴 remove |
you have it, remote doesn't | yay -Rns <pkg> |
🟡 upgrade |
both have it, versions differ | yay -S <pkg> |
Only explicitly-installed packages are compared (pacman -Qe), never
dependencies — those are pacman's job.
cd ~/dev/rust/pkgsync
cargo install --path . # puts `pkgsync` in ~/.cargo/bin
# or just run it ad-hoc during dev:
cargo run -- <args>Just run it with no arguments and pick a source from the menu:
pkgsync # menu of remembered sources + ssh_config hosts + "enter new"
pkgsync demo # sample data — safe, no machines neededThe entry menu is pre-populated with:
- recently-used sources (persisted in
~/.local/state/pkgsync/recent), and Hostaliases from~/.ssh/config,
so you usually just arrow down and press Enter. The remaining rows let you enter a new target or snapshot this machine:
- + SSH — type a hostname or IP; pkgsync runs
ssh <host> pacman -Qe. - + Local file — type a path to a
.pkgssnapshot (~/is expanded). - ⎙ Snapshot this machine — type a path; pkgsync writes this machine's
pacman -Qeto it (creating parent dirs, overwriting if present). This is the in-app way to publish your own package list for the other machine to compare against — no shell step needed.
Either way the fetch runs on a background thread (so SSH never freezes the UI;
Esc cancels a slow one) and the diff appears.
You can also skip the menu by passing one or more targets, then pick in-app:
pkgsync <dir> # every *.pkgs file in a directory
pkgsync <file.pkgs> # a single state file (auto-selected)
pkgsync <ssh-host> # another machine over SSH (auto-selected)
pkgsync <dir> <host-a> <host-b> # mix files and hosts into one pickerEach target is classified automatically: an existing directory is scanned
for *.pkgs files, an existing file becomes a file source, and anything
else is an SSH host. With exactly one target, the picker is skipped and the
fetch starts immediately.
Entry menu
| Key | Action |
|---|---|
↑/↓ or k/j |
move |
Enter |
choose this source type → input |
q |
quit |
Input field (typing a host/IP or path)
| Key | Action |
|---|---|
| any character | type into the field |
Backspace |
delete a character |
Enter |
connect / fetch |
Esc |
back to the entry menu |
Ctrl-C quits from anywhere (even mid-typing).
Source picker (when targets are passed on the CLI)
| Key | Action |
|---|---|
↑/↓ or k/j |
move |
Enter |
choose this source |
q |
quit |
Diff view
| Key | Action |
|---|---|
↑/↓ or k/j |
move cursor |
Tab / Space |
tick / untick the package for action |
a / i / u / r |
filter: all / install / upgrade / remove |
Enter |
open the confirm screen for ticked packages |
y / n |
(on confirm) apply / cancel |
R / F5 |
reload (re-fetch the current source) |
Esc |
back to the source picker |
q |
quit |
After applying, pkgsync reloads automatically so the diff reflects the change.
Selections are tracked by package name, so they survive filtering — tick a
few installs, switch to the remove filter, tick a few removes, then Enter to
apply everything at once. The confirm screen always shows the literal commands
that will run before anything happens.
After applying, pkgsync runs the commands with the real terminal attached (so you see yay's output and its sudo prompt), then exits to the shell. Re-run it to see the updated state.
Use the ⎙ Snapshot this machine menu entry to write this machine's package list to a path in the shared repo, then commit & push it so the other machine can compare against it. The equivalent on the shell is:
pacman -Qe > ~/dev/linux/dotconfigs/state/$(uname -n).pkgs
git -C ~/dev/linux/dotconfigs add state/ && \
git -C ~/dev/linux/dotconfigs commit -m "pkg state: $(uname -n)" && \
git -C ~/dev/linux/dotconfigs pushThe SSH source runs ssh -o BatchMode=yes -o ConnectTimeout=8 <host> pacman -Qe.
That means:
- Key-based auth must be set up (BatchMode disables password prompts).
- The host must be reachable — same LAN, or a VPN like Tailscale/WireGuard if the machines are across the internet.
<host>can be anything your~/.ssh/configunderstands.
If the host is down or unreachable, pass a .pkgs file as a second argument and
pkgsync falls back to it automatically.
The simplest, most robust flow — works through the GitHub repo you already push.
On the office machine, publish its state and push:
pacman -Qe > ~/dev/linux/dotconfigs/state/$(uname -n).pkgs
git -C ~/dev/linux/dotconfigs add state/ && git -C ~/dev/linux/dotconfigs commit -m "pkg state" && git -C ~/dev/linux/dotconfigs pushOn the home machine, pull and reconcile:
git -C ~/dev/linux/dotconfigs pull
pkgsync ~/dev/linux/dotconfigs/state/office.pkgsTick the packages you want to match, Enter, review the commands, y. Then
publish home's new state back so office can reconcile in the other direction.
pkgsync ~/dev/linux/dotconfigs/state/office.pkgs
i # filter to just the install candidates
Tab Tab Tab # tick the ones you actually want (skip office-only stuff)
Enter y # applypkgsync ~/dev/linux/dotconfigs/state/office.pkgs
r # filter to remove candidates (things home has that office doesn't)
Tab # tick only what you truly want gone — read carefully!
Enter yRemoval uses -Rns (drops orphaned deps + config). Don't blind-tick everything;
the remove list includes anything genuinely home-only.
pkgsync office # over SSH, always current
# or with a safety net if office might be asleep:
pkgsync office ~/dev/linux/dotconfigs/state/office.pkgspkgsync demo # or point at a real file/hostBrowse, filter, read the detail pane. If you never press Enter/y, nothing is
ever changed.
- Auto fallback (try SSH, else a state file) exists in the library
(
fetch_with_fallback) but the picker flow treats SSH and files as separate choices — if a host is down you pick its file manually. A combined "SSH with file fallback" picker entry is a possible addition. - Upgrades go to the latest repo version, not the other machine's exact version
(usually fine — a normal
pacman -Syuon both machines resolves skew anyway). - The diff reloads in full after applying; very large package sets re-fetch from scratch rather than patching the changed entries.