ReviewOS

also looking at this

stacks/uplink

fix(app): a settings.json this build cannot read no longer loops the app

#46
Merged glennmichael123 wants to merge fix/settings-fallbacks into main
4 files +130 -22
tests/unit/uplink/sqlite-store.test.tsmodified+62-1
Changes to tests/unit/uplink/sqlite-store.test.ts
@@ -6,7 +6,7 @@ import { tmpdir } from 'node:os'
66import { join } from 'node:path'
77import { loadConfig } from '../../../app/Uplink/config'
88import { MessagesDb } from '../../../app/Uplink/messages-db'
9import { cleanToken, readSettings, settingsEnv, tokenLooksValid, writeSettings } from '../../../app/Uplink/settings'
9import { cleanToken, coerceSettings, DEFAULT_SETTINGS, readSettings, settingsEnv, tokenLooksValid, writeSettings } from '../../../app/Uplink/settings'
1010import { migrate, RUN_COLUMNS, schemaVersion, SqliteStore, TABLES } from '../../../app/Uplink/sqlite-store'
1111import { Uplink } from '../../../app/Uplink/uplink'
1212import { FakeChatDb, FakeEngine, FakeSender, ME, settle } from './fixtures'
@@ -309,6 +309,67 @@ describe('settings', () => {
309309 expect(readSettings(path).engine).toBe('claude')
310310 })
311311
312 /**
313 * `readSettings` runs at the desktop agent's first statements, before the
314 * menubar exists, and launchd restarts a non-zero exit. So a file it cannot
315 * read is not an error it can report: it is an invisible restart loop. Only
316 * `engine` used to be checked, and an `allowed` that was not an array reached
317 * `settingsEnv` and threw on every launch.
318 */
319 it('falls back per field rather than throwing, whatever the file holds', () => {
320 const path = join(tempDir(), 'settings.json')
321 const broken: Array<[string, unknown]> = [
322 ['allowed', null],
323 ['allowed', 'me@example.com'],
324 ['engine', 'gemini'],
325 ['model', 42],
326 ['workdir', null],
327 ['workdir', ''],
328 ['openAtLogin', 'yes'],
329 ['paused', 1],
330 ]
331 for (const [key, value] of broken) {
332 Bun.write(path, JSON.stringify({ ...DEFAULT_SETTINGS, [key]: value }))
333 const settings = readSettings(path)
334 expect(settings[key as keyof typeof settings]).toEqual(DEFAULT_SETTINGS[key as keyof typeof DEFAULT_SETTINGS])
335 // The throw that used to take the app down, on the field that used to do it.
336 expect(() => settingsEnv(settings, {})).not.toThrow()
337 }
338 })
339
340 it('survives a file that is not an object at all', () => {
341 const path = join(tempDir(), 'settings.json')
342 for (const body of ['null', '[]', '"nope"', '5', '{ not json']) {
343 Bun.write(path, body)
344 expect(readSettings(path)).toEqual(DEFAULT_SETTINGS)
345 }
346 })
347
348 it('reads back exactly what it wrote', () => {
349 const path = join(tempDir(), 'settings.json')
350 const settings = { allowed: ['+15550001111'], engine: 'codex' as const, model: 'gpt-5-codex', workdir: '/tmp', openAtLogin: false, paused: true }
351 writeSettings(settings, path)
352 expect(readSettings(path)).toEqual(settings)
353 // And the default file too, whose `model` is null.
354 writeSettings(DEFAULT_SETTINGS, path)
355 expect(readSettings(path)).toEqual(DEFAULT_SETTINGS)
356 })
357
358 /**
359 * The popover sends the fields it changed. The same checks apply, but an
360 * absent or unusable one keeps what is set rather than reverting to a
361 * default, which is what makes it a partial update.
362 */
363 it('keeps the current value for a field the popover did not send', () => {
364 const current = { ...DEFAULT_SETTINGS, allowed: ['+15550001111'], paused: true }
365 expect(coerceSettings({ paused: false }, current)).toEqual({ ...current, paused: false })
366 expect(coerceSettings({ paused: 'no' }, current)).toEqual(current)
367 expect(coerceSettings({}, current)).toEqual(current)
368 expect(coerceSettings(undefined, current)).toEqual(current)
369 // Handles are normalized the way the popover's own path normalized them.
370 expect(coerceSettings({ allowed: [' me@example.com ', '', 5] }, current).allowed).toEqual(['me@example.com', '5'])
371 })
372
312373 it('cleans a token pasted across a line wrap', () => {
313374 const token = `sk-ant-oat01-${'a'.repeat(60)}`
314375 expect(cleanToken(`${token.slice(0, 40)}\n ${token.slice(40)}\n`)).toBe(token)