Closes #44. The twin of #9, on the file that is read first.
What was broken
readSettings checked one field. engine fell back, with a comment anticipating "a hand-edited or downgraded file", and everything else was merged raw. So an allowed that was not an array survived the read and reached settingsEnv, which calls .join(',') on it, three statements before the try that #9 put around the database.
settings.json -> readSettings (only `engine` checked)
-> settingsEnv TypeError: null is not an object
-> escapes startDesktopAgent, exit non-zero
-> KeepAlive SuccessfulExit=false -> start again -> loopNo menubar ever appears, so nothing says why. "allowed": null is what a downgrade, a hand edit, or a save interrupted halfway leaves behind, and the engine comment shows those were already expected.
What it does
One table of per-field tests, used by both directions:
- the read path falls back to the defaults, so
readSettingsalways returns usable settings - the popover's partial update falls back to what is already set, so it stays a patch
/api/settings accepts the same four fields it accepted before. The point is that the checks exist once: writing them twice is how the two drift, and the read path is the one that cannot afford to be wrong, because it runs before there is anywhere to report an error.
Measured against the repro from the issue:
settings.json | before | now |
|---|---|---|
{"allowed": null, ...} | TypeError, restart loop | starts, allowed=[] |
{"allowed": "me@example.com"} | TypeError, restart loop | starts, allowed=[] |
null | TypeError, restart loop | starts, allowed=[] |
"not even an object" | TypeError, restart loop | starts, allowed=[] |
One thing beyond the issue
The loop was invisible for a second reason, and it is the reason this class of bug is expensive rather than merely annoying. The LaunchAgent sets no StandardErrorPath, and the launcher only redirects what goes through console, so an uncaught throw at startup went nowhere at all. A failed start now writes to ~/Library/Logs/Uplink.log and exits the same way, so KeepAlive behaves exactly as before and there is finally something to read.
Verification
- 122 unit tests pass, 4 new here, including one that walks every field with a wrong type and asserts both that the fallback holds and that
settingsEnvdoes not throw - a round-trip test, so a file the app itself wrote always reads back
./buddy lintand./buddy typecheckcleanbun build --compilestill produces a binary
🤖 Generated with Claude Code