Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ SSH-to-self is a no-op on an already-locked Mac — do not special-case it.
## Key external inputs

- **Synergy client list:** `~/Library/Preferences/Synergy/synergy.conf`. This is the source of truth for which hosts to lock — do not maintain a parallel client list.
- **Synergy display names:** `~/Library/Preferences/Synergy/db.json` (sibling of the conf; overridable via `LOCK_SYNC_DB`). Synergy sanitizes screen names before writing the conf (e.g. it drops hyphens: `arich-mac` becomes the screen `arichmac-3181d4b4`), so the conf alone cannot reproduce a client's real hostname. `list-clients` recovers the true name from db.json's `computers[].name`, joined on the last 8 hex of `id` matching the conf's instance suffix. Read via `jq` (a soft dependency — macOS ships `/usr/bin/jq`; Homebrew paths are probed as a fallback). When jq or db.json is unavailable, it degrades to stripping the suffix. Use `name`, not `hostname` — the two diverge (`hostname` can be the default `Andrews-Mac-mini.local`).
- **Per-client username overrides:** `~/.config/lock-sync/config` (overridable via `LOCK_SYNC_CONFIG`). Plain text, whitespace-separated `<host> <user>`, `#` line comments. Unlisted hosts use `$USER`. Absent or empty file is a valid state.

## Runtime
Expand All @@ -45,7 +46,7 @@ All slices shipped. Install with `bin/install`; uninstall with `bin/uninstall`.
- `shellcheck -S info bin/*` — lint shell scripts (matches CI and the global bash standard).
- `bin/install` — symlink binaries into `~/.local/bin/`, write the LaunchAgent plist, bootstrap into launchd. Idempotent.
- `bin/uninstall` — bootout the agent, remove plist and symlinks. Preserves the log file. Idempotent.
- `bin/list-clients [path]` — slice (a). Parse Synergy conf, emit `<short>.local` hostnames. No arg → reads `~/Library/Preferences/Synergy/synergy.conf`.
- `bin/list-clients [path]` — slice (a). Parse Synergy conf, emit `<host>.local` hostnames, resolving real display names via the sibling `db.json` when available (see Key external inputs). No arg → reads `~/Library/Preferences/Synergy/synergy.conf`.
- `bin/lock-watcher` — slices (b)+(c1). Subscribe to `com.apple.sessionagent.screenIsLocked`; on each event emit `<ISO-8601> locked` and invoke `bin/lock-fanout`. Blocks until signaled.
- `bin/lock-fanout` — slice (c1). Reads hosts from `list-clients`, applies per-host overrides from `~/.config/lock-sync/config`, SSHes `pmset displaysleepnow` per host, emits one `<ISO-8601> client=<host> user=<user> ssh_exit=<rc>` line per host.

Expand Down
73 changes: 68 additions & 5 deletions bin/list-clients
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,84 @@ if [[ ! -f "$conf" ]] || [[ ! -r "$conf" ]]; then
exit 1
fi

awk '
# Synergy sanitizes screen names (e.g. drops hyphens) before writing them to
# synergy.conf, so the conf alone cannot reproduce a client's real hostname.
# The sibling db.json holds the true display name, joinable on the last 8 hex
# of its `id` field. Build a suffix -> name map from it when jq is available;
# otherwise fall back to stripping the Synergy instance suffix.
db="${LOCK_SYNC_DB:-$(dirname "$conf")/db.json}"

# Locate jq. launchd runs this agent with a minimal PATH that excludes
# Homebrew, so probe the common absolute locations too (cf. lock-fanout's
# hardcoded /usr/bin/ssh for the same reason).
jq_bin=""
if command -v jq >/dev/null 2>&1; then
jq_bin="$(command -v jq)"
else
for cand in /opt/homebrew/bin/jq /usr/local/bin/jq; do
if [[ -x "$cand" ]]; then
jq_bin="$cand"
break
fi
done
fi

# suffix<TAB>name lines. Empty when jq or db.json is unavailable or the JSON is
# malformed, which sends every screen down the suffix-strip fallback path.
db_map=""
if [[ -n "$jq_bin" && -r "$db" ]]; then
db_map="$("$jq_bin" -r '.data.computers[]? | select(.id and .name) | "\(.id[-8:])\t\(.name)"' "$db" 2>/dev/null || true)"
fi

