diff --git a/CLAUDE.md b/CLAUDE.md index 99f14a97..127eb92b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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//` (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 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 8d370fb7..f1866687 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -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. diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 8e12dfe8..d2605dcd 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -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 + + + + + + + +
+ + macOS Apple Silicon + +
+ + macOS Intel + +
+ + Windows + +
+ May trigger SmartScreen — click "More info" → "Run anyway" +
+ + Linux AppImage + +
+ + .deb +   + + .rpm +   + + .pacman + +
+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 diff --git a/package.json b/package.json index c89aca0f..d67ec0bb 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/main/ipc/cliInstaller.ts b/src/main/ipc/cliInstaller.ts index 6f77cadc..64806f83 100644 --- a/src/main/ipc/cliInstaller.ts +++ b/src/main/ipc/cliInstaller.ts @@ -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) { diff --git a/src/main/services/infrastructure/CliInstallerService.ts b/src/main/services/infrastructure/CliInstallerService.ts index 70ccbd8a..472901e9 100644 --- a/src/main/services/infrastructure/CliInstallerService.ts +++ b/src/main/services/infrastructure/CliInstallerService.ts @@ -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(); - 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; } +/** 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 { + 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 { + 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 | null = null; try { await Promise.race([ - this.gatherStatus(ref), + this.gatherStatus(ref, runDiag), new Promise((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 { + private async gatherStatus( + ref: { current: CliInstallationStatus }, + diag: CliInstallerStatusRunDiag + ): Promise { + 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 { + private async checkAuthStatus( + binaryPath: string, + result: CliInstallationStatus, + diag: CliInstallerStatusRunDiag + ): Promise { const doCheck = async (): Promise => { 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 | null = null; + let hitAuthTimeout = false; try { await Promise.race([ doCheck(), new Promise((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 { return new Promise((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'], }); diff --git a/src/main/services/team/ClaudeBinaryResolver.ts b/src/main/services/team/ClaudeBinaryResolver.ts index 97d37e7d..7df3f062 100644 --- a/src/main/services/team/ClaudeBinaryResolver.ts +++ b/src/main/services/team/ClaudeBinaryResolver.ts @@ -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 { 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 { .flatMap((version) => exts.map((ext) => path.join(nvmRoot, version, `claude${ext}`))); } -async function resolveFromPathEnv(binaryName: string): Promise { - const rawPath = process.env.PATH; +async function resolveFromPathEnv(binaryName: string, pathEnv?: string): Promise { + const rawPath = pathEnv && pathEnv.length > 0 ? pathEnv : process.env.PATH; if (!rawPath) { return null; } @@ -166,6 +167,9 @@ async function resolveFromExplicitPath(inputPath: string): Promise | null = null; + export class ClaudeBinaryResolver { /** * Clear the cached binary path. @@ -177,6 +181,17 @@ export class ClaudeBinaryResolver { static async resolve(): Promise { if (cachedPath !== undefined) return cachedPath; + if (!resolveInFlight) { + resolveInFlight = ClaudeBinaryResolver.runResolve().finally(() => { + resolveInFlight = null; + }); + } + return resolveInFlight; + } + + private static async runResolve(): Promise { + 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', ]; diff --git a/src/main/utils/cliAuthDiagLog.ts b/src/main/utils/cliAuthDiagLog.ts new file mode 100644 index 00000000..af4293bf --- /dev/null +++ b/src/main/utils/cliAuthDiagLog.ts @@ -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//claude-cli-auth-diag.ndjson + */ +export async function appendCliAuthDiag(entry: Record): Promise { + 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; + } +} diff --git a/src/main/utils/cliPathMerge.ts b/src/main/utils/cliPathMerge.ts new file mode 100644 index 00000000..d03e67cb --- /dev/null +++ b/src/main/utils/cliPathMerge.ts @@ -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(); + 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); +} diff --git a/src/main/utils/shellEnv.ts b/src/main/utils/shellEnv.ts index 4d6893e8..13a8c7cd 100644 --- a/src/main/utils/shellEnv.ts +++ b/src/main/utils/shellEnv.ts @@ -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(); +} diff --git a/test/main/services/infrastructure/CliInstallerService.test.ts b/test/main/services/infrastructure/CliInstallerService.test.ts index a8ebd83c..d367a286 100644 --- a/test/main/services/infrastructure/CliInstallerService.test.ts +++ b/test/main/services/infrastructure/CliInstallerService.test.ts @@ -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', () => { diff --git a/test/main/utils/cliPathMerge.test.ts b/test/main/utils/cliPathMerge.test.ts new file mode 100644 index 00000000..f671e7d6 --- /dev/null +++ b/test/main/utils/cliPathMerge.test.ts @@ -0,0 +1,71 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockGetCachedShellEnv = vi.fn<() => Record | 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); + }); +});