fix(startup): prevent app freeze by making Claude CLI detection non-blocking (#680 regression) (#720)

* fix: convert Claude CLI detection to async to prevent main process freeze

PR #680 introduced synchronous execFileSync calls for Claude CLI detection.
When terminal sessions with Claude mode are restored on startup, these
blocking calls freeze the Electron main process for 1-3 seconds.

Changes:
- Add async versions: getAugmentedEnvAsync(), getToolPathAsync(),
  getClaudeCliInvocationAsync(), invokeClaudeAsync(), resumeClaudeAsync()
- Use caching to avoid repeated subprocess calls
- Pre-warm CLI cache at startup with setImmediate() for non-blocking detection
- Fix ENOWORKSPACES npm error by running npm commands from home directory

The sync versions are preserved for backward compatibility but now include
warnings in their JSDoc comments recommending the async alternatives.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Signed-off-by: aslaker <51129804+aslaker@users.noreply.github.com>

* refactor: extract shared helpers to reduce sync/async duplication

Address PR review feedback by:
- Extract pure helper functions for Claude CLI detection:
  - getClaudeDetectionPaths(): returns platform-specific candidate paths
  - sortNvmVersionDirs(): sorts NVM versions (newest first)
  - buildClaudeDetectionResult(): builds detection result from validation
- Extract pure helper functions for Claude invocation:
  - buildClaudeShellCommand(): builds shell command for all methods
  - finalizeClaudeInvoke(): consolidates post-invocation logic
- Add .catch() error handling for all async promise calls
- Replace sync fs calls with async versions in detectClaudeAsync
- Replace writeFileSync with fsPromises.writeFile in invokeClaudeAsync
- Add 24 new unit tests for helper functions
- Fix env-handlers tests to use async mock with flushPromises()
- Fix claude-integration-handler tests with os.tmpdir() mock

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: address PR review comments for async CLI detection

- Fix TOCTOU race condition in profile-storage.ts by removing
  existence check before readFile (Comment #7)
- Add semver validation regex to sortNvmVersionDirs to filter
  malformed version strings (Comment #5)
- Refactor buildClaudeShellCommand to use discriminated union
  type for better type safety (Comment #6)
- Add async validation/detection methods for Python, Git, and
  GitHub CLI with proper timeout handling (Comment #3)
- Extract shared path-building helpers (getExpandedPlatformPaths,
  buildPathsToAdd) to reduce sync/async duplication (Comment #4)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: add env parameter to async CLI validation and pre-warm all tools

- Add `env: await getAugmentedEnvAsync()` to validateClaudeAsync,
  validatePythonAsync, validateGitAsync, and validateGitHubCLIAsync
  to prevent sync PATH resolution blocking the main thread
- Pre-warm all commonly used CLI tools (claude, git, gh, python)
  instead of just claude to avoid sync blocking on first use

Fixes mouse hover freeze on macOS where the app would hang infinitely
when the mouse entered the window.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: address PR review comments for async Windows helpers and profile deduplication

CMT-001 [MEDIUM]: detectGitAsync now uses fully async Windows helpers
- Add getWindowsExecutablePathsAsync using fs.promises.access
- Add findWindowsExecutableViaWhereAsync using promisified execFile
- Update detectGitAsync to use async helpers instead of sync versions
- Prevents blocking Electron main process on Windows

CMT-002 [LOW]: Extract shared profile parsing logic
- Add parseAndMigrateProfileData helper function
- Simplifies loadProfileStore and loadProfileStoreAsync
- Reduces code duplication for version migration and date parsing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: cast through unknown to satisfy TypeScript strict type checking

The direct cast from Record<string, unknown> to ProfileStoreData fails
TypeScript's overlap check. Cast through unknown first to allow the
intentional type assertion.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: address PR review comments for async Windows helpers and profile deduplication

Address AndyMik90's Auto Claude PR Review comments:

- [NEW-002] Add missing --location=global flag to async npm prefix detection
  in getNpmGlobalPrefixAsync (env-utils.ts line 292) to match sync version
  and prevent ENOWORKSPACES errors in monorepos

- [NEW-001/NEW-005] Update resumeClaudeAsync to match sync resumeClaude
  behavior: always use --continue, clear claudeSessionId to prevent stale
  IDs, and add deprecation warning for sessionId parameter

- [NEW-004] Remove blocking existsSync check in ClaudeProfileManager.initialize()
  by using idempotent mkdir with recursive:true directly

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Signed-off-by: aslaker <51129804+aslaker@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Alex <63423455+AlexMadera@users.noreply.github.com>
Co-authored-by: Andy <119136210+AndyMik90@users.noreply.github.com>
This commit is contained in:
Adam Slaker
2026-01-07 00:13:25 -06:00
committed by GitHub
parent e3d72d648e
commit f406959094
16 changed files with 2265 additions and 349 deletions
@@ -8,7 +8,13 @@ import { existsSync, readdirSync } from 'fs';
import os from 'os';
import { execFileSync } from 'child_process';
import { app } from 'electron';
import { getToolInfo, clearToolCache } from '../cli-tool-manager';
import {
getToolInfo,
clearToolCache,
getClaudeDetectionPaths,
sortNvmVersionDirs,
buildClaudeDetectionResult
} from '../cli-tool-manager';
// Mock Electron app
vi.mock('electron', () => ({
@@ -42,9 +48,10 @@ vi.mock('fs', () => {
};
});
// Mock child_process for execFileSync (used in validation)
// Mock child_process for execFileSync and execFile (used in validation)
vi.mock('child_process', () => ({
execFileSync: vi.fn()
execFileSync: vi.fn(),
execFile: vi.fn()
}));
// Mock env-utils to avoid PATH augmentation complexity
@@ -312,3 +319,151 @@ describe('cli-tool-manager - Claude CLI NVM detection', () => {
});
});
});
/**
* Unit tests for helper functions
*/
describe('cli-tool-manager - Helper Functions', () => {
describe('getClaudeDetectionPaths', () => {
it('should return homebrew paths on macOS', () => {
Object.defineProperty(process, 'platform', {
value: 'darwin',
writable: true
});
const paths = getClaudeDetectionPaths('/Users/test');
expect(paths.homebrewPaths).toContain('/opt/homebrew/bin/claude');
expect(paths.homebrewPaths).toContain('/usr/local/bin/claude');
});
it('should return Windows paths on win32', () => {
Object.defineProperty(process, 'platform', {
value: 'win32',
writable: true
});
const paths = getClaudeDetectionPaths('C:\\Users\\test');
// Windows paths should include AppData and Program Files
expect(paths.platformPaths.some(p => p.includes('AppData'))).toBe(true);
expect(paths.platformPaths.some(p => p.includes('Program Files'))).toBe(true);
});
it('should return Unix paths on Linux', () => {
Object.defineProperty(process, 'platform', {
value: 'linux',
writable: true
});
const paths = getClaudeDetectionPaths('/home/test');
expect(paths.platformPaths.some(p => p.includes('.local/bin/claude'))).toBe(true);
expect(paths.platformPaths.some(p => p.includes('bin/claude'))).toBe(true);
});
it('should return correct NVM versions directory', () => {
const paths = getClaudeDetectionPaths('/home/test');
expect(paths.nvmVersionsDir).toBe('/home/test/.nvm/versions/node');
});
});
describe('sortNvmVersionDirs', () => {
it('should sort versions in descending order (newest first)', () => {
const entries = [
{ name: 'v18.20.0', isDirectory: () => true },
{ name: 'v22.17.0', isDirectory: () => true },
{ name: 'v20.11.0', isDirectory: () => true }
];
const sorted = sortNvmVersionDirs(entries);
expect(sorted).toEqual(['v22.17.0', 'v20.11.0', 'v18.20.0']);
});
it('should filter out non-version directories', () => {
const entries = [
{ name: 'v20.11.0', isDirectory: () => true },
{ name: '.DS_Store', isDirectory: () => false },
{ name: 'node_modules', isDirectory: () => true },
{ name: 'current', isDirectory: () => true },
{ name: 'v22.17.0', isDirectory: () => true }
];
const sorted = sortNvmVersionDirs(entries);
expect(sorted).toEqual(['v22.17.0', 'v20.11.0']);
expect(sorted).not.toContain('.DS_Store');
expect(sorted).not.toContain('node_modules');
expect(sorted).not.toContain('current');
});
it('should return empty array when no valid versions', () => {
const entries = [
{ name: 'current', isDirectory: () => true },
{ name: 'system', isDirectory: () => true }
];
const sorted = sortNvmVersionDirs(entries);
expect(sorted).toEqual([]);
});
it('should handle single entry', () => {
const entries = [{ name: 'v20.11.0', isDirectory: () => true }];
const sorted = sortNvmVersionDirs(entries);
expect(sorted).toEqual(['v20.11.0']);
});
it('should handle empty array', () => {
const sorted = sortNvmVersionDirs([]);
expect(sorted).toEqual([]);
});
});
describe('buildClaudeDetectionResult', () => {
it('should return null when validation fails', () => {
const result = buildClaudeDetectionResult(
'/path/to/claude',
{ valid: false, message: 'Invalid CLI' },
'nvm',
'Found via NVM'
);
expect(result).toBeNull();
});
it('should return proper result when validation succeeds', () => {
const result = buildClaudeDetectionResult(
'/path/to/claude',
{ valid: true, version: '1.0.0', message: 'Valid' },
'nvm',
'Found via NVM'
);
expect(result).not.toBeNull();
expect(result?.found).toBe(true);
expect(result?.path).toBe('/path/to/claude');
expect(result?.version).toBe('1.0.0');
expect(result?.source).toBe('nvm');
expect(result?.message).toContain('Found via NVM');
expect(result?.message).toContain('/path/to/claude');
});
it('should include path in message', () => {
const result = buildClaudeDetectionResult(
'/home/user/.nvm/versions/node/v22.17.0/bin/claude',
{ valid: true, version: '2.0.0', message: 'OK' },
'nvm',
'Detected Claude CLI'
);
expect(result?.message).toContain('Detected Claude CLI');
expect(result?.message).toContain('/home/user/.nvm/versions/node/v22.17.0/bin/claude');
});
});
});
@@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { IPC_CHANNELS } from '../../shared/constants';
const {
mockGetClaudeCliInvocation,
mockGetClaudeCliInvocationAsync,
mockGetProject,
spawnMock,
mockIpcMain,
@@ -22,6 +23,7 @@ const {
return {
mockGetClaudeCliInvocation: vi.fn(),
mockGetClaudeCliInvocationAsync: vi.fn(),
mockGetProject: vi.fn(),
spawnMock: vi.fn(),
mockIpcMain: ipcMain,
@@ -30,6 +32,7 @@ const {
vi.mock('../claude-cli-utils', () => ({
getClaudeCliInvocation: mockGetClaudeCliInvocation,
getClaudeCliInvocationAsync: mockGetClaudeCliInvocationAsync,
}));
vi.mock('../project-store', () => ({
@@ -64,9 +67,15 @@ function createProc(): EventEmitter & { stdout?: EventEmitter; stderr?: EventEmi
return proc;
}
// Helper to flush all pending promises (needed for async mock resolution)
function flushPromises(): Promise<void> {
return new Promise(resolve => setTimeout(resolve, 0));
}
describe('env-handlers Claude CLI usage', () => {
beforeEach(() => {
mockGetClaudeCliInvocation.mockReset();
mockGetClaudeCliInvocationAsync.mockReset();
mockGetProject.mockReset();
spawnMock.mockReset();
});
@@ -74,7 +83,7 @@ describe('env-handlers Claude CLI usage', () => {
it('uses resolved Claude CLI path/env for auth checks', async () => {
const claudeEnv = { PATH: '/opt/claude/bin:/usr/bin' };
const command = '/opt/claude/bin/claude';
mockGetClaudeCliInvocation.mockReturnValue({
mockGetClaudeCliInvocationAsync.mockResolvedValue({
command,
env: claudeEnv,
});
@@ -94,6 +103,8 @@ describe('env-handlers Claude CLI usage', () => {
}
const resultPromise = handler({}, 'p1');
// Wait for async CLI resolution before checking spawn
await flushPromises();
expect(spawnMock).toHaveBeenCalledTimes(1);
expect(spawnMock).toHaveBeenCalledWith(
command,
@@ -120,7 +131,7 @@ describe('env-handlers Claude CLI usage', () => {
it('uses resolved Claude CLI path/env for setup-token', async () => {
const claudeEnv = { PATH: '/opt/claude/bin:/usr/bin' };
const command = '/opt/claude/bin/claude';
mockGetClaudeCliInvocation.mockReturnValue({
mockGetClaudeCliInvocationAsync.mockResolvedValue({
command,
env: claudeEnv,
});
@@ -136,6 +147,8 @@ describe('env-handlers Claude CLI usage', () => {
}
const resultPromise = handler({}, 'p2');
// Wait for async CLI resolution before checking spawn
await flushPromises();
expect(spawnMock).toHaveBeenCalledWith(
command,
['setup-token'],
@@ -153,9 +166,7 @@ describe('env-handlers Claude CLI usage', () => {
});
it('returns an error when Claude CLI resolution throws', async () => {
mockGetClaudeCliInvocation.mockImplementation(() => {
throw new Error('Claude CLI exploded');
});
mockGetClaudeCliInvocationAsync.mockRejectedValue(new Error('Claude CLI exploded'));
mockGetProject.mockReturnValue({ id: 'p3', path: '/tmp/project' });
registerEnvHandlers(() => null);
@@ -171,7 +182,7 @@ describe('env-handlers Claude CLI usage', () => {
});
it('returns an error when Claude CLI command is missing', async () => {
mockGetClaudeCliInvocation.mockReturnValue({ command: '', env: {} });
mockGetClaudeCliInvocationAsync.mockResolvedValue({ command: '', env: {} });
mockGetProject.mockReturnValue({ id: 'p4', path: '/tmp/project' });
registerEnvHandlers(() => null);
@@ -189,7 +200,7 @@ describe('env-handlers Claude CLI usage', () => {
it('returns an error when Claude CLI exits with a non-zero code', async () => {
const claudeEnv = { PATH: '/opt/claude/bin:/usr/bin' };
const command = '/opt/claude/bin/claude';
mockGetClaudeCliInvocation.mockReturnValue({
mockGetClaudeCliInvocationAsync.mockResolvedValue({
command,
env: claudeEnv,
});
@@ -205,6 +216,8 @@ describe('env-handlers Claude CLI usage', () => {
}
const resultPromise = handler({}, 'p5');
// Wait for async CLI resolution before checking spawn
await flushPromises();
expect(spawnMock).toHaveBeenCalledWith(
command,
['--version'],
+30 -2
View File
@@ -1,6 +1,6 @@
import path from 'path';
import { getAugmentedEnv } from './env-utils';
import { getToolPath } from './cli-tool-manager';
import { getAugmentedEnv, getAugmentedEnvAsync } from './env-utils';
import { getToolPath, getToolPathAsync } from './cli-tool-manager';
export type ClaudeCliInvocation = {
command: string;
@@ -37,6 +37,9 @@ function ensureCommandDirInPath(command: string, env: Record<string, string>): R
/**
* Returns the Claude CLI command path and an environment with PATH updated to include the CLI directory.
*
* WARNING: This function uses synchronous subprocess calls that block the main process.
* For use in Electron main process, prefer getClaudeCliInvocationAsync() instead.
*/
export function getClaudeCliInvocation(): ClaudeCliInvocation {
const command = getToolPath('claude');
@@ -47,3 +50,28 @@ export function getClaudeCliInvocation(): ClaudeCliInvocation {
env: ensureCommandDirInPath(command, env),
};
}
/**
* Returns the Claude CLI command path and environment asynchronously (non-blocking).
*
* Safe to call from Electron main process without blocking the event loop.
* Uses cached values if available for instant response.
*
* @example
* ```typescript
* const { command, env } = await getClaudeCliInvocationAsync();
* spawn(command, ['--version'], { env });
* ```
*/
export async function getClaudeCliInvocationAsync(): Promise<ClaudeCliInvocation> {
// Run both detections in parallel for efficiency
const [command, env] = await Promise.all([
getToolPathAsync('claude'),
getAugmentedEnvAsync(),
]);
return {
command,
env: ensureCommandDirInPath(command, env),
};
}
@@ -13,7 +13,7 @@
import { app } from 'electron';
import { join } from 'path';
import { existsSync, mkdirSync } from 'fs';
import { mkdir } from 'fs/promises';
import type {
ClaudeProfile,
ClaudeProfileSettings,
@@ -32,6 +32,7 @@ import {
} from './claude-profile/rate-limit-manager';
import {
loadProfileStore,
loadProfileStoreAsync,
saveProfileStore,
ProfileStoreData,
DEFAULT_AUTO_SWITCH_SETTINGS
@@ -57,19 +58,45 @@ import {
*/
export class ClaudeProfileManager {
private storePath: string;
private configDir: string;
private data: ProfileStoreData;
private initialized: boolean = false;
constructor() {
const configDir = join(app.getPath('userData'), 'config');
this.storePath = join(configDir, 'claude-profiles.json');
this.configDir = join(app.getPath('userData'), 'config');
this.storePath = join(this.configDir, 'claude-profiles.json');
// Ensure directory exists
if (!existsSync(configDir)) {
mkdirSync(configDir, { recursive: true });
// DON'T do file I/O here - defer to async initialize()
// Start with default data until initialized
this.data = this.createDefaultData();
}
/**
* Initialize the profile manager asynchronously (non-blocking)
* This should be called at app startup via initializeClaudeProfileManager()
*/
async initialize(): Promise<void> {
if (this.initialized) return;
// Ensure directory exists (async) - mkdir with recursive:true is idempotent
await mkdir(this.configDir, { recursive: true });
// Load existing data asynchronously
const loadedData = await loadProfileStoreAsync(this.storePath);
if (loadedData) {
this.data = loadedData;
}
// else: keep the default data from constructor
// Load existing data or initialize with default profile
this.data = this.load();
this.initialized = true;
console.warn('[ClaudeProfileManager] Initialized asynchronously');
}
/**
* Check if the profile manager has been initialized
*/
isInitialized(): boolean {
return this.initialized;
}
/**
@@ -522,11 +549,13 @@ export class ClaudeProfileManager {
}
}
// Singleton instance
// Singleton instance and initialization promise
let profileManager: ClaudeProfileManager | null = null;
let initPromise: Promise<ClaudeProfileManager> | null = null;
/**
* Get the singleton Claude profile manager instance
* Note: For async contexts, prefer initializeClaudeProfileManager() to ensure initialization
*/
export function getClaudeProfileManager(): ClaudeProfileManager {
if (!profileManager) {
@@ -534,3 +563,28 @@ export function getClaudeProfileManager(): ClaudeProfileManager {
}
return profileManager;
}
/**
* Initialize and get the singleton Claude profile manager instance (async)
* This ensures the profile manager is fully initialized before use.
* Uses promise caching to prevent concurrent initialization.
*/
export async function initializeClaudeProfileManager(): Promise<ClaudeProfileManager> {
if (!profileManager) {
profileManager = new ClaudeProfileManager();
}
// If already initialized, return immediately
if (profileManager.isInitialized()) {
return profileManager;
}
// If initialization is in progress, wait for it (promise caching)
if (!initPromise) {
initPromise = profileManager.initialize().then(() => {
return profileManager!;
});
}
return initPromise;
}
@@ -4,6 +4,7 @@
*/
import { existsSync, readFileSync, writeFileSync } from 'fs';
import { readFile } from 'fs/promises';
import type { ClaudeProfile, ClaudeAutoSwitchSettings } from '../../shared/types';
export const STORE_VERSION = 3; // Bumped for encrypted token storage
@@ -30,6 +31,42 @@ export interface ProfileStoreData {
autoSwitch?: ClaudeAutoSwitchSettings;
}
/**
* Parse and migrate profile data from JSON.
* Handles version migration and date parsing.
* Shared helper used by both sync and async loaders.
*/
function parseAndMigrateProfileData(data: Record<string, unknown>): ProfileStoreData | null {
// Handle version migration
if (data.version === 1) {
// Migrate v1 to v2: add usage and rateLimitEvents fields
data.version = STORE_VERSION;
data.autoSwitch = DEFAULT_AUTO_SWITCH_SETTINGS;
}
if (data.version === STORE_VERSION) {
// Parse dates
const profiles = data.profiles as ClaudeProfile[];
data.profiles = profiles.map((p: ClaudeProfile) => ({
...p,
createdAt: new Date(p.createdAt),
lastUsedAt: p.lastUsedAt ? new Date(p.lastUsedAt) : undefined,
usage: p.usage ? {
...p.usage,
lastUpdated: new Date(p.usage.lastUpdated)
} : undefined,
rateLimitEvents: p.rateLimitEvents?.map(e => ({
...e,
hitAt: new Date(e.hitAt),
resetAt: new Date(e.resetAt)
}))
}));
return data as unknown as ProfileStoreData;
}
return null;
}
/**
* Load profiles from disk
*/
@@ -38,32 +75,7 @@ export function loadProfileStore(storePath: string): ProfileStoreData | null {
if (existsSync(storePath)) {
const content = readFileSync(storePath, 'utf-8');
const data = JSON.parse(content);
// Handle version migration
if (data.version === 1) {
// Migrate v1 to v2: add usage and rateLimitEvents fields
data.version = STORE_VERSION;
data.autoSwitch = DEFAULT_AUTO_SWITCH_SETTINGS;
}
if (data.version === STORE_VERSION) {
// Parse dates
data.profiles = data.profiles.map((p: ClaudeProfile) => ({
...p,
createdAt: new Date(p.createdAt),
lastUsedAt: p.lastUsedAt ? new Date(p.lastUsedAt) : undefined,
usage: p.usage ? {
...p.usage,
lastUpdated: new Date(p.usage.lastUpdated)
} : undefined,
rateLimitEvents: p.rateLimitEvents?.map(e => ({
...e,
hitAt: new Date(e.hitAt),
resetAt: new Date(e.resetAt)
}))
}));
return data;
}
return parseAndMigrateProfileData(data);
}
} catch (error) {
console.error('[ProfileStorage] Error loading profiles:', error);
@@ -72,6 +84,27 @@ export function loadProfileStore(storePath: string): ProfileStoreData | null {
return null;
}
/**
* Load profiles from disk (async, non-blocking)
* Use this version for initialization to avoid blocking the main process.
*/
export async function loadProfileStoreAsync(storePath: string): Promise<ProfileStoreData | null> {
try {
// Read file directly - avoid TOCTOU race condition by not checking existence first
// If file doesn't exist, readFile will throw ENOENT which we handle below
const content = await readFile(storePath, 'utf-8');
const data = JSON.parse(content);
return parseAndMigrateProfileData(data);
} catch (error) {
// ENOENT is expected if file doesn't exist yet
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
console.error('[ProfileStorage] Error loading profiles:', error);
}
}
return null;
}
/**
* Save profiles to disk
*/
File diff suppressed because it is too large Load Diff
+248 -30
View File
@@ -12,7 +12,32 @@
import * as os from 'os';
import * as path from 'path';
import * as fs from 'fs';
import { execFileSync } from 'child_process';
import { promises as fsPromises } from 'fs';
import { execFileSync, execFile } from 'child_process';
import { promisify } from 'util';
const execFileAsync = promisify(execFile);
/**
* Check if a path exists asynchronously (non-blocking)
*
* Uses fs.promises.access which is non-blocking, unlike fs.existsSync.
*
* @param filePath - The path to check
* @returns Promise resolving to true if path exists, false otherwise
*/
async function existsAsync(filePath: string): Promise<boolean> {
try {
await fsPromises.access(filePath);
return true;
} catch {
return false;
}
}
// Cache for npm global prefix to avoid repeated async calls
let npmGlobalPrefixCache: string | null | undefined = undefined;
let npmGlobalPrefixCachePromise: Promise<string | null> | null = null;
/**
* Get npm global prefix directory dynamically
@@ -35,6 +60,7 @@ function getNpmGlobalPrefix(): string | null {
encoding: 'utf-8',
timeout: 3000,
windowsHide: true,
cwd: os.homedir(), // Run from home dir to avoid ENOWORKSPACES error in monorepos
shell: process.platform === 'win32', // Enable shell on Windows for .cmd resolution
}).trim();
@@ -61,7 +87,7 @@ function getNpmGlobalPrefix(): string | null {
* Common binary directories that should be in PATH
* These are locations where commonly used tools are installed
*/
const COMMON_BIN_PATHS: Record<string, string[]> = {
export const COMMON_BIN_PATHS: Record<string, string[]> = {
darwin: [
'/opt/homebrew/bin', // Apple Silicon Homebrew
'/usr/local/bin', // Intel Homebrew / system
@@ -86,6 +112,71 @@ const COMMON_BIN_PATHS: Record<string, string[]> = {
],
};
/**
* Get expanded platform paths for PATH augmentation
*
* Shared helper used by both sync and async getAugmentedEnv functions.
* Expands home directory (~) in paths and returns the list of candidate paths.
*
* @param additionalPaths - Optional additional paths to include
* @returns Array of expanded paths (without existence checking)
*/
function getExpandedPlatformPaths(additionalPaths?: string[]): string[] {
const platform = process.platform as 'darwin' | 'linux' | 'win32';
const homeDir = os.homedir();
// Get platform-specific paths and expand home directory
const platformPaths = COMMON_BIN_PATHS[platform] || [];
const expandedPaths = platformPaths.map(p =>
p.startsWith('~') ? p.replace('~', homeDir) : p
);
// Add user-requested additional paths (expanded)
if (additionalPaths) {
for (const p of additionalPaths) {
const expanded = p.startsWith('~') ? p.replace('~', homeDir) : p;
expandedPaths.push(expanded);
}
}
return expandedPaths;
}
/**
* Build augmented PATH by filtering existing paths
*
* Shared helper that takes candidate paths and a set of current PATH entries,
* returning only paths that should be added.
*
* @param candidatePaths - Array of paths to consider adding
* @param currentPathSet - Set of paths already in PATH
* @param existingPaths - Array of paths that actually exist on the filesystem
* @param npmPrefix - npm global prefix path (or null if not found)
* @returns Array of paths to prepend to PATH
*/
function buildPathsToAdd(
candidatePaths: string[],
currentPathSet: Set<string>,
existingPaths: Set<string>,
npmPrefix: string | null
): string[] {
const pathsToAdd: string[] = [];
// Add platform-specific paths that exist
for (const p of candidatePaths) {
if (!currentPathSet.has(p) && existingPaths.has(p)) {
pathsToAdd.push(p);
}
}
// Add npm global prefix if it exists
if (npmPrefix && !currentPathSet.has(npmPrefix) && existingPaths.has(npmPrefix)) {
pathsToAdd.push(npmPrefix);
}
return pathsToAdd;
}
/**
* Get augmented environment with additional PATH entries
*
@@ -101,43 +192,24 @@ export function getAugmentedEnv(additionalPaths?: string[]): Record<string, stri
const platform = process.platform as 'darwin' | 'linux' | 'win32';
const pathSeparator = platform === 'win32' ? ';' : ':';
// Get platform-specific paths
const platformPaths = COMMON_BIN_PATHS[platform] || [];
// Expand home directory in paths
const homeDir = os.homedir();
const expandedPaths = platformPaths.map(p =>
p.startsWith('~') ? p.replace('~', homeDir) : p
);
// Get all candidate paths (platform + additional)
const candidatePaths = getExpandedPlatformPaths(additionalPaths);
// Collect paths to add (only if they exist and aren't already in PATH)
const currentPath = env.PATH || '';
const currentPathSet = new Set(currentPath.split(pathSeparator));
const pathsToAdd: string[] = [];
// Check existence synchronously and build existing paths set
const existingPaths = new Set(candidatePaths.filter(p => fs.existsSync(p)));
// Add platform-specific paths
for (const p of expandedPaths) {
if (!currentPathSet.has(p) && fs.existsSync(p)) {
pathsToAdd.push(p);
}
}
// Add npm global prefix dynamically (cross-platform: works with standard npm, nvm, nvm-windows)
// Get npm global prefix dynamically
const npmPrefix = getNpmGlobalPrefix();
if (npmPrefix && !currentPathSet.has(npmPrefix) && fs.existsSync(npmPrefix)) {
pathsToAdd.push(npmPrefix);
if (npmPrefix && fs.existsSync(npmPrefix)) {
existingPaths.add(npmPrefix);
}
// Add user-requested additional paths
if (additionalPaths) {
for (const p of additionalPaths) {
const expanded = p.startsWith('~') ? p.replace('~', homeDir) : p;
if (!currentPathSet.has(expanded) && fs.existsSync(expanded)) {
pathsToAdd.push(expanded);
}
}
}
// Build final paths to add using shared helper
const pathsToAdd = buildPathsToAdd(candidatePaths, currentPathSet, existingPaths, npmPrefix);
// Prepend new paths to PATH (prepend so they take priority)
if (pathsToAdd.length > 0) {
@@ -188,3 +260,149 @@ export function findExecutable(command: string): string | null {
export function isCommandAvailable(command: string): boolean {
return findExecutable(command) !== null;
}
// ============================================================================
// ASYNC VERSIONS - Non-blocking alternatives for Electron main process
// ============================================================================
/**
* Get npm global prefix directory asynchronously (non-blocking)
*
* Uses caching to avoid repeated subprocess calls. Safe to call from
* Electron main process without blocking the event loop.
*
* @returns Promise resolving to npm global binaries directory, or null
*/
async function getNpmGlobalPrefixAsync(): Promise<string | null> {
// Return cached value if available
if (npmGlobalPrefixCache !== undefined) {
return npmGlobalPrefixCache;
}
// If a fetch is already in progress, wait for it
if (npmGlobalPrefixCachePromise) {
return npmGlobalPrefixCachePromise;
}
// Start the async fetch
npmGlobalPrefixCachePromise = (async () => {
try {
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
const { stdout } = await execFileAsync(npmCommand, ['config', 'get', 'prefix', '--location=global'], {
encoding: 'utf-8',
timeout: 3000,
windowsHide: true,
cwd: os.homedir(), // Run from home dir to avoid ENOWORKSPACES error in monorepos
shell: process.platform === 'win32',
});
const rawPrefix = stdout.trim();
if (!rawPrefix) {
npmGlobalPrefixCache = null;
return null;
}
const binPath = process.platform === 'win32'
? rawPrefix
: path.join(rawPrefix, 'bin');
const normalizedPath = path.normalize(binPath);
npmGlobalPrefixCache = await existsAsync(normalizedPath) ? normalizedPath : null;
return npmGlobalPrefixCache;
} catch (error) {
console.warn(`[env-utils] Failed to get npm global prefix: ${error}`);
npmGlobalPrefixCache = null;
return null;
} finally {
npmGlobalPrefixCachePromise = null;
}
})();
return npmGlobalPrefixCachePromise;
}
/**
* Get augmented environment asynchronously (non-blocking)
*
* Same as getAugmentedEnv but uses async npm prefix detection.
* Safe to call from Electron main process without blocking.
*
* @param additionalPaths - Optional array of additional paths to include
* @returns Promise resolving to environment object with augmented PATH
*/
export async function getAugmentedEnvAsync(additionalPaths?: string[]): Promise<Record<string, string>> {
const env = { ...process.env } as Record<string, string>;
const platform = process.platform as 'darwin' | 'linux' | 'win32';
const pathSeparator = platform === 'win32' ? ';' : ':';
// Get all candidate paths (platform + additional)
const candidatePaths = getExpandedPlatformPaths(additionalPaths);
// Collect paths to add (only if they exist and aren't already in PATH)
const currentPath = env.PATH || '';
const currentPathSet = new Set(currentPath.split(pathSeparator));
// Check existence asynchronously in parallel for performance
const pathChecks = await Promise.all(
candidatePaths.map(async (p) => ({ path: p, exists: await existsAsync(p) }))
);
const existingPaths = new Set(
pathChecks.filter(({ exists }) => exists).map(({ path: p }) => p)
);
// Get npm global prefix dynamically (async - non-blocking)
const npmPrefix = await getNpmGlobalPrefixAsync();
if (npmPrefix && await existsAsync(npmPrefix)) {
existingPaths.add(npmPrefix);
}
// Build final paths to add using shared helper
const pathsToAdd = buildPathsToAdd(candidatePaths, currentPathSet, existingPaths, npmPrefix);
// Prepend new paths to PATH (prepend so they take priority)
if (pathsToAdd.length > 0) {
env.PATH = [...pathsToAdd, currentPath].filter(Boolean).join(pathSeparator);
}
return env;
}
/**
* Find the full path to an executable asynchronously (non-blocking)
*
* Same as findExecutable but uses async environment augmentation.
*
* @param command - The command name to find (e.g., 'gh', 'git')
* @returns Promise resolving to the full path to the executable, or null
*/
export async function findExecutableAsync(command: string): Promise<string | null> {
const env = await getAugmentedEnvAsync();
const pathSeparator = process.platform === 'win32' ? ';' : ':';
const pathDirs = (env.PATH || '').split(pathSeparator);
const extensions = process.platform === 'win32'
? ['.exe', '.cmd', '.bat', '.ps1', '']
: [''];
for (const dir of pathDirs) {
for (const ext of extensions) {
const fullPath = path.join(dir, command + ext);
if (await existsAsync(fullPath)) {
return fullPath;
}
}
}
return null;
}
/**
* Clear the npm global prefix cache
*
* Call this if npm configuration changes and you need fresh detection.
*/
export function clearNpmPrefixCache(): void {
npmGlobalPrefixCache = undefined;
npmGlobalPrefixCachePromise = null;
}
+19
View File
@@ -35,6 +35,8 @@ import { DEFAULT_APP_SETTINGS } from '../shared/constants';
import { readSettingsFile } from './settings-utils';
import { setupErrorLogging } from './app-logger';
import { initSentryMain } from './sentry';
import { preWarmToolCache } from './cli-tool-manager';
import { initializeClaudeProfileManager } from './claude-profile-manager';
import type { AppSettings } from '../shared/types';
// ─────────────────────────────────────────────────────────────────────────────
@@ -348,6 +350,23 @@ app.whenReady().then(() => {
// Create window
createWindow();
// Pre-warm CLI tool cache in background (non-blocking)
// This ensures CLI detection is done before user needs it
// Include all commonly used tools to prevent sync blocking on first use
setImmediate(() => {
preWarmToolCache(['claude', 'git', 'gh', 'python']).catch((error) => {
console.warn('[main] Failed to pre-warm CLI cache:', error);
});
});
// Pre-initialize Claude profile manager in background (non-blocking)
// This ensures profile data is loaded before user clicks "Start Claude Code"
setImmediate(() => {
initializeClaudeProfileManager().catch((error) => {
console.warn('[main] Failed to pre-initialize profile manager:', error);
});
});
// Initialize usage monitoring after window is created
if (mainWindow) {
// Setup event forwarding from usage monitor to renderer
@@ -8,7 +8,7 @@ import { existsSync, readFileSync, writeFileSync } from 'fs';
import { spawn } from 'child_process';
import { projectStore } from '../project-store';
import { parseEnvFile } from './utils';
import { getClaudeCliInvocation } from '../claude-cli-utils';
import { getClaudeCliInvocation, getClaudeCliInvocationAsync } from '../claude-cli-utils';
import { debugError } from '../../shared/utils/debug-logger';
// GitLab environment variable keys
@@ -46,6 +46,24 @@ function resolveClaudeCliInvocation(): ResolvedClaudeCliInvocation {
}
}
/**
* Async version of resolveClaudeCliInvocation - non-blocking for main process
*/
async function resolveClaudeCliInvocationAsync(): Promise<ResolvedClaudeCliInvocation> {
try {
const invocation = await getClaudeCliInvocationAsync();
if (!invocation?.command) {
throw new Error('Claude CLI path not resolved');
}
return { command: invocation.command, env: invocation.env };
} catch (error) {
debugError('[IPC] Failed to resolve Claude CLI path:', error);
return {
error: error instanceof Error ? error.message : 'Failed to resolve Claude CLI path',
};
}
}
/**
* Register all env-related IPC handlers
@@ -573,7 +591,8 @@ ${existingVars['GRAPHITI_DB_PATH'] ? `GRAPHITI_DB_PATH=${existingVars['GRAPHITI_
return { success: false, error: 'Project not found' };
}
const resolved = resolveClaudeCliInvocation();
// Use async version to avoid blocking main process during CLI detection
const resolved = await resolveClaudeCliInvocationAsync();
if ('error' in resolved) {
return { success: false, error: resolved.error };
}
@@ -663,7 +682,8 @@ ${existingVars['GRAPHITI_DB_PATH'] ? `GRAPHITI_DB_PATH=${existingVars['GRAPHITI_
return { success: false, error: 'Project not found' };
}
const resolved = resolveClaudeCliInvocation();
// Use async version to avoid blocking main process during CLI detection
const resolved = await resolveClaudeCliInvocationAsync();
if ('error' in resolved) {
return { success: false, error: resolved.error };
}
@@ -14,7 +14,7 @@ import { AgentManager } from '../agent';
import type { BrowserWindow } from 'electron';
import { setUpdateChannel, setUpdateChannelWithDowngradeCheck } from '../app-updater';
import { getSettingsPath, readSettingsFile } from '../settings-utils';
import { configureTools, getToolPath, getToolInfo, isPathFromWrongPlatform } from '../cli-tool-manager';
import { configureTools, getToolPath, getToolInfo, isPathFromWrongPlatform, preWarmToolCache } from '../cli-tool-manager';
import { parseEnvFile } from './utils';
const settingsPath = getSettingsPath();
@@ -171,6 +171,11 @@ export function registerSettingsHandlers(
claudePath: settings.claudePath,
});
// Re-warm cache asynchronously after configuring (non-blocking)
preWarmToolCache(['claude']).catch((error) => {
console.warn('[SETTINGS_GET] Failed to re-warm CLI cache:', error);
});
return { success: true, data: settings as AppSettings };
}
);
@@ -212,6 +217,11 @@ export function registerSettingsHandlers(
githubCLIPath: newSettings.githubCLIPath,
claudePath: newSettings.claudePath,
});
// Re-warm cache asynchronously after configuring (non-blocking)
preWarmToolCache(['claude']).catch((error) => {
console.warn('[SETTINGS_SAVE] Failed to re-warm CLI cache:', error);
});
}
// Update auto-updater channel if betaUpdates setting changed
@@ -9,7 +9,7 @@ import { projectStore } from '../project-store';
import { terminalNameGenerator } from '../terminal-name-generator';
import { debugLog, debugError } from '../../shared/utils/debug-logger';
import { escapeShellArg, escapeShellArgWindows } from '../../shared/utils/shell-escape';
import { getClaudeCliInvocation } from '../claude-cli-utils';
import { getClaudeCliInvocationAsync } from '../claude-cli-utils';
/**
@@ -54,7 +54,10 @@ export function registerTerminalHandlers(
ipcMain.on(
IPC_CHANNELS.TERMINAL_INVOKE_CLAUDE,
(_, id: string, cwd?: string) => {
terminalManager.invokeClaude(id, cwd);
// Use async version to avoid blocking main process during CLI detection
terminalManager.invokeClaudeAsync(id, cwd).catch((error) => {
console.error('[terminal-handlers] Failed to invoke Claude:', error);
});
}
);
@@ -354,7 +357,7 @@ export function registerTerminalHandlers(
// Build the login command with the profile's config dir
// Use platform-specific syntax and escaping for environment variables
let loginCommand: string;
const { command: claudeCmd, env: claudeEnv } = getClaudeCliInvocation();
const { command: claudeCmd, env: claudeEnv } = await getClaudeCliInvocationAsync();
const pathPrefix = claudeEnv.PATH
? (process.platform === 'win32'
? `set "PATH=${escapeShellArgWindows(claudeEnv.PATH)}" && `
@@ -635,7 +638,10 @@ export function registerTerminalHandlers(
ipcMain.on(
IPC_CHANNELS.TERMINAL_RESUME_CLAUDE,
(_, id: string, sessionId?: string) => {
terminalManager.resumeClaude(id, sessionId);
// Use async version to avoid blocking main process during CLI detection
terminalManager.resumeClaudeAsync(id, sessionId).catch((error) => {
console.error('[terminal-handlers] Failed to resume Claude:', error);
});
}
);
@@ -60,6 +60,14 @@ vi.mock('../session-handler', () => ({
releaseSessionId: mockReleaseSessionId,
}));
vi.mock('os', async (importOriginal) => {
const actual = await importOriginal<typeof import('os')>();
return {
...actual,
tmpdir: vi.fn(() => '/tmp'),
};
});
describe('claude-integration-handler', () => {
beforeEach(() => {
mockGetClaudeCliInvocation.mockClear();
@@ -407,3 +415,195 @@ describe('claude-integration-handler', () => {
expect(mockPersistSession).not.toHaveBeenCalled();
});
});
/**
* Unit tests for helper functions
*/
describe('claude-integration-handler - Helper Functions', () => {
describe('buildClaudeShellCommand', () => {
it('should build default command without cwd or PATH prefix', async () => {
const { buildClaudeShellCommand } = await import('../claude-integration-handler');
const result = buildClaudeShellCommand('', '', "'/opt/bin/claude'", { method: 'default' });
expect(result).toBe("'/opt/bin/claude'\r");
});
it('should build command with cwd', async () => {
const { buildClaudeShellCommand } = await import('../claude-integration-handler');
const result = buildClaudeShellCommand("cd '/tmp/project' && ", '', "'/opt/bin/claude'", { method: 'default' });
expect(result).toBe("cd '/tmp/project' && '/opt/bin/claude'\r");
});
it('should build command with PATH prefix', async () => {
const { buildClaudeShellCommand } = await import('../claude-integration-handler');
const result = buildClaudeShellCommand('', "PATH='/custom/path' ", "'/opt/bin/claude'", { method: 'default' });
expect(result).toBe("PATH='/custom/path' '/opt/bin/claude'\r");
});
it('should build temp-file method command with history-safe prefixes', async () => {
const { buildClaudeShellCommand } = await import('../claude-integration-handler');
const result = buildClaudeShellCommand(
"cd '/tmp/project' && ",
"PATH='/opt/bin' ",
"'/opt/bin/claude'",
{ method: 'temp-file', escapedTempFile: "'/tmp/.token-123'" }
);
expect(result).toContain('clear && ');
expect(result).toContain("cd '/tmp/project' && ");
expect(result).toContain('HISTFILE= HISTCONTROL=ignorespace');
expect(result).toContain("PATH='/opt/bin' ");
expect(result).toContain("source '/tmp/.token-123'");
expect(result).toContain("rm -f '/tmp/.token-123'");
expect(result).toContain("exec '/opt/bin/claude'");
});
it('should build config-dir method command with CLAUDE_CONFIG_DIR', async () => {
const { buildClaudeShellCommand } = await import('../claude-integration-handler');
const result = buildClaudeShellCommand(
"cd '/tmp/project' && ",
"PATH='/opt/bin' ",
"'/opt/bin/claude'",
{ method: 'config-dir', escapedConfigDir: "'/home/user/.claude-work'" }
);
expect(result).toContain('clear && ');
expect(result).toContain("cd '/tmp/project' && ");
expect(result).toContain('HISTFILE= HISTCONTROL=ignorespace');
expect(result).toContain("CLAUDE_CONFIG_DIR='/home/user/.claude-work'");
expect(result).toContain("PATH='/opt/bin' ");
expect(result).toContain("exec '/opt/bin/claude'");
});
it('should handle empty cwdCommand for temp-file method', async () => {
const { buildClaudeShellCommand } = await import('../claude-integration-handler');
const result = buildClaudeShellCommand(
'',
'',
"'/opt/bin/claude'",
{ method: 'temp-file', escapedTempFile: "'/tmp/.token'" }
);
expect(result).toContain('clear && ');
expect(result).toContain('HISTFILE= HISTCONTROL=ignorespace');
expect(result).not.toContain('cd ');
expect(result).toContain("source '/tmp/.token'");
});
});
describe('finalizeClaudeInvoke', () => {
it('should set terminal title to "Claude" for default profile', async () => {
const { finalizeClaudeInvoke } = await import('../claude-integration-handler');
const terminal = createMockTerminal();
const mockWindow = {
webContents: { send: vi.fn() }
};
finalizeClaudeInvoke(
terminal,
{ name: 'Default', isDefault: true },
'/tmp/project',
Date.now(),
() => mockWindow as any,
vi.fn()
);
expect(terminal.title).toBe('Claude');
});
it('should set terminal title to "Claude (ProfileName)" for non-default profile', async () => {
const { finalizeClaudeInvoke } = await import('../claude-integration-handler');
const terminal = createMockTerminal();
const mockWindow = {
webContents: { send: vi.fn() }
};
finalizeClaudeInvoke(
terminal,
{ name: 'Work Profile', isDefault: false },
'/tmp/project',
Date.now(),
() => mockWindow as any,
vi.fn()
);
expect(terminal.title).toBe('Claude (Work Profile)');
});
it('should send IPC message to renderer', async () => {
const { finalizeClaudeInvoke } = await import('../claude-integration-handler');
const terminal = createMockTerminal();
const mockSend = vi.fn();
const mockWindow = {
webContents: { send: mockSend }
};
finalizeClaudeInvoke(
terminal,
undefined,
'/tmp/project',
Date.now(),
() => mockWindow as any,
vi.fn()
);
expect(mockSend).toHaveBeenCalledWith(
expect.stringContaining('title'),
terminal.id,
'Claude'
);
});
it('should persist session when terminal has projectPath', async () => {
const { finalizeClaudeInvoke } = await import('../claude-integration-handler');
const terminal = createMockTerminal({ projectPath: '/tmp/project' });
finalizeClaudeInvoke(
terminal,
undefined,
'/tmp/project',
Date.now(),
() => null,
vi.fn()
);
expect(mockPersistSession).toHaveBeenCalledWith(terminal);
});
it('should call onSessionCapture when projectPath is provided', async () => {
const { finalizeClaudeInvoke } = await import('../claude-integration-handler');
const terminal = createMockTerminal();
const mockOnSessionCapture = vi.fn();
const startTime = Date.now();
finalizeClaudeInvoke(
terminal,
undefined,
'/tmp/project',
startTime,
() => null,
mockOnSessionCapture
);
expect(mockOnSessionCapture).toHaveBeenCalledWith(terminal.id, '/tmp/project', startTime);
});
it('should not crash when getWindow returns null', async () => {
const { finalizeClaudeInvoke } = await import('../claude-integration-handler');
const terminal = createMockTerminal();
expect(() => {
finalizeClaudeInvoke(
terminal,
undefined,
'/tmp/project',
Date.now(),
() => null,
vi.fn()
);
}).not.toThrow();
});
});
});
@@ -5,15 +5,16 @@
import * as os from 'os';
import * as fs from 'fs';
import { promises as fsPromises } from 'fs';
import * as path from 'path';
import * as crypto from 'crypto';
import { IPC_CHANNELS } from '../../shared/constants';
import { getClaudeProfileManager } from '../claude-profile-manager';
import { getClaudeProfileManager, initializeClaudeProfileManager } from '../claude-profile-manager';
import * as OutputParser from './output-parser';
import * as SessionHandler from './session-handler';
import { debugLog, debugError } from '../../shared/utils/debug-logger';
import { escapeShellArg, buildCdCommand } from '../../shared/utils/shell-escape';
import { getClaudeCliInvocation } from '../claude-cli-utils';
import { getClaudeCliInvocation, getClaudeCliInvocationAsync } from '../claude-cli-utils';
import type {
TerminalProcess,
WindowGetter,
@@ -25,6 +26,133 @@ function normalizePathForBash(envPath: string): string {
return process.platform === 'win32' ? envPath.replace(/;/g, ':') : envPath;
}
// ============================================================================
// SHARED HELPERS - Used by both sync and async invokeClaude
// ============================================================================
/**
* Configuration for building Claude shell commands using discriminated union.
* This provides type safety by ensuring the correct options are provided for each method.
*/
type ClaudeCommandConfig =
| { method: 'default' }
| { method: 'temp-file'; escapedTempFile: string }
| { method: 'config-dir'; escapedConfigDir: string };
/**
* Build the shell command for invoking Claude CLI.
*
* Generates the appropriate command string based on the invocation method:
* - 'default': Simple command execution
* - 'temp-file': Sources OAuth token from temp file, then removes it
* - 'config-dir': Sets CLAUDE_CONFIG_DIR for custom profile location
*
* All non-default methods include history-safe prefixes (HISTFILE=, HISTCONTROL=)
* to prevent sensitive data from appearing in shell history.
*
* @param cwdCommand - Command to change directory (empty string if no change needed)
* @param pathPrefix - PATH prefix for Claude CLI (empty string if not needed)
* @param escapedClaudeCmd - Shell-escaped Claude CLI command
* @param config - Configuration object with method and required options (discriminated union)
* @returns Complete shell command string ready for terminal.pty.write()
*
* @example
* // Default method
* buildClaudeShellCommand('cd /path && ', 'PATH=/bin ', 'claude', { method: 'default' });
* // Returns: 'cd /path && PATH=/bin claude\r'
*
* // Temp file method
* buildClaudeShellCommand('', '', 'claude', { method: 'temp-file', escapedTempFile: '/tmp/token' });
* // Returns: 'clear && HISTFILE= HISTCONTROL=ignorespace bash -c "source /tmp/token && rm -f /tmp/token && exec claude"\r'
*/
export function buildClaudeShellCommand(
cwdCommand: string,
pathPrefix: string,
escapedClaudeCmd: string,
config: ClaudeCommandConfig
): string {
switch (config.method) {
case 'temp-file':
return `clear && ${cwdCommand}HISTFILE= HISTCONTROL=ignorespace ${pathPrefix}bash -c "source ${config.escapedTempFile} && rm -f ${config.escapedTempFile} && exec ${escapedClaudeCmd}"\r`;
case 'config-dir':
return `clear && ${cwdCommand}HISTFILE= HISTCONTROL=ignorespace CLAUDE_CONFIG_DIR=${config.escapedConfigDir} ${pathPrefix}bash -c "exec ${escapedClaudeCmd}"\r`;
default:
return `${cwdCommand}${pathPrefix}${escapedClaudeCmd}\r`;
}
}
/**
* Profile information for terminal title generation
*/
interface ProfileInfo {
/** Profile name for display */
name?: string;
/** Whether this is the default profile */
isDefault?: boolean;
}
/**
* Callback type for session capture
*/
type SessionCaptureCallback = (terminalId: string, projectPath: string, startTime: number) => void;
/**
* Finalize terminal state after invoking Claude.
*
* Updates terminal title, sends IPC notification to renderer, persists session,
* and calls the session capture callback. This consolidates the post-invocation
* logic used by both sync and async invoke methods.
*
* @param terminal - The terminal process to update
* @param activeProfile - The profile being used (or undefined for default)
* @param projectPath - The project path (for session capture)
* @param startTime - Timestamp when invocation started
* @param getWindow - Function to get the BrowserWindow
* @param onSessionCapture - Callback for session capture
*
* @example
* finalizeClaudeInvoke(
* terminal,
* { name: 'Work', isDefault: false },
* '/path/to/project',
* Date.now(),
* () => mainWindow,
* (id, path, time) => console.log('Session captured')
* );
*/
export function finalizeClaudeInvoke(
terminal: TerminalProcess,
activeProfile: ProfileInfo | undefined,
projectPath: string | undefined,
startTime: number,
getWindow: WindowGetter,
onSessionCapture: SessionCaptureCallback
): void {
// Set terminal title based on profile
const title = activeProfile && !activeProfile.isDefault
? `Claude (${activeProfile.name})`
: 'Claude';
terminal.title = title;
// Notify renderer of title change
const win = getWindow();
if (win) {
win.webContents.send(IPC_CHANNELS.TERMINAL_TITLE_CHANGE, terminal.id, title);
}
// Persist session if project path is available
if (terminal.projectPath) {
SessionHandler.persistSession(terminal);
}
// Call session capture callback if project path provided
if (projectPath) {
onSessionCapture(terminal.id, projectPath, startTime);
}
}
/**
* Handle rate limit detection and profile switching
*/
@@ -217,7 +345,6 @@ export function invokeClaude(
debugLog('[ClaudeIntegration:invokeClaude] CWD:', cwd);
terminal.isClaudeMode = true;
// Release any previously claimed session ID before starting new session
SessionHandler.releaseSessionId(terminal.id);
terminal.claudeSessionId = undefined;
@@ -240,7 +367,6 @@ export function invokeClaude(
isDefault: activeProfile?.isDefault
});
// Use safe shell escaping to prevent command injection
const cwdCommand = buildCdCommand(cwd);
const { command: claudeCmd, env: claudeEnv } = getClaudeCliInvocation();
const escapedClaudeCmd = escapeShellArg(claudeCmd);
@@ -273,58 +399,20 @@ export function invokeClaude(
{ mode: 0o600 }
);
// Clear terminal and run command without adding to shell history:
// - HISTFILE= disables history file writing for the current command
// - HISTCONTROL=ignorespace causes commands starting with space to be ignored
// - Leading space ensures the command is ignored even if HISTCONTROL was already set
// - Uses subshell (...) to isolate environment changes
// This prevents temp file paths from appearing in shell history
const command = `clear && ${cwdCommand}HISTFILE= HISTCONTROL=ignorespace ${pathPrefix}bash -c "source ${escapedTempFile} && rm -f ${escapedTempFile} && exec ${escapedClaudeCmd}"\r`;
const command = buildClaudeShellCommand(cwdCommand, pathPrefix, escapedClaudeCmd, { method: 'temp-file', escapedTempFile });
debugLog('[ClaudeIntegration:invokeClaude] Executing command (temp file method, history-safe)');
terminal.pty.write(command);
profileManager.markProfileUsed(activeProfile.id);
// Update terminal title and persist session
const title = `Claude (${activeProfile.name})`;
terminal.title = title;
const win = getWindow();
if (win) {
win.webContents.send(IPC_CHANNELS.TERMINAL_TITLE_CHANGE, terminal.id, title);
}
if (terminal.projectPath) {
SessionHandler.persistSession(terminal);
}
if (projectPath) {
onSessionCapture(terminal.id, projectPath, startTime);
}
finalizeClaudeInvoke(terminal, activeProfile, projectPath, startTime, getWindow, onSessionCapture);
debugLog('[ClaudeIntegration:invokeClaude] ========== INVOKE CLAUDE COMPLETE (temp file) ==========');
return;
} else if (activeProfile.configDir) {
// Clear terminal and run command without adding to shell history:
// Same history-disabling technique as temp file method above
// SECURITY: Use escapeShellArg for configDir to prevent command injection
// Set CLAUDE_CONFIG_DIR as env var before bash -c to avoid embedding user input in the command string
const escapedConfigDir = escapeShellArg(activeProfile.configDir);
const command = `clear && ${cwdCommand}HISTFILE= HISTCONTROL=ignorespace CLAUDE_CONFIG_DIR=${escapedConfigDir} ${pathPrefix}bash -c "exec ${escapedClaudeCmd}"\r`;
const command = buildClaudeShellCommand(cwdCommand, pathPrefix, escapedClaudeCmd, { method: 'config-dir', escapedConfigDir });
debugLog('[ClaudeIntegration:invokeClaude] Executing command (configDir method, history-safe)');
terminal.pty.write(command);
profileManager.markProfileUsed(activeProfile.id);
// Update terminal title and persist session
const title = `Claude (${activeProfile.name})`;
terminal.title = title;
const win = getWindow();
if (win) {
win.webContents.send(IPC_CHANNELS.TERMINAL_TITLE_CHANGE, terminal.id, title);
}
if (terminal.projectPath) {
SessionHandler.persistSession(terminal);
}
if (projectPath) {
onSessionCapture(terminal.id, projectPath, startTime);
}
finalizeClaudeInvoke(terminal, activeProfile, projectPath, startTime, getWindow, onSessionCapture);
debugLog('[ClaudeIntegration:invokeClaude] ========== INVOKE CLAUDE COMPLETE (configDir) ==========');
return;
} else {
@@ -336,7 +424,7 @@ export function invokeClaude(
debugLog('[ClaudeIntegration:invokeClaude] Using terminal environment for non-default profile:', activeProfile.name);
}
const command = `${cwdCommand}${pathPrefix}${escapedClaudeCmd}\r`;
const command = buildClaudeShellCommand(cwdCommand, pathPrefix, escapedClaudeCmd, { method: 'default' });
debugLog('[ClaudeIntegration:invokeClaude] Executing command (default method):', command);
terminal.pty.write(command);
@@ -344,25 +432,7 @@ export function invokeClaude(
profileManager.markProfileUsed(activeProfile.id);
}
// Update terminal title in main process and notify renderer
const title = activeProfile && !activeProfile.isDefault
? `Claude (${activeProfile.name})`
: 'Claude';
terminal.title = title;
const win = getWindow();
if (win) {
win.webContents.send(IPC_CHANNELS.TERMINAL_TITLE_CHANGE, terminal.id, title);
}
if (terminal.projectPath) {
SessionHandler.persistSession(terminal);
}
if (projectPath) {
onSessionCapture(terminal.id, projectPath, startTime);
}
finalizeClaudeInvoke(terminal, activeProfile, projectPath, startTime, getWindow, onSessionCapture);
debugLog('[ClaudeIntegration:invokeClaude] ========== INVOKE CLAUDE COMPLETE (default) ==========');
}
@@ -421,6 +491,171 @@ export function resumeClaude(
}
}
// ============================================================================
// ASYNC VERSIONS - Non-blocking alternatives for Electron main process
// ============================================================================
/**
* Invoke Claude asynchronously (non-blocking)
*
* Safe to call from Electron main process without blocking the event loop.
* Uses async CLI detection which doesn't block on subprocess calls.
*/
export async function invokeClaudeAsync(
terminal: TerminalProcess,
cwd: string | undefined,
profileId: string | undefined,
getWindow: WindowGetter,
onSessionCapture: (terminalId: string, projectPath: string, startTime: number) => void
): Promise<void> {
debugLog('[ClaudeIntegration:invokeClaudeAsync] ========== INVOKE CLAUDE START (async) ==========');
debugLog('[ClaudeIntegration:invokeClaudeAsync] Terminal ID:', terminal.id);
debugLog('[ClaudeIntegration:invokeClaudeAsync] Requested profile ID:', profileId);
debugLog('[ClaudeIntegration:invokeClaudeAsync] CWD:', cwd);
terminal.isClaudeMode = true;
SessionHandler.releaseSessionId(terminal.id);
terminal.claudeSessionId = undefined;
const startTime = Date.now();
const projectPath = cwd || terminal.projectPath || terminal.cwd;
// Ensure profile manager is initialized (async, yields to event loop)
const profileManager = await initializeClaudeProfileManager();
const activeProfile = profileId
? profileManager.getProfile(profileId)
: profileManager.getActiveProfile();
const previousProfileId = terminal.claudeProfileId;
terminal.claudeProfileId = activeProfile?.id;
debugLog('[ClaudeIntegration:invokeClaudeAsync] Profile resolution:', {
previousProfileId,
newProfileId: activeProfile?.id,
profileName: activeProfile?.name,
hasOAuthToken: !!activeProfile?.oauthToken,
isDefault: activeProfile?.isDefault
});
// Async CLI invocation - non-blocking
const cwdCommand = buildCdCommand(cwd);
const { command: claudeCmd, env: claudeEnv } = await getClaudeCliInvocationAsync();
const escapedClaudeCmd = escapeShellArg(claudeCmd);
const pathPrefix = claudeEnv.PATH
? `PATH=${escapeShellArg(normalizePathForBash(claudeEnv.PATH))} `
: '';
const needsEnvOverride = profileId && profileId !== previousProfileId;
debugLog('[ClaudeIntegration:invokeClaudeAsync] Environment override check:', {
profileIdProvided: !!profileId,
previousProfileId,
needsEnvOverride
});
if (needsEnvOverride && activeProfile && !activeProfile.isDefault) {
const token = profileManager.getProfileToken(activeProfile.id);
debugLog('[ClaudeIntegration:invokeClaudeAsync] Token retrieval:', {
hasToken: !!token,
tokenLength: token?.length
});
if (token) {
const nonce = crypto.randomBytes(8).toString('hex');
const tempFile = path.join(os.tmpdir(), `.claude-token-${Date.now()}-${nonce}`);
const escapedTempFile = escapeShellArg(tempFile);
debugLog('[ClaudeIntegration:invokeClaudeAsync] Writing token to temp file:', tempFile);
await fsPromises.writeFile(
tempFile,
`export CLAUDE_CODE_OAUTH_TOKEN=${escapeShellArg(token)}\n`,
{ mode: 0o600 }
);
const command = buildClaudeShellCommand(cwdCommand, pathPrefix, escapedClaudeCmd, { method: 'temp-file', escapedTempFile });
debugLog('[ClaudeIntegration:invokeClaudeAsync] Executing command (temp file method, history-safe)');
terminal.pty.write(command);
profileManager.markProfileUsed(activeProfile.id);
finalizeClaudeInvoke(terminal, activeProfile, projectPath, startTime, getWindow, onSessionCapture);
debugLog('[ClaudeIntegration:invokeClaudeAsync] ========== INVOKE CLAUDE COMPLETE (temp file) ==========');
return;
} else if (activeProfile.configDir) {
const escapedConfigDir = escapeShellArg(activeProfile.configDir);
const command = buildClaudeShellCommand(cwdCommand, pathPrefix, escapedClaudeCmd, { method: 'config-dir', escapedConfigDir });
debugLog('[ClaudeIntegration:invokeClaudeAsync] Executing command (configDir method, history-safe)');
terminal.pty.write(command);
profileManager.markProfileUsed(activeProfile.id);
finalizeClaudeInvoke(terminal, activeProfile, projectPath, startTime, getWindow, onSessionCapture);
debugLog('[ClaudeIntegration:invokeClaudeAsync] ========== INVOKE CLAUDE COMPLETE (configDir) ==========');
return;
} else {
debugLog('[ClaudeIntegration:invokeClaudeAsync] WARNING: No token or configDir available for non-default profile');
}
}
if (activeProfile && !activeProfile.isDefault) {
debugLog('[ClaudeIntegration:invokeClaudeAsync] Using terminal environment for non-default profile:', activeProfile.name);
}
const command = buildClaudeShellCommand(cwdCommand, pathPrefix, escapedClaudeCmd, { method: 'default' });
debugLog('[ClaudeIntegration:invokeClaudeAsync] Executing command (default method):', command);
terminal.pty.write(command);
if (activeProfile) {
profileManager.markProfileUsed(activeProfile.id);
}
finalizeClaudeInvoke(terminal, activeProfile, projectPath, startTime, getWindow, onSessionCapture);
debugLog('[ClaudeIntegration:invokeClaudeAsync] ========== INVOKE CLAUDE COMPLETE (default) ==========');
}
/**
* Resume Claude asynchronously (non-blocking)
*
* Safe to call from Electron main process without blocking the event loop.
* Uses async CLI detection which doesn't block on subprocess calls.
*/
export async function resumeClaudeAsync(
terminal: TerminalProcess,
sessionId: string | undefined,
getWindow: WindowGetter
): Promise<void> {
terminal.isClaudeMode = true;
SessionHandler.releaseSessionId(terminal.id);
// Async CLI invocation - non-blocking
const { command: claudeCmd, env: claudeEnv } = await getClaudeCliInvocationAsync();
const escapedClaudeCmd = escapeShellArg(claudeCmd);
const pathPrefix = claudeEnv.PATH
? `PATH=${escapeShellArg(normalizePathForBash(claudeEnv.PATH))} `
: '';
// Always use --continue which resumes the most recent session in the current directory.
// This is more reliable than --resume with session IDs since Auto Claude already restores
// terminals to their correct cwd/projectPath.
//
// Note: We clear claudeSessionId because --continue doesn't track specific sessions,
// and we don't want stale IDs persisting through SessionHandler.persistSession().
terminal.claudeSessionId = undefined;
// Deprecation warning for callers still passing sessionId
if (sessionId) {
console.warn('[ClaudeIntegration:resumeClaudeAsync] sessionId parameter is deprecated and ignored; using claude --continue instead');
}
const command = `${pathPrefix}${escapedClaudeCmd} --continue`;
terminal.pty.write(`${command}\r`);
terminal.title = 'Claude';
const win = getWindow();
if (win) {
win.webContents.send(IPC_CHANNELS.TERMINAL_TITLE_CHANGE, terminal.id, 'Claude');
}
if (terminal.projectPath) {
SessionHandler.persistSession(terminal);
}
}
/**
* Configuration for waiting for Claude to exit
*/
@@ -525,7 +760,7 @@ export async function switchClaudeProfile(
terminal: TerminalProcess,
profileId: string,
getWindow: WindowGetter,
invokeClaudeCallback: (terminalId: string, cwd: string | undefined, profileId: string) => void,
invokeClaudeCallback: (terminalId: string, cwd: string | undefined, profileId: string) => Promise<void>,
clearRateLimitCallback: (terminalId: string) => void
): Promise<{ success: boolean; error?: string }> {
// Always-on tracing
@@ -543,7 +778,8 @@ export async function switchClaudeProfile(
cwd: terminal.cwd
});
const profileManager = getClaudeProfileManager();
// Ensure profile manager is initialized (async, yields to event loop)
const profileManager = await initializeClaudeProfileManager();
const profile = profileManager.getProfile(profileId);
console.warn('[ClaudeIntegration:switchClaudeProfile] Profile found:', profile?.name || 'NOT FOUND');
@@ -611,7 +847,7 @@ export async function switchClaudeProfile(
projectPath,
profileId
});
invokeClaudeCallback(terminal.id, projectPath, profileId);
await invokeClaudeCallback(terminal.id, projectPath, profileId);
debugLog('[ClaudeIntegration:switchClaudeProfile] Setting active profile in profile manager');
profileManager.setActiveProfile(profileId);
@@ -82,7 +82,10 @@ export class TerminalManager {
);
},
onResumeNeeded: (terminalId, sessionId) => {
this.resumeClaude(terminalId, sessionId);
// Use async version to avoid blocking main process
this.resumeClaudeAsync(terminalId, sessionId).catch((error) => {
console.error('[terminal-manager] Failed to resume Claude session:', error);
});
}
},
cols,
@@ -133,8 +136,35 @@ export class TerminalManager {
}
}
/**
* Invoke Claude in a terminal with optional profile override (async - non-blocking)
*/
async invokeClaudeAsync(id: string, cwd?: string, profileId?: string): Promise<void> {
const terminal = this.terminals.get(id);
if (!terminal) {
return;
}
await ClaudeIntegration.invokeClaudeAsync(
terminal,
cwd,
profileId,
this.getWindow,
(terminalId, projectPath, startTime) => {
SessionHandler.captureClaudeSessionId(
terminalId,
projectPath,
startTime,
this.terminals,
this.getWindow
);
}
);
}
/**
* Invoke Claude in a terminal with optional profile override
* @deprecated Use invokeClaudeAsync for non-blocking behavior
*/
invokeClaude(id: string, cwd?: string, profileId?: string): void {
const terminal = this.terminals.get(id);
@@ -172,13 +202,26 @@ export class TerminalManager {
terminal,
profileId,
this.getWindow,
(terminalId, cwd, profileId) => this.invokeClaude(terminalId, cwd, profileId),
async (terminalId, cwd, profileId) => this.invokeClaudeAsync(terminalId, cwd, profileId),
(terminalId) => this.lastNotifiedRateLimitReset.delete(terminalId)
);
}
/**
* Resume Claude in a terminal asynchronously (non-blocking)
*/
async resumeClaudeAsync(id: string, sessionId?: string): Promise<void> {
const terminal = this.terminals.get(id);
if (!terminal) {
return;
}
await ClaudeIntegration.resumeClaudeAsync(terminal, sessionId, this.getWindow);
}
/**
* Resume Claude in a terminal with a specific session ID
* @deprecated Use resumeClaudeAsync for non-blocking behavior
*/
resumeClaude(id: string, sessionId?: string): void {
const terminal = this.terminals.get(id);
@@ -244,7 +287,10 @@ export class TerminalManager {
);
},
onResumeNeeded: (terminalId, sessionId) => {
this.resumeClaude(terminalId, sessionId);
// Use async version to avoid blocking main process
this.resumeClaudeAsync(terminalId, sessionId).catch((error) => {
console.error('[terminal-manager] Failed to resume Claude session:', error);
});
}
},
cols,
+112 -1
View File
@@ -9,10 +9,14 @@
*/
import { existsSync } from 'fs';
import { execFileSync } from 'child_process';
import { access, constants } from 'fs/promises';
import { execFileSync, execFile } from 'child_process';
import { promisify } from 'util';
import path from 'path';
import os from 'os';
const execFileAsync = promisify(execFile);
export interface WindowsToolPaths {
toolName: string;
executable: string;
@@ -170,3 +174,110 @@ export function findWindowsExecutableViaWhere(
return null;
}
}
/**
* Async version of getWindowsExecutablePaths.
* Use this in async contexts to avoid blocking the main process.
*/
export async function getWindowsExecutablePathsAsync(
toolPaths: WindowsToolPaths,
logPrefix: string = '[Windows Paths]'
): Promise<string[]> {
// Only run on Windows
if (process.platform !== 'win32') {
return [];
}
const validPaths: string[] = [];
for (const pattern of toolPaths.patterns) {
const expandedDir = expandWindowsPath(pattern);
if (!expandedDir) {
console.warn(`${logPrefix} Could not expand path pattern: ${pattern}`);
continue;
}
const fullPath = path.join(expandedDir, toolPaths.executable);
// Security validation - reject potentially dangerous paths
if (!isSecurePath(fullPath)) {
console.warn(`${logPrefix} Path failed security validation: ${fullPath}`);
continue;
}
try {
await access(fullPath, constants.F_OK);
validPaths.push(fullPath);
} catch {
// File doesn't exist, skip
}
}
return validPaths;
}
/**
* Async version of findWindowsExecutableViaWhere.
* Use this in async contexts to avoid blocking the main process.
*
* Find a Windows executable using the `where` command.
* This is the most reliable method as it searches:
* - All directories in PATH
* - App Paths registry entries
* - Current directory
*
* Works regardless of where the tool is installed (custom paths, different drives, etc.)
*
* @param executable - The executable name (e.g., 'git', 'gh', 'python')
* @param logPrefix - Prefix for console logging
* @returns The full path to the executable, or null if not found
*/
export async function findWindowsExecutableViaWhereAsync(
executable: string,
logPrefix: string = '[Windows Where]'
): Promise<string | null> {
if (process.platform !== 'win32') {
return null;
}
// Security: Only allow simple executable names (alphanumeric, dash, underscore, dot)
if (!/^[\w.-]+$/.test(executable)) {
console.warn(`${logPrefix} Invalid executable name: ${executable}`);
return null;
}
try {
// Use 'where' command to find the executable
// where.exe is a built-in Windows command that finds executables
const { stdout } = await execFileAsync('where.exe', [executable], {
encoding: 'utf-8',
timeout: 5000,
windowsHide: true,
});
// 'where' returns multiple paths separated by newlines if found in multiple locations
// We take the first one (highest priority in PATH)
const paths = stdout.trim().split(/\r?\n/).filter(p => p.trim());
if (paths.length > 0) {
const foundPath = paths[0].trim();
// Validate the path exists and is secure
try {
await access(foundPath, constants.F_OK);
if (isSecurePath(foundPath)) {
console.log(`${logPrefix} Found via where: ${foundPath}`);
return foundPath;
}
} catch {
// Path doesn't exist
}
}
return null;
} catch {
// 'where' returns exit code 1 if not found, which throws an error
return null;
}
}
+92 -92
View File
@@ -118,6 +118,98 @@
"npm": ">=10.0.0"
}
},
"apps/frontend/node_modules/@lydell/node-pty": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@lydell/node-pty/-/node-pty-1.1.0.tgz",
"integrity": "sha512-VDD8LtlMTOrPKWMXUAcB9+LTktzuunqrMwkYR1DMRBkS6LQrCt+0/Ws1o2rMml/n3guePpS7cxhHF7Nm5K4iMw==",
"license": "MIT",
"optionalDependencies": {
"@lydell/node-pty-darwin-arm64": "1.1.0",
"@lydell/node-pty-darwin-x64": "1.1.0",
"@lydell/node-pty-linux-arm64": "1.1.0",
"@lydell/node-pty-linux-x64": "1.1.0",
"@lydell/node-pty-win32-arm64": "1.1.0",
"@lydell/node-pty-win32-x64": "1.1.0"
}
},
"apps/frontend/node_modules/@lydell/node-pty-darwin-arm64": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@lydell/node-pty-darwin-arm64/-/node-pty-darwin-arm64-1.1.0.tgz",
"integrity": "sha512-7kFD+owAA61qmhJCtoMbqj3Uvff3YHDiU+4on5F2vQdcMI3MuwGi7dM6MkFG/yuzpw8LF2xULpL71tOPUfxs0w==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"apps/frontend/node_modules/@lydell/node-pty-darwin-x64": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@lydell/node-pty-darwin-x64/-/node-pty-darwin-x64-1.1.0.tgz",
"integrity": "sha512-XZdvqj5FjAMjH8bdp0YfaZjur5DrCIDD1VYiE9EkkYVMDQqRUPHYV3U8BVEQVT9hYfjmpr7dNaELF2KyISWSNA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"apps/frontend/node_modules/@lydell/node-pty-linux-arm64": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@lydell/node-pty-linux-arm64/-/node-pty-linux-arm64-1.1.0.tgz",
"integrity": "sha512-yyDBmalCfHpLiQMT2zyLcqL2Fay4Xy7rIs8GH4dqKLnEviMvPGOK7LADVkKAsbsyXBSISL3Lt1m1MtxhPH6ckg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"apps/frontend/node_modules/@lydell/node-pty-linux-x64": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@lydell/node-pty-linux-x64/-/node-pty-linux-x64-1.1.0.tgz",
"integrity": "sha512-NcNqRTD14QT+vXcEuqSSvmWY+0+WUBn2uRE8EN0zKtDpIEr9d+YiFj16Uqds6QfcLCHfZmC+Ls7YzwTaqDnanA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"apps/frontend/node_modules/@lydell/node-pty-win32-arm64": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@lydell/node-pty-win32-arm64/-/node-pty-win32-arm64-1.1.0.tgz",
"integrity": "sha512-JOMbCou+0fA7d/m97faIIfIU0jOv8sn2OR7tI45u3AmldKoKoLP8zHY6SAvDDnI3fccO1R2HeR1doVjpS7HM0w==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"apps/frontend/node_modules/@lydell/node-pty-win32-x64": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@lydell/node-pty-win32-x64/-/node-pty-win32-x64-1.1.0.tgz",
"integrity": "sha512-3N56BZ+WDFnUMYRtsrr7Ky2mhWGl9xXcyqR6cexfuCqcz9RNWL+KoXRv/nZylY5dYaXkft4JaR1uVu+roiZDAw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@acemir/cssom": {
"version": "0.9.30",
"resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.30.tgz",
@@ -2563,98 +2655,6 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@lydell/node-pty": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@lydell/node-pty/-/node-pty-1.1.0.tgz",
"integrity": "sha512-VDD8LtlMTOrPKWMXUAcB9+LTktzuunqrMwkYR1DMRBkS6LQrCt+0/Ws1o2rMml/n3guePpS7cxhHF7Nm5K4iMw==",
"license": "MIT",
"optionalDependencies": {
"@lydell/node-pty-darwin-arm64": "1.1.0",
"@lydell/node-pty-darwin-x64": "1.1.0",
"@lydell/node-pty-linux-arm64": "1.1.0",
"@lydell/node-pty-linux-x64": "1.1.0",
"@lydell/node-pty-win32-arm64": "1.1.0",
"@lydell/node-pty-win32-x64": "1.1.0"
}
},
"node_modules/@lydell/node-pty-darwin-arm64": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@lydell/node-pty-darwin-arm64/-/node-pty-darwin-arm64-1.1.0.tgz",
"integrity": "sha512-7kFD+owAA61qmhJCtoMbqj3Uvff3YHDiU+4on5F2vQdcMI3MuwGi7dM6MkFG/yuzpw8LF2xULpL71tOPUfxs0w==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@lydell/node-pty-darwin-x64": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@lydell/node-pty-darwin-x64/-/node-pty-darwin-x64-1.1.0.tgz",
"integrity": "sha512-XZdvqj5FjAMjH8bdp0YfaZjur5DrCIDD1VYiE9EkkYVMDQqRUPHYV3U8BVEQVT9hYfjmpr7dNaELF2KyISWSNA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@lydell/node-pty-linux-arm64": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@lydell/node-pty-linux-arm64/-/node-pty-linux-arm64-1.1.0.tgz",
"integrity": "sha512-yyDBmalCfHpLiQMT2zyLcqL2Fay4Xy7rIs8GH4dqKLnEviMvPGOK7LADVkKAsbsyXBSISL3Lt1m1MtxhPH6ckg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@lydell/node-pty-linux-x64": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@lydell/node-pty-linux-x64/-/node-pty-linux-x64-1.1.0.tgz",
"integrity": "sha512-NcNqRTD14QT+vXcEuqSSvmWY+0+WUBn2uRE8EN0zKtDpIEr9d+YiFj16Uqds6QfcLCHfZmC+Ls7YzwTaqDnanA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@lydell/node-pty-win32-arm64": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@lydell/node-pty-win32-arm64/-/node-pty-win32-arm64-1.1.0.tgz",
"integrity": "sha512-JOMbCou+0fA7d/m97faIIfIU0jOv8sn2OR7tI45u3AmldKoKoLP8zHY6SAvDDnI3fccO1R2HeR1doVjpS7HM0w==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@lydell/node-pty-win32-x64": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@lydell/node-pty-win32-x64/-/node-pty-win32-x64-1.1.0.tgz",
"integrity": "sha512-3N56BZ+WDFnUMYRtsrr7Ky2mhWGl9xXcyqR6cexfuCqcz9RNWL+KoXRv/nZylY5dYaXkft4JaR1uVu+roiZDAw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@malept/cross-spawn-promise": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz",