also looking at this
fix(app): a fresh install now either works or says why
#8
16 files
+512
-75
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -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 | |
| @@ -1,6 +1,7 @@ | ||
| 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' | |
| 4 | 5 | import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' |
| 5 | 6 | import { homedir } from 'node:os' |
| 6 | 7 | import { join } from 'node:path' |
| @@ -41,7 +42,25 @@ const LOCK_PATH = join(DATA_DIR, 'uplink.pid') | ||
| 41 | 42 | const LAUNCH_AGENT = join(homedir(), 'Library', 'LaunchAgents', `${LABEL}.plist`) |
| 42 | 43 | const RETRY_OPEN_MS = 5_000 |
| 43 | 44 | |
| 44 | type EngineState = { ok: boolean, detail: string, checkedAt: number } | |
| 45 | type EngineState = EngineProbe & { checkedAt: number } | |
| 46 | ||
| 47 | /** | |
| 48 | * One row of the popover's setup list. Declared rather than inferred, because | |
| 49 | * the entries genuinely differ (only an engine row carries `install`) and an | |
| 50 | * inferred union puts the shared fields out of reach. | |
| 51 | */ | |
| 52 | interface PopoverCheck { | |
| 53 | id: string | |
| 54 | name: string | |
| 55 | ok: boolean | |
| 56 | detail: string | |
| 57 | /** Shown, but not counted towards readiness. */ | |
| 58 | informational: boolean | |
| 59 | engineId?: EngineId | |
| 60 | label?: string | |
| 61 | reason?: ProbeReason | |
| 62 | install?: EngineInstall | |
| 63 | } | |
| 45 | 64 | |
| 46 | 65 | export interface DesktopAgent { |
| 47 | 66 | port: number |
| @@ -218,7 +237,7 @@ export async function startDesktopAgent(options: { version: string }): Promise<D | ||
| 218 | 237 | queued: run.queued, |
| 219 | 238 | })) ?? [] |
| 220 | 239 | const allowed = uplink?.allowedHandles ?? settings.allowed |
| 221 | const checks = [ | |
| 240 | const checks: PopoverCheck[] = [ | |
| 222 | 241 | { |
| 223 | 242 | id: 'fda', |
| 224 | 243 | name: 'Read Messages', |
| @@ -229,13 +248,19 @@ export async function startDesktopAgent(options: { version: string }): Promise<D | ||
| 229 | 248 | ...allEngines(config).map((engine) => { |
| 230 | 249 | const state = engines.get(engine.id) |
| 231 | 250 | const selected = engine.id === config.engine |
| 251 | const reason = state?.reason ?? 'failed' | |
| 252 | const missing = reason === 'missing' | |
| 232 | 253 | return { |
| 233 | 254 | // `engine` is the selected one, and the popover draws its sign-in |
| 234 | 255 | // step. The other is listed so switching is discoverable. |
| 235 | 256 | id: selected ? 'engine' : `engine:${engine.id}`, |
| 236 | name: selected ? `Sign in to ${engine.label}` : engine.label, | |
| 257 | // A missing CLI is not a sign-in problem, and calling it one sends | |
| 258 | // the person to run a command that does not exist either. | |
| 259 | name: missing ? `Install ${engine.label}` : selected ? `Sign in to ${engine.label}` : engine.label, | |
| 237 | 260 | engineId: engine.id, |
| 238 | 261 | label: engine.label, |
| 262 | reason, | |
| 263 | install: engine.install, | |
| 239 | 264 | ok: state?.ok ?? false, |
| 240 | 265 | detail: checkingEngines && !state ? 'Checking' : (state?.detail ?? 'Not checked yet'), |
| 241 | 266 | informational: !selected, |
| @@ -300,6 +325,15 @@ export async function startDesktopAgent(options: { version: string }): Promise<D | ||
| 300 | 325 | Bun.spawnSync(['open', 'x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles']) |
| 301 | 326 | return json({ ok: true }) |
| 302 | 327 | |
| 328 | case '/api/open/install-docs': { | |
| 329 | // The selected engine's own documentation, so the link cannot drift | |
| 330 | // from the command shown beside it. | |
| 331 | const { url } = allEngines(config).find(engine => engine.id === config.engine)?.install ?? { url: '' } | |
| 332 | if (url) | |
| 333 | Bun.spawnSync(['open', url]) | |
| 334 | return json({ ok: Boolean(url), url }) | |
| 335 | } | |
| 336 | ||
| 303 | 337 | case '/api/open/terminal': { |
| 304 | 338 | // Both sign-ins need a real terminal for their browser round trip. |
| 305 | 339 | 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' |
| @@ -61,14 +61,23 @@ export async function readHeartbeat(appDir: string): Promise<Heartbeat | null> { | ||
| 61 | 61 | const HEARTBEAT_STALE_MS = 2 * 60_000 |
| 62 | 62 | |
| 63 | 63 | /** |
| 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. | |
| 64 | * What to do about an engine that cannot answer, which depends on why. | |
| 65 | * | |
| 66 | * A missing CLI used to be reported inside the sign-in step, so the person was | |
| 67 | * told to run a command that does not exist either. These are three problems | |
| 68 | * with three different next steps. | |
| 67 | 69 | */ |
| 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' | |
| 70 | function engineFix(engine: Engine, reason: ProbeReason): string { | |
| 71 | if (reason === 'missing') | |
| 72 | return `Install it: ${engine.install.command} (see ${engine.install.url}), then ./buddy uplink:restart` | |
| 73 | ||
| 74 | if (reason === 'signed-out') { | |
| 75 | return engine.id === 'claude' | |
| 76 | ? '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' | |
| 77 | : 'Run codex login (or codex login --device-auth if you are on SSH), then ./buddy uplink:restart' | |
| 78 | } | |
| 79 | ||
| 80 | return `${engine.label} is installed and signed in, but did not answer. The detail above is what it said.` | |
| 72 | 81 | } |
| 73 | 82 | |
| 74 | 83 | export async function runChecks(config: UplinkConfig, appDir: string): Promise<Check[]> { |
| @@ -119,16 +128,20 @@ export async function runChecks(config: UplinkConfig, appDir: string): Promise<C | ||
| 119 | 128 | for (const engine of allEngines(config)) { |
| 120 | 129 | const selected = engine.id === config.engine |
| 121 | 130 | const probe = await engine.probe() |
| 131 | const name = probe.reason === 'missing' | |
| 132 | ? `${engine.label} (not installed)` | |
| 133 | : selected ? `${engine.label} account` : `${engine.label} (available, not selected)` | |
| 134 | ||
| 122 | 135 | checks.push({ |
| 123 | name: selected ? `${engine.label} account` : `${engine.label} (available, not selected)`, | |
| 136 | name, | |
| 124 | 137 | ok: probe.ok, |
| 125 | 138 | detail: probe.detail, |
| 126 | 139 | informational: !selected, |
| 127 | 140 | fix: probe.ok |
| 128 | 141 | ? undefined |
| 129 | 142 | : selected |
| 130 | ? engineFix(engine) | |
| 131 | : `Optional. To use it, set UPLINK_ENGINE=${engine.id} in .env, then: ${engineFix(engine)}`, | |
| 143 | ? engineFix(engine, probe.reason) | |
| 144 | : `Optional. To use it, set UPLINK_ENGINE=${engine.id} in .env, then: ${engineFix(engine, probe.reason)}`, | |
| 132 | 145 | }) |
| 133 | 146 | } |
| 134 | 147 | |
| @@ -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 | |
| @@ -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,4 +1,4 @@ | ||
| 1 | import type { Engine, EngineEvent, EngineRequest, EngineResult, EngineRun } from '../../../app/Uplink/engine' | |
| 1 | import type { Engine, EngineEvent, EngineProbe, EngineRequest, EngineResult, EngineRun } from '../../../app/Uplink/engine' | |
| 2 | 2 | import type { ReplyTarget, Sender } from '../../../app/Uplink/sender' |
| 3 | 3 | import { Database } from 'bun:sqlite' |
| 4 | 4 | import { mkdtempSync } from 'node:fs' |
| @@ -148,10 +148,11 @@ export class FakeEngine implements Engine { | ||
| 148 | 148 | readonly id = 'claude' as const |
| 149 | 149 | readonly label = 'Claude Code' |
| 150 | 150 | readonly authFailureHint = 'run "claude setup-token" on the Mac and give Uplink the token' |
| 151 | readonly install = { command: 'bun install -g @anthropic-ai/claude-code', url: 'https://docs.claude.com/en/docs/claude-code' } | |
| 151 | 152 | |
| 152 | probeResult: { ok: boolean, detail: string } = { ok: true, detail: 'Signed in' } | |
| 153 | probeResult: EngineProbe = { ok: true, reason: 'ok', detail: 'Signed in' } | |
| 153 | 154 | |
| 154 | async probe(): Promise<{ ok: boolean, detail: string }> { | |
| 155 | async probe(): Promise<EngineProbe> { | |
| 155 | 156 | this.probes += 1 |
| 156 | 157 | return this.probeResult |
| 157 | 158 | } |