Compare commits

...

10 Commits

Author SHA1 Message Date
Kacper
0aef72540e fix(auth): Enhance credential detection logic for OAuth
- Updated getClaudeAuthIndicators() to ensure that empty or token-less credential files do not prevent the detection of valid credentials in subsequent paths.
- Improved error handling for settings file readability checks, providing clearer feedback on file access issues.
- Added unit tests to validate the new behavior, ensuring that the system continues to check all credential paths even when some files are empty or invalid.

This change improves the robustness of the credential detection process and enhances user experience by allowing for more flexible credential management.
2026-02-02 17:54:23 +01:00
Kacper
aad3ff2cdf fix(auth): Improve OAuth credential detection and startup warning
- Enhanced getClaudeAuthIndicators() to return detailed check information
  including file paths checked and specific error details for debugging
- Added debug logging to server startup credential detection for easier
  troubleshooting in Docker environments
- Show paths that were checked in the warning message to help users debug
  mount issues
- Added support for CLAUDE_CODE_OAUTH_TOKEN environment variable
- Return authType in verify-claude-auth response to distinguish between
  OAuth and CLI authentication methods
- Updated UI to show specific success messages for Claude Code subscription
  vs generic CLI auth
- Added Docker troubleshooting tips to sandbox risk dialog
- Added comprehensive unit tests for OAuth credential detection scenarios

Closes #721

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 17:35:03 +01:00
Shirone
ebc7987988 Merge pull request #720 from noamloewenstern/fix/board-view-concurrency-null-worktree
fix(ui): handle null selectedWorktree in max concurrency handler
2026-02-02 15:31:44 +00:00
Shirone
29b3eef500 Merge pull request #744 from AutoMaker-Org/fix/git-project-initial-branch
fix(server): Use 'main' as default branch for new git projects
2026-02-02 14:20:03 +00:00
Kacper
010e516b0e fix(server): Use 'main' as default branch for new git projects
Git initialization now explicitly specifies --initial-branch=main to match
GitHub's default branch standard (since October 2020). This prevents the
branch name mismatch that caused features to disappear from the UI when
pushing to GitHub.

Fixes #734

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 15:07:43 +01:00
Shirone
00e4712ae7 Merge pull request #743 from AutoMaker-Org/fix/broken-syslinks-on-server
fix(electron): Fix broken symlinks in server bundle preventing app startup
2026-02-02 13:50:39 +00:00
Kacper
4b4ae04fbe refactor: Address PR review feedback for symlink and directory handling
- Use lstatSync with try/catch for robust broken symlink detection
- Remove redundant existsSync check before mkdirSync with recursive: true

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 14:36:24 +01:00
Kacper
04775af561 fix(electron): Fix broken symlinks in server bundle preventing app startup
Fixes #742

This commit resolves two critical issues that prevented the Electron app from starting:

1. **Broken symlinks in server bundle**
   - After npm install, local @automaker/* packages were symlinked in node_modules
   - These symlinks broke after electron-builder packaging since relative paths no longer existed
   - Solution: Added Step 6b in prepare-server.mjs to replace symlinks with real directory copies
   - Added lstatSync and resolve imports to support symlink detection and replacement

2. **electronUserDataWriteFileSync fails on first launch**
   - The userData directory doesn't exist on first app launch
   - Writing .api-key file would fail with ENOENT error
   - Solution: Added directory existence check and creation with { recursive: true } before writing

Files modified:
- apps/ui/scripts/prepare-server.mjs: Added symlink replacement logic after npm install
- libs/platform/src/system-paths.ts: Added parent directory creation in electronUserDataWriteFileSync

Verification: After these fixes, npm run build:electron produces a working app that starts without ERR_MODULE_NOT_FOUND errors.

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-02 14:26:59 +01:00
Noam Loewenstern
07f777da22 Update apps/ui/src/components/views/board-view.tsx
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-01-30 02:52:27 +02:00
Noam Loewenstern
b10501ea79 fix(ui): handle null selectedWorktree in max concurrency handler 2026-01-30 02:44:51 +02:00
14 changed files with 1119 additions and 51 deletions

View File

@@ -121,21 +121,89 @@ const BOX_CONTENT_WIDTH = 67;
// The Claude Agent SDK can use either ANTHROPIC_API_KEY or Claude Code CLI authentication
(async () => {
const hasAnthropicKey = !!process.env.ANTHROPIC_API_KEY;
const hasEnvOAuthToken = !!process.env.CLAUDE_CODE_OAUTH_TOKEN;
logger.debug('[CREDENTIAL_CHECK] Starting credential detection...');
logger.debug('[CREDENTIAL_CHECK] Environment variables:', {
hasAnthropicKey,
hasEnvOAuthToken,
});
if (hasAnthropicKey) {
logger.info('✓ ANTHROPIC_API_KEY detected');
return;
}
if (hasEnvOAuthToken) {
logger.info('✓ CLAUDE_CODE_OAUTH_TOKEN detected');
return;
}
// Check for Claude Code CLI authentication
// Store indicators outside the try block so we can use them in the warning message
let cliAuthIndicators: Awaited<ReturnType<typeof getClaudeAuthIndicators>> | null = null;
try {
const indicators = await getClaudeAuthIndicators();
cliAuthIndicators = await getClaudeAuthIndicators();
const indicators = cliAuthIndicators;
// Log detailed credential detection results
logger.debug('[CREDENTIAL_CHECK] Claude CLI auth indicators:', {
hasCredentialsFile: indicators.hasCredentialsFile,
hasSettingsFile: indicators.hasSettingsFile,
hasStatsCacheWithActivity: indicators.hasStatsCacheWithActivity,
hasProjectsSessions: indicators.hasProjectsSessions,
credentials: indicators.credentials,
});
logger.debug('[CREDENTIAL_CHECK] File check details:', {
settingsFile: {
path: indicators.checks.settingsFile.path,
exists: indicators.checks.settingsFile.exists,
readable: indicators.checks.settingsFile.readable,
error: indicators.checks.settingsFile.error,
},
statsCache: {
path: indicators.checks.statsCache.path,
exists: indicators.checks.statsCache.exists,
readable: indicators.checks.statsCache.readable,
hasDailyActivity: indicators.checks.statsCache.hasDailyActivity,
error: indicators.checks.statsCache.error,
},
projectsDir: {
path: indicators.checks.projectsDir.path,
exists: indicators.checks.projectsDir.exists,
readable: indicators.checks.projectsDir.readable,
entryCount: indicators.checks.projectsDir.entryCount,
error: indicators.checks.projectsDir.error,
},
credentialFiles: indicators.checks.credentialFiles.map((cf) => ({
path: cf.path,
exists: cf.exists,
readable: cf.readable,
error: cf.error,
})),
});
const hasCliAuth =
indicators.hasStatsCacheWithActivity ||
(indicators.hasSettingsFile && indicators.hasProjectsSessions) ||
(indicators.hasCredentialsFile &&
(indicators.credentials?.hasOAuthToken || indicators.credentials?.hasApiKey));
logger.debug('[CREDENTIAL_CHECK] Auth determination:', {
hasCliAuth,
reason: hasCliAuth
? indicators.hasStatsCacheWithActivity
? 'stats cache with activity'
: indicators.hasSettingsFile && indicators.hasProjectsSessions
? 'settings file + project sessions'
: indicators.credentials?.hasOAuthToken
? 'credentials file with OAuth token'
: 'credentials file with API key'
: 'no valid credentials found',
});
if (hasCliAuth) {
logger.info('✓ Claude Code CLI authentication detected');
return;
@@ -145,7 +213,7 @@ const BOX_CONTENT_WIDTH = 67;
logger.warn('Error checking for Claude Code CLI authentication:', error);
}
// No authentication found - show warning
// No authentication found - show warning with paths that were checked
const wHeader = '⚠️ WARNING: No Claude authentication configured'.padEnd(BOX_CONTENT_WIDTH);
const w1 = 'The Claude Agent SDK requires authentication to function.'.padEnd(BOX_CONTENT_WIDTH);
const w2 = 'Options:'.padEnd(BOX_CONTENT_WIDTH);
@@ -158,6 +226,33 @@ const BOX_CONTENT_WIDTH = 67;
BOX_CONTENT_WIDTH
);
// Build paths checked summary from the indicators (if available)
let pathsCheckedInfo = '';
if (cliAuthIndicators) {
const pathsChecked: string[] = [];
// Collect paths that were checked
if (cliAuthIndicators.checks.settingsFile.path) {
pathsChecked.push(`Settings: ${cliAuthIndicators.checks.settingsFile.path}`);
}
if (cliAuthIndicators.checks.statsCache.path) {
pathsChecked.push(`Stats cache: ${cliAuthIndicators.checks.statsCache.path}`);
}
if (cliAuthIndicators.checks.projectsDir.path) {
pathsChecked.push(`Projects dir: ${cliAuthIndicators.checks.projectsDir.path}`);
}
for (const credFile of cliAuthIndicators.checks.credentialFiles) {
pathsChecked.push(`Credentials: ${credFile.path}`);
}
if (pathsChecked.length > 0) {
pathsCheckedInfo = `
║ ║
${'Paths checked:'.padEnd(BOX_CONTENT_WIDTH)}
${pathsChecked.map((p) => `${p.substring(0, BOX_CONTENT_WIDTH - 2).padEnd(BOX_CONTENT_WIDTH - 2)}`).join('\n')}`;
}
}
logger.warn(`
╔═════════════════════════════════════════════════════════════════════╗
${wHeader}
@@ -169,7 +264,7 @@ const BOX_CONTENT_WIDTH = 67;
${w3}
${w4}
${w5}
${w6}
${w6}${pathsCheckedInfo}
║ ║
╚═════════════════════════════════════════════════════════════════════╝
`);

View File

@@ -320,9 +320,28 @@ export function createVerifyClaudeAuthHandler() {
authMethod,
});
// Determine specific auth type for success messages
let authType: 'oauth' | 'api_key' | 'cli' | undefined;
if (authenticated) {
if (authMethod === 'api_key') {
authType = 'api_key';
} else if (authMethod === 'cli') {
// Check if CLI auth is via OAuth (Claude Code subscription) or generic CLI
// OAuth tokens are stored in the credentials file by the Claude CLI
const { getClaudeAuthIndicators } = await import('@automaker/platform');
const indicators = await getClaudeAuthIndicators();
if (indicators.credentials?.hasOAuthToken) {
authType = 'oauth';
} else {
authType = 'cli';
}
}
}
res.json({
success: true,
authenticated,
authType,
error: errorMessage || undefined,
});
} catch (error) {

View File

@@ -43,10 +43,14 @@ export function createInitGitHandler() {
// .git doesn't exist, continue with initialization
}
// Initialize git and create an initial empty commit
await execAsync(`git init && git commit --allow-empty -m "Initial commit"`, {
cwd: projectPath,
});
// Initialize git with 'main' as the default branch (matching GitHub's standard since 2020)
// and create an initial empty commit
await execAsync(
`git init --initial-branch=main && git commit --allow-empty -m "Initial commit"`,
{
cwd: projectPath,
}
);
res.json({
success: true,

View File

@@ -20,8 +20,8 @@ export interface TestRepo {
export async function createTestGitRepo(): Promise<TestRepo> {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'automaker-test-'));
// Initialize git repo
await execAsync('git init', { cwd: tmpDir });
// Initialize git repo with 'main' as the default branch (matching GitHub's standard)
await execAsync('git init --initial-branch=main', { cwd: tmpDir });
// Use environment variables instead of git config to avoid affecting user's git config
// These env vars override git config without modifying it
@@ -38,9 +38,6 @@ export async function createTestGitRepo(): Promise<TestRepo> {
await execAsync('git add .', { cwd: tmpDir, env: gitEnv });
await execAsync('git commit -m "Initial commit"', { cwd: tmpDir, env: gitEnv });
// Create main branch explicitly
await execAsync('git branch -M main', { cwd: tmpDir });
return {
path: tmpDir,
cleanup: async () => {

View File

@@ -14,7 +14,8 @@ describe('worktree create route - repositories without commits', () => {
async function initRepoWithoutCommit() {
repoPath = await fs.mkdtemp(path.join(os.tmpdir(), 'automaker-no-commit-'));
await execAsync('git init', { cwd: repoPath });
// Initialize with 'main' as the default branch (matching GitHub's standard)
await execAsync('git init --initial-branch=main', { cwd: repoPath });
// Don't set git config - use environment variables in commit operations instead
// to avoid affecting user's git config
// Intentionally skip creating an initial commit

View File

@@ -7,8 +7,8 @@
*/
import { execSync } from 'child_process';
import { cpSync, existsSync, mkdirSync, rmSync, writeFileSync, readFileSync } from 'fs';
import { join, dirname } from 'path';
import { cpSync, existsSync, mkdirSync, rmSync, writeFileSync, readFileSync, lstatSync } from 'fs';
import { join, dirname, resolve } from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
@@ -112,6 +112,29 @@ execSync('npm install --omit=dev', {
},
});
// Step 6b: Replace symlinks for local packages with real copies
// npm install creates symlinks for file: references, but these break when packaged by electron-builder
console.log('🔗 Replacing symlinks with real directory copies...');
const nodeModulesAutomaker = join(BUNDLE_DIR, 'node_modules', '@automaker');
for (const pkgName of LOCAL_PACKAGES) {
const pkgDir = pkgName.replace('@automaker/', '');
const nmPkgPath = join(nodeModulesAutomaker, pkgDir);
try {
// lstatSync does not follow symlinks, allowing us to check for broken ones
if (lstatSync(nmPkgPath).isSymbolicLink()) {
const realPath = resolve(BUNDLE_DIR, 'libs', pkgDir);
rmSync(nmPkgPath);
cpSync(realPath, nmPkgPath, { recursive: true });
console.log(` ✓ Replaced symlink: ${pkgName}`);
}
} catch (error) {
// If the path doesn't exist, lstatSync throws ENOENT. We can safely ignore this.
if (error.code !== 'ENOENT') {
throw error;
}
}
}
// Step 7: Rebuild native modules for current architecture
// This is critical for modules like node-pty that have native bindings
console.log('🔨 Rebuilding native modules for current architecture...');

