fix: packaged app CLI login detection, PATH merge, IPC status cache

This commit is contained in:
iliya 2026-03-19 22:10:10 +02:00
parent 6ee3d49255
commit 731d15b722
12 changed files with 548 additions and 110 deletions

View file

@ -139,6 +139,9 @@ pnpm typecheck
### Test Failures
Check for changes in message parsing or chunk building logic.
### Packaged app: CLI / “Not logged in”
Each successful run of **`CliInstallerService.getStatus()`** tries to append one NDJSON line to **`claude-cli-auth-diag.ndjson`** (field **`diagFile`**: full path). Typical location: Electron **`app.getPath('logs')`** — on macOS often `~/Library/Logs/<product name>/` (exact folder is OS- and build-specific). If the file exceeds **512 KiB**, it is **truncated to empty** before the next append (avoids unbounded growth). **No line is written** if the app is not under Electron, log dir cannot be resolved, or disk write fails. **IPC** (`cliInstaller:getStatus`) **dedupes** work for **5s** (`STATUS_CACHE_TTL_MS` in `src/main/ipc/cliInstaller.ts`), so rapid UI polls do **not** each trigger a new file append. Default logger hides `info`/`warn` in production; **`logger.error`** still goes to the console (e.g. if assembling the diag line throws — should be rare).
## TypeScript Conventions
### Naming

View file

@ -6,10 +6,11 @@ The format is based on Keep a Changelog and this project follows Semantic Versio
## [Unreleased]
## [1.0.2] - 2026-03-19
### Added
- `general.autoExpandAIGroups` setting: automatically expands all AI response groups when opening a transcript or when new AI responses arrive in a live session. Defaults to off. Stored in the on-disk config so it persists across restarts.
- Strict IPC input validation guards for project/session/subagent/search limits.
- `get-waterfall-data` IPC endpoint implementation.
- Cross-platform path normalization in renderer path resolvers.
@ -17,14 +18,21 @@ The format is based on Keep a Changelog and this project follows Semantic Versio
- CI workflow for macOS/Windows (typecheck, lint, test, build).
- Release workflow for signed package builds.
- Open-source governance docs (`LICENSE`, `CONTRIBUTING`, `CODE_OF_CONDUCT`, `SECURITY`).
- Capped NDJSON diagnostic log for Claude CLI auth/status in packaged builds (Electron logs directory).
### Changed
- `readMentionedFile` preload API signature now requires `projectRoot`.
- Notification update event contract standardized to `{ total, unreadCount }`.
- Session pagination uses cached displayable-content detection for performance.
- File watcher error detection optimized for append-only updates.
- CLI status gathering uses interactive shell environment, merged PATH, and config directory hints aligned with terminal sessions.
- Claude binary resolution deduplicates concurrent resolve calls and uses consistent HOME when probing install locations.
### Fixed
- Lint violations in navigation and markdown/subagent UI components.
- Test mock drift causing runtime errors in test output.
- Multiple Windows path handling edge cases.
- Packaged builds could show "not logged in" despite a working CLI in the shell.
- IPC CLI installer cache clears when `getStatus` fails so the UI does not stay on stale auth state.

View file

