fix: packaged app CLI login detection, PATH merge, IPC status cache
This commit is contained in:
parent
6ee3d49255
commit
731d15b722
12 changed files with 548 additions and 110 deletions
|
|
@ -139,6 +139,9 @@ pnpm typecheck
|
||||||
### Test Failures
|
### Test Failures
|
||||||
Check for changes in message parsing or chunk building logic.
|
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
|
## TypeScript Conventions
|
||||||
|
|
||||||
### Naming
|
### Naming
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,11 @@ The format is based on Keep a Changelog and this project follows Semantic Versio
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [1.0.2] - 2026-03-19
|
||||||
|
|
||||||
### Added
|
### 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.
|
- `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.
|
- Strict IPC input validation guards for project/session/subagent/search limits.
|
||||||
- `get-waterfall-data` IPC endpoint implementation.
|
- `get-waterfall-data` IPC endpoint implementation.
|
||||||
- Cross-platform path normalization in renderer path resolvers.
|
- 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).
|
- CI workflow for macOS/Windows (typecheck, lint, test, build).
|
||||||
- Release workflow for signed package builds.
|
- Release workflow for signed package builds.
|
||||||
- Open-source governance docs (`LICENSE`, `CONTRIBUTING`, `CODE_OF_CONDUCT`, `SECURITY`).
|
- 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
|
### Changed
|
||||||
|
|
||||||
- `readMentionedFile` preload API signature now requires `projectRoot`.
|
- `readMentionedFile` preload API signature now requires `projectRoot`.
|
||||||
- Notification update event contract standardized to `{ total, unreadCount }`.
|
- Notification update event contract standardized to `{ total, unreadCount }`.
|
||||||
- Session pagination uses cached displayable-content detection for performance.
|
- Session pagination uses cached displayable-content detection for performance.
|
||||||
- File watcher error detection optimized for append-only updates.
|
- 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
|
### Fixed
|
||||||
|
|
||||||
- Lint violations in navigation and markdown/subagent UI components.
|
- Lint violations in navigation and markdown/subagent UI components.
|
||||||
- Test mock drift causing runtime errors in test output.
|
- Test mock drift causing runtime errors in test output.
|
||||||
- Multiple Windows path handling edge cases.
|
- 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.
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,70 @@
|
||||||
# Release Guide
|
# 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>
|
||||||
|
<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>
|
||||||
|
<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)
|
## Versioning (SemVer)
|
||||||
|
|
||||||
Format: `MAJOR.MINOR.PATCH`
|
Format: `MAJOR.MINOR.PATCH`
|
||||||
|
|
@ -166,14 +231,14 @@ electron-builder generates `latest-mac.yml`, `latest.yml`, `latest-linux.yml` al
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Create and publish a release
|
# Create and publish a release
|
||||||
git tag v1.1.0
|
git tag v1.0.2
|
||||||
git push origin v1.1.0
|
git push origin v1.0.2
|
||||||
# Wait for CI to finish (~10 min), then update notes
|
# Wait for CI to finish (~10 min), then update notes
|
||||||
|
|
||||||
# Delete a release (if needed)
|
# Delete a release (if needed)
|
||||||
gh release delete v1.1.0 --repo 777genius/claude_agent_teams_ui --yes
|
gh release delete v1.0.2 --repo 777genius/claude_agent_teams_ui --yes
|
||||||
git tag -d v1.1.0
|
git tag -d v1.0.2
|
||||||
git push origin :refs/tags/v1.1.0
|
git push origin :refs/tags/v1.0.2
|
||||||
|
|
||||||
# Check workflow status
|
# Check workflow status
|
||||||
gh run list --repo 777genius/claude_agent_teams_ui --workflow release.yml --limit 3
|
gh run list --repo 777genius/claude_agent_teams_ui --workflow release.yml --limit 3
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "claude-agent-teams-ui",
|
"name": "claude-agent-teams-ui",
|
||||||
"type": "module",
|
"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",
|
"description": "Desktop app that visualizes Claude Code session execution — explore conversations, track context usage, and analyze tool calls",
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"author": {
|
"author": {
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,10 @@ async function handleGetStatus(
|
||||||
cachedStatus = { value: status, at: Date.now() };
|
cachedStatus = { value: status, at: Date.now() };
|
||||||
return status;
|
return status;
|
||||||
})
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
cachedStatus = null;
|
||||||
|
throw err;
|
||||||
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
const ms = Date.now() - startedAt;
|
const ms = Date.now() - startedAt;
|
||||||
if (ms >= 2000) {
|
if (ms >= 2000) {
|
||||||
|
|
|
||||||
|
|
@ -17,17 +17,23 @@
|
||||||
* - Human-readable error messages per phase
|
* - 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 { execCli, killProcessTree, spawnCli } from '@main/utils/childProcess';
|
||||||
import { getHomeDir } from '@main/utils/pathDecoder';
|
import { getClaudeBasePath, getHomeDir } from '@main/utils/pathDecoder';
|
||||||
import { getCachedShellEnv } from '@main/utils/shellEnv';
|
import {
|
||||||
|
getCachedShellEnv,
|
||||||
|
getShellPreferredHome,
|
||||||
|
resolveInteractiveShellEnv,
|
||||||
|
} from '@main/utils/shellEnv';
|
||||||
import { getErrorMessage } from '@shared/utils/errorHandling';
|
import { getErrorMessage } from '@shared/utils/errorHandling';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { createHash } from 'crypto';
|
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 http from 'http';
|
||||||
import https from 'https';
|
import https from 'https';
|
||||||
import { tmpdir } from 'os';
|
import { tmpdir } from 'os';
|
||||||
import { dirname, join } from 'path';
|
import { join, posix as pathPosix, win32 as pathWin32 } from 'path';
|
||||||
|
|
||||||
import { ClaudeBinaryResolver } from '../team/ClaudeBinaryResolver';
|
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.
|
* Build env for child processes with correct HOME and enriched PATH.
|
||||||
*
|
* PATH merging lives in `cliPathMerge.ts` (shared with binary discovery).
|
||||||
* 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).
|
|
||||||
*/
|
*/
|
||||||
function buildChildEnv(binaryPath?: string | null): NodeJS.ProcessEnv {
|
function buildChildEnv(binaryPath?: string | null): NodeJS.ProcessEnv {
|
||||||
const home = getHomeDir();
|
const home = getShellPreferredHome();
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...process.env,
|
...process.env,
|
||||||
HOME: home,
|
HOME: home,
|
||||||
USERPROFILE: 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
|
// Helpers
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -292,6 +269,39 @@ interface GcsManifest {
|
||||||
platforms?: Record<string, GcsPlatformEntry>;
|
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
|
// Service
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -300,10 +310,83 @@ export class CliInstallerService {
|
||||||
private mainWindow: BrowserWindow | null = null;
|
private mainWindow: BrowserWindow | null = null;
|
||||||
private installing = false;
|
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 {
|
setMainWindow(window: BrowserWindow | null): void {
|
||||||
this.mainWindow = window;
|
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
|
// Public: getStatus
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -322,10 +405,11 @@ export class CliInstallerService {
|
||||||
// Run the actual status gathering with an overall timeout.
|
// Run the actual status gathering with an overall timeout.
|
||||||
// On timeout, return whatever partial result was collected so far.
|
// On timeout, return whatever partial result was collected so far.
|
||||||
const ref = { current: result };
|
const ref = { current: result };
|
||||||
|
const runDiag = createCliInstallerRunDiag();
|
||||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
try {
|
try {
|
||||||
await Promise.race([
|
await Promise.race([
|
||||||
this.gatherStatus(ref),
|
this.gatherStatus(ref, runDiag),
|
||||||
new Promise<void>((resolve) => {
|
new Promise<void>((resolve) => {
|
||||||
timer = setTimeout(() => {
|
timer = setTimeout(() => {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
|
|
@ -335,13 +419,20 @@ export class CliInstallerService {
|
||||||
}, GET_STATUS_TIMEOUT_MS);
|
}, GET_STATUS_TIMEOUT_MS);
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
runDiag.gatherError = getErrorMessage(err);
|
||||||
|
throw err;
|
||||||
} finally {
|
} finally {
|
||||||
if (timer) {
|
if (timer) {
|
||||||
clearTimeout(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)
|
* 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 r = ref.current;
|
||||||
const binaryPath = await ClaudeBinaryResolver.resolve();
|
const binaryPath = await ClaudeBinaryResolver.resolve();
|
||||||
if (binaryPath) {
|
if (binaryPath) {
|
||||||
|
|
@ -361,19 +458,20 @@ export class CliInstallerService {
|
||||||
try {
|
try {
|
||||||
const { stdout } = await execCli(binaryPath, ['--version'], {
|
const { stdout } = await execCli(binaryPath, ['--version'], {
|
||||||
timeout: VERSION_TIMEOUT_MS,
|
timeout: VERSION_TIMEOUT_MS,
|
||||||
env: buildChildEnv(binaryPath),
|
env: this.envForCli(binaryPath),
|
||||||
});
|
});
|
||||||
r.installedVersion = normalizeVersion(stdout);
|
r.installedVersion = normalizeVersion(stdout);
|
||||||
logger.info(
|
logger.info(
|
||||||
`Installed CLI version: "${stdout.trim()}" → normalized: "${r.installedVersion}"`
|
`Installed CLI version: "${stdout.trim()}" → normalized: "${r.installedVersion}"`
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} 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.
|
// Auth and GCS version check are independent — run in parallel.
|
||||||
// Both mutate `r` directly so partial results survive the outer timeout.
|
// 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 {
|
} else {
|
||||||
// No binary — still check latest version for "install" prompt
|
// No binary — still check latest version for "install" prompt
|
||||||
await this.fetchLatestVersion(r);
|
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.
|
* 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> => {
|
const doCheck = async (): Promise<void> => {
|
||||||
for (let authAttempt = 1; authAttempt <= AUTH_STATUS_MAX_RETRIES; authAttempt++) {
|
for (let authAttempt = 1; authAttempt <= AUTH_STATUS_MAX_RETRIES; authAttempt++) {
|
||||||
|
diag.authAttempts = authAttempt;
|
||||||
try {
|
try {
|
||||||
const { stdout: authStdout } = await execCli(binaryPath, ['auth', 'status'], {
|
const { stdout: authStdout } = await execCli(binaryPath, ['auth', 'status'], {
|
||||||
timeout: VERSION_TIMEOUT_MS,
|
timeout: VERSION_TIMEOUT_MS,
|
||||||
env: buildChildEnv(binaryPath),
|
env: this.envForCli(binaryPath),
|
||||||
});
|
});
|
||||||
const auth = JSON.parse(authStdout.trim()) as {
|
diag.authStdoutLen = authStdout.length;
|
||||||
loggedIn?: boolean;
|
diag.authStdoutTail = authStdout.slice(-DIAG_AUTH_STDOUT_TAIL);
|
||||||
authMethod?: string;
|
const auth = parseClaudeAuthStatusStdout(authStdout);
|
||||||
};
|
|
||||||
result.authLoggedIn = auth.loggedIn === true;
|
result.authLoggedIn = auth.loggedIn === true;
|
||||||
result.authMethod = auth.authMethod ?? null;
|
result.authMethod = auth.authMethod ?? null;
|
||||||
|
diag.authLastError = null;
|
||||||
logger.info(
|
logger.info(
|
||||||
`Auth status: loggedIn=${result.authLoggedIn}, method=${result.authMethod ?? 'null'}` +
|
`Auth status: loggedIn=${result.authLoggedIn}, method=${result.authMethod ?? 'null'}` +
|
||||||
(authAttempt > 1 ? ` (attempt ${authAttempt})` : '')
|
(authAttempt > 1 ? ` (attempt ${authAttempt})` : '')
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
const msg = getErrorMessage(err);
|
||||||
|
diag.authLastError = msg;
|
||||||
if (authAttempt < AUTH_STATUS_MAX_RETRIES) {
|
if (authAttempt < AUTH_STATUS_MAX_RETRIES) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`Auth status check failed (attempt ${authAttempt}/${AUTH_STATUS_MAX_RETRIES}), ` +
|
`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));
|
await new Promise((resolve) => setTimeout(resolve, AUTH_STATUS_RETRY_DELAY_MS));
|
||||||
} else {
|
} else {
|
||||||
logger.warn(
|
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;
|
result.authLoggedIn = false;
|
||||||
}
|
}
|
||||||
|
|
@ -424,11 +529,13 @@ export class CliInstallerService {
|
||||||
|
|
||||||
// Own timeout so slow auth doesn't eat the overall getStatus budget
|
// Own timeout so slow auth doesn't eat the overall getStatus budget
|
||||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let hitAuthTimeout = false;
|
||||||
try {
|
try {
|
||||||
await Promise.race([
|
await Promise.race([
|
||||||
doCheck(),
|
doCheck(),
|
||||||
new Promise<void>((resolve) => {
|
new Promise<void>((resolve) => {
|
||||||
timer = setTimeout(() => {
|
timer = setTimeout(() => {
|
||||||
|
hitAuthTimeout = true;
|
||||||
logger.warn(`Auth status check timed out after ${AUTH_TOTAL_TIMEOUT_MS}ms`);
|
logger.warn(`Auth status check timed out after ${AUTH_TOTAL_TIMEOUT_MS}ms`);
|
||||||
resolve();
|
resolve();
|
||||||
}, AUTH_TOTAL_TIMEOUT_MS);
|
}, AUTH_TOTAL_TIMEOUT_MS);
|
||||||
|
|
@ -438,6 +545,7 @@ export class CliInstallerService {
|
||||||
if (timer) {
|
if (timer) {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
}
|
}
|
||||||
|
diag.authTimedOut = hitAuthTimeout;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -658,7 +766,7 @@ export class CliInstallerService {
|
||||||
private async runInstallWithStreaming(binaryPath: string, attempt = 1): Promise<void> {
|
private async runInstallWithStreaming(binaryPath: string, attempt = 1): Promise<void> {
|
||||||
return new Promise<void>((resolve, reject) => {
|
return new Promise<void>((resolve, reject) => {
|
||||||
const child = spawnCli(binaryPath, ['install'], {
|
const child = spawnCli(binaryPath, ['install'], {
|
||||||
env: { ...buildChildEnv(binaryPath), CLAUDE_SKIP_ANALYTICS: '1' },
|
env: { ...this.envForCli(binaryPath), CLAUDE_SKIP_ANALYTICS: '1' },
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
|
|
@ -65,7 +66,7 @@ async function collectNvmCandidates(): Promise<string[]> {
|
||||||
return collectNvmWindowsCandidates();
|
return collectNvmWindowsCandidates();
|
||||||
}
|
}
|
||||||
|
|
||||||
const nvmNodeRoot = path.join(getHomeDir(), '.nvm', 'versions', 'node');
|
const nvmNodeRoot = path.join(getShellPreferredHome(), '.nvm', 'versions', 'node');
|
||||||
let versions: string[];
|
let versions: string[];
|
||||||
try {
|
try {
|
||||||
versions = await fs.promises.readdir(nvmNodeRoot);
|
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}`)));
|
.flatMap((version) => exts.map((ext) => path.join(nvmRoot, version, `claude${ext}`)));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveFromPathEnv(binaryName: string): Promise<string | null> {
|
async function resolveFromPathEnv(binaryName: string, pathEnv?: string): Promise<string | null> {
|
||||||
const rawPath = process.env.PATH;
|
const rawPath = pathEnv && pathEnv.length > 0 ? pathEnv : process.env.PATH;
|
||||||
if (!rawPath) {
|
if (!rawPath) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -166,6 +167,9 @@ async function resolveFromExplicitPath(inputPath: string): Promise<string | null
|
||||||
|
|
||||||
let cachedPath: string | null | undefined;
|
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 {
|
export class ClaudeBinaryResolver {
|
||||||
/**
|
/**
|
||||||
* Clear the cached binary path.
|
* Clear the cached binary path.
|
||||||
|
|
@ -177,6 +181,17 @@ export class ClaudeBinaryResolver {
|
||||||
|
|
||||||
static async resolve(): Promise<string | null> {
|
static async resolve(): Promise<string | null> {
|
||||||
if (cachedPath !== undefined) return cachedPath;
|
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();
|
const overrideRaw = process.env.CLAUDE_CLI_PATH?.trim();
|
||||||
if (overrideRaw) {
|
if (overrideRaw) {
|
||||||
|
|
@ -184,7 +199,7 @@ export class ClaudeBinaryResolver {
|
||||||
path.isAbsolute(overrideRaw) || overrideRaw.includes('\\') || overrideRaw.includes('/');
|
path.isAbsolute(overrideRaw) || overrideRaw.includes('\\') || overrideRaw.includes('/');
|
||||||
const resolvedOverride = looksLikePath
|
const resolvedOverride = looksLikePath
|
||||||
? await resolveFromExplicitPath(overrideRaw)
|
? await resolveFromExplicitPath(overrideRaw)
|
||||||
: await resolveFromPathEnv(overrideRaw);
|
: await resolveFromPathEnv(overrideRaw, enrichedPath);
|
||||||
|
|
||||||
if (resolvedOverride) {
|
if (resolvedOverride) {
|
||||||
cachedPath = resolvedOverride;
|
cachedPath = resolvedOverride;
|
||||||
|
|
@ -193,7 +208,7 @@ export class ClaudeBinaryResolver {
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseBinaryName = 'claude';
|
const baseBinaryName = 'claude';
|
||||||
const fromPath = await resolveFromPathEnv(baseBinaryName);
|
const fromPath = await resolveFromPathEnv(baseBinaryName, enrichedPath);
|
||||||
if (fromPath) {
|
if (fromPath) {
|
||||||
cachedPath = fromPath;
|
cachedPath = fromPath;
|
||||||
return cachedPath;
|
return cachedPath;
|
||||||
|
|
@ -202,13 +217,14 @@ export class ClaudeBinaryResolver {
|
||||||
const platformBinaryNames =
|
const platformBinaryNames =
|
||||||
process.platform === 'win32' ? expandWindowsBinaryNames(baseBinaryName) : [baseBinaryName];
|
process.platform === 'win32' ? expandWindowsBinaryNames(baseBinaryName) : [baseBinaryName];
|
||||||
|
|
||||||
|
const home = getShellPreferredHome();
|
||||||
const candidateDirs: string[] =
|
const candidateDirs: string[] =
|
||||||
process.platform === 'win32'
|
process.platform === 'win32'
|
||||||
? [
|
? [
|
||||||
// Windows: npm global install
|
// Windows: npm global install
|
||||||
path.join(getHomeDir(), 'AppData', 'Roaming', 'npm'),
|
path.join(home, 'AppData', 'Roaming', 'npm'),
|
||||||
// Windows: scoop, chocolatey, and other package managers
|
// Windows: scoop, chocolatey, and other package managers
|
||||||
path.join(getHomeDir(), 'scoop', 'shims'),
|
path.join(home, 'scoop', 'shims'),
|
||||||
// Windows: Local programs
|
// Windows: Local programs
|
||||||
...(process.env.LOCALAPPDATA
|
...(process.env.LOCALAPPDATA
|
||||||
? [path.join(process.env.LOCALAPPDATA, 'Programs', 'claude')]
|
? [path.join(process.env.LOCALAPPDATA, 'Programs', 'claude')]
|
||||||
|
|
@ -218,9 +234,9 @@ export class ClaudeBinaryResolver {
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
// Unix: native binary installation path (claude install)
|
// Unix: native binary installation path (claude install)
|
||||||
path.join(getHomeDir(), '.local', 'bin'),
|
path.join(home, '.local', 'bin'),
|
||||||
path.join(getHomeDir(), '.npm-global', 'bin'),
|
path.join(home, '.npm-global', 'bin'),
|
||||||
path.join(getHomeDir(), '.npm', 'bin'),
|
path.join(home, '.npm', 'bin'),
|
||||||
'/usr/local/bin',
|
'/usr/local/bin',
|
||||||
'/opt/homebrew/bin',
|
'/opt/homebrew/bin',
|
||||||
];
|
];
|
||||||
|
|
|
||||||
72
src/main/utils/cliAuthDiagLog.ts
Normal file
72
src/main/utils/cliAuthDiagLog.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
66
src/main/utils/cliPathMerge.ts
Normal file
66
src/main/utils/cliPathMerge.ts
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
* and any other service that needs the user's shell environment.
|
* and any other service that needs the user's shell environment.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { getHomeDir } from '@main/utils/pathDecoder';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { spawn } from 'child_process';
|
import { spawn } from 'child_process';
|
||||||
|
|
||||||
|
|
@ -150,3 +151,12 @@ export function clearShellEnvCache(): void {
|
||||||
export function getCachedShellEnv(): NodeJS.ProcessEnv | null {
|
export function getCachedShellEnv(): NodeJS.ProcessEnv | null {
|
||||||
return cachedInteractiveShellEnv;
|
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();
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -127,6 +127,21 @@ describe('CliInstallerService', () => {
|
||||||
expect.objectContaining({ timeout: expect.any(Number) })
|
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', () => {
|
describe('install mutex', () => {
|
||||||
|
|
|
||||||
71
test/main/utils/cliPathMerge.test.ts
Normal file
71
test/main/utils/cliPathMerge.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue