ReviewOS

also looking at this

stacks/uplink

feat(config): move Uplink's settings into config/uplink.ts

#42
Merged glennmichael123 wants to merge feat/config-in-config-dir into main
5 files +309 -71
app/Uplink/config.tsmodified+61-71
Changes to app/Uplink/config.ts
@@ -1,91 +1,83 @@
11import type { CodexPermission } from './codex-engine'
22import type { EngineId } from './engine'
3import type { UplinkConfig } from './types'
34import { homedir } from 'node:os'
45import { join } from 'node:path'
56import process from 'node:process'
7import { applyEnvVarsToConfig } from 'bunfig'
8import defaults from '../../config/uplink'
69import { CODEX_PERMISSIONS } from './codex-engine'
710import { isEngineId } from './engine'
811import { parseHandleList } from './handles'
9import { DEFAULT_MESSAGES_DB } from './messages-db'
1012
11export interface UplinkConfig {
12 /** Handles allowed to command Uplink. Empty means "my own handles only". */
13 allowed: string[]
14 messagesDb: string
15 pollMs: number
16 /** On start, ignore commands older than this, so a Mac waking up does not replay a day of texts. */
17 catchUpMs: number
18 replyPrefix: string
19 /** Characters per text; longer replies are split. */
20 maxChars: number
21 /** Parts sent at once; the rest waits for "more". */
22 maxParts: number
23 /** Send "Working on it" once a run passes this. 0 disables. */
24 ackAfterMs: number
25 /** Send a progress line this often during long runs. 0 disables. */
26 progressEveryMs: number
27 /** A thread idle longer than this starts a fresh agent session. */
28 sessionIdleMs: number
29 workdir: string
30 /** Which agent CLI answers a text, unless a thread says otherwise. */
31 engine: EngineId
32 claudeBin: string
33 claudeModel: string | null
34 /** How much Claude Code may do without asking. */
35 permissionMode: string
36 codexBin: string
37 codexModel: string | null
38 /** How much Codex may do without asking. The two CLIs spell this differently. */
39 codexPermission: CodexPermission
40 timeoutMs: number
41}
13export type { UplinkConfig }
4214
43type Env = Record<string, string | undefined>
15/**
16 * Reads `config/uplink.ts`, applies the `UPLINK_*` environment over it, then
17 * resolves what a config file cannot state: paths written with `~`, and where
18 * the agent CLIs actually are on this Mac.
19 *
20 * The environment step is bunfig's own `applyEnvVarsToConfig`, the same
21 * mechanism the rest of the Stacks family uses, rather than a hand-rolled
22 * reader. It derives the variable name from the key, so `pollMs` is
23 * `UPLINK_POLL_MS`, and coerces to the type of the default: numbers with
24 * `Number`, booleans from "true", and a comma separated list into an array.
25 *
26 * Note it is NOT `loadConfig({ name: 'uplink', checkEnv: true })`, which looks
27 * like the obvious call and would break every variable documented in
28 * `.env.example`. bunfig applies the environment to `defaultConfig` and then
29 * merges the config file OVER the result, so a key present in `config/uplink.ts`
30 * would silently ignore its `UPLINK_*` variable. Uplink is configured through
31 * `.env`, so the file is the defaults and the environment wins.
32 */
33export function loadConfig(env: Env = process.env): UplinkConfig {
34 const applied = withEnv(env, () => applyEnvVarsToConfig('uplink', defaults) as UplinkConfig)
4435
45function int(env: Env, name: string, fallback: number): number {
46 const raw = env[name]
47 const value = raw === undefined || raw === '' ? Number.NaN : Number(raw)
48 return Number.isFinite(value) ? value : fallback
36 return {
37 ...applied,
38 // Normalized rather than trusted: a handle can be written "(555) 123-4567"
39 // in a config file as easily as in a variable.
40 allowed: parseHandleList(applied.allowed.join(',')),
41 messagesDb: expandHome(applied.messagesDb),
42 workdir: expandHome(applied.workdir),
43 engine: engineId(applied.engine),
44 claudeBin: expandHome(applied.claudeBin) || findClaude(),
45 // UPLINK_MODEL predates the second engine and meant Claude's model alias,
46 // so an existing .env keeps working.
47 claudeModel: applied.claudeModel || env.UPLINK_MODEL || null,
48 codexBin: expandHome(applied.codexBin) || findCodex(),
49 codexModel: applied.codexModel || null,
50 codexPermission: codexPermission(applied.codexPermission),
51 }
4952}
5053
51function str(env: Env, name: string, fallback: string): string {
52 const raw = env[name]
53 return raw === undefined || raw === '' ? fallback : raw
54type Env = Record<string, string | undefined>
55
56/**
57 * `applyEnvVarsToConfig` reads `process.env` and takes no environment argument,
58 * so an injected one is installed for the duration of the call. The call is
59 * synchronous, so nothing else observes the swap.
60 *
61 * The downloadable app is the reason this exists: it has no `.env` and builds
62 * an environment from `settings.json` instead.
63 */
64function withEnv<T>(env: Env, read: () => T): T {
65 if (env === process.env)
66 return read()
67 const real = process.env
68 process.env = env as NodeJS.ProcessEnv
69 try {
70 return read()
71 }
72 finally {
73 process.env = real
74 }
5475}
5576
5677function expandHome(path: string): string {
5778 return path === '~' ? homedir() : path.replace(/^~\//, `${homedir()}/`)
5879}
5980
60const MINUTE = 60_000
61
62/** From .env in the Stacks app; the downloadable app passes `settingsEnv(settings)`. */
63export function loadConfig(env: Env = process.env): UplinkConfig {
64 return {
65 allowed: parseHandleList(env.UPLINK_ALLOWED),
66 messagesDb: expandHome(str(env, 'UPLINK_MESSAGES_DB', DEFAULT_MESSAGES_DB)),
67 pollMs: int(env, 'UPLINK_POLL_MS', 2000),
68 catchUpMs: int(env, 'UPLINK_CATCH_UP_MS', 30 * MINUTE),
69 replyPrefix: str(env, 'UPLINK_REPLY_PREFIX', '🛰 '),
70 maxChars: int(env, 'UPLINK_MAX_CHARS', 1200),
71 maxParts: int(env, 'UPLINK_MAX_PARTS', 3),
72 ackAfterMs: int(env, 'UPLINK_ACK_AFTER_MS', 20_000),
73 progressEveryMs: int(env, 'UPLINK_PROGRESS_EVERY_MS', 10 * MINUTE),
74 sessionIdleMs: int(env, 'UPLINK_SESSION_IDLE_MS', 6 * 60 * MINUTE),
75 workdir: expandHome(str(env, 'UPLINK_WORKDIR', homedir())),
76 engine: engineId(env.UPLINK_ENGINE),
77 claudeBin: expandHome(str(env, 'UPLINK_CLAUDE_BIN', findClaude())),
78 // UPLINK_MODEL predates the second engine and meant Claude's model alias,
79 // so an existing .env keeps working.
80 claudeModel: env.UPLINK_CLAUDE_MODEL || env.UPLINK_MODEL || null,
81 permissionMode: str(env, 'UPLINK_PERMISSION_MODE', 'bypassPermissions'),
82 codexBin: expandHome(str(env, 'UPLINK_CODEX_BIN', findCodex())),
83 codexModel: env.UPLINK_CODEX_MODEL || null,
84 codexPermission: codexPermission(env.UPLINK_CODEX_PERMISSION),
85 timeoutMs: int(env, 'UPLINK_TIMEOUT_MS', 90 * MINUTE),
86 }
87}
88
8981function engineId(value: string | undefined): EngineId {
9082 return value && isEngineId(value) ? value : 'claude'
9183}
@@ -93,8 +85,6 @@ function engineId(value: string | undefined): EngineId {
9385function codexPermission(value: string | undefined): CodexPermission {
9486 return value && (CODEX_PERMISSIONS as readonly string[]).includes(value)
9587 ? value as CodexPermission
96 // Claude Code runs with bypassPermissions, because nobody is at the Mac to
97 // approve anything. Codex's equivalent is the same trade, spelled its way.
9888 : 'bypass'
9989}
10090
app/Uplink/types.tsadded+42-0
Changes to app/Uplink/types.ts
@@ -0,0 +1,42 @@
1import type { CodexPermission } from './codex-engine'
2import type { EngineId } from './engine'
3
4/**
5 * Uplink's settings, shared by `config/uplink.ts` and the code that reads it.
6 *
7 * The type lives here rather than beside the loader so the config file can be
8 * typed without importing the loader, which imports the config file.
9 */
10export interface UplinkConfig {
11 /** Handles allowed to command Uplink. Empty means "my own handles only". */
12 allowed: string[]
13 messagesDb: string
14 pollMs: number
15 /** On start, ignore commands older than this, so a Mac waking up does not replay a day of texts. */
16 catchUpMs: number
17 replyPrefix: string
18 /** Characters per text; longer replies are split. */
19 maxChars: number
20 /** Parts sent at once; the rest waits for "more". */
21 maxParts: number
22 /** Send "Working on it" once a run passes this. 0 disables. */
23 ackAfterMs: number
24 /** Send a progress line this often during long runs. 0 disables. */
25 progressEveryMs: number
26 /** A thread idle longer than this starts a fresh agent session. */
27 sessionIdleMs: number
28 workdir: string
29 /** Which agent CLI answers a text, unless a thread says otherwise. */
30 engine: EngineId
31 /** Empty means "find it on this Mac", which is what a config file cannot know. */
32 claudeBin: string
33 claudeModel: string | null
34 /** How much Claude Code may do without asking. */
35 permissionMode: string
36 /** Empty means "find it on this Mac". */
37 codexBin: string
38 codexModel: string | null
39 /** How much Codex may do without asking. The two CLIs spell this differently. */
40 codexPermission: CodexPermission
41 timeoutMs: number
42}
config/uplink.tsadded+106-0
Changes to config/uplink.ts
@@ -0,0 +1,106 @@
1import type { UplinkConfig } from '../app/Uplink/types'
2import { homedir } from 'node:os'
3import { join } from 'node:path'
4
5/**
6 * **Uplink Configuration**
7 *
8 * Text this Mac, even over satellite, and an agent answers. These are the
9 * defaults; every key can be overridden by the matching `UPLINK_*` environment
10 * variable, which is what `.env` sets and what the downloadable app writes from
11 * its own settings.
12 *
13 * The variable name is derived from the key: `pollMs` is `UPLINK_POLL_MS`,
14 * `maxChars` is `UPLINK_MAX_CHARS`. Nothing here needs to list them.
15 */
16
17const MINUTE = 60_000
18
19export default {
20 /**
21 * Handles allowed to command Uplink, as `UPLINK_ALLOWED`, comma separated.
22 * Empty means this Mac's own Messages handles, the "text myself" setup.
23 *
24 * Anyone listed can make an agent act on this Mac, so list only yourself.
25 */
26 allowed: [],
27
28 /**
29 * The Messages database. Reading it needs Full Disk Access.
30 */
31 messagesDb: join(homedir(), 'Library', 'Messages', 'chat.db'),
32
33 /**
34 * How often to look for new texts.
35 */
36 pollMs: 2000,
37
38 /**
39 * On start, ignore commands older than this, so a Mac waking up does not
40 * replay a day of texts.
41 */
42 catchUpMs: 30 * MINUTE,
43
44 /**
45 * Every reply starts with this, so replies stand apart in a thread with
46 * yourself and Uplink never mistakes its own reply for a new command.
47 */
48 replyPrefix: '🛰 ',
49
50 /**
51 * Characters per text. Longer replies are split, and past `maxParts` the rest
52 * waits for "more", because over a thin link a wall of texts is worse than a
53 * short one.
54 */
55 maxChars: 1200,
56 maxParts: 3,
57
58 /**
59 * "Working on it" once a run passes this, then a progress line this often.
60 * Either can be 0 to disable it.
61 */
62 ackAfterMs: 20_000,
63 progressEveryMs: 10 * MINUTE,
64
65 /**
66 * A thread idle longer than this starts a fresh agent session rather than
67 * resuming one whose context has gone stale.
68 */
69 sessionIdleMs: 6 * 60 * MINUTE,
70
71 /**
72 * Where a run starts when a text names no path.
73 */
74 workdir: homedir(),
75
76 /**
77 * Which agent CLI answers a text: `claude` or `codex`.
78 */
79 engine: 'claude',
80
81 /**
82 * Claude Code. An empty `claudeBin` means "find it on this Mac", which a
83 * config file cannot know: launchd hands agents a bare PATH, so the binary is
84 * looked for where each installer puts it. Set it to pin a specific one.
85 *
86 * `bypassPermissions` because nobody is at the Mac to approve anything.
87 */
88 claudeBin: '',
89 claudeModel: null,
90 permissionMode: 'bypassPermissions',
91
92 /**
93 * Codex. Empty `codexBin` is the same "find it" rule, and it includes the
94 * copy the ChatGPT desktop app bundles, which is not on PATH.
95 *
96 * `bypass` is Codex's spelling of the same trade `permissionMode` makes.
97 */
98 codexBin: '',
99 codexModel: null,
100 codexPermission: 'bypass',
101
102 /**
103 * How long one run may take before it is stopped.
104 */
105 timeoutMs: 90 * MINUTE,
106} satisfies UplinkConfig
package.jsonmodified+1-0
Changes to package.json
@@ -87,6 +87,7 @@
8787 },
8888 "dependencies": {
8989 "stacks": "^0.75.12",
90 "bunfig": "^0.15.19",
9091 "pickier": "^0.1.65",
9192 "@stacksjs/env": "^0.75.12",
9293 "@stacksjs/path": "^0.75.12",
tests/unit/uplink/config.test.tsadded+99-0
Changes to tests/unit/uplink/config.test.ts
@@ -0,0 +1,99 @@
1import { describe, expect, it } from 'bun:test'
2import process from 'node:process'
3import { loadConfig } from '../../../app/Uplink/config'
4import defaults from '../../../config/uplink'
5
6describe('loadConfig', () => {
7 it('reads its defaults from config/uplink.ts', () => {
8 const config = loadConfig({})
9 expect(config.pollMs).toBe(defaults.pollMs)
10 expect(config.maxChars).toBe(defaults.maxChars)
11 expect(config.replyPrefix).toBe(defaults.replyPrefix)
12 expect(config.engine).toBe(defaults.engine)
13 })
14
15 /**
16 * The whole reason this loader does not call bunfig's `loadConfig`. That
17 * applies the environment to `defaultConfig` and then merges the config file
18 * over the result, so every key present in config/uplink.ts would ignore its
19 * UPLINK_* variable, silently, and .env is how Uplink is configured.
20 */
21 it('lets the environment override a key the config file already sets', () => {
22 const config = loadConfig({
23 UPLINK_POLL_MS: '55',
24 UPLINK_MAX_CHARS: '400',
25 UPLINK_REPLY_PREFIX: 'sat ',
26 UPLINK_ENGINE: 'codex',
27 })
28 expect(config.pollMs).toBe(55)
29 expect(config.maxChars).toBe(400)
30 expect(config.replyPrefix).toBe('sat ')
31 expect(config.engine).toBe('codex')
32
33 // And the file still wins over nothing at all.
34 expect(loadConfig({}).pollMs).toBe(defaults.pollMs)
35 })
36
37 it('derives the variable name from the key, for every documented one', () => {
38 // These are the names .env.example documents. If bunfig's derivation ever
39 // stops matching them, an existing .env goes quietly ignored.
40 const config = loadConfig({
41 UPLINK_CATCH_UP_MS: '1000',
42 UPLINK_MAX_PARTS: '5',
43 UPLINK_ACK_AFTER_MS: '1',
44 UPLINK_PROGRESS_EVERY_MS: '2',
45 UPLINK_SESSION_IDLE_MS: '3',
46 UPLINK_TIMEOUT_MS: '4',
47 UPLINK_WORKDIR: '/tmp/work',
48 UPLINK_MESSAGES_DB: '/tmp/chat.db',
49 UPLINK_PERMISSION_MODE: 'acceptEdits',
50 UPLINK_CODEX_PERMISSION: 'read-only',
51 UPLINK_CLAUDE_MODEL: 'sonnet',
52 UPLINK_CODEX_MODEL: 'gpt-5-codex',
53 })
54 expect(config).toMatchObject({
55 catchUpMs: 1000,
56 maxParts: 5,
57 ackAfterMs: 1,
58 progressEveryMs: 2,
59 sessionIdleMs: 3,
60 timeoutMs: 4,
61 workdir: '/tmp/work',
62 messagesDb: '/tmp/chat.db',
63 permissionMode: 'acceptEdits',
64 codexPermission: 'read-only',
65 claudeModel: 'sonnet',
66 codexModel: 'gpt-5-codex',
67 })
68 })
69
70 it('normalizes a handle list however it was written', () => {
71 expect(loadConfig({ UPLINK_ALLOWED: '(555) 000-1111, me@example.com' }).allowed)
72 .toEqual(['+15550001111', 'me@example.com'])
73 expect(loadConfig({}).allowed).toEqual([])
74 })
75
76 it('keeps UPLINK_MODEL working, which predates the second engine', () => {
77 expect(loadConfig({ UPLINK_MODEL: 'opus' }).claudeModel).toBe('opus')
78 // The explicit name wins when both are set.
79 expect(loadConfig({ UPLINK_MODEL: 'opus', UPLINK_CLAUDE_MODEL: 'sonnet' }).claudeModel).toBe('sonnet')
80 })
81
82 it('falls back rather than throwing on a value it does not recognize', () => {
83 expect(loadConfig({ UPLINK_ENGINE: 'gemini' }).engine).toBe('claude')
84 expect(loadConfig({ UPLINK_CODEX_PERMISSION: 'whatever' }).codexPermission).toBe('bypass')
85 })
86
87 it('finds an agent binary when the config file names none', () => {
88 // An empty bin means "find it on this Mac", which a config file cannot
89 // know. A named one is taken as given.
90 expect(loadConfig({ UPLINK_CLAUDE_BIN: '/custom/claude' }).claudeBin).toBe('/custom/claude')
91 expect(loadConfig({}).claudeBin).not.toBe('')
92 })
93
94 it('leaves process.env alone when given an environment to read instead', () => {
95 const before = process.env.UPLINK_POLL_MS
96 loadConfig({ UPLINK_POLL_MS: '55' })
97 expect(process.env.UPLINK_POLL_MS).toBe(before)
98 })
99})