# Turn a raw Synergy screen label into an SSH-ready <host>.local name.
resolve() {
local label="$1"
local suffix="" base="$label" name=""

# Match a trailing -<8 hex> Synergy instance suffix using a glob with
# explicit character classes. Avoids [[ =~ ]] with a {8} interval, whose
# support varies across bash builds, and BASH_REMATCH — keeping this runnable
# under the system bash 3.2 that launchd's minimal PATH selects.
local hex='[0-9a-fA-F]'
if [[ "$label" == *-$hex$hex$hex$hex$hex$hex$hex$hex ]]; then
suffix="${label##*-}"
base="${label%-*}"
fi

if [[ -n "$suffix" && -n "$db_map" ]]; then
name="$(printf '%s\n' "$db_map" \
| awk -F'\t' -v s="$suffix" 'tolower($1) == tolower(s) { print $2; exit }')"
fi

# No db match (or no suffix): keep the suffix-stripped label, as before.
[[ -n "$name" ]] || name="$base"
name="$(printf '%s' "$name" | tr '[:upper:]' '[:lower:]')"
[[ "$name" == *.local ]] || name="$name.local"
printf '%s\n' "$name"
}

# awk extracts raw screen labels (still guarding section boundaries and the
# unterminated-section error); bash resolves each against db.json. Capturing to
# a variable lets awk's exit 2 propagate via set -e before anything is printed.
labels="$(awk '
/^section: screens[[:space:]]*$/ { in_screens = 1; next }
in_screens && /^end[[:space:]]*$/ { in_screens = 0; next }
in_screens && /^\t[^ \t].*:[[:space:]]*$/ {
name = $0
sub(/^\t/, "", name)
sub(/:[[:space:]]*$/, "", name)
sub(/-[0-9a-fA-F]{8}$/, "", name)
names[++n] = name ".local"
print name
}
END {
if (in_screens) {
print "list-clients: section: screens not closed" > "/dev/stderr"
exit 2
}
for (i = 1; i <= n; i++) print names[i]
}
' "$conf"
' "$conf")"

[[ -n "$labels" ]] || exit 0
while IFS= read -r label; do
[[ -n "$label" ]] || continue
resolve "$label"
done <<<"$labels"
155 changes: 155 additions & 0 deletions docs/plans/2026-07-14-db-json-hostname-resolution-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
# `list-clients` db.json hostname resolution

Date: 2026-07-14
Status: approved, ready to implement

## Purpose

Fix a client that fails to lock because its derived hostname is wrong.

A Mac whose real `.local` name is `arich-mac.local` shows up in `synergy.conf`
as the screen `arichmac-3181d4b4` — Synergy dropped the internal hyphen when it
generated the screen name. `list-clients` strips the `-3181d4b4` instance
suffix and emits `arichmac.local`, which does not resolve. SSH to it fails, so
the machine never locks.

## Root cause (what is NOT the bug)

The suffix-strip regex `-[0-9a-fA-F]{8}$` in `bin/list-clients` is **not**
responsible. It only removes a trailing hyphen plus exactly eight hex
characters. A bare `arich-mac` does not match it (`-mac` is three characters
and `m` is not hex), so the regex cannot turn `arich-mac` into `arichmac`.

The hyphen is gone before `list-clients` ever runs. Synergy sanitizes the
host's name into a screen identifier and writes the hyphenless form to
`synergy.conf`. The information needed to reverse that — the real display name
— is not in `synergy.conf`. It lives in a sibling file, `db.json`:

```json
{
"id": "c62aa4f29f078b74d81a30b352495a06a68b16f85a98ec7247c51f033181d4b4",
"name": "arich-mac",
"hostname": "arich-mac"
}
```

The last eight hex characters of `id` (`3181d4b4`) equal the suffix Synergy
appends to the screen name (`arichmac-3181d4b4`). That is the join key.

### Why `name`, not `hostname`

