also looking at this
fix(app): a fresh install now either works or says why
#8
16 files
+512
-75
| @@ -30,7 +30,7 @@ async function openMessages(config: ReturnType<typeof loadConfig>, appDir: strin | ||
| 30 | 30 | catch (error) { |
| 31 | 31 | const message = error instanceof MessagesAccessError ? error.message : String(error) |
| 32 | 32 | console.error(`[uplink] ${message} Retrying in ${RETRY_OPEN_MS / 1000}s.`) |
| 33 | await writeHeartbeat(appDir, { pid: process.pid, startedAt, lastPollAt: null, lastError: message, allowed: config.allowed, own: [], active: [] }) | |
| 33 | await writeHeartbeat(appDir, { pid: process.pid, startedAt, lastPollAt: null, lastError: message, automation: null, lastSendError: null, allowed: config.allowed, own: [], active: [] }) | |
| 34 | 34 | await Bun.sleep(RETRY_OPEN_MS) |
| 35 | 35 | } |
| 36 | 36 | } |
| @@ -73,24 +73,39 @@ export default defineCommand((cli) => { | ||
| 73 | 73 | keepMessagesOpen() |
| 74 | 74 | |
| 75 | 75 | const messages = await openMessages(config, appDir, startedAt) |
| 76 | const sender = new AppleScriptSender() | |
| 76 | 77 | const uplink = new Uplink({ |
| 77 | 78 | config, |
| 78 | 79 | messages, |
| 79 | sender: new AppleScriptSender(), | |
| 80 | sender, | |
| 80 | 81 | engine: selectedEngine(config), |
| 81 | 82 | store: new ModelStore(`${appDir}/storage/uplink/cursor`), |
| 82 | 83 | }) |
| 83 | 84 | await uplink.start() |
| 84 | 85 | |
| 85 | const beat = (): Promise<number> => writeHeartbeat(appDir, { | |
| 86 | pid: process.pid, | |
| 87 | startedAt, | |
| 88 | lastPollAt: uplink.lastPollAt, | |
| 89 | lastError: uplink.lastError, | |
| 90 | allowed: uplink.allowedHandles, | |
| 91 | own: uplink.ownHandles, | |
| 92 | active: uplink.activeRuns, | |
| 93 | }) | |
| 86 | // The Automation grant is per app, so only this process can prove that | |
| 87 | // Uplink.app may drive Messages. Ask once at start, and again whenever a | |
| 88 | // send has failed, so the doctor and the dashboard report the subject | |
| 89 | // that actually sends rather than whichever terminal ran them. | |
| 90 | let automation = await sender.canSend() | |
| 91 | if (!automation.ok) | |
| 92 | console.error(`[uplink] cannot send through Messages: ${automation.detail}`) | |
| 93 | ||
| 94 | const beat = async (): Promise<number> => { | |
| 95 | if (uplink.lastSendError && automation.ok) | |
| 96 | automation = await sender.canSend() | |
| 97 | return writeHeartbeat(appDir, { | |
| 98 | pid: process.pid, | |
| 99 | startedAt, | |
| 100 | lastPollAt: uplink.lastPollAt, | |
| 101 | lastError: uplink.lastError, | |
| 102 | automation: { ok: automation.ok, detail: automation.detail }, | |
| 103 | lastSendError: uplink.lastSendError, | |
| 104 | allowed: uplink.allowedHandles, | |
| 105 | own: uplink.ownHandles, | |
| 106 | active: uplink.activeRuns, | |
| 107 | }) | |
| 108 | } | |
| 94 | 109 | // .env is read once, at start. Editing it (a new token, say) used to do |
| 95 | 110 | // nothing until someone remembered uplink:restart, and every text in |
| 96 | 111 | // between failed. Under launchd, which restarts us, pick it up on our |
| @@ -16,9 +16,17 @@ import { dirname, join } from 'node:path' | ||
| 16 | 16 | import process from 'node:process' |
| 17 | 17 | import { format } from 'node:util' |
| 18 | 18 | import pkg from '../../package.json' |
| 19 | import { agentPath } from '../Uplink/agent-path' | |
| 19 | 20 | import { acquireSingleInstance, startDesktopAgent } from '../Uplink/desktop-agent' |
| 20 | 21 | import { LOG_PATH } from '../Uplink/settings' |
| 21 | 22 | |
| 23 | // Launched from Finder or from its own LaunchAgent, this process gets the bare | |
| 24 | // launchd PATH, and everything below it inherits that: the engines, their | |
| 25 | // probes, and osascript. Widen it once, here, before anything spawns. Without | |
| 26 | // this a `claude` installed by bun, npm or nvm is invisible to the app on a Mac | |
| 27 | // where it works in a terminal. | |
| 28 | process.env.PATH = agentPath() | |
| 29 | ||
| 22 | 30 | // Launched from Finder or at login there is no terminal, so keep a log where |
| 23 | 31 | // Console.app looks for one. |
| 24 | 32 | if (!process.stdout.isTTY) { |
| @@ -0,0 +1,39 @@ | ||
| 1 | import { homedir } from 'node:os' | |
| 2 | import { join } from 'node:path' | |
| 3 | import process from 'node:process' | |
| 4 | ||
| 5 | /** | |
| 6 | * The PATH an Uplink process needs. | |
| 7 | * | |
| 8 | * launchd hands agents a bare PATH, and Uplink shells out to `claude`, `codex`, | |
| 9 | * `git` and `osascript`. The from-source launcher has always built this list; | |
| 10 | * the downloadable app did not, so a `claude` installed by bun, npm or nvm was | |
| 11 | * invisible to it on a Mac where it works perfectly in a terminal. Both use | |
| 12 | * this now, so the two cannot drift. | |
| 13 | * | |
| 14 | * The user's own locations come first: Uplink's bundled tools must not shadow | |
| 15 | * the CLI a person deliberately installed. Whatever PATH already exists is kept | |
| 16 | * last, so a real environment (a terminal, a debugging session) still wins over | |
| 17 | * nothing. | |
| 18 | */ | |
| 19 | export function agentPath(options: { appDir?: string, env?: Record<string, string | undefined> } = {}): string { | |
| 20 | const env = options.env ?? process.env | |
| 21 | const home = env.HOME ?? homedir() | |
| 22 | ||
| 23 | return [ | |
| 24 | // Where the installers put the agent CLIs. | |
| 25 | join(home, '.local', 'bin'), | |
| 26 | join(home, '.bun', 'bin'), | |
| 27 | join(home, '.npm-global', 'bin'), | |
| 28 | join(home, '.claude', 'local'), | |
| 29 | '/opt/homebrew/bin', | |
| 30 | '/usr/local/bin', | |
| 31 | // The from-source install's own pinned toolchain, after the user's. | |
| 32 | options.appDir ? join(options.appDir, 'pantry', '.bin') : '', | |
| 33 | '/usr/bin', | |
| 34 | '/bin', | |
| 35 | '/usr/sbin', | |
| 36 | '/sbin', | |
| 37 | env.PATH ?? '', | |
| 38 | ].filter(Boolean).join(':') | |
| 39 | } | |
| @@ -1,11 +1,11 @@ | ||
| 1 | 1 | import type { Subprocess } from 'bun' |
| 2 | import type { Engine, EngineProbe, EngineRequest, EngineResult, EngineRun } from './engine' | |
| 2 | import type { Engine, EngineInstall, EngineProbe, EngineRequest, EngineResult, EngineRun } from './engine' | |
| 3 | 3 | import { randomUUID } from 'node:crypto' |
| 4 | 4 | import { rmSync } from 'node:fs' |
| 5 | 5 | import { tmpdir } from 'node:os' |
| 6 | 6 | import { join } from 'node:path' |
| 7 | 7 | import process from 'node:process' |
| 8 | import { PROBE_TIMEOUT_MS } from './engine' | |
| 8 | import { binaryExists, PROBE_TIMEOUT_MS } from './engine' | |
| 9 | 9 | import { truncate } from './format' |
| 10 | 10 | |
| 11 | 11 | /** |
| @@ -60,6 +60,10 @@ export class CodexEngine implements Engine { | ||
| 60 | 60 | readonly id = 'codex' as const |
| 61 | 61 | readonly label = 'Codex' |
| 62 | 62 | readonly authFailureHint = 'run "codex login" on the Mac (or "codex login --device-auth" over SSH)' |
| 63 | readonly install: EngineInstall = { | |
| 64 | command: 'bun install -g @openai/codex', | |
| 65 | url: 'https://developers.openai.com/codex/cli', | |
| 66 | } | |
| 63 | 67 | |
| 64 | 68 | constructor(private readonly options: CodexEngineOptions) {} |
| 65 | 69 | |
| @@ -69,6 +73,9 @@ export class CodexEngine implements Engine { | ||
| 69 | 73 | * can be checked as often as the UI likes. |
| 70 | 74 | */ |
| 71 | 75 | async probe(): Promise<EngineProbe> { |
| 76 | if (!binaryExists(this.options.bin)) | |
| 77 | return { ok: false, reason: 'missing', detail: 'Codex is not installed on this Mac.' } | |
| 78 | ||
| 72 | 79 | try { |
| 73 | 80 | const proc = Bun.spawn([this.options.bin, 'login', 'status'], { |
| 74 | 81 | cwd: tmpdir(), |
| @@ -81,13 +88,15 @@ export class CodexEngine implements Engine { | ||
| 81 | 88 | const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]) |
| 82 | 89 | const exitCode = await proc.exited |
| 83 | 90 | clearTimeout(timer) |
| 91 | // `codex login status` prints to stderr, not stdout, and exits 0 when | |
| 92 | // signed in. | |
| 84 | 93 | const said = truncate((out.trim() || err.trim()) || 'Codex said nothing', 140) |
| 85 | return exitCode === 0 | |
| 86 | ? { ok: true, detail: said } | |
| 87 | : { ok: false, detail: said === 'Codex said nothing' ? 'Not signed in yet.' : said } | |
| 94 | if (exitCode === 0) | |
| 95 | return { ok: true, reason: 'ok', detail: said } | |
| 96 | return { ok: false, reason: 'signed-out', detail: said === 'Codex said nothing' ? 'Not signed in yet.' : said } | |
| 88 | 97 | } |
| 89 | catch { | |
| 90 | return { ok: false, detail: `Could not run ${this.options.bin}. Is Codex installed?` } | |
| 98 | catch (error) { | |
| 99 | return { ok: false, reason: 'failed', detail: `Could not run ${this.options.bin}: ${error instanceof Error ? error.message : String(error)}` } | |
| 91 | 100 | } |
| 92 | 101 | } |
| 93 | 102 | |
| @@ -109,6 +109,10 @@ function findClaude(): string { | ||
| 109 | 109 | for (const candidate of [ |
| 110 | 110 | join(homedir(), '.local', 'bin', 'claude'), |
| 111 | 111 | join(homedir(), '.claude', 'local', 'claude'), |
| 112 | // `bun install -g` and an npm global prefix, both of which the PATH the | |
| 113 | // launchers build also covers. | |
| 114 | join(homedir(), '.bun', 'bin', 'claude'), | |
| 115 | join(homedir(), '.npm-global', 'bin', 'claude'), | |
| 112 | 116 | '/opt/homebrew/bin/claude', |
| 113 | 117 | '/usr/local/bin/claude', |
| 114 | 118 | ]) { |
| @@ -1,6 +1,8 @@ | ||
| 1 | 1 | /* eslint-disable no-console */ |
| 2 | 2 | import type { Server } from 'bun' |
| 3 | 3 | import type { UplinkConfig } from './config' |
| 4 | import type { EngineId, EngineInstall, EngineProbe, ProbeReason } from './engine' | |
| 5 | import type { AutomationState } from './sender' | |
| 4 | 6 | import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' |
| 5 | 7 | import { homedir } from 'node:os' |
| 6 | 8 | import { join } from 'node:path' |
| @@ -41,7 +43,25 @@ const LOCK_PATH = join(DATA_DIR, 'uplink.pid') | ||
| 41 | 43 | const LAUNCH_AGENT = join(homedir(), 'Library', 'LaunchAgents', `${LABEL}.plist`) |
| 42 | 44 | const RETRY_OPEN_MS = 5_000 |
| 43 | 45 | |
| 44 | type EngineState = { ok: boolean, detail: string, checkedAt: number } | |
| 46 | type EngineState = EngineProbe & { checkedAt: number } | |
| 47 | ||
| 48 | /** | |
| 49 | * One row of the popover's setup list. Declared rather than inferred, because | |
| 50 | * the entries genuinely differ (only an engine row carries `install`) and an | |
| 51 | * inferred union puts the shared fields out of reach. | |
| 52 | */ | |
| 53 | interface PopoverCheck { | |
| 54 | id: string | |
| 55 | name: string | |
| 56 | ok: boolean | |
| 57 | detail: string | |
| 58 | /** Shown, but not counted towards readiness. */ | |
| 59 | informational: boolean | |
| 60 | engineId?: EngineId | |
| 61 | label?: string | |
| 62 | reason?: ProbeReason | |
| 63 | install?: EngineInstall | |
| 64 | } | |
| 45 | 65 | |
| 46 | 66 | export interface DesktopAgent { |
| 47 | 67 | port: number |
| @@ -142,6 +162,7 @@ export async function startDesktopAgent(options: { version: string }): Promise<D | ||
| 142 | 162 | if (settings.openAtLogin && (!existsSync(LAUNCH_AGENT) || !readFileSync(LAUNCH_AGENT, 'utf8').includes(`<string>${process.execPath}</string>`))) |
| 143 | 163 | setOpenAtLogin(true) |
| 144 | 164 | |
| 165 | const sender = new AppleScriptSender() | |
| 145 | 166 | const store = new SqliteStore(DATABASE_PATH) |
| 146 | 167 | const startedAt = Date.now() |
| 147 | 168 | let messages: MessagesDb | null = null |
| @@ -150,6 +171,17 @@ export async function startDesktopAgent(options: { version: string }): Promise<D | ||
| 150 | 171 | let knownAllowed: string[] = [] |
| 151 | 172 | const engines = new Map<string, EngineState>() |
| 152 | 173 | let checkingEngines = false |
| 174 | let automation: AutomationState | null = null | |
| 175 | ||
| 176 | /** | |
| 177 | * Whether macOS will let this app drive Messages. This process is Uplink.app, | |
| 178 | * so unlike `buddy uplink:doctor` under Terminal it is the right subject to | |
| 179 | * ask. The first call raises the prompt, which is why setup calls it rather | |
| 180 | * than leaving it to the first real reply. | |
| 181 | */ | |
| 182 | const checkAutomation = async (): Promise<void> => { | |
| 183 | automation = await sender.canSend() | |
| 184 | } | |
| 153 | 185 | |
| 154 | 186 | /** |
| 155 | 187 | * Probe every engine, not only the selected one: the popover shows the other |
| @@ -180,7 +212,7 @@ export async function startDesktopAgent(options: { version: string }): Promise<D | ||
| 180 | 212 | messagesError = error instanceof MessagesAccessError ? 'Uplink needs Full Disk Access to read Messages.' : String(error) |
| 181 | 213 | return |
| 182 | 214 | } |
| 183 | uplink = new Uplink({ config, messages, sender: new AppleScriptSender(), engine: selectedEngine(config), store }) | |
| 215 | uplink = new Uplink({ config, messages, sender, engine: selectedEngine(config), store }) | |
| 184 | 216 | await uplink.start() |
| 185 | 217 | } |
| 186 | 218 | |
| @@ -200,10 +232,16 @@ export async function startDesktopAgent(options: { version: string }): Promise<D | ||
| 200 | 232 | const keepMessagesOpen = setInterval(() => { |
| 201 | 233 | if (uplink && Bun.spawnSync(['pgrep', '-x', 'Messages']).exitCode !== 0) |
| 202 | 234 | Bun.spawnSync(['open', '-g', '-a', 'Messages']) |
| 235 | // Re-ask while the answer is anything but yes. A refusal only changes in | |
| 236 | // System Settings, and a timeout from a Messages that was still starting | |
| 237 | // should not leave the app stuck in setup for ever. | |
| 238 | if (!automation?.ok) | |
| 239 | void checkAutomation() | |
| 203 | 240 | }, 15_000) |
| 204 | 241 | |
| 205 | 242 | await startWatching() |
| 206 | 243 | void checkEngines() |
| 244 | void checkAutomation() | |
| 207 | 245 | |
| 208 | 246 | const status = () => { |
| 209 | 247 | const recent = store.recentRuns(8).map(run => ({ |
| @@ -223,7 +261,7 @@ export async function startDesktopAgent(options: { version: string }): Promise<D | ||
| 223 | 261 | if (uplink) |
| 224 | 262 | knownAllowed = uplink.allowedHandles |
| 225 | 263 | const allowed = uplink ? knownAllowed : settings.allowed.length > 0 ? settings.allowed : knownAllowed |
| 226 | const checks = [ | |
| 264 | const checks: PopoverCheck[] = [ | |
| 227 | 265 | { |
| 228 | 266 | id: 'fda', |
| 229 | 267 | name: 'Read Messages', |
| @@ -234,13 +272,19 @@ export async function startDesktopAgent(options: { version: string }): Promise<D | ||
| 234 | 272 | ...allEngines(config).map((engine) => { |
| 235 | 273 | const state = engines.get(engine.id) |
| 236 | 274 | const selected = engine.id === config.engine |
| 275 | const reason = state?.reason ?? 'failed' | |
| 276 | const missing = reason === 'missing' | |
| 237 | 277 | return { |
| 238 | 278 | // `engine` is the selected one, and the popover draws its sign-in |
| 239 | 279 | // step. The other is listed so switching is discoverable. |
| 240 | 280 | id: selected ? 'engine' : `engine:${engine.id}`, |
| 241 | name: selected ? `Sign in to ${engine.label}` : engine.label, | |
| 281 | // A missing CLI is not a sign-in problem, and calling it one sends | |
| 282 | // the person to run a command that does not exist either. | |
| 283 | name: missing ? `Install ${engine.label}` : selected ? `Sign in to ${engine.label}` : engine.label, | |
| 242 | 284 | engineId: engine.id, |
| 243 | 285 | label: engine.label, |
| 286 | reason, | |
| 287 | install: engine.install, | |
| 244 | 288 | ok: state?.ok ?? false, |
| 245 | 289 | detail: checkingEngines && !state ? 'Checking' : (state?.detail ?? 'Not checked yet'), |
| 246 | 290 | informational: !selected, |
| @@ -253,6 +297,16 @@ export async function startDesktopAgent(options: { version: string }): Promise<D | ||
| 253 | 297 | detail: allowed.length > 0 ? allowed.join(', ') : 'No handles yet. Sign in to Messages, or add your number.', |
| 254 | 298 | informational: false, |
| 255 | 299 | }, |
| 300 | { | |
| 301 | // The outbound half of the chain. Without this the app reported itself | |
| 302 | // ready while every reply was refused, so a first text produced a run | |
| 303 | // marked done and a phone that heard nothing. | |
| 304 | id: 'automation', | |
| 305 | name: 'Send replies', | |
| 306 | ok: automation?.ok ?? false, | |
| 307 | detail: uplink?.lastSendError ?? automation?.detail ?? 'Not checked yet', | |
| 308 | informational: false, | |
| 309 | }, | |
| 256 | 310 | ] |
| 257 | 311 | // An engine the person has not selected must not hold the app in setup. |
| 258 | 312 | const ready = checks.every(check => check.ok || check.informational) |
| @@ -305,6 +359,24 @@ export async function startDesktopAgent(options: { version: string }): Promise<D | ||
| 305 | 359 | Bun.spawnSync(['open', 'x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles']) |
| 306 | 360 | return json({ ok: true }) |
| 307 | 361 | |
| 362 | case '/api/automation/test': | |
| 363 | await checkAutomation() | |
| 364 | return json(automation) | |
| 365 | ||
| 366 | case '/api/open/automation': { | |
| 367 | Bun.spawnSync(['open', 'x-apple.systempreferences:com.apple.preference.security?Privacy_Automation']) | |
| 368 | return json({ ok: true }) | |
| 369 | } | |
| 370 | ||
| 371 | case '/api/open/install-docs': { | |
| 372 | // The selected engine's own documentation, so the link cannot drift | |
| 373 | // from the command shown beside it. | |
| 374 | const { url } = allEngines(config).find(engine => engine.id === config.engine)?.install ?? { url: '' } | |
| 375 | if (url) | |
| 376 | Bun.spawnSync(['open', url]) | |
| 377 | return json({ ok: Boolean(url), url }) | |
| 378 | } | |
| 379 | ||
| 308 | 380 | case '/api/open/terminal': { |
| 309 | 381 | // Both sign-ins need a real terminal for their browser round trip. |
| 310 | 382 | const command = config.engine === 'codex' ? 'codex login' : 'claude setup-token' |
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | import type { UplinkConfig } from './config' |
| 2 | import type { Engine } from './engine' | |
| 2 | import type { Engine, ProbeReason } from './engine' | |
| 3 | 3 | import { existsSync } from 'node:fs' |
| 4 | 4 | import { join } from 'node:path' |
| 5 | 5 | import { allEngines } from './engines' |
| @@ -35,6 +35,10 @@ export interface Heartbeat { | ||
| 35 | 35 | startedAt: number |
| 36 | 36 | lastPollAt: number | null |
| 37 | 37 | lastError: string | null |
| 38 | /** Whether the watcher itself can drive Messages. Null before it has asked. */ | |
| 39 | automation: { ok: boolean, detail: string } | null | |
| 40 | /** Why the last reply could not be delivered, if one could not. */ | |
| 41 | lastSendError: string | null | |
| 38 | 42 | allowed: string[] |
| 39 | 43 | own: string[] |
| 40 | 44 | active: Array<{ prompt: string, startedAt: number, lastActivity: string | null, queued: number }> |
| @@ -61,14 +65,23 @@ export async function readHeartbeat(appDir: string): Promise<Heartbeat | null> { | ||
| 61 | 65 | const HEARTBEAT_STALE_MS = 2 * 60_000 |
| 62 | 66 | |
| 63 | 67 | /** |
| 64 | * What to do about an engine that cannot answer. The engine's own | |
| 65 | * `authFailureHint` is written for a text message, so it names the CLI and the | |
| 66 | * command but not the surrounding `.env` step this install needs. | |
| 68 | * What to do about an engine that cannot answer, which depends on why. | |
| 69 | * | |
| 70 | * A missing CLI used to be reported inside the sign-in step, so the person was | |
| 71 | * told to run a command that does not exist either. These are three problems | |
| 72 | * with three different next steps. | |
| 67 | 73 | */ |
| 68 | function engineFix(engine: Engine): string { | |
| 69 | return engine.id === 'claude' | |
| 70 | ? 'Run claude setup-token, copy the WHOLE token (it can wrap onto a second line), then ./buddy env:set CLAUDE_CODE_OAUTH_TOKEN <token> and ./buddy uplink:restart' | |
| 71 | : 'Run codex login (or codex login --device-auth if you are on SSH), then ./buddy uplink:restart' | |
| 74 | function engineFix(engine: Engine, reason: ProbeReason): string { | |
| 75 | if (reason === 'missing') | |
| 76 | return `Install it: ${engine.install.command} (see ${engine.install.url}), then ./buddy uplink:restart` | |
| 77 | ||
| 78 | if (reason === 'signed-out') { | |
| 79 | return engine.id === 'claude' | |
| 80 | ? 'Run claude setup-token, copy the WHOLE token (it can wrap onto a second line), then ./buddy env:set CLAUDE_CODE_OAUTH_TOKEN <token> and ./buddy uplink:restart' | |
| 81 | : 'Run codex login (or codex login --device-auth if you are on SSH), then ./buddy uplink:restart' | |
| 82 | } | |
| 83 | ||
| 84 | return `${engine.label} is installed and signed in, but did not answer. The detail above is what it said.` | |
| 72 | 85 | } |
| 73 | 86 | |
| 74 | 87 | export async function runChecks(config: UplinkConfig, appDir: string): Promise<Check[]> { |
| @@ -119,16 +132,20 @@ export async function runChecks(config: UplinkConfig, appDir: string): Promise<C | ||
| 119 | 132 | for (const engine of allEngines(config)) { |
| 120 | 133 | const selected = engine.id === config.engine |
| 121 | 134 | const probe = await engine.probe() |
| 135 | const name = probe.reason === 'missing' | |
| 136 | ? `${engine.label} (not installed)` | |
| 137 | : selected ? `${engine.label} account` : `${engine.label} (available, not selected)` | |
| 138 | ||
| 122 | 139 | checks.push({ |
| 123 | name: selected ? `${engine.label} account` : `${engine.label} (available, not selected)`, | |
| 140 | name, | |
| 124 | 141 | ok: probe.ok, |
| 125 | 142 | detail: probe.detail, |
| 126 | 143 | informational: !selected, |
| 127 | 144 | fix: probe.ok |
| 128 | 145 | ? undefined |
| 129 | 146 | : selected |
| 130 | ? engineFix(engine) | |
| 131 | : `Optional. To use it, set UPLINK_ENGINE=${engine.id} in .env, then: ${engineFix(engine)}`, | |
| 147 | ? engineFix(engine, probe.reason) | |
| 148 | : `Optional. To use it, set UPLINK_ENGINE=${engine.id} in .env, then: ${engineFix(engine, probe.reason)}`, | |
| 132 | 149 | }) |
| 133 | 150 | } |
| 134 | 151 | |
| @@ -137,7 +154,28 @@ export async function runChecks(config: UplinkConfig, appDir: string): Promise<C | ||
| 137 | 154 | ? { name: 'Messages app', ok: true, detail: 'running' } |
| 138 | 155 | : { name: 'Messages app', ok: false, detail: 'not running', fix: 'Open Messages and sign in with your Apple ID' }) |
| 139 | 156 | |
| 140 | checks.push({ name: 'Sending replies', ok: true, detail: 'macOS asks once, on the first reply, to let Uplink control Messages - click Allow' }) | |
| 157 | // The Automation grant is per app: the watcher inside Uplink.app and this | |
| 158 | // command under Terminal are different subjects. Only the watcher's answer is | |
| 159 | // about the thing that will actually send, so ask the heartbeat rather than | |
| 160 | // measuring this process and reporting it as Uplink's. | |
| 161 | if (fresh && heartbeat.automation) { | |
| 162 | checks.push(heartbeat.automation.ok | |
| 163 | ? { name: 'Sending replies', ok: true, detail: heartbeat.automation.detail } | |
| 164 | : { | |
| 165 | name: 'Sending replies', | |
| 166 | ok: false, | |
| 167 | detail: heartbeat.lastSendError ?? heartbeat.automation.detail, | |
| 168 | fix: `Allow ${paths.bundle} to control Messages in System Settings > Privacy & Security > Automation, then ./buddy uplink:restart. macOS asks once; if it was declined, the switch is in that pane.`, | |
| 169 | }) | |
| 170 | } | |
| 171 | else { | |
| 172 | checks.push({ | |
| 173 | name: 'Sending replies', | |
| 174 | ok: false, | |
| 175 | detail: 'not checked: the watcher is not running, and only it can prove Uplink.app may control Messages', | |
| 176 | fix: 'Start it with ./buddy uplink:install, then run this again', | |
| 177 | }) | |
| 178 | } | |
| 141 | 179 | |
| 142 | 180 | return checks |
| 143 | 181 | } |
| @@ -53,12 +53,34 @@ export function isEngineId(value: string): value is EngineId { | ||
| 53 | 53 | return (ENGINE_IDS as readonly string[]).includes(value) |
| 54 | 54 | } |
| 55 | 55 | |
| 56 | /** | |
| 57 | * Why an engine cannot answer. These are three different problems with three | |
| 58 | * different next steps, and setup used to collapse them into one: a missing | |
| 59 | * binary was reported inside the "Sign in" step, which sends the person to run | |
| 60 | * a command that does not exist either. | |
| 61 | */ | |
| 62 | export type ProbeReason = 'ok' | 'missing' | 'signed-out' | 'failed' | |
| 63 | ||
| 56 | 64 | /** Whether a CLI can answer right now, for the doctor and the menubar. */ |
| 57 | 65 | export interface EngineProbe { |
| 58 | 66 | ok: boolean |
| 67 | reason: ProbeReason | |
| 59 | 68 | detail: string |
| 60 | 69 | } |
| 61 | 70 | |
| 71 | /** Where to get an engine that is not installed. */ | |
| 72 | export interface EngineInstall { | |
| 73 | command: string | |
| 74 | url: string | |
| 75 | } | |
| 76 | ||
| 77 | /** True when `bin` is something this machine can actually run. */ | |
| 78 | export function binaryExists(bin: string): boolean { | |
| 79 | // An absolute path from findClaude/findCodex, or a bare name to resolve | |
| 80 | // against PATH, which the launchers widen before anything spawns. | |
| 81 | return bin.includes('/') ? Bun.file(bin).size > 0 : Bun.which(bin) !== null | |
| 82 | } | |
| 83 | ||
| 62 | 84 | export interface Engine { |
| 63 | 85 | readonly id: EngineId |
| 64 | 86 | /** How the engine is named to a person: "Claude Code", "Codex". */ |
| @@ -68,6 +90,8 @@ export interface Engine { | ||
| 68 | 90 | * from the Mac, so this has to name the CLI and the exact command. |
| 69 | 91 | */ |
| 70 | 92 | readonly authFailureHint: string |
| 93 | /** How to install this CLI, for a Mac that does not have it. */ | |
| 94 | readonly install: EngineInstall | |
| 71 | 95 | run: (request: EngineRequest) => EngineRun |
| 72 | 96 | /** |
| 73 | 97 | * Can this CLI answer right now. Separate from `run` because setup has to |
| @@ -95,6 +119,10 @@ export class ClaudeEngine implements Engine { | ||
| 95 | 119 | readonly id = 'claude' as const |
| 96 | 120 | readonly label = 'Claude Code' |
| 97 | 121 | readonly authFailureHint = 'run "claude setup-token" on the Mac and give Uplink the token' |
| 122 | readonly install: EngineInstall = { | |
| 123 | command: 'bun install -g @anthropic-ai/claude-code', | |
| 124 | url: 'https://docs.claude.com/en/docs/claude-code', | |
| 125 | } | |
| 98 | 126 | |
| 99 | 127 | constructor(private readonly options: ClaudeEngineOptions) {} |
| 100 | 128 | |
| @@ -104,6 +132,11 @@ export class ClaudeEngine implements Engine { | ||
| 104 | 132 | * rejected with a 401 on first use. |
| 105 | 133 | */ |
| 106 | 134 | async probe(): Promise<EngineProbe> { |
| 135 | // Asked before spawning, because a missing binary and a logged-out one are | |
| 136 | // different problems and Bun throws rather than exiting 127 for the former. | |
| 137 | if (!binaryExists(this.options.bin)) | |
| 138 | return { ok: false, reason: 'missing', detail: 'Claude Code is not installed on this Mac.' } | |
| 139 | ||
| 107 | 140 | try { |
| 108 | 141 | const proc = Bun.spawn([this.options.bin, '-p', 'Reply with exactly: ok', '--model', 'haiku', '--output-format', 'json'], { |
| 109 | 142 | cwd: tmpdir(), |
| @@ -119,12 +152,18 @@ export class ClaudeEngine implements Engine { | ||
| 119 | 152 | const result = JSON.parse(text) as { is_error?: boolean, result?: string } |
| 120 | 153 | if (result.is_error) { |
| 121 | 154 | const said = truncate(result.result ?? 'Claude did not answer', 140) |
| 122 | return { ok: false, detail: AUTH_FAILURE.test(said) ? 'Not signed in yet.' : said } | |
| 155 | return AUTH_FAILURE.test(said) | |
| 156 | ? { ok: false, reason: 'signed-out', detail: 'Not signed in yet.' } | |
| 157 | : { ok: false, reason: 'failed', detail: said } | |
| 158 | } | |
| 159 | return { | |
| 160 | ok: true, | |
| 161 | reason: 'ok', | |
| 162 | detail: this.env().CLAUDE_CODE_OAUTH_TOKEN ? 'Signed in with your token' : 'Signed in through the Claude CLI', | |
| 123 | 163 | } |
| 124 | return { ok: true, detail: this.env().CLAUDE_CODE_OAUTH_TOKEN ? 'Signed in with your token' : 'Signed in through the Claude CLI' } | |
| 125 | 164 | } |
| 126 | catch { | |
| 127 | return { ok: false, detail: `Could not run ${this.options.bin}. Is Claude Code installed?` } | |
| 165 | catch (error) { | |
| 166 | return { ok: false, reason: 'failed', detail: `Could not run ${this.options.bin}: ${error instanceof Error ? error.message : String(error)}` } | |
| 128 | 167 | } |
| 129 | 168 | } |
| 130 | 169 | |
| @@ -16,25 +16,15 @@ | ||
| 16 | 16 | */ |
| 17 | 17 | import { join } from 'node:path' |
| 18 | 18 | import process from 'node:process' |
| 19 | import { agentPath } from './agent-path' | |
| 19 | 20 | |
| 20 | 21 | declare const UPLINK_APP_DIR: string |
| 21 | 22 | |
| 22 | 23 | const appDir = process.env.UPLINK_APP_DIR || UPLINK_APP_DIR |
| 23 | const home = process.env.HOME ?? '' | |
| 24 | 24 | |
| 25 | // launchd hands agents a bare PATH; the watcher shells out to claude, git and osascript. | |
| 26 | const PATH = [ | |
| 27 | join(appDir, 'pantry', '.bin'), | |
| 28 | join(home, '.local', 'bin'), | |
| 29 | join(home, '.bun', 'bin'), | |
| 30 | '/opt/homebrew/bin', | |
| 31 | '/usr/local/bin', | |
| 32 | '/usr/bin', | |
| 33 | '/bin', | |
| 34 | '/usr/sbin', | |
| 35 | '/sbin', | |
| 36 | process.env.PATH ?? '', | |
| 37 | ].filter(Boolean).join(':') | |
| 25 | // launchd hands agents a bare PATH; the watcher shells out to claude, codex, | |
| 26 | // git and osascript. | |
| 27 | const PATH = agentPath({ appDir }) | |
| 38 | 28 | |
| 39 | 29 | const watcher = Bun.spawn([join(appDir, 'buddy'), 'uplink:watch'], { |
| 40 | 30 | cwd: appDir, |
| @@ -15,8 +15,27 @@ export interface ReplyTarget { | ||
| 15 | 15 | service: string |
| 16 | 16 | } |
| 17 | 17 | |
| 18 | /** | |
| 19 | * Whether macOS will let this process drive Messages. | |
| 20 | * | |
| 21 | * The grant is per app and per process identity: the watcher inside Uplink.app | |
| 22 | * and `buddy uplink:doctor` under Terminal are two different subjects, so a | |
| 23 | * result is only ever about the process that asked. | |
| 24 | */ | |
| 25 | export interface AutomationState { | |
| 26 | ok: boolean | |
| 27 | reason: 'ok' | 'denied' | 'failed' | |
| 28 | detail: string | |
| 29 | } | |
| 30 | ||
| 18 | 31 | export interface Sender { |
| 19 | 32 | send: (target: ReplyTarget, text: string) => Promise<void> |
| 33 | /** | |
| 34 | * Can this process send at all. The first call raises the macOS Automation | |
| 35 | * prompt, which is the point of having it: it has to happen while someone is | |
| 36 | * at the Mac, not on the first real reply to a phone that then hears nothing. | |
| 37 | */ | |
| 38 | canSend: () => Promise<AutomationState> | |
| 20 | 39 | } |
| 21 | 40 | |
| 22 | 41 | const SCRIPT = ` |
| @@ -40,7 +59,51 @@ on run argv | ||
| 40 | 59 | end run |
| 41 | 60 | ` |
| 42 | 61 | |
| 62 | /** | |
| 63 | * Reads from Messages and sends nothing, so it can be run during setup and on | |
| 64 | * demand. Counting accounts needs exactly the Automation permission a real | |
| 65 | * reply needs. | |
| 66 | */ | |
| 67 | const PROBE_SCRIPT = 'tell application "Messages" to count of accounts' | |
| 68 | ||
| 69 | /** macOS error -1743 is "not authorized to send Apple events". */ | |
| 70 | const NOT_AUTHORIZED = /-1743|not authori[sz]ed|not allowed to send|doesn't have permission/i | |
| 71 | ||
| 72 | /** -1712 is an AppleEvent timeout, which a busy or starting Messages produces. */ | |
| 73 | const TIMED_OUT = /-1712|timed out/i | |
| 74 | ||
| 75 | /** Long enough for someone to read the macOS prompt and click Allow. */ | |
| 76 | const PROBE_TIMEOUT_MS = 60_000 | |
| 77 | ||
| 43 | 78 | export class AppleScriptSender implements Sender { |
| 79 | async canSend(): Promise<AutomationState> { | |
| 80 | try { | |
| 81 | const proc = Bun.spawn(['osascript', '-e', PROBE_SCRIPT], { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' }) | |
| 82 | // The prompt itself blocks the script until someone answers it, and a | |
| 83 | // busy or starting Messages can time out on its own, so this waits | |
| 84 | // generously rather than reporting a refusal that has not happened. | |
| 85 | const timer = setTimeout(() => proc.kill(), PROBE_TIMEOUT_MS) | |
| 86 | const [code, stderr] = await Promise.all([proc.exited, new Response(proc.stderr).text()]) | |
| 87 | clearTimeout(timer) | |
| 88 | ||
| 89 | if (code === 0) | |
| 90 | return { ok: true, reason: 'ok', detail: 'Uplink can send through Messages' } | |
| 91 | ||
| 92 | const said = stderr.trim() | |
| 93 | if (NOT_AUTHORIZED.test(said)) | |
| 94 | return { ok: false, reason: 'denied', detail: 'macOS has not been allowed to let Uplink control Messages' } | |
| 95 | ||
| 96 | // A timeout is not a refusal. Say so, because the two want different | |
| 97 | // things from the person reading it. | |
| 98 | return TIMED_OUT.test(said) | |
| 99 | ? { ok: false, reason: 'failed', detail: 'Messages did not answer in time. It may still be starting up.' } | |
| 100 | : { ok: false, reason: 'failed', detail: said || `osascript exited ${code}` } | |
| 101 | } | |
| 102 | catch (error) { | |
| 103 | return { ok: false, reason: 'failed', detail: error instanceof Error ? error.message : String(error) } | |
| 104 | } | |
| 105 | } | |
| 106 | ||
| 44 | 107 | async send(target: ReplyTarget, text: string): Promise<void> { |
| 45 | 108 | const proc = Bun.spawn( |
| 46 | 109 | ['osascript', '-e', SCRIPT, text, target.chatGuid, target.handle, target.service === 'SMS' ? 'SMS' : 'iMessage'], |
| @@ -89,6 +89,8 @@ export class Uplink { | ||
| 89 | 89 | private timer: ReturnType<typeof setTimeout> | null = null |
| 90 | 90 | lastPollAt: number | null = null |
| 91 | 91 | lastError: string | null = null |
| 92 | /** Why the last reply could not be delivered, for the heartbeat and the popover. */ | |
| 93 | lastSendError: string | null = null | |
| 92 | 94 | private ticking = false |
| 93 | 95 | private stopped = false |
| 94 | 96 | |
| @@ -408,7 +410,16 @@ export class Uplink { | ||
| 408 | 410 | const text = result.authFailure |
| 409 | 411 | ? `I cannot reach ${engine.label}: it is not signed in on this Mac. Someone there needs to ${engine.authFailureHint}.` |
| 410 | 412 | : result.ok ? result.text : `That failed: ${result.text}` |
| 411 | await this.reply(active.target, text, { keepRest: true }) | |
| 413 | ||
| 414 | // A run whose answer never left the Mac is not done, whatever the agent | |
| 415 | // did. Recording it as done is how a first install reports itself healthy | |
| 416 | // while the phone hears nothing. | |
| 417 | if (!await this.reply(active.target, text, { keepRest: true })) { | |
| 418 | await this.deps.store.updateRun(active.runId, { | |
| 419 | status: 'failed', | |
| 420 | error: `The answer could not be delivered through Messages: ${this.lastSendError ?? 'unknown error'}`, | |
| 421 | }) | |
| 422 | } | |
| 412 | 423 | } |
| 413 | 424 | |
| 414 | 425 | const next = state.queue.shift() |
| @@ -416,19 +427,28 @@ export class Uplink { | ||
| 416 | 427 | await this.begin(state, next) |
| 417 | 428 | } |
| 418 | 429 | |
| 430 | /** | |
| 431 | * Answers a control word. `reply` reports whether it delivered, which | |
| 432 | * `finish` acts on for a real answer; a control reply is said and forgotten, | |
| 433 | * so the result is deliberately dropped here. | |
| 434 | */ | |
| 419 | 435 | private async control(command: ControlCommand, target: ReplyTarget): Promise<void> { |
| 420 | 436 | const state = this.chat(target.chatGuid) |
| 421 | 437 | |
| 422 | 438 | switch (command) { |
| 423 | 439 | case 'help': |
| 424 | return this.reply(target, HELP_TEXT) | |
| 440 | await this.reply(target, HELP_TEXT) | |
| 441 | return | |
| 425 | 442 | |
| 426 | 443 | case 'ping': |
| 427 | return this.reply(target, `pong (up ${formatDuration(this.now() - this.startedAt)})`) | |
| 444 | await this.reply(target, `pong (up ${formatDuration(this.now() - this.startedAt)})`) | |
| 445 | return | |
| 428 | 446 | |
| 429 | 447 | case 'status': { |
| 430 | if (!state.active) | |
| 431 | return this.reply(target, 'Idle. Text me a question or a task.') | |
| 448 | if (!state.active) { | |
| 449 | await this.reply(target, 'Idle. Text me a question or a task.') | |
| 450 | return | |
| 451 | } | |
| 432 | 452 | const active = state.active |
| 433 | 453 | const lines = [ |
| 434 | 454 | `Working ${formatDuration(this.now() - active.startedAt)} on: ${truncate(active.prompt, 80)}`, |
| @@ -436,23 +456,28 @@ export class Uplink { | ||
| 436 | 456 | ] |
| 437 | 457 | if (state.queue.length > 0) |
| 438 | 458 | lines.push(`${state.queue.length} queued.`) |
| 439 | return this.reply(target, lines.join('\n')) | |
| 459 | await this.reply(target, lines.join('\n')) | |
| 460 | return | |
| 440 | 461 | } |
| 441 | 462 | |
| 442 | 463 | case 'stop': |
| 443 | return this.reply(target, await this.stopThread(state, 'Stopped') ?? 'Nothing running.') | |
| 464 | await this.reply(target, await this.stopThread(state, 'Stopped') ?? 'Nothing running.') | |
| 465 | return | |
| 444 | 466 | |
| 445 | 467 | case 'new': { |
| 446 | 468 | const conversation = await this.conversation(target) |
| 447 | 469 | await this.deps.store.saveConversation({ ...conversation, sessionId: null, cwd: null, moreText: null }) |
| 448 | return this.reply(target, 'Fresh start. Your next text begins a new conversation.') | |
| 470 | await this.reply(target, 'Fresh start. Your next text begins a new conversation.') | |
| 471 | return | |
| 449 | 472 | } |
| 450 | 473 | |
| 451 | 474 | case 'more': { |
| 452 | 475 | const conversation = await this.conversation(target) |
| 453 | if (!conversation.moreText) | |
| 454 | return this.reply(target, 'Nothing more to send.') | |
| 455 | return this.reply(target, conversation.moreText, { keepRest: true }) | |
| 476 | if (!conversation.moreText) { | |
| 477 | await this.reply(target, 'Nothing more to send.') | |
| 478 | return | |
| 479 | } | |
| 480 | await this.reply(target, conversation.moreText, { keepRest: true }) | |
| 456 | 481 | } |
| 457 | 482 | } |
| 458 | 483 | } |
| @@ -463,7 +488,7 @@ export class Uplink { | ||
| 463 | 488 | * and easy to lose track of. Short status replies never touch the stored |
| 464 | 489 | * conversation, so one sent mid-run cannot race the run's own save. |
| 465 | 490 | */ |
| 466 | async reply(target: ReplyTarget, text: string, options: { keepRest?: boolean } = {}): Promise<void> { | |
| 491 | async reply(target: ReplyTarget, text: string, options: { keepRest?: boolean } = {}): Promise<boolean> { | |
| 467 | 492 | const parts = chunk(toPlainText(text), this.config.maxChars - this.config.replyPrefix.length) |
| 468 | 493 | const now = parts.slice(0, this.config.maxParts) |
| 469 | 494 | const later = parts.slice(this.config.maxParts) |
| @@ -482,10 +507,13 @@ export class Uplink { | ||
| 482 | 507 | await this.deps.sender.send(target, body) |
| 483 | 508 | } |
| 484 | 509 | catch (error) { |
| 485 | this.log.error(`Reply to ${target.handle} failed: ${error instanceof Error ? error.message : String(error)}`) | |
| 486 | return | |
| 510 | this.lastSendError = error instanceof Error ? error.message : String(error) | |
| 511 | this.log.error(`Reply to ${target.handle} failed: ${this.lastSendError}`) | |
| 512 | return false | |
| 487 | 513 | } |
| 488 | 514 | } |
| 515 | this.lastSendError = null | |
| 516 | return true | |
| 489 | 517 | } |
| 490 | 518 | } |
| 491 | 519 | |
| @@ -0,0 +1,44 @@ | ||
| 1 | import { describe, expect, it } from 'bun:test' | |
| 2 | import { agentPath } from '../../../app/Uplink/agent-path' | |
| 3 | ||
| 4 | const env = { HOME: '/Users/someone', PATH: '/usr/bin:/bin' } | |
| 5 | ||
| 6 | describe('agentPath', () => { | |
| 7 | it('covers every place the agent CLIs are installed', () => { | |
| 8 | const entries = agentPath({ env }).split(':') | |
| 9 | ||
| 10 | // The list the launchers used to disagree about. `~/.bun/bin` is the gap | |
| 11 | // that made a bun-installed claude invisible to the downloadable app. | |
| 12 | for (const expected of [ | |
| 13 | '/Users/someone/.local/bin', | |
| 14 | '/Users/someone/.bun/bin', | |
| 15 | '/Users/someone/.npm-global/bin', | |
| 16 | '/Users/someone/.claude/local', | |
| 17 | '/opt/homebrew/bin', | |
| 18 | '/usr/local/bin', | |
| 19 | ]) | |
| 20 | expect(entries).toContain(expected) | |
| 21 | }) | |
| 22 | ||
| 23 | it("puts the user's own locations before Uplink's bundled toolchain", () => { | |
| 24 | const entries = agentPath({ appDir: '/app', env }).split(':') | |
| 25 | expect(entries).toContain('/app/pantry/.bin') | |
| 26 | // Uplink's pinned bun must not shadow the CLI a person installed themselves. | |
| 27 | expect(entries.indexOf('/Users/someone/.bun/bin')).toBeLessThan(entries.indexOf('/app/pantry/.bin')) | |
| 28 | }) | |
| 29 | ||
| 30 | it('keeps any existing PATH, last, so a real environment still wins', () => { | |
| 31 | const entries = agentPath({ env }).split(':') | |
| 32 | expect(entries.at(-1)).toBe('/bin') | |
| 33 | expect(entries.at(-2)).toBe('/usr/bin') | |
| 34 | }) | |
| 35 | ||
| 36 | it('omits the app directory when there is none, rather than emitting an empty entry', () => { | |
| 37 | expect(agentPath({ env })).not.toContain('::') | |
| 38 | expect(agentPath({ env }).split(':').filter(Boolean).length).toBe(agentPath({ env }).split(':').length) | |
| 39 | }) | |
| 40 | ||
| 41 | it('falls back to the real home when the environment has none', () => { | |
| 42 | expect(agentPath({ env: { PATH: '/usr/bin' } })).toContain('/.bun/bin') | |
| 43 | }) | |
| 44 | }) | |
| @@ -210,7 +210,7 @@ describe('CodexEngine', () => { | ||
| 210 | 210 | // The real CLI prints this on stderr, not stdout. |
| 211 | 211 | writeFileSync(path, '#!/bin/sh\necho "Logged in using ChatGPT" >&2\nexit 0\n') |
| 212 | 212 | chmodSync(path, 0o755) |
| 213 | expect(await engine(path).probe()).toEqual({ ok: true, detail: 'Logged in using ChatGPT' }) | |
| 213 | expect(await engine(path).probe()).toEqual({ ok: true, reason: 'ok', detail: 'Logged in using ChatGPT' }) | |
| 214 | 214 | }) |
| 215 | 215 | |
| 216 | 216 | it('is not signed in when it exits non-zero', async () => { |
| @@ -219,13 +219,17 @@ describe('CodexEngine', () => { | ||
| 219 | 219 | chmodSync(path, 0o755) |
| 220 | 220 | const probe = await engine(path).probe() |
| 221 | 221 | expect(probe.ok).toBe(false) |
| 222 | expect(probe.reason).toBe('signed-out') | |
| 222 | 223 | expect(probe.detail).toContain('Not logged in') |
| 223 | 224 | }) |
| 224 | 225 | |
| 225 | it('says so when the binary is missing', async () => { | |
| 226 | it('reports a missing binary as missing, not as signed out', async () => { | |
| 227 | // These are different problems with different next steps, and setup used | |
| 228 | // to collapse them into one. | |
| 226 | 229 | const probe = await engine(join(dir, 'not-installed')).probe() |
| 227 | 230 | expect(probe.ok).toBe(false) |
| 228 | expect(probe.detail).toContain('Is Codex installed?') | |
| 231 | expect(probe.reason).toBe('missing') | |
| 232 | expect(probe.detail).toContain('not installed') | |
| 229 | 233 | }) |
| 230 | 234 | }) |
| 231 | 235 | }) |
| @@ -1,5 +1,5 @@ | ||
| 1 | import type { Engine, EngineEvent, EngineRequest, EngineResult, EngineRun } from '../../../app/Uplink/engine' | |
| 2 | import type { ReplyTarget, Sender } from '../../../app/Uplink/sender' | |
| 1 | import type { Engine, EngineEvent, EngineProbe, EngineRequest, EngineResult, EngineRun } from '../../../app/Uplink/engine' | |
| 2 | import type { AutomationState, ReplyTarget, Sender } from '../../../app/Uplink/sender' | |
| 3 | 3 | import { Database } from 'bun:sqlite' |
| 4 | 4 | import { mkdtempSync } from 'node:fs' |
| 5 | 5 | import { tmpdir } from 'node:os' |
| @@ -119,10 +119,20 @@ export class FakeChatDb { | ||
| 119 | 119 | |
| 120 | 120 | export class FakeSender implements Sender { |
| 121 | 121 | sent: Array<{ target: ReplyTarget, text: string }> = [] |
| 122 | /** Set to make every send fail, the way a refused Automation grant does. */ | |
| 123 | refuse: string | null = null | |
| 124 | automation: AutomationState = { ok: true, reason: 'ok', detail: 'Uplink can send through Messages' } | |
| 125 | ||
| 122 | 126 | /** Mirror each send into chat.db, the way Messages records it. */ |
| 123 | 127 | constructor(private readonly db?: FakeChatDb) {} |
| 124 | 128 | |
| 129 | async canSend(): Promise<AutomationState> { | |
| 130 | return this.automation | |
| 131 | } | |
| 132 | ||
| 125 | 133 | async send(target: ReplyTarget, text: string): Promise<void> { |
| 134 | if (this.refuse) | |
| 135 | throw new Error(this.refuse) | |
| 126 | 136 | this.sent.push({ target, text }) |
| 127 | 137 | this.db?.add({ chat: target.handle, text, fromMe: true }) |
| 128 | 138 | } |
| @@ -148,10 +158,11 @@ export class FakeEngine implements Engine { | ||
| 148 | 158 | readonly id = 'claude' as const |
| 149 | 159 | readonly label = 'Claude Code' |
| 150 | 160 | readonly authFailureHint = 'run "claude setup-token" on the Mac and give Uplink the token' |
| 161 | readonly install = { command: 'bun install -g @anthropic-ai/claude-code', url: 'https://docs.claude.com/en/docs/claude-code' } | |
| 151 | 162 | |
| 152 | probeResult: { ok: boolean, detail: string } = { ok: true, detail: 'Signed in' } | |
| 163 | probeResult: EngineProbe = { ok: true, reason: 'ok', detail: 'Signed in' } | |
| 153 | 164 | |
| 154 | async probe(): Promise<{ ok: boolean, detail: string }> { | |
| 165 | async probe(): Promise<EngineProbe> { | |
| 155 | 166 | this.probes += 1 |
| 156 | 167 | return this.probeResult |
| 157 | 168 | } |
| @@ -214,6 +214,23 @@ describe('Uplink', () => { | ||
| 214 | 214 | expect(h.engine.runs[1].request.sessionId).toBeNull() |
| 215 | 215 | }) |
| 216 | 216 | |
| 217 | it('does not record a run as done when the reply could not be delivered', async () => { | |
| 218 | const h = await harness() | |
| 219 | // What a refused Messages Automation grant looks like from here. Without | |
| 220 | // this the run was recorded done, the dashboard showed an answer, and the | |
| 221 | // phone heard nothing at all. | |
| 222 | h.sender.refuse = 'Messages refused to send (osascript exit 1): not authorized to send Apple events' | |
| 223 | h.fake.add({ chat: ME, text: 'hello', fromMe: true }) | |
| 224 | await h.uplink.tick() | |
| 225 | h.engine.finish(0, 'Here are the scores') | |
| 226 | await settle() | |
| 227 | ||
| 228 | expect(h.sender.texts()).toEqual([]) | |
| 229 | expect(h.store.runs.get(1)?.status).toBe('failed') | |
| 230 | expect(h.store.runs.get(1)?.error).toContain('could not be delivered') | |
| 231 | expect(h.uplink.lastSendError).toContain('not authorized') | |
| 232 | }) | |
| 233 | ||
| 217 | 234 | it('tells the user which CLI is not logged in, and how to fix it', async () => { |
| 218 | 235 | const h = await harness() |
| 219 | 236 | h.fake.add({ chat: ME, text: 'hello', fromMe: true }) |