From 73e689a7a69060ec50e09b56bc88c980efb6eaf4 Mon Sep 17 00:00:00 2001 From: AndyMik90 Date: Sat, 14 Mar 2026 17:29:36 +0100 Subject: [PATCH] fix(core): prevent duplicate profile creation and clean up existing duplicates - saveProfile() now checks for existing profile with same email before creating a new entry, reusing the existing profile instead - Add deduplicateExistingProfiles() startup migration that collapses duplicate profiles per email, keeping active/default profiles - Cleans up accountPriorityOrder references to removed duplicates Co-Authored-By: Claude Opus 4.6 --- .../src/main/claude-profile-manager.ts | 112 ++++++++++++++++-- 1 file changed, 104 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/main/claude-profile-manager.ts b/apps/desktop/src/main/claude-profile-manager.ts index 95f813e7..cdc4b2c3 100644 --- a/apps/desktop/src/main/claude-profile-manager.ts +++ b/apps/desktop/src/main/claude-profile-manager.ts @@ -105,6 +105,10 @@ export class ClaudeProfileManager { // This repairs emails that were truncated due to ANSI escape codes in terminal output this.migrateCorruptedEmails(); + // Deduplicate profiles that share the same email + // This cleans up duplicates created by repeated auth logins + this.deduplicateExistingProfiles(); + // Populate missing subscription metadata for existing profiles // This reads subscriptionType and rateLimitTier from Keychain credentials this.populateSubscriptionMetadata(); @@ -206,6 +210,68 @@ export class ClaudeProfileManager { } } + /** + * Deduplicate existing profiles that share the same email. + * Keeps one profile per email, preferring: active > default > most recently created. + * Updates activeProfileId if the active profile was merged away. + */ + private deduplicateExistingProfiles(): void { + const emailMap = new Map(); + + for (const profile of this.data.profiles) { + if (!profile.email) continue; + const key = profile.email.toLowerCase(); + const group = emailMap.get(key) || []; + group.push(profile); + emailMap.set(key, group); + } + + const duplicateGroups = [...emailMap.values()].filter(g => g.length > 1); + if (duplicateGroups.length === 0) return; + + const removedIds = new Set(); + + for (const group of duplicateGroups) { + // Pick the winner: active profile > default > first in list + const winner = + group.find(p => p.id === this.data.activeProfileId) || + group.find(p => p.isDefault) || + group[0]; + + for (const profile of group) { + if (profile.id === winner.id) continue; + removedIds.add(profile.id); + } + + // Merge: ensure winner has best metadata + if (!winner.isDefault) { + const defaultInGroup = group.find(p => p.isDefault); + if (defaultInGroup) winner.isDefault = true; + } + } + + if (removedIds.size === 0) return; + + const before = this.data.profiles.length; + this.data.profiles = this.data.profiles.filter(p => !removedIds.has(p.id)); + + // Fix activeProfileId if it was removed + if (this.data.activeProfileId && removedIds.has(this.data.activeProfileId)) { + const defaultProfile = this.data.profiles.find(p => p.isDefault); + this.data.activeProfileId = defaultProfile?.id || this.data.profiles[0]?.id; + } + + // Clean up accountPriorityOrder references + if (this.data.accountPriorityOrder) { + this.data.accountPriorityOrder = this.data.accountPriorityOrder.filter( + id => !removedIds.has(id.replace('oauth-', '')) + ); + } + + this.save(); + console.warn(`[ClaudeProfileManager] Deduplicated profiles: ${before} → ${this.data.profiles.length} (removed ${removedIds.size} duplicates)`); + } + /** * Check if the profile manager has been initialized */ @@ -354,7 +420,11 @@ export class ClaudeProfileManager { } /** - * Save or update a profile + * Save or update a profile. + * + * Deduplicates by email: if a profile with the same email already exists + * (different ID but same underlying account), the existing profile is updated + * instead of creating a duplicate entry. */ saveProfile(profile: ClaudeProfile): ClaudeProfile { // Expand ~ in configDir path @@ -362,16 +432,42 @@ export class ClaudeProfileManager { profile.configDir = expandHomePath(profile.configDir); } - const index = this.data.profiles.findIndex(p => p.id === profile.id); + // First check: exact ID match (update existing profile) + const indexById = this.data.profiles.findIndex(p => p.id === profile.id); - if (index >= 0) { - // Update existing - this.data.profiles[index] = profile; - } else { - // Add new - this.data.profiles.push(profile); + if (indexById >= 0) { + // Update existing by ID + this.data.profiles[indexById] = profile; + this.save(); + return profile; } + // Second check: same email already exists (prevent duplicate) + if (profile.email) { + const emailLower = profile.email.toLowerCase(); + const indexByEmail = this.data.profiles.findIndex( + p => p.email?.toLowerCase() === emailLower + ); + + if (indexByEmail >= 0) { + const existing = this.data.profiles[indexByEmail]; + debugLog('[ClaudeProfileManager] Reusing existing profile for email:', profile.email, + '(existing ID:', existing.id, ', incoming ID:', profile.id, ')'); + + // Update the existing profile with fresh data, keeping its ID and default status + this.data.profiles[indexByEmail] = { + ...profile, + id: existing.id, + isDefault: existing.isDefault || profile.isDefault, + name: profile.name || existing.name, + }; + this.save(); + return this.data.profiles[indexByEmail]; + } + } + + // No match — add new profile + this.data.profiles.push(profile); this.save(); return profile; }