`db.json` carries both `name` (the user-facing display name) and `hostname`
(macOS's default network name). They diverge. On the current machine:

| conf suffix | db `name` | db `hostname` | today's output |
| ----------- | --------- | ---------------------- | -------------- |
| 995cd2f5 | asiago | ASIAGO.local | asiago.local |
| 7681d488 | MIMOLETTE | Andrews-Mac-mini.local | mimolette.local|
| cb476f5e | TILSIT | TILSIT | tilsit.local |
| 3181d4b4 | arich-mac | arich-mac | arichmac.local |

`mimolette` is a working client today at `mimolette.local`. Its `hostname`
field is `Andrews-Mac-mini.local` — resolving by `hostname` would break it.
Resolving by `name` reproduces every current output (case aside, and `.local`
resolution is case-insensitive) and additionally fixes `arich-mac`.

## Contract changes

`bin/list-clients` gains one input and no new output shape.

- **New input:** `db.json`, located at `${LOCK_SYNC_DB:-<dir of conf>/db.json}`.
The `LOCK_SYNC_DB` override mirrors the existing `LOCK_SYNC_CONFIG` pattern
and keeps tests isolated.
- **Output:** unchanged — one `<host>.local` per line, in `section: screens`
order. Exit codes unchanged (`0` ok, `1` conf unreadable, `2` conf malformed).

`synergy.conf` remains the source of truth for *which* hosts to lock. `db.json`
only refines *what each host is named*.

## Resolution algorithm

1. Build a suffix→name map, once, only when `jq` is on PATH **and** `db.json`
is readable:

```bash
jq -r '.data.computers[]? | select(.id and .name) | "\(.id[-8:])\t\(.name)"' "$db"
```

(Machine entries live at `.data.computers[]` — `.data` is an object of many
keys, not the machine array.)

Load into `declare -A dbname`. If `jq` is absent, `db.json` is missing, or
`jq` exits non-zero (malformed JSON), the map stays empty.

2. The awk stage keeps its section parsing (including the "screens not closed"
→ exit 2 guard) but emits the **raw** screen label, e.g. `arichmac-3181d4b4`.

3. Bash resolves each label:
- If it ends in `-<8 hex>` and that suffix is a key in `dbname`, use
`dbname[suffix]`, lowercased.
- Otherwise strip a trailing `-<8 hex>` (today's heuristic) and keep the rest.
- Append `.local` unless the name already ends in `.local`.

Result: `arichmac-3181d4b4` → `arich-mac` → `arich-mac.local`. When no `db.json`
sits beside the conf, every screen takes the heuristic path — identical to
today.

## Why this shape

- **Fallback, never regression:** the resolver degrades to the current
behavior when jq or `db.json` is unavailable, so machines without jq (or an
older Synergy without `db.json`) are no worse off than today.
- **`jq` over hand-rolled JSON parsing:** `db.json` is minified; extracting
fields with awk/grep is fragile against reordering. jq is already installed
on the server Mac; `bin/install` should note it as a soft dependency.
- **Map built once, not per screen:** one `jq` invocation, then O(1) lookups.
- **Suffix as key:** the eight-hex tail is a truncated sha256; a collision is
astronomically unlikely. First match wins; not worth guarding.

## Deliberately NOT handled (YAGNI)

- Resolving by `hostname` (breaks `mimolette`; see above).
- Mocking `jq` absence in tests — gated on `command -v jq`, so its absence is
just the empty-map branch. Documented, not simulated.
- Rewriting hostnames via the per-client override config — that file maps
`<host> <user>` and is the wrong layer for name repair.

## Tests

`tests/list-clients.bats`, same tmpdir pattern (write `conf` + sibling
`db.json`, run, assert).

Happy paths:

1. `db.json` maps a hyphenless screen back to its display name
(`arichmac-3181d4b4` → `arich-mac.local`). Regression test for this bug.
2. db `name` wins over the stripped conf label.
3. Case normalization (`MIMOLETTE` → `mimolette.local`).
4. Multiple screens, mixed — each resolved from db.

Fallback / anti-regression:

1. Suffix present in conf but absent from `db.json` → heuristic for that screen,
others still resolved from db.
2. `db.json` present but malformed (jq errors) → whole run falls back, exit 0.
3. No `db.json` sibling → heuristic. Already covered by the existing fixture
tests, which must stay green **unmodified** — the real proof of
non-regression.

## CI

The new tests need `jq`. GitHub-hosted `ubuntu-latest` ships jq, so no workflow
change is required; confirm during verification. If a future runner lacks it,
the tests that assert db resolution would fail (not fall back silently), which
is the desired signal.

## Implementation order (TDD)

1. Add the new bats tests.
2. Run `bats tests/` — confirm the new tests fail, existing ones pass.
3. Implement the resolver in `bin/list-clients`.
4. Run `bats tests/` — all green.
5. `shellcheck -S info bin/*` clean.
6. Run against the real conf — confirm `arich-mac.local`.
7. Update `CLAUDE.md` (db.json is now a key external input; note jq soft dep).
8. Commit, push, PR.
Loading
Loading