diff --git a/packages/types/src/cloud.ts b/packages/types/src/cloud.ts index 5ef90b6e5a..c8acc2bcae 100644 --- a/packages/types/src/cloud.ts +++ b/packages/types/src/cloud.ts @@ -2,6 +2,7 @@ import { z } from "zod" import { globalSettingsSchema } from "./global-settings.js" import { mcpMarketplaceItemSchema } from "./marketplace.js" +import { discriminatedProviderSettingsWithIdSchema } from "./provider-settings.js" /** * CloudUserInfo @@ -114,6 +115,7 @@ export const organizationSettingsSchema = z.object({ hiddenMcps: z.array(z.string()).optional(), hideMarketplaceMcps: z.boolean().optional(), mcps: z.array(mcpMarketplaceItemSchema).optional(), + providerProfiles: z.record(z.string(), discriminatedProviderSettingsWithIdSchema).optional(), }) export type OrganizationSettings = z.infer diff --git a/packages/types/src/provider-settings.ts b/packages/types/src/provider-settings.ts index 2ad6c87ddd..207c60a524 100644 --- a/packages/types/src/provider-settings.ts +++ b/packages/types/src/provider-settings.ts @@ -327,6 +327,13 @@ export const providerSettingsSchema = z.object({ }) export type ProviderSettings = z.infer + +export const providerSettingsWithIdSchema = providerSettingsSchema.extend({ id: z.string().optional() }) +export const discriminatedProviderSettingsWithIdSchema = providerSettingsSchemaDiscriminated.and( + z.object({ id: z.string().optional() }), +) +export type ProviderSettingsWithId = z.infer + export const PROVIDER_SETTINGS_KEYS = providerSettingsSchema.keyof().options export const MODEL_ID_KEYS: Partial[] = [ diff --git a/src/core/config/ProviderSettingsManager.ts b/src/core/config/ProviderSettingsManager.ts index 350c8136f2..1d2e96b9c0 100644 --- a/src/core/config/ProviderSettingsManager.ts +++ b/src/core/config/ProviderSettingsManager.ts @@ -1,27 +1,30 @@ import { ExtensionContext } from "vscode" import { z, ZodError } from "zod" +import deepEqual from "fast-deep-equal" import { - type ProviderSettingsEntry, - providerSettingsSchema, - providerSettingsSchemaDiscriminated, + type ProviderSettingsWithId, + providerSettingsWithIdSchema, + discriminatedProviderSettingsWithIdSchema, + isSecretStateKey, + ProviderSettingsEntry, DEFAULT_CONSECUTIVE_MISTAKE_LIMIT, } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" import { Mode, modes } from "../../shared/modes" -const providerSettingsWithIdSchema = providerSettingsSchema.extend({ id: z.string().optional() }) -const discriminatedProviderSettingsWithIdSchema = providerSettingsSchemaDiscriminated.and( - z.object({ id: z.string().optional() }), -) - -type ProviderSettingsWithId = z.infer +export interface SyncCloudProfilesResult { + hasChanges: boolean + activeProfileChanged: boolean + activeProfileId: string +} export const providerProfilesSchema = z.object({ currentApiConfigName: z.string(), apiConfigs: z.record(z.string(), providerSettingsWithIdSchema), modeApiConfigs: z.record(z.string(), z.string()).optional(), + cloudProfileIds: z.array(z.string()).optional(), migrations: z .object({ rateLimitSecondsMigrated: z.boolean().optional(), @@ -304,7 +307,7 @@ export class ProviderSettingsManager { const id = config.id || existingId || this.generateId() // Filter out settings from other providers. - const filteredConfig = providerSettingsSchemaDiscriminated.parse(config) + const filteredConfig = discriminatedProviderSettingsWithIdSchema.parse(config) providerProfiles.apiConfigs[name] = { ...filteredConfig, id } await this.store(providerProfiles) return id @@ -529,4 +532,209 @@ export class ProviderSettingsManager { throw new Error(`Failed to write provider profiles to secrets: ${error}`) } } + + private findUniqueProfileName(baseName: string, existingNames: Set): string { + if (!existingNames.has(baseName)) { + return baseName + } + + // Try _local first + const localName = `${baseName}_local` + if (!existingNames.has(localName)) { + return localName + } + + // Try _1, _2, etc. + let counter = 1 + let candidateName: string + do { + candidateName = `${baseName}_${counter}` + counter++ + } while (existingNames.has(candidateName)) + + return candidateName + } + + public async syncCloudProfiles( + cloudProfiles: Record, + currentActiveProfileName?: string, + ): Promise { + try { + return await this.lock(async () => { + const providerProfiles = await this.load() + const changedProfiles: string[] = [] + const existingNames = new Set(Object.keys(providerProfiles.apiConfigs)) + + let activeProfileChanged = false + let activeProfileId = "" + + if (currentActiveProfileName && providerProfiles.apiConfigs[currentActiveProfileName]) { + activeProfileId = providerProfiles.apiConfigs[currentActiveProfileName].id || "" + } + + const currentCloudIds = new Set(providerProfiles.cloudProfileIds || []) + const newCloudIds = new Set( + Object.values(cloudProfiles) + .map((p) => p.id) + .filter((id): id is string => Boolean(id)), + ) + + // Step 1: Delete profiles that are cloud-managed but not in the new cloud profiles + for (const [name, profile] of Object.entries(providerProfiles.apiConfigs)) { + if (profile.id && currentCloudIds.has(profile.id) && !newCloudIds.has(profile.id)) { + // Check if we're deleting the active profile + if (name === currentActiveProfileName) { + activeProfileChanged = true + activeProfileId = "" // Clear the active profile ID since it's being deleted + } + delete providerProfiles.apiConfigs[name] + changedProfiles.push(name) + existingNames.delete(name) + } + } + + // Step 2: Process each cloud profile + for (const [cloudName, cloudProfile] of Object.entries(cloudProfiles)) { + if (!cloudProfile.id) { + continue // Skip profiles without IDs + } + + // Find existing profile with matching ID + const existingEntry = Object.entries(providerProfiles.apiConfigs).find( + ([_, profile]) => profile.id === cloudProfile.id, + ) + + if (existingEntry) { + // Step 3: Update existing profile + const [existingName, existingProfile] = existingEntry + + // Check if this is the active profile + const isActiveProfile = existingName === currentActiveProfileName + + // Merge settings, preserving secret keys + const updatedProfile: ProviderSettingsWithId = { ...cloudProfile } + for (const [key, value] of Object.entries(existingProfile)) { + if (isSecretStateKey(key) && value !== undefined) { + ;(updatedProfile as any)[key] = value + } + } + + // Check if the profile actually changed using deepEqual + const profileChanged = !deepEqual(existingProfile, updatedProfile) + + // Handle name change + if (existingName !== cloudName) { + // Remove old entry + delete providerProfiles.apiConfigs[existingName] + existingNames.delete(existingName) + + // Handle name conflict + let finalName = cloudName + if (existingNames.has(cloudName)) { + // There's a conflict - rename the existing non-cloud profile + const conflictingProfile = providerProfiles.apiConfigs[cloudName] + if (conflictingProfile.id !== cloudProfile.id) { + const newName = this.findUniqueProfileName(cloudName, existingNames) + providerProfiles.apiConfigs[newName] = conflictingProfile + existingNames.add(newName) + changedProfiles.push(newName) + } + delete providerProfiles.apiConfigs[cloudName] + existingNames.delete(cloudName) + } + + // Add updated profile with new name + providerProfiles.apiConfigs[finalName] = updatedProfile + existingNames.add(finalName) + changedProfiles.push(finalName) + if (existingName !== finalName) { + changedProfiles.push(existingName) // Mark old name as changed (deleted) + } + + // If this was the active profile, mark it as changed + if (isActiveProfile) { + activeProfileChanged = true + activeProfileId = cloudProfile.id || "" + } + } else if (profileChanged) { + // Same name, but profile content changed - update in place + providerProfiles.apiConfigs[existingName] = updatedProfile + changedProfiles.push(existingName) + + // If this was the active profile and settings changed, mark it as changed + if (isActiveProfile) { + activeProfileChanged = true + activeProfileId = cloudProfile.id || "" + } + } + // If name is the same and profile hasn't changed, do nothing + } else { + // Step 4: Add new cloud profile + let finalName = cloudName + + // Handle name conflict with existing non-cloud profile + if (existingNames.has(cloudName)) { + const existingProfile = providerProfiles.apiConfigs[cloudName] + if (existingProfile.id !== cloudProfile.id) { + // Rename the existing profile + const newName = this.findUniqueProfileName(cloudName, existingNames) + providerProfiles.apiConfigs[newName] = existingProfile + existingNames.add(newName) + changedProfiles.push(newName) + + // Remove the old entry + delete providerProfiles.apiConfigs[cloudName] + existingNames.delete(cloudName) + } + } + + // Add the new cloud profile (without secret keys) + const newProfile: ProviderSettingsWithId = { ...cloudProfile } + // Remove any secret keys from cloud profile + for (const key of Object.keys(newProfile)) { + if (isSecretStateKey(key)) { + delete (newProfile as any)[key] + } + } + + providerProfiles.apiConfigs[finalName] = newProfile + existingNames.add(finalName) + changedProfiles.push(finalName) + } + } + + // Step 5: Handle case where all profiles might be deleted + if (Object.keys(providerProfiles.apiConfigs).length === 0 && changedProfiles.length > 0) { + // Create a default profile only if we have changed profiles + const defaultProfile = { id: this.generateId() } + providerProfiles.apiConfigs["default"] = defaultProfile + activeProfileChanged = true + activeProfileId = defaultProfile.id || "" + changedProfiles.push("default") + } + + // Step 6: If active profile was deleted, find a replacement + if (activeProfileChanged && !activeProfileId) { + const firstProfile = Object.values(providerProfiles.apiConfigs)[0] + if (firstProfile?.id) { + activeProfileId = firstProfile.id + } + } + + // Step 7: Update cloudProfileIds + providerProfiles.cloudProfileIds = Array.from(newCloudIds) + + // Save the updated profiles + await this.store(providerProfiles) + + return { + hasChanges: changedProfiles.length > 0, + activeProfileChanged, + activeProfileId, + } + }) + } catch (error) { + throw new Error(`Failed to sync cloud profiles: ${error}`) + } + } } diff --git a/src/core/config/__tests__/ProviderSettingsManager.spec.ts b/src/core/config/__tests__/ProviderSettingsManager.spec.ts index e52c1974b6..e95d2b100b 100644 --- a/src/core/config/__tests__/ProviderSettingsManager.spec.ts +++ b/src/core/config/__tests__/ProviderSettingsManager.spec.ts @@ -4,7 +4,7 @@ import { ExtensionContext } from "vscode" import type { ProviderSettings } from "@roo-code/types" -import { ProviderSettingsManager, ProviderProfiles } from "../ProviderSettingsManager" +import { ProviderSettingsManager, ProviderProfiles, SyncCloudProfilesResult } from "../ProviderSettingsManager" // Mock VSCode ExtensionContext const mockSecrets = { @@ -678,4 +678,447 @@ describe("ProviderSettingsManager", () => { ) }) }) + + describe("syncCloudProfiles", () => { + it("should add new cloud profiles without secret keys", async () => { + const existingConfig: ProviderProfiles = { + currentApiConfigName: "default", + apiConfigs: { + default: { id: "default-id" }, + }, + cloudProfileIds: [], + } + + mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig)) + + const cloudProfiles = { + "cloud-profile": { + id: "cloud-id-1", + apiProvider: "anthropic" as const, + apiKey: "secret-key", // This should be removed + apiModelId: "claude-3-opus-20240229", + }, + } + + const result = await providerSettingsManager.syncCloudProfiles(cloudProfiles) + + expect(result.hasChanges).toBe(true) + expect(result.activeProfileChanged).toBe(false) + expect(result.activeProfileId).toBe("") + + const storedConfig = JSON.parse(mockSecrets.store.mock.calls[0][1]) + expect(storedConfig.apiConfigs["cloud-profile"]).toEqual({ + id: "cloud-id-1", + apiProvider: "anthropic", + apiModelId: "claude-3-opus-20240229", + // apiKey should be removed + }) + expect(storedConfig.cloudProfileIds).toEqual(["cloud-id-1"]) + }) + + it("should update existing cloud profiles by ID, preserving secret keys", async () => { + const existingConfig: ProviderProfiles = { + currentApiConfigName: "default", + apiConfigs: { + default: { id: "default-id" }, + "existing-cloud": { + id: "cloud-id-1", + apiProvider: "anthropic" as const, + apiKey: "existing-secret", + apiModelId: "claude-3-haiku-20240307", + }, + }, + cloudProfileIds: ["cloud-id-1"], + } + + mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig)) + + const cloudProfiles = { + "updated-name": { + id: "cloud-id-1", + apiProvider: "anthropic" as const, + apiKey: "new-secret", // Should be ignored + apiModelId: "claude-3-opus-20240229", + }, + } + + const result = await providerSettingsManager.syncCloudProfiles(cloudProfiles) + + expect(result.hasChanges).toBe(true) + expect(result.activeProfileChanged).toBe(false) + expect(result.activeProfileId).toBe("") + + const storedConfig = JSON.parse(mockSecrets.store.mock.calls[0][1]) + expect(storedConfig.apiConfigs["updated-name"]).toEqual({ + id: "cloud-id-1", + apiProvider: "anthropic", + apiKey: "existing-secret", // Preserved + apiModelId: "claude-3-opus-20240229", // Updated + }) + expect(storedConfig.apiConfigs["existing-cloud"]).toBeUndefined() + expect(storedConfig.cloudProfileIds).toEqual(["cloud-id-1"]) + }) + + it("should delete cloud profiles not in the new cloud profiles", async () => { + const existingConfig: ProviderProfiles = { + currentApiConfigName: "default", + apiConfigs: { + default: { id: "default-id" }, + "cloud-profile-1": { id: "cloud-id-1", apiProvider: "anthropic" as const }, + "cloud-profile-2": { id: "cloud-id-2", apiProvider: "openai" as const }, + }, + cloudProfileIds: ["cloud-id-1", "cloud-id-2"], + } + + mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig)) + + const cloudProfiles = { + "cloud-profile-1": { + id: "cloud-id-1", + apiProvider: "anthropic" as const, + }, + // cloud-profile-2 is missing, should be deleted + } + + const result = await providerSettingsManager.syncCloudProfiles(cloudProfiles) + + expect(result.hasChanges).toBe(true) + expect(result.activeProfileChanged).toBe(false) + expect(result.activeProfileId).toBe("") + + const storedConfig = JSON.parse(mockSecrets.store.mock.calls[0][1]) + expect(storedConfig.apiConfigs["cloud-profile-1"]).toBeDefined() + expect(storedConfig.apiConfigs["cloud-profile-2"]).toBeUndefined() + expect(storedConfig.cloudProfileIds).toEqual(["cloud-id-1"]) + }) + + it("should rename existing non-cloud profile when cloud profile has same name", async () => { + const existingConfig: ProviderProfiles = { + currentApiConfigName: "default", + apiConfigs: { + default: { id: "default-id" }, + "conflict-name": { id: "local-id", apiProvider: "openai" as const }, + }, + cloudProfileIds: [], + } + + mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig)) + + const cloudProfiles = { + "conflict-name": { + id: "cloud-id-1", + apiProvider: "anthropic" as const, + }, + } + + const result = await providerSettingsManager.syncCloudProfiles(cloudProfiles) + + expect(result.hasChanges).toBe(true) + expect(result.activeProfileChanged).toBe(false) + expect(result.activeProfileId).toBe("") + + const storedConfig = JSON.parse(mockSecrets.store.mock.calls[0][1]) + expect(storedConfig.apiConfigs["conflict-name"]).toEqual({ + id: "cloud-id-1", + apiProvider: "anthropic", + }) + expect(storedConfig.apiConfigs["conflict-name_local"]).toEqual({ + id: "local-id", + apiProvider: "openai", + }) + expect(storedConfig.cloudProfileIds).toEqual(["cloud-id-1"]) + }) + + it("should handle multiple naming conflicts with incremental suffixes", async () => { + const existingConfig: ProviderProfiles = { + currentApiConfigName: "default", + apiConfigs: { + default: { id: "default-id" }, + "conflict-name": { id: "local-id-1", apiProvider: "openai" as const }, + "conflict-name_local": { id: "local-id-2", apiProvider: "vertex" as const }, + }, + cloudProfileIds: [], + } + + mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig)) + + const cloudProfiles = { + "conflict-name": { + id: "cloud-id-1", + apiProvider: "anthropic" as const, + }, + } + + const result = await providerSettingsManager.syncCloudProfiles(cloudProfiles) + + expect(result.hasChanges).toBe(true) + expect(result.activeProfileChanged).toBe(false) + expect(result.activeProfileId).toBe("") + + const storedConfig = JSON.parse(mockSecrets.store.mock.calls[0][1]) + expect(storedConfig.apiConfigs["conflict-name"]).toEqual({ + id: "cloud-id-1", + apiProvider: "anthropic", + }) + expect(storedConfig.apiConfigs["conflict-name_1"]).toEqual({ + id: "local-id-1", + apiProvider: "openai", + }) + expect(storedConfig.apiConfigs["conflict-name_local"]).toEqual({ + id: "local-id-2", + apiProvider: "vertex", + }) + }) + + it("should handle empty cloud profiles by deleting all cloud-managed profiles", async () => { + const existingConfig: ProviderProfiles = { + currentApiConfigName: "default", + apiConfigs: { + default: { id: "default-id" }, + "cloud-profile-1": { id: "cloud-id-1", apiProvider: "anthropic" as const }, + "cloud-profile-2": { id: "cloud-id-2", apiProvider: "openai" as const }, + }, + cloudProfileIds: ["cloud-id-1", "cloud-id-2"], + } + + mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig)) + + const cloudProfiles = {} + + const result = await providerSettingsManager.syncCloudProfiles(cloudProfiles) + + expect(result.hasChanges).toBe(true) + expect(result.activeProfileChanged).toBe(false) + expect(result.activeProfileId).toBe("") + + const storedConfig = JSON.parse(mockSecrets.store.mock.calls[0][1]) + expect(storedConfig.apiConfigs["cloud-profile-1"]).toBeUndefined() + expect(storedConfig.apiConfigs["cloud-profile-2"]).toBeUndefined() + expect(storedConfig.apiConfigs["default"]).toBeDefined() + expect(storedConfig.cloudProfileIds).toEqual([]) + }) + + it("should skip cloud profiles without IDs", async () => { + const existingConfig: ProviderProfiles = { + currentApiConfigName: "default", + apiConfigs: { + default: { id: "default-id" }, + }, + cloudProfileIds: [], + } + + mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig)) + + const cloudProfiles = { + "valid-profile": { + id: "cloud-id-1", + apiProvider: "anthropic" as const, + }, + "invalid-profile": { + // Missing id + apiProvider: "openai" as const, + }, + } + + const result = await providerSettingsManager.syncCloudProfiles(cloudProfiles) + + expect(result.hasChanges).toBe(true) + expect(result.activeProfileChanged).toBe(false) + expect(result.activeProfileId).toBe("") + + const storedConfig = JSON.parse(mockSecrets.store.mock.calls[0][1]) + expect(storedConfig.apiConfigs["valid-profile"]).toBeDefined() + expect(storedConfig.apiConfigs["invalid-profile"]).toBeUndefined() + expect(storedConfig.cloudProfileIds).toEqual(["cloud-id-1"]) + }) + + it("should handle complex sync scenario with multiple operations", async () => { + const existingConfig: ProviderProfiles = { + currentApiConfigName: "default", + apiConfigs: { + default: { id: "default-id" }, + "keep-cloud": { id: "cloud-id-1", apiProvider: "anthropic" as const, apiKey: "secret1" }, + "delete-cloud": { id: "cloud-id-2", apiProvider: "openai" as const }, + "rename-me": { id: "local-id", apiProvider: "vertex" as const }, + }, + cloudProfileIds: ["cloud-id-1", "cloud-id-2"], + } + + mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig)) + + const cloudProfiles = { + "updated-keep": { + id: "cloud-id-1", + apiProvider: "anthropic" as const, + apiKey: "new-secret", // Should be ignored + apiModelId: "claude-3-opus-20240229", + }, + "rename-me": { + id: "cloud-id-3", + apiProvider: "openai" as const, + }, + // delete-cloud is missing (should be deleted) + // new profile + "new-cloud": { + id: "cloud-id-4", + apiProvider: "vertex" as const, + }, + } + + const result = await providerSettingsManager.syncCloudProfiles(cloudProfiles) + + expect(result.hasChanges).toBe(true) + expect(result.activeProfileChanged).toBe(false) + expect(result.activeProfileId).toBe("") + + const storedConfig = JSON.parse(mockSecrets.store.mock.calls[0][1]) + + // Check deletions + expect(storedConfig.apiConfigs["delete-cloud"]).toBeUndefined() + expect(storedConfig.apiConfigs["keep-cloud"]).toBeUndefined() + + // Check updates + expect(storedConfig.apiConfigs["updated-keep"]).toEqual({ + id: "cloud-id-1", + apiProvider: "anthropic", + apiKey: "secret1", // preserved + apiModelId: "claude-3-opus-20240229", + }) + + // Check renames + expect(storedConfig.apiConfigs["rename-me_local"]).toEqual({ + id: "local-id", + apiProvider: "vertex", + }) + expect(storedConfig.apiConfigs["rename-me"]).toEqual({ + id: "cloud-id-3", + apiProvider: "openai", + }) + + // Check new additions + expect(storedConfig.apiConfigs["new-cloud"]).toEqual({ + id: "cloud-id-4", + apiProvider: "vertex", + }) + + expect(storedConfig.cloudProfileIds).toEqual(["cloud-id-1", "cloud-id-3", "cloud-id-4"]) + }) + + it("should throw error if secrets storage fails", async () => { + mockSecrets.get.mockResolvedValue( + JSON.stringify({ + currentApiConfigName: "default", + apiConfigs: { default: { id: "default-id" } }, + cloudProfileIds: [], + }), + ) + mockSecrets.store.mockRejectedValue(new Error("Storage failed")) + + await expect(providerSettingsManager.syncCloudProfiles({})).rejects.toThrow( + "Failed to sync cloud profiles: Error: Failed to write provider profiles to secrets: Error: Storage failed", + ) + }) + + it("should track active profile changes when active profile is updated", async () => { + const existingConfig: ProviderProfiles = { + currentApiConfigName: "active-profile", + apiConfigs: { + "active-profile": { + id: "active-id", + apiProvider: "anthropic" as const, + apiKey: "old-key", + }, + }, + cloudProfileIds: ["active-id"], + } + + mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig)) + + const cloudProfiles = { + "active-profile": { + id: "active-id", + apiProvider: "anthropic" as const, + apiModelId: "claude-3-opus-20240229", // Updated setting + }, + } + + const result = await providerSettingsManager.syncCloudProfiles(cloudProfiles, "active-profile") + + expect(result.hasChanges).toBe(true) + expect(result.activeProfileChanged).toBe(true) + expect(result.activeProfileId).toBe("active-id") + }) + + it("should track active profile changes when active profile is deleted", async () => { + const existingConfig: ProviderProfiles = { + currentApiConfigName: "active-profile", + apiConfigs: { + "active-profile": { id: "active-id", apiProvider: "anthropic" as const }, + "backup-profile": { id: "backup-id", apiProvider: "openai" as const }, + }, + cloudProfileIds: ["active-id"], + } + + mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig)) + + const cloudProfiles = {} // Active profile deleted + + const result = await providerSettingsManager.syncCloudProfiles(cloudProfiles, "active-profile") + + expect(result.hasChanges).toBe(true) + expect(result.activeProfileChanged).toBe(true) + expect(result.activeProfileId).toBe("backup-id") // Should switch to first available + }) + + it("should create default profile when all profiles are deleted", async () => { + const existingConfig: ProviderProfiles = { + currentApiConfigName: "only-profile", + apiConfigs: { + "only-profile": { id: "only-id", apiProvider: "anthropic" as const }, + }, + cloudProfileIds: ["only-id"], + } + + mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig)) + + const cloudProfiles = {} // All profiles deleted + + const result = await providerSettingsManager.syncCloudProfiles(cloudProfiles, "only-profile") + + expect(result.hasChanges).toBe(true) + expect(result.activeProfileChanged).toBe(true) + expect(result.activeProfileId).toBeTruthy() // Should have new default profile ID + + const storedConfig = JSON.parse(mockSecrets.store.mock.calls[0][1]) + expect(storedConfig.apiConfigs["default"]).toBeDefined() + expect(storedConfig.apiConfigs["default"].id).toBe(result.activeProfileId) + }) + + it("should not mark active profile as changed when it's not affected", async () => { + const existingConfig: ProviderProfiles = { + currentApiConfigName: "local-profile", + apiConfigs: { + "local-profile": { id: "local-id", apiProvider: "anthropic" as const }, + "cloud-profile": { id: "cloud-id", apiProvider: "openai" as const }, + }, + cloudProfileIds: ["cloud-id"], + } + + mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig)) + + const cloudProfiles = { + "cloud-profile": { + id: "cloud-id", + apiProvider: "openai" as const, + apiModelId: "gpt-4", // Updated cloud profile + }, + } + + const result = await providerSettingsManager.syncCloudProfiles(cloudProfiles, "local-profile") + + expect(result.hasChanges).toBe(true) + expect(result.activeProfileChanged).toBe(false) + expect(result.activeProfileId).toBe("local-id") + }) + }) }) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 99c2a514b2..980eb1f07b 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -15,6 +15,7 @@ import { type ProviderSettings, type RooCodeSettings, type ProviderSettingsEntry, + type ProviderSettingsWithId, type TelemetryProperties, type TelemetryPropertiesProvider, type CodeActionId, @@ -153,6 +154,76 @@ export class ClineProvider }) this.marketplaceManager = new MarketplaceManager(this.context, this.customModesManager) + + // Initialize cloud profile sync + this.initializeCloudProfileSync().catch((error) => { + this.log(`Failed to initialize cloud profile sync: ${error}`) + }) + } + + /** + * Initialize cloud profile synchronization + */ + private async initializeCloudProfileSync() { + try { + // Check if authenticated and sync profiles + if (CloudService.hasInstance() && CloudService.instance.isAuthenticated()) { + await this.syncCloudProfiles() + } + + // Set up listener for future updates + if (CloudService.hasInstance()) { + CloudService.instance.on("settings-updated", this.handleCloudSettingsUpdate) + } + } catch (error) { + this.log(`Error in initializeCloudProfileSync: ${error}`) + } + } + + /** + * Handle cloud settings updates + */ + private handleCloudSettingsUpdate = async () => { + try { + await this.syncCloudProfiles() + } catch (error) { + this.log(`Error handling cloud settings update: ${error}`) + } + } + + /** + * Synchronize cloud profiles with local profiles + */ + private async syncCloudProfiles() { + try { + const settings = CloudService.instance.getOrganizationSettings() + if (!settings?.providerProfiles) { + return + } + + const currentApiConfigName = this.getGlobalState("currentApiConfigName") + const result = await this.providerSettingsManager.syncCloudProfiles( + settings.providerProfiles, + currentApiConfigName, + ) + + if (result.hasChanges) { + // Update list + await this.updateGlobalState("listApiConfigMeta", await this.providerSettingsManager.listConfig()) + + if (result.activeProfileChanged && result.activeProfileId) { + // Reload full settings for new active profile + const profile = await this.providerSettingsManager.getProfile({ + id: result.activeProfileId, + }) + await this.activateProviderProfile({ name: profile.name }) + } + + await this.postStateToWebview() + } + } catch (error) { + this.log(`Error syncing cloud profiles: ${error}`) + } } // Adds a new Cline instance to clineStack, marking the start of a new task. @@ -282,6 +353,11 @@ export class ClineProvider this.clearWebviewResources() + // Clean up cloud service event listener + if (CloudService.hasInstance()) { + CloudService.instance.off("settings-updated", this.handleCloudSettingsUpdate) + } + while (this.disposables.length) { const x = this.disposables.pop()