also looking at this
feat(config): move Uplink's settings into config/uplink.ts
#42
5 files
+309
-71
| @@ -1,91 +1,83 @@ | ||
| 1 | 1 | import type { CodexPermission } from './codex-engine' |
| 2 | 2 | import type { EngineId } from './engine' |
| 3 | import type { UplinkConfig } from './types' | |
| 3 | 4 | import { homedir } from 'node:os' |
| 4 | 5 | import { join } from 'node:path' |
| 5 | 6 | import process from 'node:process' |
| 7 | import { applyEnvVarsToConfig } from 'bunfig' | |
| 8 | import defaults from '../../config/uplink' | |
| 6 | 9 | import { CODEX_PERMISSIONS } from './codex-engine' |
| 7 | 10 | import { isEngineId } from './engine' |
| 8 | 11 | import { parseHandleList } from './handles' |
| 9 | import { DEFAULT_MESSAGES_DB } from './messages-db' | |
| 10 | 12 | |
| 11 | export 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 | } | |
| 13 | export type { UplinkConfig } | |
| 42 | 14 | |
| 43 | type 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 | */ | |
| 33 | export function loadConfig(env: Env = process.env): UplinkConfig { | |
| 34 | const applied = withEnv(env, () => applyEnvVarsToConfig('uplink', defaults) as UplinkConfig) | |
| 44 | 35 | |
| 45 | function 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 | } | |
| 49 | 52 | } |
| 50 | 53 | |
| 51 | function str(env: Env, name: string, fallback: string): string { | |
| 52 | const raw = env[name] | |
| 53 | return raw === undefined || raw === '' ? fallback : raw | |
| 54 | type 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 | */ | |
| 64 | function 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 | } | |
| 54 | 75 | } |
| 55 | 76 | |
| 56 | 77 | function expandHome(path: string): string { |
| 57 | 78 | return path === '~' ? homedir() : path.replace(/^~\//, `${homedir()}/`) |
| 58 | 79 | } |
| 59 | 80 | |
| 60 | const MINUTE = 60_000 | |
| 61 | ||
| 62 | /** From .env in the Stacks app; the downloadable app passes `settingsEnv(settings)`. */ | |
| 63 | export 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 | ||
| 89 | 81 | function engineId(value: string | undefined): EngineId { |
| 90 | 82 | return value && isEngineId(value) ? value : 'claude' |
| 91 | 83 | } |
| @@ -93,8 +85,6 @@ function engineId(value: string | undefined): EngineId { | ||
| 93 | 85 | function codexPermission(value: string | undefined): CodexPermission { |
| 94 | 86 | return value && (CODEX_PERMISSIONS as readonly string[]).includes(value) |
| 95 | 87 | ? 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. | |
| 98 | 88 | : 'bypass' |
| 99 | 89 | } |
| 100 | 90 | |
| @@ -0,0 +1,42 @@ | ||
| 1 | import type { CodexPermission } from './codex-engine' | |
| 2 | import 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 | */ | |
| 10 | export 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 | } | |
| @@ -0,0 +1,106 @@ | ||
| 1 | import type { UplinkConfig } from '../app/Uplink/types' | |
| 2 | import { homedir } from 'node:os' | |
| 3 | import { 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 | ||
| 17 | const MINUTE = 60_000 | |
| 18 | ||
| 19 | export 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 | |
| @@ -0,0 +1,99 @@ | ||
| 1 | import { describe, expect, it } from 'bun:test' | |
| 2 | import process from 'node:process' | |
| 3 | import { loadConfig } from '../../../app/Uplink/config' | |
| 4 | import defaults from '../../../config/uplink' | |
| 5 | ||
| 6 | describe('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 | }) | |