Recently Written · git

sbm-suckless

sbm before the rewrite: bookmarks in a plain file, picked with dmenu. Suckless style.

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

Log | Files | Refs


commit 62d340ecf4fc4e01a4fbe229f182681683d318c8
Spenser Truex <truex@equwal.com>
2026-09-21 05:39:56 -0700

bm-sync: keep the bookmark file the same on all devices

bm-sync sends $BOOKMARKS to an sbm-sync server with the version of the
last sync. The server merges the changes of each device and sends back
the result. bm-sync then writes the result to the file. Thus a bookmark
that you add or delete on one device goes to 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, once on each device
    bm-sync          # sync now

bm-sync is POSIX sh and needs curl. It uses the design of bm-sync in
equwal/sbm. curl gets the password through stdin and the token through
a file, so neither shows in ps. A lock directory keeps one sync at a
time. When bm changes the file during a request, bm-sync sends the file
again. bm-sync writes the file through cat, so a symlink stays a
symlink.

Two guards prevent data loss. An empty or missing file goes to the
server without a version, so the server deletes nothing. Before
bm-sync writes the file, it removes the version. If the write fails,
the next sync then deletes nothing.

bm add runs bm-sync -q in the background when bm-sync is on the PATH.
The app and the add-on write lines as URL<tab>description<tab>tags.
bm copy now takes the first field, which ends at a space or a tab.
Thus it copies the URL of lines in both formats.

make install, uninstall and dist include bm-sync. make install now
makes ${PREFIX}/bin. Before, cp made a file with the name bin when
the directory did not exist. make check runs test.sh. In test.sh,
stand-ins for curl, dmenu and xclip play the server, the menu and the
clipboard.

 Makefile |  15 +++--
 README   |  27 ++++++++
 bm       |   4 +-
 bm-sync  | 184 ++++++++++++++++++++++++++++++++++++++++++++++++++++
 test.sh  | 220 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 5 files changed, 443 insertions(+), 7 deletions(-)
diff --git a/Makefile b/Makefile
index 358edd7..fdfb16c 100644
--- a/Makefile
+++ b/Makefile
@@ -1,12 +1,15 @@
 include config.mk
 
 all:
-	@echo Nothing to build: use make install, make uninstall, or make dist.
+	@echo Nothing to build: use make install, make uninstall, make check, or make dist.
+
+check:
+	@sh test.sh
 
 dist:
 	@echo creating dist tarball
 	@mkdir -p sbm-${VERSION}-temp
-	@cp -R Makefile config.mk bm  sbm-${VERSION}-temp
+	@cp -R Makefile config.mk bm bm-sync test.sh sbm-${VERSION}-temp
 	@mv sbm-${VERSION}-temp sbm-${VERSION}
 	@tar -cf sbm-${VERSION}.tar sbm-${VERSION}
 	@gzip sbm-${VERSION}.tar
@@ -14,10 +17,10 @@ dist:
 
 install:
 	@echo installing scripts to ${DESTDIR}${PREFIX}/bin
-	@mkdir -p ${DESTDIR}${PREFIX}
-	@cp bm ${DESTDIR}${PREFIX}/bin
-	@chmod 755 ${DESTDIR}${PREFIX}/bin/bm
+	@mkdir -p ${DESTDIR}${PREFIX}/bin
+	@cp bm bm-sync ${DESTDIR}${PREFIX}/bin
+	@chmod 755 ${DESTDIR}${PREFIX}/bin/bm ${DESTDIR}${PREFIX}/bin/bm-sync
 
 uninstall:
 	@echo removing scripts
-	rm -f ${DESTDIR}${PREFIX}/bin/bm
+	rm -f ${DESTDIR}${PREFIX}/bin/bm ${DESTDIR}${PREFIX}/bin/bm-sync
diff --git a/README b/README
index 5ab559f..83704b5 100644
--- a/README
+++ b/README
@@ -8,6 +8,7 @@ And interact with this file using the dmenu script.
 
 == Scripts ==
 bm      search & copy, add, or edit a bookmark. Incomplete.
+bm-sync sync the bookmark file with your other devices.
 bmks    the old version from somewhere. Will be removed soon.
 == Install ==
 Requirements: xclip, dmenu, make
@@ -36,6 +37,32 @@ URL<tab>description\n"
 Some ideas taken from Karl Voit:
 https://karl-voit.at/2022/01/29/How-to-Use-Tags/
 
