Recently Written · git

sbm

dmenu bookmarks. Instantly fuzzy search thousands of bookmarks and plumb them. LOOKING FOR SUCKLESS SOFTWARE EDITION? GO TO sbm-suckless INSTEAD

git clone https://github.com/equwal/sbm

Log | Files | Refs


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