diff --git a/packages/cli/src/auth/install/applyProviderInstallPlan.test.ts b/packages/cli/src/auth/install/applyProviderInstallPlan.test.ts index e28f32f42..20206b333 100644 --- a/packages/cli/src/auth/install/applyProviderInstallPlan.test.ts +++ b/packages/cli/src/auth/install/applyProviderInstallPlan.test.ts @@ -13,6 +13,7 @@ import type { ProviderInstallPlan } from '../types.js'; vi.mock('../../utils/settingsUtils.js', () => ({ backupSettingsFile: vi.fn(), restoreSettingsFromBackup: vi.fn(), + cleanupSettingsBackup: vi.fn(), })); vi.mock('../../config/modelProvidersScope.js', () => ({ @@ -20,12 +21,18 @@ vi.mock('../../config/modelProvidersScope.js', () => ({ })); function createSettings(modelProviders = {}) { + const settingsObj = { + settings: {}, + originalSettings: {}, + path: '/tmp/settings.json', + }; return { merged: { modelProviders, }, setValue: vi.fn(), - forScope: vi.fn(() => ({ path: '/tmp/settings.json' })), + forScope: vi.fn(() => settingsObj), + recomputeMerged: vi.fn(), }; } diff --git a/packages/cli/src/auth/install/applyProviderInstallPlan.ts b/packages/cli/src/auth/install/applyProviderInstallPlan.ts index e08507a10..03a9807d4 100644 --- a/packages/cli/src/auth/install/applyProviderInstallPlan.ts +++ b/packages/cli/src/auth/install/applyProviderInstallPlan.ts @@ -8,6 +8,7 @@ import type { ModelProvidersConfig } from '@qwen-code/qwen-code-core'; import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js'; import { backupSettingsFile, + cleanupSettingsBackup, restoreSettingsFromBackup, } from '../../utils/settingsUtils.js'; import type { @@ -61,6 +62,14 @@ export async function applyProviderInstallPlan( backupSettingsFile(settingsFile.path); const previousEnvValues = new Map(); + const previousSettingsSnapshot = structuredClone(settingsFile.settings); + const previousOriginalSnapshot = structuredClone( + settingsFile.originalSettings, + ); + const previousModelProviders: ModelProvidersConfig = { + ...((settings.merged.modelProviders as ModelProvidersConfig | undefined) ?? + {}), + }; try { for (const [key, value] of Object.entries(plan.env ?? {})) { @@ -133,12 +142,23 @@ export async function applyProviderInstallPlan( await config.refreshAuth(plan.authType); } + cleanupSettingsBackup(settingsFile.path); + return { persistScope, updatedModelProviders, }; } catch (error) { restoreSettingsFromBackup(settingsFile.path); + + // Restore in-memory settings state + settingsFile.settings = previousSettingsSnapshot; + settingsFile.originalSettings = previousOriginalSnapshot; + settings.recomputeMerged(); + + // Restore in-memory config state + config.reloadModelProvidersConfig(previousModelProviders); + for (const [key, prev] of previousEnvValues) { if (prev === undefined) { delete process.env[key]; diff --git a/packages/cli/src/auth/providers/alibaba/codingPlan.ts b/packages/cli/src/auth/providers/alibaba/codingPlan.ts index c3d2dd330..dde31c5e9 100644 --- a/packages/cli/src/auth/providers/alibaba/codingPlan.ts +++ b/packages/cli/src/auth/providers/alibaba/codingPlan.ts @@ -83,8 +83,8 @@ export const codingPlanProvider: ProviderConfig = { ? 'ModelStudio Coding Plan for Global/Intl' : 'ModelStudio Coding Plan', apiKeyPlaceholder: 'sk-sp-...', - validateApiKey: (key, baseUrl) => - baseUrl === CODING_PLAN_CHINA_BASE_URL && !key.startsWith('sk-sp-') + validateApiKey: (key) => + !key.startsWith('sk-sp-') ? 'Invalid API key. Coding Plan API keys start with "sk-sp-". Please check.' : null, ownsModel: (model) => diff --git a/packages/cli/src/commands/auth/openrouter.test.ts b/packages/cli/src/commands/auth/openrouter.test.ts index 69199e3ce..4d30753bc 100644 --- a/packages/cli/src/commands/auth/openrouter.test.ts +++ b/packages/cli/src/commands/auth/openrouter.test.ts @@ -48,6 +48,8 @@ vi.mock('../../config/config.js', () => ({ vi.mock('../../utils/settingsUtils.js', () => ({ backupSettingsFile: mockBackupSettingsFile, + restoreSettingsFromBackup: vi.fn(), + cleanupSettingsBackup: vi.fn(), })); vi.mock('../../config/modelProvidersScope.js', () => ({ diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 38e86bc9c..613b88589 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -438,6 +438,10 @@ export class LoadedSettings { saveSettings(settingsFile, createSettingsUpdate(key, value)); } + recomputeMerged(): void { + this._merged = this.computeMergedSettings(); + } + /** * Get user-level hooks from user settings (not merged with workspace). * These hooks should always be loaded regardless of folder trust. diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index dc939016f..059a65fe7 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -79,6 +79,12 @@ vi.mock('./hooks/useIdeTrustListener.js'); vi.mock('./hooks/useMessageQueue.js'); vi.mock('./hooks/useAutoAcceptIndicator.js'); vi.mock('./hooks/useGitBranchName.js'); +vi.mock('./hooks/useProviderUpdates.js', () => ({ + useProviderUpdates: vi.fn(() => ({ + providerUpdateRequest: undefined, + dismissProviderUpdate: vi.fn(), + })), +})); vi.mock('./contexts/VimModeContext.js'); vi.mock('./contexts/SessionContext.js'); vi.mock('./contexts/AgentViewContext.js', () => ({ diff --git a/packages/cli/src/ui/auth/useAuth.test.ts b/packages/cli/src/ui/auth/useAuth.test.ts index f07411ef8..f51f53694 100644 --- a/packages/cli/src/ui/auth/useAuth.test.ts +++ b/packages/cli/src/ui/auth/useAuth.test.ts @@ -28,6 +28,8 @@ vi.mock('../hooks/useQwenAuth.js', () => ({ vi.mock('../../utils/settingsUtils.js', () => ({ backupSettingsFile: vi.fn(), + restoreSettingsFromBackup: vi.fn(), + cleanupSettingsBackup: vi.fn(), })); vi.mock('../../config/modelProvidersScope.js', () => ({ diff --git a/packages/cli/src/ui/hooks/useProviderUpdates.test.ts b/packages/cli/src/ui/hooks/useProviderUpdates.test.ts index 78c56c389..e595ce77f 100644 --- a/packages/cli/src/ui/hooks/useProviderUpdates.test.ts +++ b/packages/cli/src/ui/hooks/useProviderUpdates.test.ts @@ -25,6 +25,8 @@ import { vi.mock('../../utils/settingsUtils.js', () => ({ backupSettingsFile: vi.fn(), + restoreSettingsFromBackup: vi.fn(), + cleanupSettingsBackup: vi.fn(), })); const chinaTemplate = buildProviderTemplate( diff --git a/packages/cli/src/utils/settingsUtils.ts b/packages/cli/src/utils/settingsUtils.ts index 64344dd7e..a651fab12 100644 --- a/packages/cli/src/utils/settingsUtils.ts +++ b/packages/cli/src/utils/settingsUtils.ts @@ -624,7 +624,7 @@ export function getEffectiveDisplayValue( /** * Backup a settings file before modification. - * Creates a backup with `.orig` suffix if the file exists and backup doesn't already exist. + * Always creates a fresh backup with `.orig` suffix (overwrites any stale backup). * @param filePath - Path to the settings file to backup * @returns boolean indicating whether a backup was created */ @@ -632,10 +632,8 @@ export function backupSettingsFile(filePath: string): boolean { try { if (fs.existsSync(filePath)) { const backupPath = `${filePath}.orig`; - if (!fs.existsSync(backupPath)) { - fs.renameSync(filePath, backupPath); - return true; - } + fs.copyFileSync(filePath, backupPath); + return true; } } catch (_e) { // Ignore backup errors, proceed without backup @@ -645,6 +643,7 @@ export function backupSettingsFile(filePath: string): boolean { /** * Restore a settings file from its `.orig` backup created by {@link backupSettingsFile}. + * Removes the backup file after a successful restore. * @param filePath - Path to the settings file to restore * @returns boolean indicating whether the restore succeeded */ @@ -653,6 +652,7 @@ export function restoreSettingsFromBackup(filePath: string): boolean { const backupPath = `${filePath}.orig`; if (fs.existsSync(backupPath)) { fs.copyFileSync(backupPath, filePath); + fs.unlinkSync(backupPath); return true; } } catch (_e) { @@ -661,4 +661,19 @@ export function restoreSettingsFromBackup(filePath: string): boolean { return false; } +/** + * Remove the `.orig` backup after a successful operation. + * @param filePath - Path to the settings file whose backup should be removed + */ +export function cleanupSettingsBackup(filePath: string): void { + try { + const backupPath = `${filePath}.orig`; + if (fs.existsSync(backupPath)) { + fs.unlinkSync(backupPath); + } + } catch (_e) { + // Ignore cleanup errors — non-critical + } +} + export const TEST_ONLY = { clearFlattenedSchema }; diff --git a/packages/vscode-ide-companion/src/services/settingsWriter.ts b/packages/vscode-ide-companion/src/services/settingsWriter.ts index de274db00..9c88198b2 100644 --- a/packages/vscode-ide-companion/src/services/settingsWriter.ts +++ b/packages/vscode-ide-companion/src/services/settingsWriter.ts @@ -145,8 +145,14 @@ export function writeCodingPlanConfig( })); providers[AuthType.USE_OPENAI] = [...planModels, ...nonCodingPlan]; - // Coding Plan metadata - settings.codingPlan = { region: codingRegion, version: planConfig.version }; + // Coding Plan metadata — write to the providerMetadata namespace that + // the CLI now reads from. Remove legacy top-level key if present. + const providerMetadata = ensureNestedObject(settings, 'providerMetadata'); + providerMetadata['coding-plan'] = { + region: codingRegion, + version: planConfig.version, + }; + delete settings.codingPlan; // Default model const defaultModelId = planConfig.template[0]?.id ?? 'qwen3.5-plus'; @@ -214,6 +220,11 @@ export function writeModelProvidersConfig(params: { for (const plan of SUBSCRIPTION_PLAN_OPTIONS) { delete settings[plan.metadataKey]; } + const pm = settings.providerMetadata as Record | undefined; + if (pm) { + delete pm['coding-plan']; + delete pm['token-plan']; + } writeSettings(settings); } @@ -297,10 +308,15 @@ export function clearPersistedAuth(): void { delete env['OPENAI_API_KEY']; } - // Remove subscription plan metadata + // Remove subscription plan metadata (legacy + new namespace) for (const plan of SUBSCRIPTION_PLAN_OPTIONS) { delete settings[plan.metadataKey]; } + const pm = settings.providerMetadata as Record | undefined; + if (pm) { + delete pm['coding-plan']; + delete pm['token-plan']; + } writeSettings(settings); } catch (error) {