+== Sync ==
+bm-sync keeps the bookmark file the same on all your devices. It works
+with bm on other computers, the sbm app for Android and the sbm add-on for
+Firefox and Chrome. It needs curl. Like bm, it uses the file in $BOOKMARKS.
+
+    bm-sync login        # once on each device: email and password
+    bm-sync              # sync now
+    bm-sync status       # the server and the time of the last sync
+    bm-sync logout       # sign out on this device
+
+After bm adds a bookmark, bm runs bm-sync in the background. The server
+merges the changes of each device. When you add or delete a bookmark on
+one device, sync adds or deletes it on the others. bm-sync keeps the
+sign-in token in ~/.config/sbm/sync. Only you can read this file.
+
+By default, bm-sync uses the server https://sbm.subread.space. Create an
+account there first. The server is free software
+(https://github.com/equwal/sbm-sync), so you can run your own:
+
+    bm-sync login https://sbm.example.org
+
+bm writes lines as "URL description | tags". The app and the add-on write
+lines as "URL<tab>description<tab>tags", and bm-sync puts these lines in
+your file. The file can hold lines in both formats. bm copies the URL of
+both.
+
 == Tip ==
 The bookmarks can be converted from the web browser formats using
 netscape-bookmark-converter at https://github.com/jhh/netscape-bookmark-converter
diff --git a/bm b/bm
index f0fb19d..a8c9d8f 100755
--- a/bm
+++ b/bm
@@ -17,7 +17,7 @@ help () {
 }
 
 copy () {
-    sort -t2 "$BOOKMARKS" | dmenu -i -l $DMENULINES -p "bookmark:" | cut -f 1 -d ' ' | xclip -i -selection clipboard
+    sort -t2 "$BOOKMARKS" | dmenu -i -l $DMENULINES -p "bookmark:" | awk '{ print $1 }' | xclip -i -selection clipboard
 }
 
 add () {
@@ -35,6 +35,8 @@ add () {
            | sed 's/ $//')"
 
     printf "%s %s | %s\\n" "$url" "$desc" "$tags" >> "$BOOKMARKS"
+    # bm-sync, if installed, sends the bookmark to your other devices.
+    if command -v bm-sync >/dev/null 2>&1; then bm-sync -q >/dev/null 2>&1 & fi
 }
 
 edit () {
diff --git a/bm-sync b/bm-sync
new file mode 100755
index 0000000..8839d35
--- /dev/null
+++ b/bm-sync
@@ -0,0 +1,184 @@
+#!/bin/sh
+
+# bm-sync: keep $BOOKMARKS the same on all your devices through an sbm-sync
+# server (https://github.com/equwal/sbm-sync). bm runs it after each add.
+#
+# bm-sync sends the file and the version of the last sync. The server merges
+# the changes into its copy. Then it sends back the result and its version.
+
+CONFIG=${SBM_SYNC_CONFIG:-${XDG_CONFIG_HOME:-$HOME/.config}/sbm/sync}
+SERVER=https://sbm.subread.space
+STATE=$BOOKMARKS.sync   # 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, bm-sync syncs $BOOKMARKS.
+  login   sign in and sync (default server: https://sbm.subread.space)
+  logout  sign out on this device
+  status  show the server and the time of the last sync
+  -q      print only errors
+EOF
+}
+
+die () {
+    printf 'bm-sync: %s\n' "$*" >&2
+    exit 1
+}
+
+say () {
+    [ -n "$quiet" ] || printf 'bm-sync: %s\n' "$*"
+}
+
+# setting <name>: the value of <name> in the config file.
+setting () {
+    sed -n "s/^$1=//p" "$CONFIG" 2>/dev/null | head -n 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
+
+    # curl gets the password through stdin and the token through a file.
+    # Other users can see command lines 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")"
+
+    # The config file holds the token, so only you can read it.
+    mkdir -p "$(dirname "$CONFIG")" && : > "$CONFIG" && chmod 600 "$CONFIG" || exit 1
+    printf 'server=%s\ntoken=%s\n' "$server" "$(tr -d '\r\n' < "$work/out")" > "$CONFIG"
+    rm -f "$STATE"
+    say "signed in to $server"
+    sync
+}
+
+logout () {
+    if [ -e "$CONFIG" ]; then
+        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 () {
+    [ -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 stops. The
+    # first bm-sync then syncs again.
+    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"
+            say "bm-sync $pid syncs now, and then syncs again"
+            exit 0
+        fi
+        rm -rf "$LOCK"   # a killed bm-sync left the lock
+        mkdir "$LOCK" 2>/dev/null || die "cannot lock $LOCK"
+    fi
+    locked=1
+    echo $$ > "$LOCK/pid"
+
+    printf 'Authorization: Bearer %s\n' "$token" > "$work/auth"
+    : >> "$BOOKMARKS" || exit 1   # a new device has no file yet
+    tries=0
+    while :; do
+        rm -f "$LOCK/again"
+        cat "$BOOKMARKS" > "$work/sent" || die "cannot read $BOOKMARKS"
+        # An empty file with a version tells the server to delete all
+        # bookmarks. Without a version, the server deletes nothing.
+        [ -s "$work/sent" ] || rm -f "$STATE"
+        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 during the request: send the new file.
+        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
+            # Forget the version first. If the write fails, the next sync
+            # then deletes nothing on the server.
+            rm -f "$STATE"
+            # Write through cat, not mv: a symlink stays a symlink.
+            cat "$work/out" > "$BOOKMARKS" || die "cannot write $BOOKMARKS"
+        fi
+        printf '%s\n' "$version" > "$STATE"
+        [ -e "$LOCK/again" ] || break
+    done
+    say "in sync: $(grep -c . "$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
+
+[ -n "$BOOKMARKS" ] || die 'BOOKMARKS is not set: set it to your bookmark file, as the README says'
+command -v curl >/dev/null 2>&1 || die 'curl is not installed'
+# mkdir fails when the directory exists. Thus bm-sync does not use a
+# directory that another user made.
+work=${TMPDIR:-/tmp}/bm-sync.$$
+mkdir -m 700 "$work" || die "cannot create $work"
+locked=
+trap cleanup EXIT
+trap 'exit 1' HUP INT TERM
+
+case ${1:-sync} in
+    login)  shift; login "$@" ;;
+    logout) logout ;;
+    status) status ;;
+    sync)   sync ;;
+    *)      usage >&2; exit 2 ;;
+esac
diff --git a/test.sh b/test.sh
new file mode 100755
index 0000000..9c255c4
--- /dev/null
+++ b/test.sh
@@ -0,0 +1,220 @@
+#!/bin/sh
+
+# Tests for bm and bm-sync: sh test.sh, or dash test.sh. The shell that runs
+# this file also runs bm and bm-sync. Stand-ins for curl, dmenu, xclip and
+# bm-sync play the server, the menu, the clipboard and the background sync.
+# Thus the tests do not use the network or your files.
+
+here=$(cd "$(dirname "$0")" && pwd)
+# Where /proc exists, it gives the name of this shell.
+sh=$(tr '\000' '\n' 2>/dev/null < /proc/$$/cmdline | sed -n 1p)
+sh=${sh:-sh}
+t=${TMPDIR:-/tmp}/sbm-test.$$
+mkdir -m 700 "$t" || exit 1
+trap 'rm -rf "$t"' EXIT
+trap 'exit 1' HUP INT TERM
+mkdir "$t/bin" "$t/srv"
+srv=$t/srv
+PATH=$t/bin:$PATH
+export T="$t" BOOKMARKS="$t/bookmarks" USERTAGS="$t/usertags" SBM_SYNC_CONFIG="$t/sync"
+pass=0
+fail=0
+
+# eq <name> <result> <expected>
+eq () {
+    if [ "$2" = "$3" ]; then
+        pass=$((pass + 1))
+        printf 'ok   %s\n' "$1"
+    else
+        fail=$((fail + 1))
+        printf 'FAIL %s: expected [%s], got [%s]\n' "$1" "$3" "$2"
+    fi
+}
+
+# dmenu: each call takes the next line of $T/answers. It prints the first
+# menu line that holds the answer. Else it prints the answer, as typed text.
+cat > "$t/bin/dmenu" <<'EOF'
+#!/bin/sh
+a=$(sed -n 1p "$T/answers")
+sed 1d "$T/answers" > "$T/answers.new"
+mv "$T/answers.new" "$T/answers"
+awk -v a="$a" 'index($0, a) && !n { print; n = 1 } END { if (!n) print a }'
+EOF
+# xclip: -o prints the clipboard, -i fills it.
+cat > "$t/bin/xclip" <<'EOF'
+#!/bin/sh
+if [ "$1" = -o ]; then cat "$T/clip" 2>/dev/null; else cat > "$T/clip"; fi
+EOF
+# bm-sync: records its arguments. The tests of bm-sync use the real one.
+cat > "$t/bin/bm-sync" <<'EOF'
+#!/bin/sh
+printf '%s\n' "$*" >> "$T/bm-sync.log"
+EOF
+# curl: plays an sbm-sync server that keeps its file in $T/srv/file. Lines
+# in $T/srv/other come from another device, once. With $T/srv/code, the
+# server refuses with that status. With $T/srv/touch, bm adds a line during
+# the request. With $T/srv/broken, bm-sync cannot read the answer.
+cat > "$t/bin/curl" <<'EOF'
+#!/bin/sh
+srv=$T/srv
+printf '%s\n' "$*" >> "$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 >> "$srv/log"
+        exit 0 ;;
+esac
+[ "$auth" = 'Authorization: Bearer tok123' ] || answer 401 'not signed in'
+[ ! -e "$srv/code" ] || answer "$(cat "$srv/code")" 'sync is paused'
+printf '%s\n' "${url#*base=}" >> "$srv/bases"
+cat "$data" "$srv/other" > "$srv/file" 2>/dev/null
+rm -f "$srv/other"
+version=v$(cksum < "$srv/file" | cut -d ' ' -f 1)
+echo "$version" >> "$srv/versions"
+cp "$srv/file" "$out"
+[ ! -e "$srv/broken" ] || { rm -f "$out"; mkdir "$out"; }
+printf 'HTTP/1.1 200 OK\r\nsbm-version: %s\r\n\r\n' "$version" > "$head"
+if [ -e "$srv/touch" ]; then
+    rm -f "$srv/touch"
+    echo 'https://c.example C | ' >> "$BOOKMARKS"
+fi
+printf 200
+EOF
+chmod +x "$t/bin/dmenu" "$t/bin/xclip" "$t/bin/bm-sync" "$t/bin/curl"
+echo "shell: $sh"
+
+# ---- bm ----
+
+echo 'code  | code repos, docs' > "$USERTAGS"
+printf 'https://old.example Old site | code\nhttps://new.example\tNew site\tcode\n' > "$BOOKMARKS"
+echo old.example > "$t/answers"
+$sh "$here/bm" -c
+eq 'copy takes the URL of a line of bm' "$(cat "$t/clip")" https://old.example
+echo new.example > "$t/answers"
+$sh "$here/bm" -c
+eq 'copy takes the URL of a line of the app' "$(cat "$t/clip")" https://new.example
+
+: > "$BOOKMARKS"
+printf '%s\n' https://add.example 'Add site' code done > "$t/answers"
+$sh "$here/bm" -a
+eq 'add writes the bookmark' "$(cut -d ' ' -f 1 "$BOOKMARKS")" https://add.example
+# bm runs bm-sync in the background: wait for it.
+i=0
+while [ ! -s "$t/bm-sync.log" ] && [ $i -lt 10 ]; do
+    sleep 1
+    i=$((i + 1))
+done
+eq 'add runs bm-sync -q' "$(cat "$t/bm-sync.log" 2>/dev/null)" -q
+
+# ---- bm-sync ----
+
+bs () {
+    $sh "$here/bm-sync" "$@"
+}
+
+(unset BOOKMARKS; bs) 2>"$t/err"
+eq 'without BOOKMARKS, bm-sync fails and says so' "$?:$(grep -c BOOKMARKS "$t/err")" 1:1
+
+echo 'https://a.example A | code' > "$BOOKMARKS"
+bs 2>"$t/err"
+eq 'without an account, bm-sync fails and says how to sign in' \
+    "$?:$(cat "$t/err")" '1:bm-sync: not signed in: run bm-sync login'
+
+: > "$SBM_SYNC_CONFIG"
+chmod 644 "$SBM_SYNC_CONFIG"
+printf 'me@example.org\nsecret pass \n' | bs login https://sync.example/ >/dev/null 2>&1
+eq 'login keeps the server and the token in a file that only you can read' \
+    "$(ls -l "$SBM_SYNC_CONFIG" | cut -c 1-10) $(tr '\n' ' ' < "$SBM_SYNC_CONFIG")" \
+    '-rw------- server=https://sync.example token=tok123 '
+eq 'the password and the token are not in the arguments of curl' \
+    "$(grep -c -e secret -e tok123 "$srv/argv")" 0
+eq 'login syncs at once, without a version' \
+    "$(cat "$srv/file")|$(cat "$srv/bases")" 'https://a.example A | code|'
+
+printf 'https://b.example\tB\torg\n' > "$srv/other"
+bs -q
+eq 'sync writes the merged file' \
+    "$(cat "$BOOKMARKS")" "$(printf 'https://a.example A | code\nhttps://b.example\tB\torg')"
+eq 'sync keeps the version' "$(cat "$BOOKMARKS.sync")" "$(sed -n '$p' "$srv/versions")"
+v=$(cat "$BOOKMARKS.sync")
+bs -q
+eq 'the next sync sends that version' "$(sed -n '$p' "$srv/bases")" "$v"
+
+: > "$srv/touch"
+bs -q
+eq 'a bookmark that bm adds during a sync goes to the server too' \
+    "$(grep -c c.example "$srv/file") $(grep -c c.example "$BOOKMARKS")" '1 1'
+
+echo 402 > "$srv/code"
+cp "$BOOKMARKS" "$t/file.before"
+cp "$BOOKMARKS.sync" "$t/version.before"
+bs -q 2>"$t/err"
+eq 'on 402, bm-sync fails, says why, and keeps the file and the version' \
+    "$?:$(cat "$t/err"):$(cmp -s "$t/file.before" "$BOOKMARKS" &&
+        cmp -s "$t/version.before" "$BOOKMARKS.sync" && echo same)" \
+    '1:bm-sync: sync is paused:same'
+rm -f "$srv/code"
+
+rm -f "$BOOKMARKS"
+bs -q
+eq 'bm-sync makes a missing file and sends it without a version' \
+    "$([ -f "$BOOKMARKS" ] && echo made):$(sed -n '$p' "$srv/bases")" 'made:'
+
+echo 'https://d.example D | ' >> "$BOOKMARKS"
+: > "$srv/broken"
+bs -q 2>/dev/null
+eq 'after a failed write, bm-sync has no version, so the next sync deletes nothing' \
+    "$?:$([ -e "$BOOKMARKS.sync" ] || echo none)" '1:none'
+rm -f "$srv/broken"
+
+mkdir "$BOOKMARKS.sync.lock"
+echo $$ > "$BOOKMARKS.sync.lock/pid"
+n=$(grep -c '' "$srv/bases")
+bs -q
+eq 'while a sync runs, bm-sync leaves a note for it and sends nothing' \
+    "$?:$([ -e "$BOOKMARKS.sync.lock/again" ] && echo note):$(grep -c '' "$srv/bases")" \
+    "0:note:$n"
+true &
+wait $!
+echo $! > "$BOOKMARKS.sync.lock/pid"
+bs -q
+eq 'bm-sync takes a lock that a killed bm-sync left' \
+    "$?:$(grep -c '' "$srv/bases")" "0:$((n + 1))"
+
+eq 'status shows the server' "$(bs status | sed -n 1p)" 'server: https://sync.example'
+
+bs -q logout
+eq 'logout removes the config and signs out on the server' \
+    "$([ -e "$SBM_SYNC_CONFIG" ] || echo gone) $(cat "$srv/log")" 'gone logout'
+eq 'bm-sync leaves no lock' "$(ls -d "$BOOKMARKS.sync.lock" 2>/dev/null)" ''
+
+# ---- make ----
+
+if command -v make >/dev/null 2>&1; then
+    (cd "$here" && make -s install DESTDIR="$t/dest" PREFIX=/usr) >/dev/null
+    eq 'make install installs bm and bm-sync' \
+        "$(cd "$t/dest/usr/bin" && for f in *; do [ -x "$f" ] && printf '%s ' "$f"; done)" \
+        'bm bm-sync '
+    (cd "$here" && make -s uninstall DESTDIR="$t/dest" PREFIX=/usr) >/dev/null
+    eq 'make uninstall removes them' "$(ls "$t/dest/usr/bin")" ''
+fi
+
+echo "$pass passed, $fail failed"
+[ "$fail" -eq 0 ]