commit cc4c8606b9cbb79140dc466d00b25e8c62dbfb2d
Spenser Truex <truex@equwal.com>
2026-09-21 04:29:07 -0700
bm-sync: keep the bookmark file the same on all devices
bm-sync sends the bookmark file to an sbm-sync server with the version
that it got last time. The server merges and sends the file back, and
bm-sync writes it. So bookmarks that you add or remove on one device
are added or removed on the others: computers with bm, the sbm app for
Android and the sbm add-on for Firefox and Chrome.
bm-sync login # email and password; the token goes to ~/.config/sbm/sync
bm-sync # sync now
bm already runs "bm-sync -q" in the background after each change when
bm-sync is installed. It is POSIX sh and needs curl. The password goes
to curl through stdin and the token through a header file, so neither
shows in ps. A lock directory keeps one sync at a time; a second sync
leaves a note, and the first one syncs again. When bm changes the file
during a sync, bm-sync sends the file again.
The tests play the server with a stand-in for curl.
Makefile | 2 +-
README | 25 ++-
bm-sync | 195 +++++++++++++++++++++
config.mk | 3 +-
contrib/windows/sbm.nsi | 4 +-
packaging/chocolatey/tools/chocolateyInstall.ps1 | 2 +-
packaging/chocolatey/tools/chocolateyUninstall.ps1 | 2 +-
test/run.sh | 94 ++++++++++
8 files changed, 320 insertions(+), 7 deletions(-)
diff --git a/Makefile b/Makefile
index d4dd1c8..39a7737 100644
--- a/Makefile
+++ b/Makefile
@@ -1,6 +1,6 @@
include config.mk
-SCRIPTS = bm bm-migrate bm-import bm-check bm-html bm-title bm-commit bm-watch
+SCRIPTS = bm bm-migrate bm-import bm-check bm-html bm-title bm-commit bm-watch bm-sync
TESTS = test/run.sh test/fakemenu test/fakefzf
all:
diff --git a/README b/README
index e313e90..5526431 100644
--- a/README
+++ b/README
@@ -24,6 +24,7 @@ other Unix tools, and you install only the ones you use:
bm-title a URL -> the page's title
bm-commit commit the bookmark file to git
bm-watch browser bookmarks -> bm, now and after each change
+ bm-sync the bookmark file <-> your other devices
bm-import bookmarks.html | bm --merge
bm --tag code --list | bm-check -
@@ -37,8 +38,8 @@ date +%s.
== Requirements ==
dmenu (or fzf), one of xclip, xsel or wl-clipboard, sh, awk, make.
-curl for bm-title and bm-check, jq for bm-import of Chromium files, git for
-bm-commit.
+curl for bm-title, bm-check and bm-sync, jq for bm-import of Chromium files,
+git for bm-commit.
== Install ==
Edit TOOLS in config.mk to choose what gets installed, then
@@ -189,6 +190,26 @@ pushes or pulls.
A file that merely sits inside a bigger repository (your dotfiles) is left
alone unless SBM_GIT=1. SBM_GIT=0 turns commits off.
+== Sync ==
+bm-sync keeps the bookmark file the same on all your computers, the sbm app
+for Android and the sbm add-on for Firefox and Chrome.
+
+ bm-sync login # once on each computer: email and password
+ bm-sync # sync now
+
+When bm-sync is installed and signed in, bm syncs after each change, in the
+background. Bookmarks that you add or remove on one device are added or
+removed on the others. The file stays a plain file, and bm works without a
+network. The sign-in token is kept in ~/.config/sbm/sync.
+
+The server is sbm-sync (https://github.com/equwal/sbm-sync). bm-sync uses
+https://sbm.subread.space unless you give another server: create an account
+there. The server is free software, so you can run your own:
+
+ bm-sync login https://sbm.example.org
+
+The sbm app for Android is at https://github.com/equwal/sbm-android.
+
== Migrating from the old format ==
Earlier versions wrote "URL description | tag tag".
diff --git a/bm-sync b/bm-sync
new file mode 100755
index 0000000..a12b86c
--- /dev/null
+++ b/bm-sync
@@ -0,0 +1,195 @@
+#!/bin/sh
+
+# bm-sync: keep the bookmark file the same on all your devices, through an
+# sbm-sync server (https://github.com/equwal/sbm-sync). bm runs it after each
+# change when it is installed; you can also run it yourself, or from cron.
+#
+# The server merges: bookmarks that you add or remove here are added or
+# removed there, and the other way round. The file stays a plain file.
+
+DATADIR=${XDG_DATA_HOME:-$HOME/.local/share}/sbm
+BOOKMARKS=${BOOKMARKS:-$DATADIR/bookmarks}
+CONFIG=${SBM_SYNC_CONFIG:-${XDG_CONFIG_HOME:-$HOME/.config}/sbm/sync}
+SERVER=https://sbm.subread.space
+STATE=$BOOKMARKS.sync # name of the version of the last sync
+LOCK=$BOOKMARKS.sync.lock
+quiet=
+
+usage () {
+ cat <<'EOF'
+usage: bm-sync [-q] [login [server] | logout | status]
+Without a command: sync the bookmark file now.
+ login sign in to the server (default https://sbm.subread.space)
+ and sync; asks for your email and password
+ logout sign out on this computer
+ status show the server and the time of the last sync
+ -q print nothing but errors
+EOF
+}
+
+die () {
+ printf 'bm-sync: %s\n' "$*" >&2
+ exit 1
+}
+
+say () {
+ [ -n "$quiet" ] || printf 'bm-sync: %s\n' "$*"
+}
+
+# setting <name>: a value from the config file.
+setting () {
+ sed -n "s/^$1=//p" "$CONFIG" 2>/dev/null | head -n 1
+}
+
+# private <file>: create an empty file that only you can read.
+private () {
+ (umask 077; : > "$1")
+}
+
+cleanup () {
+ rm -rf "$work"
+ [ -z "$locked" ] || rm -rf "$LOCK"
+}
+
+login () {
+ server=${1:-$SERVER}
+ server=${server%/}
+ printf 'email: ' >&2
+ read -r email || exit 1
+ printf 'password: ' >&2
+ stty -echo 2>/dev/null
+ IFS= read -r password
+ rc=$?
+ stty echo 2>/dev/null
+ printf '\n' >&2
+ [ $rc -eq 0 ] || exit 1
+
+ # The password goes through stdin: on the command line, other users
+ # could see it with ps.
+ code=$(printf '%s' "$password" | curl -sS -o "$work/out" -w '%{http_code}' \
+ --data-urlencode "email=$email" --data-urlencode 'password@-' \
+ -- "$server/api/login") || die "cannot reach $server"
+ [ "$code" = 200 ] || die "$(cat "$work/out")"
+ token=$(tr -d '\r\n' < "$work/out")
+
+ mkdir -p "$(dirname "$CONFIG")" || exit 1
+ private "$CONFIG" || exit 1
+ printf 'server=%s\ntoken=%s\n' "$server" "$token" > "$CONFIG"
+ rm -f "$STATE"
+ say "signed in to $server"
+ sync
+}
+
+logout () {
+ if [ -e "$CONFIG" ]; then
+ private "$work/auth"
+ printf 'Authorization: Bearer %s\n' "$(setting token)" > "$work/auth"
+ curl -sS -o /dev/null -X POST -H "@$work/auth" -- "$(setting server)/api/logout" \
+ 2>/dev/null
+ rm -f "$CONFIG" "$STATE"
+ fi
+ say 'signed out'
+}
+
+status () {
+ if [ ! -e "$CONFIG" ]; then
+ echo 'not signed in: run bm-sync login'
+ return
+ fi
+ echo "server: $(setting server)"
+ if [ -e "$STATE" ]; then
+ echo "last sync: $(ls -l "$STATE" | awk '{ print $6, $7, $8 }')"
+ else
+ echo 'last sync: never'
+ fi
+}
+
+# sync: send the file with the name of its last version; the server gives
+# back the merged file and the name of the new version.
+sync () {
+ [ -e "$CONFIG" ] || die 'not signed in: run bm-sync login'
+ server=$(setting server)
+ token=$(setting token)
+ [ -n "$server" ] && [ -n "$token" ] || die "$CONFIG is damaged: run bm-sync login"
+
+ # One sync at a time. A second bm-sync leaves a note and exits: the
+ # first one then syncs again, so that no change waits for the next one.
+ if ! mkdir "$LOCK" 2>/dev/null; then
+ pid=$(cat "$LOCK/pid" 2>/dev/null)
+ if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
+ : > "$LOCK/again"
+ exit 0
+ fi
+ rm -rf "$LOCK" # left by a bm-sync that was killed
+ mkdir "$LOCK" 2>/dev/null || die "cannot lock $LOCK"
+ fi
+ locked=1
+ echo $$ > "$LOCK/pid"
+
+ private "$work/auth"
+ printf 'Authorization: Bearer %s\n' "$token" > "$work/auth"
+ if [ ! -e "$BOOKMARKS" ]; then
+ mkdir -p "$(dirname "$BOOKMARKS")" && : > "$BOOKMARKS" || exit 1
+ fi
+
+ tries=0
+ while :; do
+ rm -f "$LOCK/again"
+ cat "$BOOKMARKS" > "$work/sent"
+ base=$(cat "$STATE" 2>/dev/null)
+ code=$(curl -sS --max-time 60 -o "$work/out" -D "$work/head" -w '%{http_code}' \
+ -H "@$work/auth" -H 'Content-Type: text/plain; charset=utf-8' \
+ --data-binary "@$work/sent" -- "$server/api/sync?base=$base") \
+ || die "cannot reach $server"
+ case $code in
+ 200) ;;
+ 401) die 'not signed in: run bm-sync login' ;;
+ *) die "$(cat "$work/out")" ;;
+ esac
+ version=$(tr -d '\r' < "$work/head" | sed -n 's/^[Ss][Bb][Mm]-[Vv][Ee][Rr][Ss][Ii][Oo][Nn]: *//p')
+ [ -n "$version" ] || die 'the server gave no version'
+
+ # bm changed the file while it was away: send the new file. The
+ # answer to the old one is of no use.
+ if ! cmp -s "$work/sent" "$BOOKMARKS"; then
+ tries=$((tries + 1))
+ [ $tries -lt 5 ] || die 'the file changes all the time: try again later'
+ continue
+ fi
+ if ! cmp -s "$work/out" "$BOOKMARKS"; then
+ # Through cat, not mv, as in bm: a symlinked file stays a symlink.
+ cat "$work/out" > "$BOOKMARKS" || die "cannot write $BOOKMARKS"
+ if command -v bm-commit >/dev/null 2>&1; then bm-commit 'bm-sync: merge'; fi
+ fi
+ printf '%s\n' "$version" > "$STATE"
+ [ -e "$LOCK/again" ] || break
+ done
+ say "in sync: $(grep -c -v -e '^#' -e '^[[:space:]]*$' "$BOOKMARKS") bookmarks"
+}
+
+while [ $# -gt 0 ]; do
+ case $1 in
+ -q) quiet=1 ;;
+ -h|--help) usage; exit 0 ;;
+ -*) usage >&2; exit 2 ;;
+ *) break ;;
+ esac
+ shift
+done
+
+command -v curl >/dev/null 2>&1 || die 'curl is not installed'
+# mkdir fails when the name exists: a directory planted in a shared /tmp is
+# refused, not used.
+work=${TMPDIR:-/tmp}/bm-sync.$$
+mkdir -m 700 "$work" || die "cannot create $work"
+locked=
+trap cleanup EXIT
+trap 'exit 1' INT TERM
+
+case ${1:-sync} in
+ login) shift; login "$@" ;;
+ logout) logout ;;
+ status) status ;;
+ sync) sync ;;
+ *) usage >&2; exit 2 ;;
+esac
diff --git a/config.mk b/config.mk
index edaf46a..95963c2 100644
--- a/config.mk
+++ b/config.mk
@@ -12,4 +12,5 @@ MANPREFIX=${PREFIX}/share/man
# bm-title lets bm --add suggest the page title as description (curl)
# bm-commit lets bm keep the file's history in git
# bm-watch brings new browser bookmarks into bm (bm-import, jq)
-TOOLS = bm bm-migrate bm-import bm-check bm-html bm-title bm-commit bm-watch
+# bm-sync keeps the file the same on all your devices (curl)
+TOOLS = bm bm-migrate bm-import bm-check bm-html bm-title bm-commit bm-watch bm-sync
diff --git a/contrib/windows/sbm.nsi b/contrib/windows/sbm.nsi
index 25ba95e..b197c54 100644
--- a/contrib/windows/sbm.nsi
+++ b/contrib/windows/sbm.nsi
@@ -83,6 +83,7 @@ Section
File "${SRC}\bin\bm-html"
File "${SRC}\bin\bm-import"
File "${SRC}\bin\bm-migrate"
+ File "${SRC}\bin\bm-sync"
File "${SRC}\bin\bm-title"
File "${SRC}\bin\bm-watch"
File "${SRC}\bin\fzf"
@@ -98,7 +99,7 @@ Section
; Cygwin does not see an execute permission on files that a Windows
; program makes. Cygwin chmod sets it.
- nsExec::ExecToLog '"$Cygwin\bin\chmod.exe" 755 /usr/local/bin/bm /usr/local/bin/bm-check /usr/local/bin/bm-commit /usr/local/bin/bm-html /usr/local/bin/bm-import /usr/local/bin/bm-migrate /usr/local/bin/bm-title /usr/local/bin/bm-watch /usr/local/bin/fzf /usr/local/libexec/sbm/fzf.exe'
+ nsExec::ExecToLog '"$Cygwin\bin\chmod.exe" 755 /usr/local/bin/bm /usr/local/bin/bm-check /usr/local/bin/bm-commit /usr/local/bin/bm-html /usr/local/bin/bm-import /usr/local/bin/bm-migrate /usr/local/bin/bm-sync /usr/local/bin/bm-title /usr/local/bin/bm-watch /usr/local/bin/fzf /usr/local/libexec/sbm/fzf.exe'
Pop $0
${If} $0 != 0
Abort "chmod failed with exit status $0"
@@ -161,6 +162,7 @@ Section Uninstall
Delete "$Cygwin\usr\local\bin\bm-html"
Delete "$Cygwin\usr\local\bin\bm-import"
Delete "$Cygwin\usr\local\bin\bm-migrate"
+ Delete "$Cygwin\usr\local\bin\bm-sync"
Delete "$Cygwin\usr\local\bin\bm-title"
Delete "$Cygwin\usr\local\bin\bm-watch"
Delete "$Cygwin\usr\local\bin\fzf"
diff --git a/packaging/chocolatey/tools/chocolateyInstall.ps1 b/packaging/chocolatey/tools/chocolateyInstall.ps1
index 32a08b6..e6a62ca 100644
--- a/packaging/chocolatey/tools/chocolateyInstall.ps1
+++ b/packaging/chocolatey/tools/chocolateyInstall.ps1
@@ -6,7 +6,7 @@ $toolsDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
$stage = Join-Path $toolsDir 'staging'
# What Makefile's TOOLS installs.
-$scripts = @('bm', 'bm-migrate', 'bm-import', 'bm-check', 'bm-html', 'bm-title', 'bm-commit', 'bm-watch')
+$scripts = @('bm', 'bm-migrate', 'bm-import', 'bm-check', 'bm-html', 'bm-title', 'bm-commit', 'bm-watch', 'bm-sync')
# ---------------------------------------------------------------------------
# Fetch and unpack.
diff --git a/packaging/chocolatey/tools/chocolateyUninstall.ps1 b/packaging/chocolatey/tools/chocolateyUninstall.ps1
index e5788b0..a5f567e 100644
--- a/packaging/chocolatey/tools/chocolateyUninstall.ps1
+++ b/packaging/chocolatey/tools/chocolateyUninstall.ps1
@@ -2,7 +2,7 @@ $ErrorActionPreference = 'Stop'
$toolsDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
-$scripts = @('bm', 'bm-migrate', 'bm-import', 'bm-check', 'bm-html', 'bm-title', 'bm-commit', 'bm-watch')
+$scripts = @('bm', 'bm-migrate', 'bm-import', 'bm-check', 'bm-html', 'bm-title', 'bm-commit', 'bm-watch', 'bm-sync')
foreach ($s in $scripts) {
$cmdPath = Join-Path $toolsDir "$s.cmd"
diff --git a/test/run.sh b/test/run.sh
index 5408072..771c129 100755
--- a/test/run.sh
+++ b/test/run.sh
@@ -940,6 +940,100 @@ if command -v node >/dev/null 2>&1; then
eq 'bm-html: the script parses' "$?" '0'
fi
+# ---- bm-sync ----
+
+# A stand-in for curl that plays an sbm-sync server. The file of the server
+# is $srv/file. Lines in $srv/other come from another device, once. With
+# $srv/code, the server refuses with that status. With $srv/touch, bm adds a
+# bookmark while the request runs.
+srv="$work/srv"
+mkdir "$srv" "$work/syncnet"
+cat > "$work/syncnet/curl" <<'FAKE'
+#!/bin/sh
+printf '%s\n' "$*" >> "$SBM_TEST_SRV/argv"
+out= head= data= auth= url= password=
+while [ $# -gt 0 ]; do
+ case $1 in
+ -o) out=$2; shift ;;
+ -D) head=$2; shift ;;
+ -w|--max-time|-X) shift ;;
+ -H) case $2 in @*) auth=$(cat "${2#@}") ;; esac; shift ;;
+ --data-binary) data=${2#@}; shift ;;
+ --data-urlencode) case $2 in password@-) password=$(cat) ;; esac; shift ;;
+ -*) ;;
+ *) url=$1 ;;
+ esac
+ shift
+done
+answer () { printf '%s\n' "$2" > "$out"; printf '%s' "$1"; exit 0; }
+case $url in
+ */api/login)
+ [ "$password" = 'secret pass ' ] || answer 401 'wrong email or password'
+ answer 200 tok123 ;;
+ */api/logout)
+ echo logout >> "$SBM_TEST_SRV/log"
+ exit 0 ;;
+esac
+[ "$auth" = 'Authorization: Bearer tok123' ] || answer 401 'not signed in'
+[ ! -e "$SBM_TEST_SRV/code" ] || answer "$(cat "$SBM_TEST_SRV/code")" 'sync is paused'
+printf '%s\n' "${url#*base=}" >> "$SBM_TEST_SRV/bases"
+cat "$data" "$SBM_TEST_SRV/other" > "$SBM_TEST_SRV/file" 2>/dev/null
+rm -f "$SBM_TEST_SRV/other"
+version=v$(cksum < "$SBM_TEST_SRV/file" | cut -d' ' -f1)
+echo "$version" >> "$SBM_TEST_SRV/versions"
+cp "$SBM_TEST_SRV/file" "$out"
+printf 'HTTP/1.1 200 OK\r\nsbm-version: %s\r\n\r\n' "$version" > "$head"
+if [ -e "$SBM_TEST_SRV/touch" ]; then
+ rm -f "$SBM_TEST_SRV/touch"
+ printf 'https://c.example\tC\t\n' >> "$BOOKMARKS"
+fi
+printf 200
+FAKE
+chmod +x "$work/syncnet/curl"
+export SBM_TEST_SRV="$srv" SBM_SYNC_CONFIG="$work/sync.conf"
+bmsync () {
+ PATH="$work/syncnet:$PATH" ${SBM_SH:-sh} "$top/bm-sync" "$@"
+}
+
+reset
+printf 'https://a.example\tA\t\n' > "$BOOKMARKS"
+bmsync 2>"$work/err"
+eq 'bm-sync without an account says how to sign in' "$?:$(grep -c 'bm-sync login' "$work/err")" '1:1'
+
+printf 'me@example.org\nsecret pass \n' | bmsync login https://sync.example/ >/dev/null 2>&1
+eq 'bm-sync login keeps the token in a file that only you can read' \
+ "$(ls -l "$SBM_SYNC_CONFIG" | cut -c1-10) $(paste -sd ' ' - < "$SBM_SYNC_CONFIG")" \
+ '-rw------- server=https://sync.example token=tok123'
+eq 'the password and the token never go on the command line' \
+ "$(grep -c -e secret -e tok123 "$srv/argv")" '0'
+eq 'bm-sync login syncs at once, from no version' \
+ "$(cat "$srv/file"):$(sed -n 1p "$srv/bases")" "https://a.example${TAB}A${TAB}:"
+
+printf 'https://b.example\tB\t\n' > "$srv/other"
+bmsync -q
+eq 'bm-sync brings the bookmarks of other devices' \
+ "$(cut -f1 "$BOOKMARKS" | paste -sd ' ' -)" 'https://a.example https://b.example'
+eq 'bm-sync sends the version of the last sync' "$(sed -n 2p "$srv/bases")" "$(sed -n 1p "$srv/versions")"
+
+: > "$srv/touch"
+bmsync -q
+eq 'a bookmark added during a sync gets to the server too' \
+ "$(grep -c c.example "$srv/file") $(grep -c c.example "$BOOKMARKS")" '1 1'
+
+echo 402 > "$srv/code"
+cp "$BOOKMARKS" "$work/before"
+bmsync -q 2>"$work/err"
+eq 'bm-sync shows why the server refused, and keeps the file' \
+ "$?:$(cat "$work/err"):$(cmp -s "$work/before" "$BOOKMARKS" && echo same)" \
+ '1:bm-sync: sync is paused:same'
+rm -f "$srv/code"
+
+bmsync -q logout
+eq 'bm-sync logout forgets the token and signs out on the server' \
+ "$([ -e "$SBM_SYNC_CONFIG" ] || echo gone) $(cat "$srv/log")" 'gone logout'
+eq 'bm-sync leaves no lock behind' "$(ls -d "$BOOKMARKS.sync.lock" 2>/dev/null)" ''
+unset SBM_TEST_SRV SBM_SYNC_CONFIG
+
# ---- make install ----
if command -v make >/dev/null 2>&1; then