diff --git a/docs/renderer-memory-profile-2026-06-01.md b/docs/renderer-memory-profile-2026-06-01.md index 61d8d1333..f8e321a88 100644 --- a/docs/renderer-memory-profile-2026-06-01.md +++ b/docs/renderer-memory-profile-2026-06-01.md @@ -76,3 +76,16 @@ during the live profile. The fixes are covered by unit and e2e tests, but the next packaged build should be re-profiled under the same active Codex TUI load to confirm the Resource Usage display and terminal previews match the expected lower-churn behavior. + +## Follow-up: CLI Profiling Blocker + +Continuing the profile after this change confirmed the public +`/usr/local/bin/orca` command was still broken because it was a regular +generated launcher file pointing at a removed development build. The CLI +installer previously self-healed stale symlinks, but treated regular files as +conflicts. That meant Settings could not replace an Orca-owned stale launcher, +forcing profiling to use the packaged CLI fallback. + +The follow-up fix teaches the installer to recognize only generated Orca Unix +launcher files as stale and replaceable. Arbitrary regular files at the command +path remain conflicts. diff --git a/src/main/cli/cli-installer.test.ts b/src/main/cli/cli-installer.test.ts index 9f642eeda..001f88164 100644 --- a/src/main/cli/cli-installer.test.ts +++ b/src/main/cli/cli-installer.test.ts @@ -436,6 +436,86 @@ describe('CliInstaller', () => { } ) + // Why: old dev/package experiments wrote a generated Orca launcher file + // directly into /usr/local/bin/orca. That broke profiling because Settings + // treated the regular file as a hard conflict and would not self-heal it. + it.skipIf(process.platform === 'win32')( + 'replaces stale generated Unix launcher files', + async () => { + const fixture = await makeFixture() + const commandDir = join(fixture.root, 'bin') + const installPath = join(commandDir, 'orca') + const resourcesPath = join(fixture.root, 'Current.app', 'Contents', 'Resources') + const launcherPath = join(resourcesPath, 'bin', 'orca') + const oldCliPath = join(fixture.root, 'OldWorktree', 'out', 'cli', 'index.js') + await mkdir(commandDir, { recursive: true }) + await mkdir(join(resourcesPath, 'bin'), { recursive: true }) + await writeFile(launcherPath, '#!/usr/bin/env bash\n', 'utf8') + await writeFile( + installPath, + [ + '#!/usr/bin/env bash', + 'set -euo pipefail', + "ELECTRON='/tmp/Old.app/Contents/MacOS/Electron'", + `CLI='${oldCliPath}'`, + 'export ORCA_NODE_OPTIONS="${NODE_OPTIONS-}"', + 'export ORCA_NODE_REPL_EXTERNAL_MODULE="${NODE_REPL_EXTERNAL_MODULE-}"', + 'unset NODE_OPTIONS', + 'unset NODE_REPL_EXTERNAL_MODULE', + 'ELECTRON_RUN_AS_NODE=1 "$ELECTRON" "$CLI" "$@"', + '' + ].join('\n'), + 'utf8' + ) + + const installer = new CliInstaller({ + platform: 'darwin', + isPackaged: true, + resourcesPath, + commandPathOverride: installPath, + processPathEnv: commandDir + }) + + await expect(installer.getStatus()).resolves.toMatchObject({ + state: 'stale', + currentTarget: oldCliPath + }) + await expect(installer.install()).resolves.toMatchObject({ state: 'installed' }) + await expect(readlink(installPath)).resolves.toBe(launcherPath) + } + ) + + it.skipIf(process.platform === 'win32')( + 'keeps arbitrary regular files at the command path as conflicts', + async () => { + const fixture = await makeFixture() + const commandDir = join(fixture.root, 'bin') + const installPath = join(commandDir, 'orca') + const resourcesPath = await createPackagedMacLauncher(fixture.root) + await mkdir(commandDir, { recursive: true }) + await writeFile( + installPath, + '#!/usr/bin/env bash\nELECTRON_RUN_AS_NODE=1 /tmp/not-orca "$@"\n', + 'utf8' + ) + + const installer = new CliInstaller({ + platform: 'darwin', + isPackaged: true, + resourcesPath, + commandPathOverride: installPath, + processPathEnv: commandDir + }) + + await expect(installer.getStatus()).resolves.toMatchObject({ + state: 'conflict', + currentTarget: null + }) + await expect(installer.install()).rejects.toThrow('Refusing to replace non-Orca command') + await expect(readFile(installPath, 'utf8')).resolves.toContain('/tmp/not-orca') + } + ) + // Why: a dev build can temporarily own the public command on developer // machines; packaged Orca should treat that as stale, not a hard conflict. it.skipIf(process.platform === 'win32')( diff --git a/src/main/cli/cli-installer.ts b/src/main/cli/cli-installer.ts index 043566db1..e7dae5f38 100644 --- a/src/main/cli/cli-installer.ts +++ b/src/main/cli/cli-installer.ts @@ -476,6 +476,22 @@ export class CliInstaller { try { const stats = await lstat(commandPath) if (!stats.isSymbolicLink()) { + if (stats.isFile()) { + const currentContent = await readFile(commandPath, 'utf8') + const managedTarget = extractManagedUnixLauncherTarget(currentContent) + if (managedTarget) { + return this.buildStatus({ + commandPath, + launcherPath, + installMethod: 'symlink', + supported: true, + state: 'stale', + currentTarget: managedTarget, + detail: `${commandPath} contains an older Orca launcher.` + }) + } + } + return this.buildStatus({ commandPath, launcherPath, @@ -841,6 +857,36 @@ set "ORCA_LAUNCHER=${escapeWindowsBatchValue(launcherPath)}" ` } +function extractManagedUnixLauncherTarget(content: string): string | null { + if ( + !content.includes('ELECTRON_RUN_AS_NODE=1') || + !content.includes('ORCA_NODE_OPTIONS') || + !content.includes('NODE_REPL_EXTERNAL_MODULE') + ) { + return null + } + + const cliPath = extractShellAssignment(content, 'CLI') + if (!cliPath) { + return null + } + + // Why: older dev installs wrote a generated shell launcher directly to + // /usr/local/bin/orca. Treat only Orca's compiled CLI entrypoints as managed; + // arbitrary user scripts that happen to launch Electron must stay conflicts. + return /(?:^|[/\\])(?:out|app\.asar\.unpacked[/\\]out)[/\\]cli[/\\]index\.js$/.test(cliPath) + ? cliPath + : null +} + +function extractShellAssignment(content: string, name: string): string | null { + const match = new RegExp(`^${name}=('([^']*)'|"([^"]*)"|([^\\n]+))$`, 'm').exec(content) + if (!match) { + return null + } + return (match[2] ?? match[3] ?? match[4] ?? '').trim() +} + function splitPathEntries(platform: NodeJS.Platform, value: string | null): string[] { if (!value) { return []