View File

@@ -69,6 +69,29 @@ export function SandboxRiskDialog({ open, onConfirm, onDeny }: SandboxRiskDialog
For safer operation, consider running Automaker in Docker. See the README for
instructions.
</p>
<div className="bg-muted/50 border border-border rounded-lg p-4 space-y-2">
<p className="text-sm font-medium text-foreground">
Already running in Docker? Try these troubleshooting steps:
</p>
<ul className="text-sm text-muted-foreground list-disc list-inside space-y-1">
<li>
Ensure <code className="bg-muted px-1 rounded">IS_CONTAINERIZED=true</code> is
set in your docker-compose environment
</li>
<li>
Verify the server container has the environment variable:{' '}
<code className="bg-muted px-1 rounded">
docker exec automaker-server printenv IS_CONTAINERIZED
</code>
</li>
<li>Rebuild and restart containers if you recently changed the configuration</li>
<li>
Check the server logs for startup messages:{' '}
<code className="bg-muted px-1 rounded">docker-compose logs server</code>
</li>
</ul>
</div>
</div>
</DialogDescription>
</DialogHeader>

View File

@@ -1275,8 +1275,10 @@ export function BoardView() {
maxConcurrency={maxConcurrency}
runningAgentsCount={runningAutoTasks.length}
onConcurrencyChange={(newMaxConcurrency) => {
if (currentProject && selectedWorktree) {
const branchName = selectedWorktree.isMain ? null : selectedWorktree.branch;
if (currentProject) {
// If selectedWorktree is undefined or it's the main worktree, branchName will be null.
// Otherwise, use the branch name.
const branchName = selectedWorktree?.isMain === false ? selectedWorktree.branch : null;
setMaxConcurrencyForWorktree(currentProject.id, branchName, newMaxConcurrency);
// Persist to server settings so capacity checks use the correct value

View File

@@ -59,6 +59,7 @@ export function ClaudeSetupStep({ onNext, onBack, onSkip }: ClaudeSetupStepProps
// CLI Verification state
const [cliVerificationStatus, setCliVerificationStatus] = useState<VerificationStatus>('idle');
const [cliVerificationError, setCliVerificationError] = useState<string | null>(null);
const [cliAuthType, setCliAuthType] = useState<'oauth' | 'cli' | null>(null);
// API Key Verification state
const [apiKeyVerificationStatus, setApiKeyVerificationStatus] =
@@ -119,6 +120,7 @@ export function ClaudeSetupStep({ onNext, onBack, onSkip }: ClaudeSetupStepProps
const verifyCliAuth = useCallback(async () => {
setCliVerificationStatus('verifying');
setCliVerificationError(null);
setCliAuthType(null);
try {
const api = getElectronAPI();
@@ -138,12 +140,21 @@ export function ClaudeSetupStep({ onNext, onBack, onSkip }: ClaudeSetupStepProps
if (result.authenticated && !hasLimitReachedError) {
setCliVerificationStatus('verified');
// Store the auth type for displaying specific success message
const authType = result.authType === 'oauth' ? 'oauth' : 'cli';
setCliAuthType(authType);
setClaudeAuthStatus({
authenticated: true,
method: 'cli_authenticated',
method: authType === 'oauth' ? 'oauth_token' : 'cli_authenticated',
hasCredentialsFile: claudeAuthStatus?.hasCredentialsFile || false,
oauthTokenValid: authType === 'oauth',
});
toast.success('Claude CLI authentication verified!');
// Show specific success message based on auth type
if (authType === 'oauth') {
toast.success('Claude Code subscription detected and verified!');
} else {
toast.success('Claude CLI authentication verified!');
}
} else {
setCliVerificationStatus('error');
setCliVerificationError(
@@ -436,9 +447,15 @@ export function ClaudeSetupStep({ onNext, onBack, onSkip }: ClaudeSetupStepProps
<div className="flex items-center gap-3 p-4 rounded-lg bg-green-500/10 border border-green-500/20">
<CheckCircle2 className="w-5 h-5 text-green-500" />
<div>
<p className="font-medium text-foreground">CLI Authentication verified!</p>
<p className="font-medium text-foreground">
{cliAuthType === 'oauth'
? 'Claude Code subscription verified!'
: 'CLI Authentication verified!'}
</p>
<p className="text-sm text-muted-foreground">
Your Claude CLI is working correctly.
{cliAuthType === 'oauth'
? 'Your Claude Code subscription is active and ready to use.'
: 'Your Claude CLI is working correctly.'}
</p>
</div>
</div>

View File

@@ -1442,6 +1442,7 @@ interface SetupAPI {
verifyClaudeAuth: (authMethod?: 'cli' | 'api_key') => Promise<{
success: boolean;
authenticated: boolean;
authType?: 'oauth' | 'api_key' | 'cli';
error?: string;
}>;
getGhStatus?: () => Promise<{

View File

@@ -1350,6 +1350,7 @@ export class HttpApiClient implements ElectronAPI {
): Promise<{
success: boolean;
authenticated: boolean;
authType?: 'oauth' | 'api_key' | 'cli';
error?: string;
}> => this.post('/api/setup/verify-claude-auth', { authMethod, apiKey }),

View File

@@ -134,6 +134,8 @@ export {
findClaudeCliPath,
getClaudeAuthIndicators,
type ClaudeAuthIndicators,
type FileCheckResult,
type DirectoryCheckResult,
findCodexCliPath,
getCodexAuthIndicators,
type CodexAuthIndicators,

View File

@@ -750,6 +750,9 @@ export function electronUserDataWriteFileSync(
throw new Error('[SystemPaths] Electron userData path not initialized');
}
const fullPath = path.join(electronUserDataPath, relativePath);
// Ensure parent directory exists (may not exist on first launch)
const dir = path.dirname(fullPath);
fsSync.mkdirSync(dir, { recursive: true });
fsSync.writeFileSync(fullPath, data, options);
}
@@ -973,6 +976,27 @@ export async function findGitBashPath(): Promise<string | null> {
return findFirstExistingPath(getGitBashPaths());
}
/**
* Details about a file check performed during auth detection
*/
export interface FileCheckResult {
path: string;
exists: boolean;
readable: boolean;
error?: string;
}
/**
* Details about a directory check performed during auth detection
*/
export interface DirectoryCheckResult {
path: string;
exists: boolean;
readable: boolean;
entryCount: number;
error?: string;
}
/**
* Get Claude authentication status by checking various indicators
*/
@@ -985,67 +1009,165 @@ export interface ClaudeAuthIndicators {
hasOAuthToken: boolean;
hasApiKey: boolean;
} | null;
/** Detailed information about what was checked */
checks: {
settingsFile: FileCheckResult;
statsCache: FileCheckResult & { hasDailyActivity?: boolean };
projectsDir: DirectoryCheckResult;
credentialFiles: FileCheckResult[];
};
}
export async function getClaudeAuthIndicators(): Promise<ClaudeAuthIndicators> {
const settingsPath = getClaudeSettingsPath();
const statsCachePath = getClaudeStatsCachePath();
const projectsDir = getClaudeProjectsDir();
const credentialPaths = getClaudeCredentialPaths();
// Initialize checks with paths
const settingsFileCheck: FileCheckResult = {
path: settingsPath,
exists: false,
readable: false,
};
const statsCacheCheck: FileCheckResult & { hasDailyActivity?: boolean } = {
path: statsCachePath,
exists: false,
readable: false,
};
const projectsDirCheck: DirectoryCheckResult = {
path: projectsDir,
exists: false,
readable: false,
entryCount: 0,
};
const credentialFileChecks: FileCheckResult[] = credentialPaths.map((p) => ({
path: p,
exists: false,
readable: false,
}));
const result: ClaudeAuthIndicators = {
hasCredentialsFile: false,
hasSettingsFile: false,
hasStatsCacheWithActivity: false,
hasProjectsSessions: false,
credentials: null,
checks: {
settingsFile: settingsFileCheck,
statsCache: statsCacheCheck,
projectsDir: projectsDirCheck,
credentialFiles: credentialFileChecks,
},
};
// Check settings file
// First check existence, then try to read to confirm it's actually readable
try {
if (await systemPathAccess(getClaudeSettingsPath())) {
result.hasSettingsFile = true;
if (await systemPathAccess(settingsPath)) {
settingsFileCheck.exists = true;
// Try to actually read the file to confirm read permissions
try {
await systemPathReadFile(settingsPath);
settingsFileCheck.readable = true;
result.hasSettingsFile = true;
} catch (readErr) {
// File exists but cannot be read (permission denied, etc.)
settingsFileCheck.readable = false;
settingsFileCheck.error = `Cannot read: ${readErr instanceof Error ? readErr.message : String(readErr)}`;
}
}
} catch {
// Ignore errors
} catch (err) {
settingsFileCheck.error = err instanceof Error ? err.message : String(err);
}
// Check stats cache for recent activity
try {
const statsContent = await systemPathReadFile(getClaudeStatsCachePath());
const stats = JSON.parse(statsContent);
if (stats.dailyActivity && stats.dailyActivity.length > 0) {
result.hasStatsCacheWithActivity = true;
const statsContent = await systemPathReadFile(statsCachePath);
statsCacheCheck.exists = true;
statsCacheCheck.readable = true;
try {
const stats = JSON.parse(statsContent);
if (stats.dailyActivity && stats.dailyActivity.length > 0) {
statsCacheCheck.hasDailyActivity = true;
result.hasStatsCacheWithActivity = true;
} else {
statsCacheCheck.hasDailyActivity = false;
}
} catch (parseErr) {
statsCacheCheck.error = `JSON parse error: ${parseErr instanceof Error ? parseErr.message : String(parseErr)}`;
}
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
statsCacheCheck.exists = false;
} else {
statsCacheCheck.error = err instanceof Error ? err.message : String(err);
}
} catch {
// Ignore errors
}
// Check for sessions in projects directory
try {
const sessions = await systemPathReaddir(getClaudeProjectsDir());
const sessions = await systemPathReaddir(projectsDir);
projectsDirCheck.exists = true;
projectsDirCheck.readable = true;
projectsDirCheck.entryCount = sessions.length;
if (sessions.length > 0) {
result.hasProjectsSessions = true;
}
} catch {
// Ignore errors
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
projectsDirCheck.exists = false;
} else {
projectsDirCheck.error = err instanceof Error ? err.message : String(err);
}
}
// Check credentials files
const credentialPaths = getClaudeCredentialPaths();
for (const credPath of credentialPaths) {
// We iterate through all credential paths and only stop when we find a file
// that contains actual credentials (OAuth tokens or API keys). An empty or
// token-less file should not prevent checking subsequent credential paths.
for (let i = 0; i < credentialPaths.length; i++) {
const credPath = credentialPaths[i];
const credCheck = credentialFileChecks[i];
try {
const content = await systemPathReadFile(credPath);
const credentials = JSON.parse(content);
result.hasCredentialsFile = true;
// Support multiple credential formats:
// 1. Claude Code CLI format: { claudeAiOauth: { accessToken, refreshToken } }
// 2. Legacy format: { oauth_token } or { access_token }
// 3. API key format: { api_key }
const hasClaudeOauth = !!credentials.claudeAiOauth?.accessToken;
const hasLegacyOauth = !!(credentials.oauth_token || credentials.access_token);
result.credentials = {
hasOAuthToken: hasClaudeOauth || hasLegacyOauth,
hasApiKey: !!credentials.api_key,
};
break;
} catch {
// Continue to next path
credCheck.exists = true;
credCheck.readable = true;
try {
const credentials = JSON.parse(content);
// Support multiple credential formats:
// 1. Claude Code CLI format: { claudeAiOauth: { accessToken, refreshToken } }
// 2. Legacy format: { oauth_token } or { access_token }
// 3. API key format: { api_key }
const hasClaudeOauth = !!credentials.claudeAiOauth?.accessToken;
const hasLegacyOauth = !!(credentials.oauth_token || credentials.access_token);
const hasOAuthToken = hasClaudeOauth || hasLegacyOauth;
const hasApiKey = !!credentials.api_key;
// Only consider this a valid credentials file if it actually contains tokens
// An empty JSON file ({}) or file without tokens should not stop us from
// checking subsequent credential paths
if (hasOAuthToken || hasApiKey) {
result.hasCredentialsFile = true;
result.credentials = {
hasOAuthToken,
hasApiKey,
};
break; // Found valid credentials, stop searching
}
// File exists and is valid JSON but contains no tokens - continue checking other paths
} catch (parseErr) {
credCheck.error = `JSON parse error: ${parseErr instanceof Error ? parseErr.message : String(parseErr)}`;
}
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
credCheck.exists = false;
} else {
credCheck.error = err instanceof Error ? err.message : String(err);
}
}
}

View File

@@ -0,0 +1,761 @@
/**
* Unit tests for OAuth credential detection scenarios
*
* Tests the various Claude credential detection formats including:
* - Claude Code CLI OAuth format (claudeAiOauth)
* - Legacy OAuth token format (oauth_token, access_token)
* - API key format (api_key)
* - Invalid/malformed credential files
*
* These tests use real temp directories to avoid complex fs mocking issues.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import fs from 'fs/promises';
import path from 'path';
import os from 'os';
describe('OAuth Credential Detection', () => {
let tempDir: string;
let originalHomedir: () => string;
let mockClaudeDir: string;
let mockCodexDir: string;
let mockOpenCodeDir: string;
beforeEach(async () => {
// Reset modules to get fresh state
vi.resetModules();
// Create a temporary directory
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'oauth-detection-test-'));
// Create mock home directory structure
mockClaudeDir = path.join(tempDir, '.claude');
mockCodexDir = path.join(tempDir, '.codex');
mockOpenCodeDir = path.join(tempDir, '.local', 'share', 'opencode');
await fs.mkdir(mockClaudeDir, { recursive: true });
await fs.mkdir(mockCodexDir, { recursive: true });
await fs.mkdir(mockOpenCodeDir, { recursive: true });
// Mock os.homedir to return our temp directory
originalHomedir = os.homedir;
vi.spyOn(os, 'homedir').mockReturnValue(tempDir);
});
afterEach(async () => {
vi.restoreAllMocks();
// Clean up temp directory
try {
await fs.rm(tempDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
});
describe('getClaudeAuthIndicators', () => {
it('should detect Claude Code CLI OAuth format (claudeAiOauth)', async () => {
const credentialsContent = JSON.stringify({
claudeAiOauth: {
accessToken: 'oauth-access-token-12345',
refreshToken: 'oauth-refresh-token-67890',
expiresAt: Date.now() + 3600000,
},
});
await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), credentialsContent);
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
expect(indicators.hasCredentialsFile).toBe(true);
expect(indicators.credentials).not.toBeNull();
expect(indicators.credentials?.hasOAuthToken).toBe(true);
expect(indicators.credentials?.hasApiKey).toBe(false);
});
it('should detect legacy OAuth token format (oauth_token)', async () => {
const credentialsContent = JSON.stringify({
oauth_token: 'legacy-oauth-token-abcdef',
});
await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), credentialsContent);
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
expect(indicators.hasCredentialsFile).toBe(true);
expect(indicators.credentials?.hasOAuthToken).toBe(true);
expect(indicators.credentials?.hasApiKey).toBe(false);
});
it('should detect legacy access_token format', async () => {
const credentialsContent = JSON.stringify({
access_token: 'legacy-access-token-xyz',
});
await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), credentialsContent);
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
expect(indicators.hasCredentialsFile).toBe(true);
expect(indicators.credentials?.hasOAuthToken).toBe(true);
expect(indicators.credentials?.hasApiKey).toBe(false);
});
it('should detect API key format', async () => {
const credentialsContent = JSON.stringify({
api_key: 'sk-ant-api03-xxxxxxxxxxxx',
});
await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), credentialsContent);
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
expect(indicators.hasCredentialsFile).toBe(true);
expect(indicators.credentials?.hasOAuthToken).toBe(false);
expect(indicators.credentials?.hasApiKey).toBe(true);
});
it('should detect both OAuth and API key when present', async () => {
const credentialsContent = JSON.stringify({
claudeAiOauth: {
accessToken: 'oauth-token',
refreshToken: 'refresh-token',
},
api_key: 'sk-ant-api03-xxxxxxxxxxxx',
});
await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), credentialsContent);
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
expect(indicators.hasCredentialsFile).toBe(true);
expect(indicators.credentials?.hasOAuthToken).toBe(true);
expect(indicators.credentials?.hasApiKey).toBe(true);
});
it('should handle missing credentials file gracefully', async () => {
// No credentials file created
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
expect(indicators.hasCredentialsFile).toBe(false);
expect(indicators.credentials).toBeNull();
expect(indicators.checks.credentialFiles).toBeDefined();
expect(indicators.checks.credentialFiles.length).toBeGreaterThan(0);
expect(indicators.checks.credentialFiles[0].exists).toBe(false);
});
it('should handle malformed JSON in credentials file', async () => {
const malformedContent = '{ invalid json }';
await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), malformedContent);
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
// File exists but parsing fails
expect(indicators.hasCredentialsFile).toBe(false);
expect(indicators.credentials).toBeNull();
expect(indicators.checks.credentialFiles[0].exists).toBe(true);
expect(indicators.checks.credentialFiles[0].error).toContain('JSON parse error');
});
it('should handle empty credentials file', async () => {
const emptyContent = JSON.stringify({});
await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), emptyContent);
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
// Empty credentials file ({}) should NOT be treated as having credentials
// because it contains no actual tokens. This allows the system to continue
// checking subsequent credential paths that might have valid tokens.
expect(indicators.hasCredentialsFile).toBe(false);
expect(indicators.credentials).toBeNull();
// But the file should still show as existing and readable in the checks
expect(indicators.checks.credentialFiles[0].exists).toBe(true);
expect(indicators.checks.credentialFiles[0].readable).toBe(true);
});
it('should handle credentials file with null values', async () => {
const nullContent = JSON.stringify({
claudeAiOauth: null,
api_key: null,
oauth_token: null,
});
await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), nullContent);
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
// File with all null values should NOT be treated as having credentials
// because null values are not valid tokens
expect(indicators.hasCredentialsFile).toBe(false);
expect(indicators.credentials).toBeNull();
});
it('should handle credentials with empty string values', async () => {
const emptyStrings = JSON.stringify({
claudeAiOauth: {
accessToken: '',
refreshToken: '',
},
api_key: '',
});
await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), emptyStrings);
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
// Empty strings should NOT be treated as having credentials
// This allows checking subsequent credential paths for valid tokens
expect(indicators.hasCredentialsFile).toBe(false);
expect(indicators.credentials).toBeNull();
});
it('should detect settings file presence', async () => {
await fs.writeFile(
path.join(mockClaudeDir, 'settings.json'),
JSON.stringify({ theme: 'dark' })
);
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
expect(indicators.hasSettingsFile).toBe(true);
expect(indicators.checks.settingsFile.exists).toBe(true);
expect(indicators.checks.settingsFile.readable).toBe(true);
});
it('should detect stats cache with activity', async () => {
const statsContent = JSON.stringify({
dailyActivity: [
{ date: '2025-01-15', messagesCount: 10 },
{ date: '2025-01-16', messagesCount: 5 },
],
});
await fs.writeFile(path.join(mockClaudeDir, 'stats-cache.json'), statsContent);
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
expect(indicators.hasStatsCacheWithActivity).toBe(true);
expect(indicators.checks.statsCache.exists).toBe(true);
expect(indicators.checks.statsCache.hasDailyActivity).toBe(true);
});
it('should detect stats cache without activity', async () => {
const statsContent = JSON.stringify({
dailyActivity: [],
});
await fs.writeFile(path.join(mockClaudeDir, 'stats-cache.json'), statsContent);
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
expect(indicators.hasStatsCacheWithActivity).toBe(false);
expect(indicators.checks.statsCache.exists).toBe(true);
expect(indicators.checks.statsCache.hasDailyActivity).toBe(false);
});
it('should detect project sessions', async () => {
const projectsDir = path.join(mockClaudeDir, 'projects');
await fs.mkdir(projectsDir, { recursive: true });
await fs.mkdir(path.join(projectsDir, 'session-1'));
await fs.mkdir(path.join(projectsDir, 'session-2'));
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
expect(indicators.hasProjectsSessions).toBe(true);
expect(indicators.checks.projectsDir.exists).toBe(true);
expect(indicators.checks.projectsDir.entryCount).toBe(2);
});
it('should return comprehensive check details', async () => {
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
// Verify all check detail objects are present
expect(indicators.checks).toBeDefined();
expect(indicators.checks.settingsFile).toBeDefined();
expect(indicators.checks.settingsFile.path).toContain('settings.json');
expect(indicators.checks.statsCache).toBeDefined();
expect(indicators.checks.statsCache.path).toContain('stats-cache.json');
expect(indicators.checks.projectsDir).toBeDefined();
expect(indicators.checks.projectsDir.path).toContain('projects');
expect(indicators.checks.credentialFiles).toBeDefined();
expect(Array.isArray(indicators.checks.credentialFiles)).toBe(true);
});
it('should try both .credentials.json and credentials.json paths', async () => {
// Write to credentials.json (without leading dot)
const credentialsContent = JSON.stringify({
api_key: 'sk-test-key',
});
await fs.writeFile(path.join(mockClaudeDir, 'credentials.json'), credentialsContent);
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
// Should find credentials in the second path
expect(indicators.hasCredentialsFile).toBe(true);
expect(indicators.credentials?.hasApiKey).toBe(true);
});
it('should prefer first credentials file if both exist', async () => {
// Write OAuth to .credentials.json (first path checked)
await fs.writeFile(
path.join(mockClaudeDir, '.credentials.json'),
JSON.stringify({
claudeAiOauth: {
accessToken: 'oauth-token',
refreshToken: 'refresh-token',
},
})
);
// Write API key to credentials.json (second path)
await fs.writeFile(
path.join(mockClaudeDir, 'credentials.json'),
JSON.stringify({
api_key: 'sk-test-key',
})
);
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
// Should use first file (.credentials.json) which has OAuth
expect(indicators.hasCredentialsFile).toBe(true);
expect(indicators.credentials?.hasOAuthToken).toBe(true);
expect(indicators.credentials?.hasApiKey).toBe(false);
});
it('should check second credentials file if first file has no tokens', async () => {
// Write empty/token-less content to .credentials.json (first path checked)
// This tests the bug fix: previously, an empty JSON file would stop the search
await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), JSON.stringify({}));
// Write actual credentials to credentials.json (second path)
await fs.writeFile(
path.join(mockClaudeDir, 'credentials.json'),
JSON.stringify({
api_key: 'sk-test-key-from-second-file',
})
);
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
// Should find credentials in second file since first file has no tokens
expect(indicators.hasCredentialsFile).toBe(true);
expect(indicators.credentials?.hasApiKey).toBe(true);
});
});
describe('getCodexAuthIndicators', () => {
it('should detect OAuth token in Codex auth file', async () => {
const authContent = JSON.stringify({
access_token: 'codex-oauth-token-12345',
});
await fs.writeFile(path.join(mockCodexDir, 'auth.json'), authContent);
const { getCodexAuthIndicators } = await import('../src/system-paths');
const indicators = await getCodexAuthIndicators();
expect(indicators.hasAuthFile).toBe(true);
expect(indicators.hasOAuthToken).toBe(true);
expect(indicators.hasApiKey).toBe(false);
});
it('should detect API key in Codex auth file', async () => {
const authContent = JSON.stringify({
OPENAI_API_KEY: 'sk-xxxxxxxxxxxxxxxx',
});
await fs.writeFile(path.join(mockCodexDir, 'auth.json'), authContent);
const { getCodexAuthIndicators } = await import('../src/system-paths');
const indicators = await getCodexAuthIndicators();
expect(indicators.hasAuthFile).toBe(true);
expect(indicators.hasOAuthToken).toBe(false);
expect(indicators.hasApiKey).toBe(true);
});
it('should detect nested tokens in Codex auth file', async () => {
const authContent = JSON.stringify({
tokens: {
oauth_token: 'nested-oauth-token',
},
});
await fs.writeFile(path.join(mockCodexDir, 'auth.json'), authContent);
const { getCodexAuthIndicators } = await import('../src/system-paths');
const indicators = await getCodexAuthIndicators();
expect(indicators.hasAuthFile).toBe(true);
expect(indicators.hasOAuthToken).toBe(true);
});
it('should handle missing Codex auth file', async () => {
// No auth file created
const { getCodexAuthIndicators } = await import('../src/system-paths');
const indicators = await getCodexAuthIndicators();
expect(indicators.hasAuthFile).toBe(false);
expect(indicators.hasOAuthToken).toBe(false);
expect(indicators.hasApiKey).toBe(false);
});
it('should detect api_key field in Codex auth', async () => {
const authContent = JSON.stringify({
api_key: 'sk-api-key-value',
});
await fs.writeFile(path.join(mockCodexDir, 'auth.json'), authContent);
const { getCodexAuthIndicators } = await import('../src/system-paths');
const indicators = await getCodexAuthIndicators();
expect(indicators.hasAuthFile).toBe(true);
expect(indicators.hasApiKey).toBe(true);
});
});
describe('getOpenCodeAuthIndicators', () => {
it('should detect provider-specific OAuth credentials', async () => {
const authContent = JSON.stringify({
anthropic: {
type: 'oauth',
access: 'oauth-access-token',
refresh: 'oauth-refresh-token',
},
});
await fs.writeFile(path.join(mockOpenCodeDir, 'auth.json'), authContent);
const { getOpenCodeAuthIndicators } = await import('../src/system-paths');
const indicators = await getOpenCodeAuthIndicators();
expect(indicators.hasAuthFile).toBe(true);
expect(indicators.hasOAuthToken).toBe(true);
expect(indicators.hasApiKey).toBe(false);
});
it('should detect GitHub Copilot refresh token as OAuth', async () => {
const authContent = JSON.stringify({
'github-copilot': {
type: 'oauth',
access: '', // Empty access token
refresh: 'gh-refresh-token', // But has refresh token
},
});
await fs.writeFile(path.join(mockOpenCodeDir, 'auth.json'), authContent);
const { getOpenCodeAuthIndicators } = await import('../src/system-paths');
const indicators = await getOpenCodeAuthIndicators();
expect(indicators.hasAuthFile).toBe(true);
expect(indicators.hasOAuthToken).toBe(true);
});
it('should detect provider-specific API key credentials', async () => {
const authContent = JSON.stringify({
openai: {
type: 'api_key',
key: 'sk-xxxxxxxxxxxx',
},
});
await fs.writeFile(path.join(mockOpenCodeDir, 'auth.json'), authContent);
const { getOpenCodeAuthIndicators } = await import('../src/system-paths');
const indicators = await getOpenCodeAuthIndicators();
expect(indicators.hasAuthFile).toBe(true);
expect(indicators.hasOAuthToken).toBe(false);
expect(indicators.hasApiKey).toBe(true);
});
it('should detect multiple providers', async () => {
const authContent = JSON.stringify({
anthropic: {
type: 'oauth',
access: 'anthropic-token',
refresh: 'refresh-token',
},
openai: {
type: 'api_key',
key: 'sk-xxxxxxxxxxxx',
},
});
await fs.writeFile(path.join(mockOpenCodeDir, 'auth.json'), authContent);
const { getOpenCodeAuthIndicators } = await import('../src/system-paths');
const indicators = await getOpenCodeAuthIndicators();
expect(indicators.hasAuthFile).toBe(true);
expect(indicators.hasOAuthToken).toBe(true);
expect(indicators.hasApiKey).toBe(true);
});
it('should handle missing OpenCode auth file', async () => {
// No auth file created
const { getOpenCodeAuthIndicators } = await import('../src/system-paths');
const indicators = await getOpenCodeAuthIndicators();
expect(indicators.hasAuthFile).toBe(false);
expect(indicators.hasOAuthToken).toBe(false);
expect(indicators.hasApiKey).toBe(false);
});
it('should handle legacy top-level OAuth keys', async () => {
const authContent = JSON.stringify({
access_token: 'legacy-access-token',
});
await fs.writeFile(path.join(mockOpenCodeDir, 'auth.json'), authContent);
const { getOpenCodeAuthIndicators } = await import('../src/system-paths');
const indicators = await getOpenCodeAuthIndicators();
expect(indicators.hasAuthFile).toBe(true);
expect(indicators.hasOAuthToken).toBe(true);
});
it('should detect copilot provider OAuth', async () => {
const authContent = JSON.stringify({
copilot: {
type: 'oauth',
access: 'copilot-access-token',
refresh: 'copilot-refresh-token',
},
});
await fs.writeFile(path.join(mockOpenCodeDir, 'auth.json'), authContent);
const { getOpenCodeAuthIndicators } = await import('../src/system-paths');
const indicators = await getOpenCodeAuthIndicators();
expect(indicators.hasAuthFile).toBe(true);
expect(indicators.hasOAuthToken).toBe(true);
});
});
describe('Credential path helpers', () => {
it('should return correct Claude credential paths', async () => {
const { getClaudeCredentialPaths, getClaudeConfigDir } = await import('../src/system-paths');
const configDir = getClaudeConfigDir();
expect(configDir).toContain('.claude');
const credPaths = getClaudeCredentialPaths();
expect(credPaths.length).toBeGreaterThan(0);
expect(credPaths.some((p) => p.includes('.credentials.json'))).toBe(true);
expect(credPaths.some((p) => p.includes('credentials.json'))).toBe(true);
});
it('should return correct Codex auth path', async () => {
const { getCodexAuthPath, getCodexConfigDir } = await import('../src/system-paths');
const configDir = getCodexConfigDir();
expect(configDir).toContain('.codex');
const authPath = getCodexAuthPath();
expect(authPath).toContain('.codex');
expect(authPath).toContain('auth.json');
});
it('should return correct OpenCode auth path', async () => {
const { getOpenCodeAuthPath, getOpenCodeConfigDir } = await import('../src/system-paths');
const configDir = getOpenCodeConfigDir();
expect(configDir).toContain('opencode');
const authPath = getOpenCodeAuthPath();
expect(authPath).toContain('opencode');
expect(authPath).toContain('auth.json');
});
});
describe('Edge cases for credential detection', () => {
it('should handle credentials file with unexpected structure', async () => {
const unexpectedContent = JSON.stringify({
someUnexpectedKey: 'value',
nested: {
deeply: {
unexpected: true,
},
},
});
await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), unexpectedContent);
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
// File with unexpected structure but no valid tokens should NOT be treated as having credentials
expect(indicators.hasCredentialsFile).toBe(false);
expect(indicators.credentials).toBeNull();
});
it('should handle array instead of object in credentials', async () => {
const arrayContent = JSON.stringify(['token1', 'token2']);
await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), arrayContent);
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
// Array is valid JSON but wrong structure - no valid tokens, so not treated as credentials file
expect(indicators.hasCredentialsFile).toBe(false);
expect(indicators.credentials).toBeNull();
});
it('should handle numeric values in credential fields', async () => {
const numericContent = JSON.stringify({
api_key: 12345,
oauth_token: 67890,
});
await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), numericContent);
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
// Note: Current implementation uses JavaScript truthiness which accepts numbers
// This documents the actual behavior - ideally would validate string type
expect(indicators.hasCredentialsFile).toBe(true);
// The implementation checks truthiness, not strict string type
expect(indicators.credentials?.hasOAuthToken).toBe(true);
expect(indicators.credentials?.hasApiKey).toBe(true);
});
it('should handle boolean values in credential fields', async () => {
const booleanContent = JSON.stringify({
api_key: true,
oauth_token: false,
});
await fs.writeFile(path.join(mockClaudeDir, '.credentials.json'), booleanContent);
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
// Note: Current implementation uses JavaScript truthiness
// api_key: true is truthy, oauth_token: false is falsy
expect(indicators.hasCredentialsFile).toBe(true);
expect(indicators.credentials?.hasOAuthToken).toBe(false); // false is falsy
expect(indicators.credentials?.hasApiKey).toBe(true); // true is truthy
});
it('should handle malformed stats-cache.json gracefully', async () => {
await fs.writeFile(path.join(mockClaudeDir, 'stats-cache.json'), '{ invalid json }');
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
expect(indicators.hasStatsCacheWithActivity).toBe(false);
expect(indicators.checks.statsCache.exists).toBe(true);
expect(indicators.checks.statsCache.error).toBeDefined();
});
it('should handle empty projects directory', async () => {
const projectsDir = path.join(mockClaudeDir, 'projects');
await fs.mkdir(projectsDir, { recursive: true });
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
expect(indicators.hasProjectsSessions).toBe(false);
expect(indicators.checks.projectsDir.exists).toBe(true);
expect(indicators.checks.projectsDir.entryCount).toBe(0);
});
});
describe('Combined authentication scenarios', () => {
it('should detect CLI authenticated state with settings + sessions', async () => {
// Create settings file
await fs.writeFile(
path.join(mockClaudeDir, 'settings.json'),
JSON.stringify({ theme: 'dark' })
);
// Create projects directory with sessions
const projectsDir = path.join(mockClaudeDir, 'projects');
await fs.mkdir(projectsDir, { recursive: true });
await fs.mkdir(path.join(projectsDir, 'session-1'));
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
expect(indicators.hasSettingsFile).toBe(true);
expect(indicators.hasProjectsSessions).toBe(true);
});
it('should detect recent activity indicating working auth', async () => {
// Create stats cache with recent activity
await fs.writeFile(
path.join(mockClaudeDir, 'stats-cache.json'),
JSON.stringify({
dailyActivity: [{ date: new Date().toISOString().split('T')[0], messagesCount: 10 }],
})
);
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
expect(indicators.hasStatsCacheWithActivity).toBe(true);
});
it('should handle complete auth setup', async () => {
// Create all auth indicators
await fs.writeFile(
path.join(mockClaudeDir, '.credentials.json'),
JSON.stringify({
claudeAiOauth: {
accessToken: 'token',
refreshToken: 'refresh',
},
})
);
await fs.writeFile(
path.join(mockClaudeDir, 'settings.json'),
JSON.stringify({ theme: 'dark' })
);
await fs.writeFile(
path.join(mockClaudeDir, 'stats-cache.json'),
JSON.stringify({ dailyActivity: [{ date: '2025-01-15', messagesCount: 5 }] })
);
const projectsDir = path.join(mockClaudeDir, 'projects');
await fs.mkdir(projectsDir, { recursive: true });
await fs.mkdir(path.join(projectsDir, 'session-1'));
const { getClaudeAuthIndicators } = await import('../src/system-paths');
const indicators = await getClaudeAuthIndicators();
expect(indicators.hasCredentialsFile).toBe(true);
expect(indicators.hasSettingsFile).toBe(true);
expect(indicators.hasStatsCacheWithActivity).toBe(true);
expect(indicators.hasProjectsSessions).toBe(true);
expect(indicators.credentials?.hasOAuthToken).toBe(true);
});
});
});