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.
| @@ -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 | } | |
| @@ -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 | ]) { |
| @@ -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, |
| @@ -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 | }) | |