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/Desktop/launcher.tsmodified+8-0
Changes to app/Desktop/launcher.ts
@@ -16,9 +16,17 @@ import { dirname, join } from 'node:path'
1616import process from 'node:process'
1717import { format } from 'node:util'
1818import pkg from '../../package.json'
19import { agentPath } from '../Uplink/agent-path'
1920import { acquireSingleInstance, startDesktopAgent } from '../Uplink/desktop-agent'
2021import { LOG_PATH } from '../Uplink/settings'
2122
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.
28process.env.PATH = agentPath()
29
2230// Launched from Finder or at login there is no terminal, so keep a log where
2331// Console.app looks for one.
2432if (!process.stdout.isTTY) {
app/Uplink/agent-path.tsadded+39-0
Changes to app/Uplink/agent-path.ts
@@ -0,0 +1,39 @@
1import { homedir } from 'node:os'
2import { join } from 'node:path'
3import 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 */
19export 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}
app/Uplink/config.tsmodified+4-0
Changes to app/Uplink/config.ts
@@ -109,6 +109,10 @@ function findClaude(): string {
109109 for (const candidate of [
110110 join(homedir(), '.local', 'bin', 'claude'),
111111 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'),
112116 '/opt/homebrew/bin/claude',
113117 '/usr/local/bin/claude',
114118 ]) {
app/Uplink/launcher.tsmodified+4-14
Changes to app/Uplink/launcher.ts
@@ -16,25 +16,15 @@
1616 */
1717import { join } from 'node:path'
1818import process from 'node:process'
19import { agentPath } from './agent-path'
1920
2021declare const UPLINK_APP_DIR: string
2122
2223const appDir = process.env.UPLINK_APP_DIR || UPLINK_APP_DIR
23const home = process.env.HOME ?? ''
2424
25// launchd hands agents a bare PATH; the watcher shells out to claude, git and osascript.
26const 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.
27const PATH = agentPath({ appDir })
3828
3929const watcher = Bun.spawn([join(appDir, 'buddy'), 'uplink:watch'], {
4030 cwd: appDir,
tests/unit/uplink/agent-path.test.tsadded+44-0
Changes to tests/unit/uplink/agent-path.test.ts
@@ -0,0 +1,44 @@
1import { describe, expect, it } from 'bun:test'
2import { agentPath } from '../../../app/Uplink/agent-path'
3
4const env = { HOME: '/Users/someone', PATH: '/usr/bin:/bin' }
5
6describe('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})