@ -1,5 +1,70 @@
# Release Guide
## Published: v1.0.2 (2026-03-19)
Patch release: reliable Claude CLI detection and login status in packaged builds (shell PATH/HOME, `CLAUDE_CONFIG_DIR`, auth output parsing), IPC cache invalidation on status errors, concurrent binary resolution guard, capped NDJSON diagnostics. Full list: [CHANGELOG.md](./CHANGELOG.md).
After CI uploads artifacts, optional notes update:
```bash
gh release edit v1.0.2 --repo 777genius/claude_agent_teams_ui --notes "$(cat <<'EOF'
## Claude Agent Teams UI v1.0.2
Patch focused on CLI/auth reliability in packaged apps and related IPC hardening.
### What's New
- Setting to auto-expand AI response groups in transcripts (`general.autoExpandAIGroups`).
### Improvements
- CLI status uses interactive shell environment and merged PATH so packaged builds match terminal behavior.
- Stricter IPC validation and clearer notification/update contracts.
### Bug Fixes
- Fix false "not logged in" when the CLI is authenticated in the shell.
- Clear stale CLI status cache when status refresh fails.
- Windows path edge cases in tooling and tests.
### Downloads
<table>
<tr>
<td align="center">
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/download/v1.0.2/Claude.Agent.Teams.UI-1.0.2-arm64.dmg">
<img src="https://img.shields.io/badge/macOS_Apple_Silicon-.dmg-000000?style=for-the-badge&logo=apple&logoColor=white" alt="macOS Apple Silicon" />
</a>
<br />
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/download/v1.0.2/Claude.Agent.Teams.UI-1.0.2.dmg">
<img src="https://img.shields.io/badge/macOS_Intel-.dmg-434343?style=for-the-badge&logo=apple&logoColor=white" alt="macOS Intel" />
</a>
</td>
<td align="center">
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/download/v1.0.2/Claude.Agent.Teams.UI.Setup.1.0.2.exe">
<img src="https://img.shields.io/badge/Windows-Download_.exe-0078D4?style=for-the-badge&logo=windows&logoColor=white" alt="Windows" />
</a>
<br />
<sub>May trigger SmartScreen — click "More info" → "Run anyway"</sub>
</td>
<td align="center">
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/download/v1.0.2/Claude.Agent.Teams.UI-1.0.2.AppImage">
<img src="https://img.shields.io/badge/Linux-Download_.AppImage-FCC624?style=for-the-badge&logo=linux&logoColor=black" alt="Linux AppImage" />
</a>
<br />
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/download/v1.0.2/claude-agent-teams-ui_1.0.2_amd64.deb">
<img src="https://img.shields.io/badge/.deb-E95420?style=flat-square&logo=ubuntu&logoColor=white" alt=".deb" />
</a>&nbsp;
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/download/v1.0.2/claude-agent-teams-ui-1.0.2.x86_64.rpm">
<img src="https://img.shields.io/badge/.rpm-294172?style=flat-square&logo=redhat&logoColor=white" alt=".rpm" />
</a>&nbsp;
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/download/v1.0.2/claude-agent-teams-ui-1.0.2.pacman">
<img src="https://img.shields.io/badge/.pacman-1793D1?style=flat-square&logo=archlinux&logoColor=white" alt=".pacman" />
</a>
</td>
</tr>
</table>
EOF
)"
```
## Versioning (SemVer)
Format: `MAJOR.MINOR.PATCH`
@ -166,14 +231,14 @@ electron-builder generates `latest-mac.yml`, `latest.yml`, `latest-linux.yml` al
```bash
# Create and publish a release
git tag v1.1.0
git push origin v1.1.0
git tag v1.0.2
git push origin v1.0.2
# Wait for CI to finish (~10 min), then update notes
# Delete a release (if needed)
gh release delete v1.1.0 --repo 777genius/claude_agent_teams_ui --yes
git tag -d v1.1.0
git push origin :refs/tags/v1.1.0
gh release delete v1.0.2 --repo 777genius/claude_agent_teams_ui --yes
git tag -d v1.0.2
git push origin :refs/tags/v1.0.2
# Check workflow status
gh run list --repo 777genius/claude_agent_teams_ui --workflow release.yml --limit 3

View file

@ -1,7 +1,7 @@
{
"name": "claude-agent-teams-ui",
"type": "module",
"version": "1.0.1",
"version": "1.0.2",
"description": "Desktop app that visualizes Claude Code session execution — explore conversations, track context usage, and analyze tool calls",
"license": "AGPL-3.0",
"author": {

View file

@ -73,6 +73,10 @@ async function handleGetStatus(
cachedStatus = { value: status, at: Date.now() };
return status;
})
.catch((err) => {
cachedStatus = null;
throw err;
})
.finally(() => {
const ms = Date.now() - startedAt;
if (ms >= 2000) {

View file

@ -17,17 +17,23 @@
* - Human-readable error messages per phase
*/
import { appendCliAuthDiag } from '@main/utils/cliAuthDiagLog';
import { buildMergedCliPath } from '@main/utils/cliPathMerge';
import { execCli, killProcessTree, spawnCli } from '@main/utils/childProcess';
import { getHomeDir } from '@main/utils/pathDecoder';
import { getCachedShellEnv } from '@main/utils/shellEnv';
import { getClaudeBasePath, getHomeDir } from '@main/utils/pathDecoder';
import {
getCachedShellEnv,
getShellPreferredHome,
resolveInteractiveShellEnv,
} from '@main/utils/shellEnv';
import { getErrorMessage } from '@shared/utils/errorHandling';
import { createLogger } from '@shared/utils/logger';
import { createHash } from 'crypto';
import { createWriteStream, existsSync, promises as fsp, realpathSync } from 'fs';
import { createWriteStream, existsSync, promises as fsp } from 'fs';
import http from 'http';
import https from 'https';
import { tmpdir } from 'os';
import { dirname, join } from 'path';
import { join, posix as pathPosix, win32 as pathWin32 } from 'path';
import { ClaudeBinaryResolver } from '../team/ClaudeBinaryResolver';
@ -78,86 +84,57 @@ const AUTH_STATUS_RETRY_DELAY_MS = 1500;
/**
* Build env for child processes with correct HOME and enriched PATH.
*
* On macOS, apps launched from Finder/.dmg get a minimal PATH (/usr/bin:/bin:/usr/sbin:/sbin).
* Three layers ensure `claude auth status` and `claude --version` always work:
*
* 1. **Binary-derived**: add dirname(binaryPath) to PATH. If the binary is a symlink
* (e.g. ~/.local/bin/claude ~/.nvm/versions/node/v22/bin/claude), also add the
* resolved real directory this guarantees `node` is findable since npm installs
* claude next to node.
* 2. **Shell env cache**: if the pre-warm (fired at app startup) has completed,
* merge the user's full interactive shell PATH covers all custom entries.
* 3. **Sync fallback**: platform-specific common directories for when the cache
* is still cold (first launch, heavy .zshrc).
*
* On Windows this is effectively a no-op Explorer-launched apps inherit the full
* user PATH, and the shell env cache returns {} (no PATH override).
* PATH merging lives in `cliPathMerge.ts` (shared with binary discovery).
*/
function buildChildEnv(binaryPath?: string | null): NodeJS.ProcessEnv {
const home = getHomeDir();
const sep = process.platform === 'win32' ? ';' : ':';
const currentPath = process.env.PATH || '';
const extraDirs: string[] = [];
// Layer 1: binary's own directory + resolved symlink target.
// This is the most reliable source — if ClaudeBinaryResolver found the binary,
// its directory almost certainly contains `node` too (npm co-installs them).
if (binaryPath) {
const binDir = dirname(binaryPath);
extraDirs.push(binDir);
try {
const realBinDir = dirname(realpathSync(binaryPath));
if (realBinDir !== binDir) {
extraDirs.push(realBinDir);
}
} catch {
// symlink resolution failed (race condition / broken link) — ignore
}
}
// Layer 2: cached shell env (pre-warmed at startup, covers nvm/volta/fnm/custom PATH).
const cachedEnv = getCachedShellEnv();
if (cachedEnv?.PATH) {
extraDirs.push(...cachedEnv.PATH.split(sep).filter(Boolean));
} else {
// Layer 3: sync fallback — common binary directories per platform.
if (process.platform === 'win32') {
extraDirs.push(join(home, 'AppData', 'Roaming', 'npm'), join(home, 'scoop', 'shims'));
if (process.env.LOCALAPPDATA) {
extraDirs.push(join(process.env.LOCALAPPDATA, 'Programs', 'claude'));
}
if (process.env.ProgramFiles) {
extraDirs.push(join(process.env.ProgramFiles, 'claude'));
}
} else {
extraDirs.push(
join(home, '.local', 'bin'),
join(home, '.npm-global', 'bin'),
'/usr/local/bin',
'/opt/homebrew/bin'
);
}
}
// Deduplicate: extra dirs first (higher priority), then existing PATH entries.
const seen = new Set<string>();
const merged: string[] = [];
for (const dir of [...extraDirs, ...currentPath.split(sep)]) {
if (dir && !seen.has(dir)) {
seen.add(dir);
merged.push(dir);
}
}
const home = getShellPreferredHome();
return {
...process.env,
HOME: home,
USERPROFILE: home,
PATH: merged.join(sep),
PATH: buildMergedCliPath(binaryPath),
};
}
/** `claude auth status` may prefix stderr noise or warnings; extract the JSON object. */
function parseClaudeAuthStatusStdout(stdout: string): { loggedIn?: boolean; authMethod?: string } {
const trimmed = stdout.trim();
const parse = (s: string): { loggedIn?: boolean; authMethod?: string } => {
const v = JSON.parse(s) as { loggedIn?: boolean; authMethod?: string };
if (typeof v !== 'object' || v === null) {
throw new Error('auth status: not an object');
}
return v;
};
try {
return parse(trimmed);
} catch {
const start = trimmed.lastIndexOf('{');
const end = trimmed.lastIndexOf('}');
if (start >= 0 && end > start) {
return parse(trimmed.slice(start, end + 1));
}
throw new Error('auth status: no JSON object in output');
}
}
/** NDJSON: strip C0 controls (except \\t \\n \\r) so logs stay valid text and tiny. */
function stripControlForDiag(s: string): string {
return s.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '\uFFFD');
}
function clipHeadForDiag(s: string, maxLen: number): string {
return stripControlForDiag(s).slice(0, maxLen);
}
function clipTailForDiag(s: string, maxLen: number): string {
return stripControlForDiag(s).slice(-maxLen);
}
const DIAG_PATH_HEAD = 400;
const DIAG_HOME_PREVIEW = 120;
const DIAG_AUTH_STDOUT_TAIL = 160;
// =============================================================================
// Helpers
// =============================================================================
@ -292,6 +269,39 @@ interface GcsManifest {
platforms?: Record<string, GcsPlatformEntry>;
}
/** Per-`getStatus()` snapshot so parallel calls cannot clobber shared instance fields. */
interface CliInstallerStatusRunDiag {
versionError: string | null;
authAttempts: number;
authLastError: string | null;
authStdoutLen: number;
authStdoutTail: string;
authTimedOut: boolean;
gatherError: string | null;
}
function createCliInstallerRunDiag(): CliInstallerStatusRunDiag {
return {
versionError: null,
authAttempts: 0,
authLastError: null,
authStdoutLen: 0,
authStdoutTail: '',
authTimedOut: false,
gatherError: null,
};
}
function resetGatherDiag(diag: CliInstallerStatusRunDiag): void {
diag.versionError = null;
diag.authAttempts = 0;
diag.authLastError = null;
diag.authStdoutLen = 0;
diag.authStdoutTail = '';
diag.authTimedOut = false;
diag.gatherError = null;
}
// =============================================================================
// Service
// =============================================================================
@ -300,10 +310,83 @@ export class CliInstallerService {
private mainWindow: BrowserWindow | null = null;
private installing = false;
private electronMetaForDiag(): Record<string, unknown> {
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { app } = require('electron') as typeof import('electron');
return {
electronPackaged: Boolean(app?.isPackaged),
appVersion: typeof app?.getVersion === 'function' ? app.getVersion() : null,
exePath:
typeof app?.getPath === 'function'
? clipHeadForDiag(app.getPath('exe'), DIAG_PATH_HEAD)
: null,
};
} catch {
return { electronPackaged: null, appVersion: null, exePath: null };
}
}
private async writeCliInstallerStatusDiag(
r: CliInstallationStatus,
diag: CliInstallerStatusRunDiag
): Promise<void> {
const cached = getCachedShellEnv();
const procPath = process.env.PATH ?? '';
const mergedPath = buildMergedCliPath(r.binaryPath);
const shellHome = cached?.HOME?.trim();
const hasUsableShellPath = Boolean(cached?.PATH?.trim());
const pathSep = process.platform === 'win32' ? pathWin32.delimiter : pathPosix.delimiter;
await appendCliAuthDiag({
event: 'cli_installer_get_status',
...this.electronMetaForDiag(),
platform: process.platform,
arch: process.arch,
nodeVersion: process.version,
shellHasPath: hasUsableShellPath,
shellPathEntryCount: cached?.PATH ? cached.PATH.split(pathSep).filter(Boolean).length : 0,
shellHomeSet: Boolean(shellHome),
shellHomePreview: shellHome ? clipHeadForDiag(shellHome, DIAG_HOME_PREVIEW) : null,
electronHome: getHomeDir(),
preferredHome: getShellPreferredHome(),
claudeConfigDir: getClaudeBasePath(),
processPathLen: procPath.length,
processPathHead: clipHeadForDiag(procPath, DIAG_PATH_HEAD),
mergedPathLen: mergedPath.length,
mergedPathHead: clipHeadForDiag(mergedPath, DIAG_PATH_HEAD),
installed: r.installed,
binaryPath: r.binaryPath ? clipHeadForDiag(r.binaryPath, DIAG_PATH_HEAD) : null,
installedVersion: r.installedVersion,
authLoggedIn: r.authLoggedIn,
authMethod: r.authMethod,
latestVersion: r.latestVersion,
updateAvailable: r.updateAvailable,
versionProbeError: diag.versionError,
authProbeAttempts: diag.authAttempts,
authProbeLastError: diag.authLastError,
authStdoutLen: diag.authStdoutLen,
authStdoutTail: clipTailForDiag(diag.authStdoutTail, DIAG_AUTH_STDOUT_TAIL),
authProbeTimedOut: diag.authTimedOut,
gatherThrownError: diag.gatherError,
});
}
setMainWindow(window: BrowserWindow | null): void {
this.mainWindow = window;
}
/**
* Env for CLI subprocesses: login-shell vars + consistent HOME/PATH + same config root as the app.
*/
private envForCli(binaryPath: string): NodeJS.ProcessEnv {
return {
...process.env,
...(getCachedShellEnv() ?? {}),
...buildChildEnv(binaryPath),
CLAUDE_CONFIG_DIR: getClaudeBasePath(),
};
}
// ---------------------------------------------------------------------------
// Public: getStatus
// ---------------------------------------------------------------------------
@ -322,10 +405,11 @@ export class CliInstallerService {
// Run the actual status gathering with an overall timeout.
// On timeout, return whatever partial result was collected so far.
const ref = { current: result };
const runDiag = createCliInstallerRunDiag();
let timer: ReturnType<typeof setTimeout> | null = null;
try {
await Promise.race([
this.gatherStatus(ref),
this.gatherStatus(ref, runDiag),
new Promise<void>((resolve) => {
timer = setTimeout(() => {
logger.warn(
@ -335,13 +419,20 @@ export class CliInstallerService {
}, GET_STATUS_TIMEOUT_MS);
}),
]);
return result;
} catch (err) {
runDiag.gatherError = getErrorMessage(err);
throw err;
} finally {
if (timer) {
clearTimeout(timer);
}
try {
await this.writeCliInstallerStatusDiag(result, runDiag);
} catch (diagErr) {
logger.error('writeCliInstallerStatusDiag failed:', getErrorMessage(diagErr));
}
}
return result;
}
/**
@ -351,7 +442,13 @@ export class CliInstallerService {
*
* Flow: binary resolve --version (sequential) Promise.all([auth, GCS]) (parallel)
*/
private async gatherStatus(ref: { current: CliInstallationStatus }): Promise<void> {
private async gatherStatus(
ref: { current: CliInstallationStatus },
diag: CliInstallerStatusRunDiag
): Promise<void> {
resetGatherDiag(diag);
await resolveInteractiveShellEnv();
const r = ref.current;
const binaryPath = await ClaudeBinaryResolver.resolve();
if (binaryPath) {
@ -361,19 +458,20 @@ export class CliInstallerService {
try {
const { stdout } = await execCli(binaryPath, ['--version'], {
timeout: VERSION_TIMEOUT_MS,
env: buildChildEnv(binaryPath),
env: this.envForCli(binaryPath),
});
r.installedVersion = normalizeVersion(stdout);
logger.info(
`Installed CLI version: "${stdout.trim()}" → normalized: "${r.installedVersion}"`
);
} catch (err) {
logger.warn('Failed to get CLI version:', getErrorMessage(err));
diag.versionError = getErrorMessage(err);
logger.warn('Failed to get CLI version:', diag.versionError);
}
// Auth and GCS version check are independent — run in parallel.
// Both mutate `r` directly so partial results survive the outer timeout.
await Promise.all([this.checkAuthStatus(binaryPath, r), this.fetchLatestVersion(r)]);
await Promise.all([this.checkAuthStatus(binaryPath, r, diag), this.fetchLatestVersion(r)]);
} else {
// No binary — still check latest version for "install" prompt
await this.fetchLatestVersion(r);
@ -386,35 +484,42 @@ export class CliInstallerService {
* Mutates `r` directly so results survive even if the outer Promise.all hasn't resolved.
*/
private async checkAuthStatus(binaryPath: string, result: CliInstallationStatus): Promise<void> {
private async checkAuthStatus(
binaryPath: string,
result: CliInstallationStatus,
diag: CliInstallerStatusRunDiag
): Promise<void> {
const doCheck = async (): Promise<void> => {
for (let authAttempt = 1; authAttempt <= AUTH_STATUS_MAX_RETRIES; authAttempt++) {
diag.authAttempts = authAttempt;
try {
const { stdout: authStdout } = await execCli(binaryPath, ['auth', 'status'], {
timeout: VERSION_TIMEOUT_MS,
env: buildChildEnv(binaryPath),
env: this.envForCli(binaryPath),
});
const auth = JSON.parse(authStdout.trim()) as {
loggedIn?: boolean;
authMethod?: string;
};
diag.authStdoutLen = authStdout.length;
diag.authStdoutTail = authStdout.slice(-DIAG_AUTH_STDOUT_TAIL);
const auth = parseClaudeAuthStatusStdout(authStdout);
result.authLoggedIn = auth.loggedIn === true;
result.authMethod = auth.authMethod ?? null;
diag.authLastError = null;
logger.info(
`Auth status: loggedIn=${result.authLoggedIn}, method=${result.authMethod ?? 'null'}` +
(authAttempt > 1 ? ` (attempt ${authAttempt})` : '')
);
return;
} catch (err) {
const msg = getErrorMessage(err);
diag.authLastError = msg;
if (authAttempt < AUTH_STATUS_MAX_RETRIES) {
logger.warn(
`Auth status check failed (attempt ${authAttempt}/${AUTH_STATUS_MAX_RETRIES}), ` +
`retrying in ${AUTH_STATUS_RETRY_DELAY_MS}ms: ${getErrorMessage(err)}`
`retrying in ${AUTH_STATUS_RETRY_DELAY_MS}ms: ${msg}`
);
await new Promise((resolve) => setTimeout(resolve, AUTH_STATUS_RETRY_DELAY_MS));
} else {
logger.warn(
`Auth status check failed after ${AUTH_STATUS_MAX_RETRIES} attempts: ${getErrorMessage(err)}`
`Auth status check failed after ${AUTH_STATUS_MAX_RETRIES} attempts: ${msg}`
);
result.authLoggedIn = false;
}
@ -424,11 +529,13 @@ export class CliInstallerService {
// Own timeout so slow auth doesn't eat the overall getStatus budget
let timer: ReturnType<typeof setTimeout> | null = null;
let hitAuthTimeout = false;
try {
await Promise.race([
doCheck(),
new Promise<void>((resolve) => {
timer = setTimeout(() => {
hitAuthTimeout = true;
logger.warn(`Auth status check timed out after ${AUTH_TOTAL_TIMEOUT_MS}ms`);
resolve();
}, AUTH_TOTAL_TIMEOUT_MS);
@ -438,6 +545,7 @@ export class CliInstallerService {
if (timer) {
clearTimeout(timer);
}
diag.authTimedOut = hitAuthTimeout;
}
}
@ -658,7 +766,7 @@ export class CliInstallerService {
private async runInstallWithStreaming(binaryPath: string, attempt = 1): Promise<void> {
return new Promise<void>((resolve, reject) => {
const child = spawnCli(binaryPath, ['install'], {
env: { ...buildChildEnv(binaryPath), CLAUDE_SKIP_ANALYTICS: '1' },
env: { ...this.envForCli(binaryPath), CLAUDE_SKIP_ANALYTICS: '1' },
stdio: ['ignore', 'pipe', 'pipe'],
});

View file

@ -1,4 +1,5 @@
import { getHomeDir } from '@main/utils/pathDecoder';
import { buildMergedCliPath } from '@main/utils/cliPathMerge';
import { getShellPreferredHome, resolveInteractiveShellEnv } from '@main/utils/shellEnv';
import * as fs from 'fs';
import * as path from 'path';
@ -65,7 +66,7 @@ async function collectNvmCandidates(): Promise<string[]> {
return collectNvmWindowsCandidates();
}
const nvmNodeRoot = path.join(getHomeDir(), '.nvm', 'versions', 'node');
const nvmNodeRoot = path.join(getShellPreferredHome(), '.nvm', 'versions', 'node');
let versions: string[];
try {
versions = await fs.promises.readdir(nvmNodeRoot);
@ -101,8 +102,8 @@ async function collectNvmWindowsCandidates(): Promise<string[]> {
.flatMap((version) => exts.map((ext) => path.join(nvmRoot, version, `claude${ext}`)));
}
async function resolveFromPathEnv(binaryName: string): Promise<string | null> {
const rawPath = process.env.PATH;
async function resolveFromPathEnv(binaryName: string, pathEnv?: string): Promise<string | null> {
const rawPath = pathEnv && pathEnv.length > 0 ? pathEnv : process.env.PATH;
if (!rawPath) {
return null;
}
@ -166,6 +167,9 @@ async function resolveFromExplicitPath(inputPath: string): Promise<string | null
let cachedPath: string | null | undefined;
/** Coalesce concurrent first resolves so `cachedPath` is not torn by parallel scans. */
let resolveInFlight: Promise<string | null> | null = null;
export class ClaudeBinaryResolver {
/**
* Clear the cached binary path.
@ -177,6 +181,17 @@ export class ClaudeBinaryResolver {
static async resolve(): Promise<string | null> {
if (cachedPath !== undefined) return cachedPath;
if (!resolveInFlight) {
resolveInFlight = ClaudeBinaryResolver.runResolve().finally(() => {
resolveInFlight = null;
});
}
return resolveInFlight;
}
private static async runResolve(): Promise<string | null> {
await resolveInteractiveShellEnv();
const enrichedPath = buildMergedCliPath(null);
const overrideRaw = process.env.CLAUDE_CLI_PATH?.trim();
if (overrideRaw) {
@ -184,7 +199,7 @@ export class ClaudeBinaryResolver {
path.isAbsolute(overrideRaw) || overrideRaw.includes('\\') || overrideRaw.includes('/');
const resolvedOverride = looksLikePath
? await resolveFromExplicitPath(overrideRaw)
: await resolveFromPathEnv(overrideRaw);
: await resolveFromPathEnv(overrideRaw, enrichedPath);
if (resolvedOverride) {
cachedPath = resolvedOverride;
@ -193,7 +208,7 @@ export class ClaudeBinaryResolver {
}
const baseBinaryName = 'claude';
const fromPath = await resolveFromPathEnv(baseBinaryName);
const fromPath = await resolveFromPathEnv(baseBinaryName, enrichedPath);
if (fromPath) {
cachedPath = fromPath;
return cachedPath;
@ -202,13 +217,14 @@ export class ClaudeBinaryResolver {
const platformBinaryNames =
process.platform === 'win32' ? expandWindowsBinaryNames(baseBinaryName) : [baseBinaryName];
const home = getShellPreferredHome();
const candidateDirs: string[] =
process.platform === 'win32'
? [
// Windows: npm global install
path.join(getHomeDir(), 'AppData', 'Roaming', 'npm'),
path.join(home, 'AppData', 'Roaming', 'npm'),
// Windows: scoop, chocolatey, and other package managers
path.join(getHomeDir(), 'scoop', 'shims'),
path.join(home, 'scoop', 'shims'),
// Windows: Local programs
...(process.env.LOCALAPPDATA
? [path.join(process.env.LOCALAPPDATA, 'Programs', 'claude')]
@ -218,9 +234,9 @@ export class ClaudeBinaryResolver {
]
: [
// Unix: native binary installation path (claude install)
path.join(getHomeDir(), '.local', 'bin'),
path.join(getHomeDir(), '.npm-global', 'bin'),
path.join(getHomeDir(), '.npm', 'bin'),
path.join(home, '.local', 'bin'),
path.join(home, '.npm-global', 'bin'),
path.join(home, '.npm', 'bin'),
'/usr/local/bin',
'/opt/homebrew/bin',
];

View file

@ -0,0 +1,72 @@
/**
* Persistent CLI/auth diagnostics for packaged apps.
* console.info/warn are suppressed in production (see shared logger); this file
* appends NDJSON lines under Electron's logs directory when possible.
*/
import { appendFile, mkdir, stat, truncate } from 'fs/promises';
import { join } from 'path';
const FILE_NAME = 'claude-cli-auth-diag.ndjson';
/** Prevent unbounded growth if getStatus runs often (e.g. UI polling). */
const MAX_DIAG_FILE_BYTES = 512 * 1024;
function resolveLogsDirectory(): string | null {
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports -- lazy: tests / non-Electron
const { app } = require('electron') as typeof import('electron');
if (!app?.getPath) {
return null;
}
try {
return app.getPath('logs');
} catch {
try {
return join(app.getPath('userData'), 'logs');
} catch {
return null;
}
}
} catch {
return null;
}
}
/**
* Append one JSON line (NDJSON). Safe no-op outside Electron or on I/O errors.
* Typical macOS path: ~/Library/Logs/<product>/claude-cli-auth-diag.ndjson
*/
export async function appendCliAuthDiag(entry: Record<string, unknown>): Promise<string | null> {
const dir = resolveLogsDirectory();
if (!dir) {
return null;
}
const filePath = join(dir, FILE_NAME);
let line: string;
try {
line =
JSON.stringify({
t: new Date().toISOString(),
diagFile: filePath,
...entry,
}) + '\n';
} catch {
return null;
}
try {
await mkdir(dir, { recursive: true });
try {
const st = await stat(filePath);
if (st.size > MAX_DIAG_FILE_BYTES) {
await truncate(filePath, 0);
}
} catch {
/* file missing — ok */
}
await appendFile(filePath, line, 'utf8');
return filePath;
} catch {
return null;
}
}

View file

@ -0,0 +1,66 @@
/**
* Merged PATH for Claude CLI discovery and child processes.
* Packaged macOS apps get a minimal PATH; login-shell cache fixes that once warm.
*/
import { realpathSync } from 'fs';
import { dirname, join, posix as pathPosix, win32 as pathWin32 } from 'path';
import { getCachedShellEnv, getShellPreferredHome } from '@main/utils/shellEnv';
/**
* Build a PATH string that prefers the CLI binary directory, then the user's
* interactive shell PATH (when cached), then common install locations, then the
* current process PATH.
*/
export function buildMergedCliPath(binaryPath?: string | null): string {
const home = getShellPreferredHome();
const sep = process.platform === 'win32' ? pathWin32.delimiter : pathPosix.delimiter;
const currentPath = process.env.PATH || '';
const extraDirs: string[] = [];
if (binaryPath) {
const binDir = dirname(binaryPath);
extraDirs.push(binDir);
try {
const realBinDir = dirname(realpathSync(binaryPath));
if (realBinDir !== binDir) {
extraDirs.push(realBinDir);
}
} catch {
/* symlink resolution failed — ignore */
}
}
const cachedEnv = getCachedShellEnv();
if (cachedEnv?.PATH) {
extraDirs.push(...cachedEnv.PATH.split(sep).filter(Boolean));
} else if (process.platform === 'win32') {
extraDirs.push(join(home, 'AppData', 'Roaming', 'npm'), join(home, 'scoop', 'shims'));
if (process.env.LOCALAPPDATA) {
extraDirs.push(join(process.env.LOCALAPPDATA, 'Programs', 'claude'));
}
if (process.env.ProgramFiles) {
extraDirs.push(join(process.env.ProgramFiles, 'claude'));
}
} else {
extraDirs.push(
join(home, '.local', 'bin'),
join(home, '.npm-global', 'bin'),
join(home, '.npm', 'bin'),
'/usr/local/bin',
'/opt/homebrew/bin'
);
}
const seen = new Set<string>();
const merged: string[] = [];
for (const dir of [...extraDirs, ...currentPath.split(sep)]) {
if (dir && !seen.has(dir)) {
seen.add(dir);
merged.push(dir);
}
}
return merged.join(sep);
}

View file

@ -9,6 +9,7 @@
* and any other service that needs the user's shell environment.
*/
import { getHomeDir } from '@main/utils/pathDecoder';
import { createLogger } from '@shared/utils/logger';
import { spawn } from 'child_process';
@ -150,3 +151,12 @@ export function clearShellEnvCache(): void {
export function getCachedShellEnv(): NodeJS.ProcessEnv | null {
return cachedInteractiveShellEnv;
}
/**
* HOME from login/interactive shell when resolved, else Electron/Node home.
* Matches TeamProvisioningService so CLI reads the same ~/.claude as the terminal.
*/
export function getShellPreferredHome(): string {
const fromShell = getCachedShellEnv()?.HOME?.trim();
return fromShell || getHomeDir();
}

View file

@ -127,6 +127,21 @@ describe('CliInstallerService', () => {
expect.objectContaining({ timeout: expect.any(Number) })
);
});
it('treats auth as logged in when JSON is embedded after stdout noise', async () => {
allowConsoleLogs();
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/usr/local/bin/claude');
vi.mocked(execCli)
.mockResolvedValueOnce({ stdout: '2.3.4', stderr: '' })
.mockResolvedValueOnce({
stdout: 'notice: something\n{"loggedIn":true,"authMethod":"oauth_token"}\n',
stderr: '',
});
const status = await service.getStatus();
expect(status.authLoggedIn).toBe(true);
expect(status.authMethod).toBe('oauth_token');
});
});
describe('install mutex', () => {

View file

@ -0,0 +1,71 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const mockGetCachedShellEnv = vi.fn<() => Record<string, string> | null>();
const mockGetShellPreferredHome = vi.fn<() => string>();
vi.mock('@main/utils/shellEnv', () => ({
getCachedShellEnv: () => mockGetCachedShellEnv(),
getShellPreferredHome: () => mockGetShellPreferredHome(),
}));
describe('buildMergedCliPath', () => {
let buildMergedCliPath: typeof import('@main/utils/cliPathMerge').buildMergedCliPath;
const originalPlatform = process.platform;
beforeEach(async () => {
vi.resetModules();
mockGetShellPreferredHome.mockReturnValue('/home/testuser');
mockGetCachedShellEnv.mockReturnValue(null);
process.env.PATH = '/usr/bin';
({ buildMergedCliPath } = await import('@main/utils/cliPathMerge'));
});
afterEach(() => {
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
});
it('on darwin/linux with cold shell cache prepends standard user bin dirs before process PATH', () => {
Object.defineProperty(process, 'platform', { value: 'linux', configurable: true });
const p = buildMergedCliPath(null);
expect(p.split(':')).toEqual(
expect.arrayContaining([
'/home/testuser/.local/bin',
'/home/testuser/.npm-global/bin',
'/home/testuser/.npm/bin',
'/usr/local/bin',
'/opt/homebrew/bin',
'/usr/bin',
])
);
expect(p.startsWith('/home/testuser/.local/bin')).toBe(true);
});
it('on win32 with cold shell cache uses semicolon and npm-style dirs', () => {
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
mockGetShellPreferredHome.mockReturnValue('C:\\Users\\testuser');
process.env.LOCALAPPDATA = 'C:\\Users\\testuser\\AppData\\Local';
process.env.ProgramFiles = 'C:\\Program Files';
const p = buildMergedCliPath(null);
const parts = p.split(';');
expect(parts.some((x) => /Roaming[/\\]npm/i.test(x))).toBe(true);
expect(parts.some((x) => /Programs[/\\]claude/i.test(x))).toBe(true);
expect(parts[parts.length - 1]).toBe('/usr/bin');
});
it('when shell cache has PATH, uses that instead of static fallback dirs', () => {
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });
mockGetCachedShellEnv.mockReturnValue({ PATH: '/opt/custom/bin:/bin' });
const p = buildMergedCliPath(null);
expect(p.startsWith('/opt/custom/bin')).toBe(true);
expect(p).toContain('/bin');
expect(p).toContain('/usr/bin');
expect(p).not.toContain('/home/testuser/.local/bin');
});
it('prepends binary directory when binaryPath is set', () => {
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });
mockGetCachedShellEnv.mockReturnValue({ PATH: '/x/bin' });
const p = buildMergedCliPath('/opt/node/bin/claude');
expect(p.startsWith('/opt/node/bin')).toBe(true);
});
});