ReviewOS

also looking at this

stacks/uplink

fix(app): a fresh install now either works or says why

#8
Merged glennmichael123 wants to merge fix/setup-blockers into main
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.

app/Uplink/codex-engine.tsmodified+16-7
Changes to app/Uplink/codex-engine.ts
@@ -1,11 +1,11 @@
11import type { Subprocess } from 'bun'
2import type { Engine, EngineProbe, EngineRequest, EngineResult, EngineRun } from './engine'
2import type { Engine, EngineInstall, EngineProbe, EngineRequest, EngineResult, EngineRun } from './engine'
33import { randomUUID } from 'node:crypto'
44import { rmSync } from 'node:fs'
55import { tmpdir } from 'node:os'
66import { join } from 'node:path'
77import process from 'node:process'
8import { PROBE_TIMEOUT_MS } from './engine'
8import { binaryExists, PROBE_TIMEOUT_MS } from './engine'
99import { truncate } from './format'
1010
1111/**
@@ -60,6 +60,10 @@ export class CodexEngine implements Engine {
6060 readonly id = 'codex' as const
6161 readonly label = 'Codex'
6262 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 }
6367
6468 constructor(private readonly options: CodexEngineOptions) {}
6569
@@ -69,6 +73,9 @@ export class CodexEngine implements Engine {
6973 * can be checked as often as the UI likes.
7074 */
7175 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
7279 try {
7380 const proc = Bun.spawn([this.options.bin, 'login', 'status'], {
7481 cwd: tmpdir(),
@@ -81,13 +88,15 @@ export class CodexEngine implements Engine {
8188 const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()])
8289 const exitCode = await proc.exited
8390 clearTimeout(timer)
91 // `codex login status` prints to stderr, not stdout, and exits 0 when
92 // signed in.
8493 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 }
8897 }
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)}` }
91100 }
92101 }
93102
app/Uplink/desktop-agent.tsmodified+37-3
Changes to app/Uplink/desktop-agent.ts
@@ -1,6 +1,7 @@
11/* eslint-disable no-console */
22import type { Server } from 'bun'
33import type { UplinkConfig } from './config'
4import type { EngineId, EngineInstall, EngineProbe, ProbeReason } from './engine'
45import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
56import { homedir } from 'node:os'
67import { join } from 'node:path'
@@ -41,7 +42,25 @@ const LOCK_PATH = join(DATA_DIR, 'uplink.pid')
4142const LAUNCH_AGENT = join(homedir(), 'Library', 'LaunchAgents', `${LABEL}.plist`)
4243const RETRY_OPEN_MS = 5_000
4344
44type EngineState = { ok: boolean, detail: string, checkedAt: number }
45type 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 */
52interface 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}
4564
4665export interface DesktopAgent {
4766 port: number
@@ -218,7 +237,7 @@ export async function startDesktopAgent(options: { version: string }): Promise<D
218237 queued: run.queued,
219238 })) ?? []
220239 const allowed = uplink?.allowedHandles ?? settings.allowed
221 const checks = [
240 const checks: PopoverCheck[] = [
222241 {
223242 id: 'fda',
224243 name: 'Read Messages',
@@ -229,13 +248,19 @@ export async function startDesktopAgent(options: { version: string }): Promise<D
229248 ...allEngines(config).map((engine) => {
230249 const state = engines.get(engine.id)
231250 const selected = engine.id === config.engine
251 const reason = state?.reason ?? 'failed'
252 const missing = reason === 'missing'
232253 return {
233254 // `engine` is the selected one, and the popover draws its sign-in
234255 // step. The other is listed so switching is discoverable.
235256 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,
237260 engineId: engine.id,
238261 label: engine.label,
262 reason,
263 install: engine.install,
239264 ok: state?.ok ?? false,
240265 detail: checkingEngines && !state ? 'Checking' : (state?.detail ?? 'Not checked yet'),
241266 informational: !selected,
@@ -300,6 +325,15 @@ export async function startDesktopAgent(options: { version: string }): Promise<D
300325 Bun.spawnSync(['open', 'x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles'])
301326 return json({ ok: true })
302327
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
303337 case '/api/open/terminal': {
304338 // Both sign-ins need a real terminal for their browser round trip.
305339 const command = config.engine === 'codex' ? 'codex login' : 'claude setup-token'
app/Uplink/doctor.tsmodified+24-11
Changes to app/Uplink/doctor.ts
@@ -1,5 +1,5 @@
11import type { UplinkConfig } from './config'
2import type { Engine } from './engine'
2import type { Engine, ProbeReason } from './engine'
33import { existsSync } from 'node:fs'
44import { join } from 'node:path'
55import { allEngines } from './engines'
@@ -61,14 +61,23 @@ export async function readHeartbeat(appDir: string): Promise<Heartbeat | null> {
6161const HEARTBEAT_STALE_MS = 2 * 60_000
6262
6363/**
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.
6769 */
68function 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'
70function 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.`
7281}
7382
7483export async function runChecks(config: UplinkConfig, appDir: string): Promise<Check[]> {
@@ -119,16 +128,20 @@ export async function runChecks(config: UplinkConfig, appDir: string): Promise<C
119128 for (const engine of allEngines(config)) {
120129 const selected = engine.id === config.engine
121130 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
122135 checks.push({
123 name: selected ? `${engine.label} account` : `${engine.label} (available, not selected)`,
136 name,
124137 ok: probe.ok,
125138 detail: probe.detail,
126139 informational: !selected,
127140 fix: probe.ok
128141 ? undefined
129142 : 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)}`,
132145 })
133146 }
134147
app/Uplink/engine.tsmodified+43-4
Changes to app/Uplink/engine.ts
@@ -53,12 +53,34 @@ export function isEngineId(value: string): value is EngineId {
5353 return (ENGINE_IDS as readonly string[]).includes(value)
5454}
5555
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 */
62export type ProbeReason = 'ok' | 'missing' | 'signed-out' | 'failed'
63
5664/** Whether a CLI can answer right now, for the doctor and the menubar. */
5765export interface EngineProbe {
5866 ok: boolean
67 reason: ProbeReason
5968 detail: string
6069}
6170
71/** Where to get an engine that is not installed. */
72export interface EngineInstall {
73 command: string
74 url: string
75}
76
77/** True when `bin` is something this machine can actually run. */
78export 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
6284export interface Engine {
6385 readonly id: EngineId
6486 /** How the engine is named to a person: "Claude Code", "Codex". */
@@ -68,6 +90,8 @@ export interface Engine {
6890 * from the Mac, so this has to name the CLI and the exact command.
6991 */
7092 readonly authFailureHint: string
93 /** How to install this CLI, for a Mac that does not have it. */
94 readonly install: EngineInstall
7195 run: (request: EngineRequest) => EngineRun
7296 /**
7397 * Can this CLI answer right now. Separate from `run` because setup has to
@@ -95,6 +119,10 @@ export class ClaudeEngine implements Engine {
95119 readonly id = 'claude' as const
96120 readonly label = 'Claude Code'
97121 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 }
98126
99127 constructor(private readonly options: ClaudeEngineOptions) {}
100128
@@ -104,6 +132,11 @@ export class ClaudeEngine implements Engine {
104132 * rejected with a 401 on first use.
105133 */
106134 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
107140 try {
108141 const proc = Bun.spawn([this.options.bin, '-p', 'Reply with exactly: ok', '--model', 'haiku', '--output-format', 'json'], {
109142 cwd: tmpdir(),
@@ -119,12 +152,18 @@ export class ClaudeEngine implements Engine {
119152 const result = JSON.parse(text) as { is_error?: boolean, result?: string }
120153 if (result.is_error) {
121154 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',
123163 }
124 return { ok: true, detail: this.env().CLAUDE_CODE_OAUTH_TOKEN ? 'Signed in with your token' : 'Signed in through the Claude CLI' }
125164 }
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)}` }
128167 }
129168 }
130169
app/Uplink/menubar.htmlmodified+33-1
Changes to app/Uplink/menubar.html
@@ -94,6 +94,18 @@
9494 <div data-when-todo>
9595 <label for="engine">Answer texts with</label>
9696 <select id="engine"></select>
97 <!-- A missing CLI is not a sign-in problem. Nothing below applies
98 until it is installed, so the sign-in blocks are hidden. -->
99 <div id="engine-missing" hidden>
100 <p class="help"><strong data-missing-label></strong> is not installed on this Mac. Uplink runs it for you, but it cannot install it.</p>
101 <label for="install-command">Install it with</label>
102 <input id="install-command" readonly spellcheck="false">
103 <div class="actions">
104 <button data-action="copy-install">Copy command</button>
105 <button data-action="open-install-docs">Open the docs</button>
106 <button class="primary" data-action="test-engine">Check again</button>
107 </div>
108 </div>
97109 <!-- Each CLI signs in its own way, so only the selected one's
98110 instructions are shown. -->
99111 <div data-engine-only="claude">
@@ -198,8 +210,17 @@
198210 }
199211 picker.value = selected
200212
213 // A missing CLI replaces the sign-in instructions rather than sitting
214 // above them: signing in is not possible until it exists.
215 const missing = check?.reason === 'missing'
216 $('#engine-missing').hidden = !missing
217 if (missing) {
218 $('[data-missing-label]').textContent = label
219 $('#install-command').value = check.install?.command || ''
220 }
221
201222 for (const node of document.querySelectorAll('[data-engine-only]'))
202 node.hidden = node.dataset.engineOnly !== selected
223 node.hidden = missing || node.dataset.engineOnly !== selected
203224 }
204225
205226 function render(status) {
@@ -311,6 +332,17 @@
311332 'open-fda': () => api('/api/open/full-disk-access', {}),
312333 'open-terminal': () => api('/api/open/terminal', {}),
313334 'test-engine': () => api('/api/engine/test', {}).then(refresh),
335 'copy-install': async () => {
336 const field = $('#install-command')
337 field.select()
338 // The webview may refuse the async clipboard API without a secure
339 // origin, so fall back to the selection the person can copy themselves.
340 try {
341 await navigator.clipboard.writeText(field.value)
342 }
343 catch {}
344 },
345 'open-install-docs': () => api('/api/open/install-docs', {}),
314346 'save-token': async () => {
315347 const error = $('#token-error')
316348 error.hidden = true
tests/unit/uplink/codex-engine.test.tsmodified+7-3
Changes to tests/unit/uplink/codex-engine.test.ts
@@ -210,7 +210,7 @@ describe('CodexEngine', () => {
210210 // The real CLI prints this on stderr, not stdout.
211211 writeFileSync(path, '#!/bin/sh\necho "Logged in using ChatGPT" >&2\nexit 0\n')
212212 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' })
214214 })
215215
216216 it('is not signed in when it exits non-zero', async () => {
@@ -219,13 +219,17 @@ describe('CodexEngine', () => {
219219 chmodSync(path, 0o755)
220220 const probe = await engine(path).probe()
221221 expect(probe.ok).toBe(false)
222 expect(probe.reason).toBe('signed-out')
222223 expect(probe.detail).toContain('Not logged in')
223224 })
224225
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.
226229 const probe = await engine(join(dir, 'not-installed')).probe()
227230 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')
229233 })
230234 })
231235})
tests/unit/uplink/fixtures.tsmodified+4-3
Changes to tests/unit/uplink/fixtures.ts
@@ -1,4 +1,4 @@
1import type { Engine, EngineEvent, EngineRequest, EngineResult, EngineRun } from '../../../app/Uplink/engine'
1import type { Engine, EngineEvent, EngineProbe, EngineRequest, EngineResult, EngineRun } from '../../../app/Uplink/engine'
22import type { ReplyTarget, Sender } from '../../../app/Uplink/sender'
33import { Database } from 'bun:sqlite'
44import { mkdtempSync } from 'node:fs'
@@ -148,10 +148,11 @@ export class FakeEngine implements Engine {
148148 readonly id = 'claude' as const
149149 readonly label = 'Claude Code'
150150 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' }
151152
152 probeResult: { ok: boolean, detail: string } = { ok: true, detail: 'Signed in' }
153 probeResult: EngineProbe = { ok: true, reason: 'ok', detail: 'Signed in' }
153154
154 async probe(): Promise<{ ok: boolean, detail: string }> {
155 async probe(): Promise<EngineProbe> {
155156 this.probes += 1
156157 return this.probeResult
157158 }