Cloud: support syncing provider profiles from the cloud (#6540)

This commit is contained in:
John Richmond 2025-08-01 13:00:09 -07:00 • committed by GitHub
parent f7b2bb0384
commit 8353ca2519
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 747 additions and 11 deletions

View file

@ -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<typeof organizationSettingsSchema>

View file

@ -327,6 +327,13 @@ export const providerSettingsSchema = z.object({
})
export type ProviderSettings = z.infer<typeof providerSettingsSchema>
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<typeof providerSettingsWithIdSchema>
export const PROVIDER_SETTINGS_KEYS = providerSettingsSchema.keyof().options
export const MODEL_ID_KEYS: Partial<keyof ProviderSettings>[] = [

View file

@ -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<typeof providerSettingsWithIdSchema>
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>): 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<string, ProviderSettingsWithId>,
currentActiveProfileName?: string,
): Promise<SyncCloudProfilesResult> {
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}`)
}
}
}

View file

@ -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")
})
})
})

View file

@ -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()