|
1 | 1 | import { ExtensionContext } from "vscode" |
2 | 2 | import { z, ZodError } from "zod" |
| 3 | +import deepEqual from "fast-deep-equal" |
3 | 4 |
|
4 | 5 | import { |
5 | | - type ProviderSettingsEntry, |
6 | | - providerSettingsSchema, |
7 | | - providerSettingsSchemaDiscriminated, |
| 6 | + type ProviderSettingsWithId, |
| 7 | + providerSettingsWithIdSchema, |
| 8 | + discriminatedProviderSettingsWithIdSchema, |
| 9 | + isSecretStateKey, |
| 10 | + ProviderSettingsEntry, |
8 | 11 | DEFAULT_CONSECUTIVE_MISTAKE_LIMIT, |
9 | 12 | } from "@roo-code/types" |
10 | 13 | import { TelemetryService } from "@roo-code/telemetry" |
11 | 14 |
|
12 | 15 | import { Mode, modes } from "../../shared/modes" |
13 | 16 |
|
14 | | -const providerSettingsWithIdSchema = providerSettingsSchema.extend({ id: z.string().optional() }) |
15 | | -const discriminatedProviderSettingsWithIdSchema = providerSettingsSchemaDiscriminated.and( |
16 | | - z.object({ id: z.string().optional() }), |
17 | | -) |
18 | | - |
19 | | -type ProviderSettingsWithId = z.infer<typeof providerSettingsWithIdSchema> |
| 17 | +export interface SyncCloudProfilesResult { |
| 18 | + hasChanges: boolean |
| 19 | + activeProfileChanged: boolean |
| 20 | + activeProfileId: string |
| 21 | +} |
20 | 22 |
|
21 | 23 | export const providerProfilesSchema = z.object({ |
22 | 24 | currentApiConfigName: z.string(), |
23 | 25 | apiConfigs: z.record(z.string(), providerSettingsWithIdSchema), |
24 | 26 | modeApiConfigs: z.record(z.string(), z.string()).optional(), |
| 27 | + cloudProfileIds: z.array(z.string()).optional(), |
25 | 28 | migrations: z |
26 | 29 | .object({ |
27 | 30 | rateLimitSecondsMigrated: z.boolean().optional(), |
@@ -304,7 +307,7 @@ export class ProviderSettingsManager { |
304 | 307 | const id = config.id || existingId || this.generateId() |
305 | 308 |
|
306 | 309 | // Filter out settings from other providers. |
307 | | - const filteredConfig = providerSettingsSchemaDiscriminated.parse(config) |
| 310 | + const filteredConfig = discriminatedProviderSettingsWithIdSchema.parse(config) |
308 | 311 | providerProfiles.apiConfigs[name] = { ...filteredConfig, id } |
309 | 312 | await this.store(providerProfiles) |
310 | 313 | return id |
@@ -529,4 +532,209 @@ export class ProviderSettingsManager { |
529 | 532 | throw new Error(`Failed to write provider profiles to secrets: ${error}`) |
530 | 533 | } |
531 | 534 | } |
| 535 | + |
| 536 | + private findUniqueProfileName(baseName: string, existingNames: Set<string>): string { |
| 537 | + if (!existingNames.has(baseName)) { |
| 538 | + return baseName |
| 539 | + } |
| 540 | + |
| 541 | + // Try _local first |
| 542 | + const localName = `${baseName}_local` |
| 543 | + if (!existingNames.has(localName)) { |
| 544 | + return localName |
| 545 | + } |
| 546 | + |
| 547 | + // Try _1, _2, etc. |
| 548 | + let counter = 1 |
| 549 | + let candidateName: string |
| 550 | + do { |
| 551 | + candidateName = `${baseName}_${counter}` |
| 552 | + counter++ |
| 553 | + } while (existingNames.has(candidateName)) |
| 554 | + |
| 555 | + return candidateName |
| 556 | + } |
| 557 | + |
| 558 | + public async syncCloudProfiles( |
| 559 | + cloudProfiles: Record<string, ProviderSettingsWithId>, |
| 560 | + currentActiveProfileName?: string, |
| 561 | + ): Promise<SyncCloudProfilesResult> { |
| 562 | + try { |
| 563 | + return await this.lock(async () => { |
| 564 | + const providerProfiles = await this.load() |
| 565 | + const changedProfiles: string[] = [] |
| 566 | + const existingNames = new Set(Object.keys(providerProfiles.apiConfigs)) |
| 567 | + |
| 568 | + let activeProfileChanged = false |
| 569 | + let activeProfileId = "" |
| 570 | + |
| 571 | + if (currentActiveProfileName && providerProfiles.apiConfigs[currentActiveProfileName]) { |
| 572 | + activeProfileId = providerProfiles.apiConfigs[currentActiveProfileName].id || "" |
| 573 | + } |
| 574 | + |
| 575 | + const currentCloudIds = new Set(providerProfiles.cloudProfileIds || []) |
| 576 | + const newCloudIds = new Set( |
| 577 | + Object.values(cloudProfiles) |
| 578 | + .map((p) => p.id) |
| 579 | + .filter((id): id is string => Boolean(id)), |
| 580 | + ) |
| 581 | + |
| 582 | + // Step 1: Delete profiles that are cloud-managed but not in the new cloud profiles |
| 583 | + for (const [name, profile] of Object.entries(providerProfiles.apiConfigs)) { |
| 584 | + if (profile.id && currentCloudIds.has(profile.id) && !newCloudIds.has(profile.id)) { |
| 585 | + // Check if we're deleting the active profile |
| 586 | + if (name === currentActiveProfileName) { |
| 587 | + activeProfileChanged = true |
| 588 | + activeProfileId = "" // Clear the active profile ID since it's being deleted |
| 589 | + } |
| 590 | + delete providerProfiles.apiConfigs[name] |
| 591 | + changedProfiles.push(name) |
| 592 | + existingNames.delete(name) |
| 593 | + } |
| 594 | + } |
| 595 | + |
| 596 | + // Step 2: Process each cloud profile |
| 597 | + for (const [cloudName, cloudProfile] of Object.entries(cloudProfiles)) { |
| 598 | + if (!cloudProfile.id) { |
| 599 | + continue // Skip profiles without IDs |
| 600 | + } |
| 601 | + |
| 602 | + // Find existing profile with matching ID |
| 603 | + const existingEntry = Object.entries(providerProfiles.apiConfigs).find( |
| 604 | + ([_, profile]) => profile.id === cloudProfile.id, |
| 605 | + ) |
| 606 | + |
| 607 | + if (existingEntry) { |
| 608 | + // Step 3: Update existing profile |
| 609 | + const [existingName, existingProfile] = existingEntry |
| 610 | + |
| 611 | + // Check if this is the active profile |
| 612 | + const isActiveProfile = existingName === currentActiveProfileName |
| 613 | + |
| 614 | + // Merge settings, preserving secret keys |
| 615 | + const updatedProfile: ProviderSettingsWithId = { ...cloudProfile } |
| 616 | + for (const [key, value] of Object.entries(existingProfile)) { |
| 617 | + if (isSecretStateKey(key) && value !== undefined) { |
| 618 | + ;(updatedProfile as any)[key] = value |
| 619 | + } |
| 620 | + } |
| 621 | + |
| 622 | + // Check if the profile actually changed using deepEqual |
| 623 | + const profileChanged = !deepEqual(existingProfile, updatedProfile) |
| 624 | + |
| 625 | + // Handle name change |
| 626 | + if (existingName !== cloudName) { |
| 627 | + // Remove old entry |
| 628 | + delete providerProfiles.apiConfigs[existingName] |
| 629 | + existingNames.delete(existingName) |
| 630 | + |
| 631 | + // Handle name conflict |
| 632 | + let finalName = cloudName |
| 633 | + if (existingNames.has(cloudName)) { |
| 634 | + // There's a conflict - rename the existing non-cloud profile |
| 635 | + const conflictingProfile = providerProfiles.apiConfigs[cloudName] |
| 636 | + if (conflictingProfile.id !== cloudProfile.id) { |
| 637 | + const newName = this.findUniqueProfileName(cloudName, existingNames) |
| 638 | + providerProfiles.apiConfigs[newName] = conflictingProfile |
| 639 | + existingNames.add(newName) |
| 640 | + changedProfiles.push(newName) |
| 641 | + } |
| 642 | + delete providerProfiles.apiConfigs[cloudName] |
| 643 | + existingNames.delete(cloudName) |
| 644 | + } |
| 645 | + |
| 646 | + // Add updated profile with new name |
| 647 | + providerProfiles.apiConfigs[finalName] = updatedProfile |
| 648 | + existingNames.add(finalName) |
| 649 | + changedProfiles.push(finalName) |
| 650 | + if (existingName !== finalName) { |
| 651 | + changedProfiles.push(existingName) // Mark old name as changed (deleted) |
| 652 | + } |
| 653 | + |
| 654 | + // If this was the active profile, mark it as changed |
| 655 | + if (isActiveProfile) { |
| 656 | + activeProfileChanged = true |
| 657 | + activeProfileId = cloudProfile.id || "" |
| 658 | + } |
| 659 | + } else if (profileChanged) { |
| 660 | + // Same name, but profile content changed - update in place |
| 661 | + providerProfiles.apiConfigs[existingName] = updatedProfile |
| 662 | + changedProfiles.push(existingName) |
| 663 | + |
| 664 | + // If this was the active profile and settings changed, mark it as changed |
| 665 | + if (isActiveProfile) { |
| 666 | + activeProfileChanged = true |
| 667 | + activeProfileId = cloudProfile.id || "" |
| 668 | + } |
| 669 | + } |
| 670 | + // If name is the same and profile hasn't changed, do nothing |
| 671 | + } else { |
| 672 | + // Step 4: Add new cloud profile |
| 673 | + let finalName = cloudName |
| 674 | + |
| 675 | + // Handle name conflict with existing non-cloud profile |
| 676 | + if (existingNames.has(cloudName)) { |
| 677 | + const existingProfile = providerProfiles.apiConfigs[cloudName] |
| 678 | + if (existingProfile.id !== cloudProfile.id) { |
| 679 | + // Rename the existing profile |
| 680 | + const newName = this.findUniqueProfileName(cloudName, existingNames) |
| 681 | + providerProfiles.apiConfigs[newName] = existingProfile |
| 682 | + existingNames.add(newName) |
| 683 | + changedProfiles.push(newName) |
| 684 | + |
| 685 | + // Remove the old entry |
| 686 | + delete providerProfiles.apiConfigs[cloudName] |
| 687 | + existingNames.delete(cloudName) |
| 688 | + } |
| 689 | + } |
| 690 | + |
| 691 | + // Add the new cloud profile (without secret keys) |
| 692 | + const newProfile: ProviderSettingsWithId = { ...cloudProfile } |
| 693 | + // Remove any secret keys from cloud profile |
| 694 | + for (const key of Object.keys(newProfile)) { |
| 695 | + if (isSecretStateKey(key)) { |
| 696 | + delete (newProfile as any)[key] |
| 697 | + } |
| 698 | + } |
| 699 | + |
| 700 | + providerProfiles.apiConfigs[finalName] = newProfile |
| 701 | + existingNames.add(finalName) |
| 702 | + changedProfiles.push(finalName) |
| 703 | + } |
| 704 | + } |
| 705 | + |
| 706 | + // Step 5: Handle case where all profiles might be deleted |
| 707 | + if (Object.keys(providerProfiles.apiConfigs).length === 0 && changedProfiles.length > 0) { |
| 708 | + // Create a default profile only if we have changed profiles |
| 709 | + const defaultProfile = { id: this.generateId() } |
| 710 | + providerProfiles.apiConfigs["default"] = defaultProfile |
| 711 | + activeProfileChanged = true |
| 712 | + activeProfileId = defaultProfile.id || "" |
| 713 | + changedProfiles.push("default") |
| 714 | + } |
| 715 | + |
| 716 | + // Step 6: If active profile was deleted, find a replacement |
| 717 | + if (activeProfileChanged && !activeProfileId) { |
| 718 | + const firstProfile = Object.values(providerProfiles.apiConfigs)[0] |
| 719 | + if (firstProfile?.id) { |
| 720 | + activeProfileId = firstProfile.id |
| 721 | + } |
| 722 | + } |
| 723 | + |
| 724 | + // Step 7: Update cloudProfileIds |
| 725 | + providerProfiles.cloudProfileIds = Array.from(newCloudIds) |
| 726 | + |
| 727 | + // Save the updated profiles |
| 728 | + await this.store(providerProfiles) |
| 729 | + |
| 730 | + return { |
| 731 | + hasChanges: changedProfiles.length > 0, |
| 732 | + activeProfileChanged, |
| 733 | + activeProfileId, |
| 734 | + } |
| 735 | + }) |
| 736 | + } catch (error) { |
| 737 | + throw new Error(`Failed to sync cloud profiles: ${error}`) |
| 738 | + } |
| 739 | + } |
532 | 740 | } |
0 commit comments