| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { createLogger, atomicWriteJson, DEFAULT_BACKUP_COUNT } from '@automaker/utils'; |
| import * as secureFs from '../lib/secure-fs.js'; |
| import os from 'os'; |
| import path from 'path'; |
| import fs from 'fs/promises'; |
|
|
| import { |
| getGlobalSettingsPath, |
| getCredentialsPath, |
| getProjectSettingsPath, |
| ensureDataDir, |
| ensureAutomakerDir, |
| } from '@automaker/platform'; |
| import type { |
| GlobalSettings, |
| Credentials, |
| ProjectSettings, |
| KeyboardShortcuts, |
| ProjectRef, |
| TrashedProjectRef, |
| BoardBackgroundSettings, |
| WorktreeInfo, |
| PhaseModelConfig, |
| PhaseModelEntry, |
| FeatureTemplate, |
| ClaudeApiProfile, |
| ClaudeCompatibleProvider, |
| ProviderModel, |
| } from '../types/settings.js'; |
| import { |
| DEFAULT_GLOBAL_SETTINGS, |
| DEFAULT_CREDENTIALS, |
| DEFAULT_PROJECT_SETTINGS, |
| DEFAULT_PHASE_MODELS, |
| DEFAULT_FEATURE_TEMPLATES, |
| SETTINGS_VERSION, |
| CREDENTIALS_VERSION, |
| PROJECT_SETTINGS_VERSION, |
| } from '../types/settings.js'; |
| import { |
| DEFAULT_MAX_CONCURRENCY, |
| migrateModelId, |
| migrateCursorModelIds, |
| migrateOpencodeModelIds, |
| } from '@automaker/types'; |
|
|
| const logger = createLogger('SettingsService'); |
|
|
| |
| |
| |
| |
| async function readJsonFile<T>(filePath: string, defaultValue: T): Promise<T> { |
| try { |
| const content = (await secureFs.readFile(filePath, 'utf-8')) as string; |
| return JSON.parse(content) as T; |
| } catch (error) { |
| if ((error as NodeJS.ErrnoException).code === 'ENOENT') { |
| return defaultValue; |
| } |
| logger.error(`Error reading ${filePath}:`, error); |
| return defaultValue; |
| } |
| } |
|
|
| |
| |
| |
| async function fileExists(filePath: string): Promise<boolean> { |
| try { |
| await secureFs.access(filePath); |
| return true; |
| } catch { |
| return false; |
| } |
| } |
|
|
| |
| |
| |
| async function writeSettingsJson(filePath: string, data: unknown): Promise<void> { |
| await atomicWriteJson(filePath, data, { backupCount: DEFAULT_BACKUP_COUNT }); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export class SettingsService { |
| private dataDir: string; |
|
|
| |
| |
| |
| |
| |
| constructor(dataDir: string) { |
| this.dataDir = dataDir; |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| async getGlobalSettings(): Promise<GlobalSettings> { |
| const settingsPath = getGlobalSettingsPath(this.dataDir); |
| const settings = await readJsonFile<GlobalSettings>(settingsPath, DEFAULT_GLOBAL_SETTINGS); |
|
|
| |
| const migratedPhaseModels = this.migratePhaseModels(settings); |
|
|
| |
| const migratedModelSettings = this.migrateModelSettings(settings); |
|
|
| |
| |
| |
| const mergedFeatureTemplates = this.mergeBuiltInTemplates(settings.featureTemplates); |
|
|
| |
| let result: GlobalSettings = { |
| ...DEFAULT_GLOBAL_SETTINGS, |
| ...settings, |
| ...migratedModelSettings, |
| keyboardShortcuts: { |
| ...DEFAULT_GLOBAL_SETTINGS.keyboardShortcuts, |
| ...settings.keyboardShortcuts, |
| }, |
| phaseModels: migratedPhaseModels, |
| featureTemplates: mergedFeatureTemplates, |
| }; |
|
|
| |
| const storedVersion = settings.version || 1; |
| let needsSave = false; |
|
|
| |
| |
| if (storedVersion < 3) { |
| logger.info( |
| `Migrating settings from v${storedVersion} to v3: converting phase models to PhaseModelEntry format` |
| ); |
| needsSave = true; |
| } |
|
|
| |
| |
| |
| if (storedVersion < 4) { |
| if (settings.setupComplete === undefined) result.setupComplete = true; |
| if (settings.isFirstRun === undefined) result.isFirstRun = false; |
| if (settings.skipClaudeSetup === undefined) result.skipClaudeSetup = false; |
| needsSave = true; |
| } |
|
|
| |
| |
| |
| if (storedVersion < 5) { |
| try { |
| const credentials = await this.getCredentials(); |
| const hasAnthropicKey = !!credentials.apiKeys?.anthropic; |
| const hasNoProfiles = !result.claudeApiProfiles || result.claudeApiProfiles.length === 0; |
| const hasNoActiveProfile = !result.activeClaudeApiProfileId; |
|
|
| if (hasAnthropicKey && hasNoProfiles && hasNoActiveProfile) { |
| const directAnthropicProfile = { |
| id: `profile-${Date.now()}-direct-anthropic`, |
| name: 'Direct Anthropic', |
| baseUrl: 'https://api.anthropic.com', |
| apiKeySource: 'credentials' as const, |
| useAuthToken: false, |
| }; |
|
|
| result.claudeApiProfiles = [directAnthropicProfile]; |
| result.activeClaudeApiProfileId = directAnthropicProfile.id; |
|
|
| logger.info( |
| 'Migration v4->v5: Created "Direct Anthropic" profile using existing credentials' |
| ); |
| } |
| } catch (error) { |
| logger.warn( |
| 'Migration v4->v5: Could not check credentials for auto-profile creation:', |
| error |
| ); |
| } |
| needsSave = true; |
| } |
|
|
| |
| |
| |
| if (storedVersion < 6) { |
| const legacyProfiles = settings.claudeApiProfiles || []; |
| if ( |
| legacyProfiles.length > 0 && |
| (!result.claudeCompatibleProviders || result.claudeCompatibleProviders.length === 0) |
| ) { |
| logger.info( |
| `Migration v5->v6: Converting ${legacyProfiles.length} Claude API profile(s) to compatible providers` |
| ); |
| result.claudeCompatibleProviders = this.migrateProfilesToProviders(legacyProfiles); |
| } |
| |
| if (result.activeClaudeApiProfileId) { |
| logger.info('Migration v5->v6: Removing deprecated activeClaudeApiProfileId'); |
| delete result.activeClaudeApiProfileId; |
| } |
| needsSave = true; |
| } |
|
|
| |
| if (needsSave) { |
| result.version = SETTINGS_VERSION; |
| } |
|
|
| |
| if (needsSave) { |
| try { |
| await ensureDataDir(this.dataDir); |
| await writeSettingsJson(settingsPath, result); |
| logger.info('Settings migration complete'); |
| } catch (error) { |
| logger.error('Failed to save migrated settings:', error); |
| } |
| } |
|
|
| return result; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| private mergeBuiltInTemplates(storedTemplates: FeatureTemplate[] | undefined): FeatureTemplate[] { |
| if (!storedTemplates) { |
| return DEFAULT_FEATURE_TEMPLATES; |
| } |
|
|
| const storedIds = new Set(storedTemplates.map((t) => t.id)); |
| const missingBuiltIns = DEFAULT_FEATURE_TEMPLATES.filter((t) => !storedIds.has(t.id)); |
|
|
| if (missingBuiltIns.length === 0) { |
| return storedTemplates; |
| } |
|
|
| |
| return [...storedTemplates, ...missingBuiltIns]; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| private migratePhaseModels(settings: Partial<GlobalSettings>): PhaseModelConfig { |
| |
| const result: PhaseModelConfig = { ...DEFAULT_PHASE_MODELS }; |
|
|
| |
| if (settings.phaseModels) { |
| |
| const merged: PhaseModelConfig = { ...DEFAULT_PHASE_MODELS }; |
| for (const key of Object.keys(settings.phaseModels) as Array<keyof PhaseModelConfig>) { |
| const value = settings.phaseModels[key]; |
| if (value !== undefined) { |
| |
| merged[key] = this.toPhaseModelEntry(value); |
| } |
| } |
| return merged; |
| } |
|
|
| |
| |
| if (settings.enhancementModel) { |
| result.enhancementModel = this.toPhaseModelEntry(settings.enhancementModel); |
| logger.debug(`Migrated legacy enhancementModel: ${settings.enhancementModel}`); |
| } |
| if (settings.validationModel) { |
| result.validationModel = this.toPhaseModelEntry(settings.validationModel); |
| logger.debug(`Migrated legacy validationModel: ${settings.validationModel}`); |
| } |
|
|
| return result; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| private toPhaseModelEntry(value: string | PhaseModelEntry): PhaseModelEntry { |
| if (typeof value === 'string') { |
| |
| return { model: migrateModelId(value) as PhaseModelEntry['model'] }; |
| } |
| |
| return { |
| ...value, |
| model: migrateModelId(value.model) as PhaseModelEntry['model'], |
| }; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| private migrateProfilesToProviders(profiles: ClaudeApiProfile[]): ClaudeCompatibleProvider[] { |
| return profiles.map((profile): ClaudeCompatibleProvider => { |
| |
| const models: ProviderModel[] = []; |
|
|
| if (profile.modelMappings) { |
| |
| if (profile.modelMappings.haiku) { |
| models.push({ |
| id: profile.modelMappings.haiku, |
| displayName: this.inferModelDisplayName(profile.modelMappings.haiku, 'haiku'), |
| mapsToClaudeModel: 'haiku', |
| }); |
| } |
| |
| if (profile.modelMappings.sonnet) { |
| models.push({ |
| id: profile.modelMappings.sonnet, |
| displayName: this.inferModelDisplayName(profile.modelMappings.sonnet, 'sonnet'), |
| mapsToClaudeModel: 'sonnet', |
| }); |
| } |
| |
| if (profile.modelMappings.opus) { |
| models.push({ |
| id: profile.modelMappings.opus, |
| displayName: this.inferModelDisplayName(profile.modelMappings.opus, 'opus'), |
| mapsToClaudeModel: 'opus', |
| }); |
| } |
| } |
|
|
| |
| const providerType = this.inferProviderType(profile); |
|
|
| return { |
| id: profile.id, |
| name: profile.name, |
| providerType, |
| enabled: true, |
| baseUrl: profile.baseUrl, |
| apiKeySource: profile.apiKeySource ?? 'inline', |
| apiKey: profile.apiKey, |
| useAuthToken: profile.useAuthToken, |
| timeoutMs: profile.timeoutMs, |
| disableNonessentialTraffic: profile.disableNonessentialTraffic, |
| models, |
| }; |
| }); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| private inferModelDisplayName(modelId: string, tier: 'haiku' | 'sonnet' | 'opus'): string { |
| |
| const lowerModelId = modelId.toLowerCase(); |
|
|
| |
| if (lowerModelId.includes('glm')) { |
| return modelId.replace(/-/g, ' ').replace(/glm/i, 'GLM'); |
| } |
|
|
| |
| if (lowerModelId.includes('minimax')) { |
| return modelId.replace(/-/g, ' ').replace(/minimax/i, 'MiniMax'); |
| } |
|
|
| |
| if (lowerModelId.includes('claude')) { |
| return modelId; |
| } |
|
|
| |
| return `${modelId} (${tier})`; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| private inferProviderType(profile: ClaudeApiProfile): ClaudeCompatibleProvider['providerType'] { |
| const baseUrl = profile.baseUrl.toLowerCase(); |
| const name = profile.name.toLowerCase(); |
|
|
| |
| if (baseUrl.includes('z.ai') || baseUrl.includes('zhipuai')) { |
| return 'glm'; |
| } |
| if (baseUrl.includes('minimax')) { |
| return 'minimax'; |
| } |
| if (baseUrl.includes('openrouter')) { |
| return 'openrouter'; |
| } |
| if (baseUrl.includes('anthropic.com')) { |
| return 'anthropic'; |
| } |
|
|
| |
| if (name.includes('glm') || name.includes('zhipu')) { |
| return 'glm'; |
| } |
| if (name.includes('minimax')) { |
| return 'minimax'; |
| } |
| if (name.includes('openrouter')) { |
| return 'openrouter'; |
| } |
| if (name.includes('anthropic') || name.includes('direct')) { |
| return 'anthropic'; |
| } |
|
|
| |
| return 'custom'; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| private migrateModelSettings(settings: Partial<GlobalSettings>): Partial<GlobalSettings> { |
| const migrated: Partial<GlobalSettings> = { ...settings }; |
|
|
| |
| if (settings.enabledCursorModels) { |
| migrated.enabledCursorModels = migrateCursorModelIds( |
| settings.enabledCursorModels as string[] |
| ); |
| } |
|
|
| |
| if (settings.cursorDefaultModel) { |
| const migratedDefault = migrateCursorModelIds([settings.cursorDefaultModel as string]); |
| if (migratedDefault.length > 0) { |
| migrated.cursorDefaultModel = migratedDefault[0]; |
| } |
| } |
|
|
| |
| if (settings.enabledOpencodeModels) { |
| migrated.enabledOpencodeModels = migrateOpencodeModelIds( |
| settings.enabledOpencodeModels as string[] |
| ); |
| } |
|
|
| |
| if (settings.opencodeDefaultModel) { |
| const migratedDefault = migrateOpencodeModelIds([settings.opencodeDefaultModel as string]); |
| if (migratedDefault.length > 0) { |
| migrated.opencodeDefaultModel = migratedDefault[0]; |
| } |
| } |
|
|
| return migrated; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| async updateGlobalSettings(updates: Partial<GlobalSettings>): Promise<GlobalSettings> { |
| await ensureDataDir(this.dataDir); |
| const settingsPath = getGlobalSettingsPath(this.dataDir); |
|
|
| const current = await this.getGlobalSettings(); |
|
|
| |
| |
| |
| const sanitizedUpdates: Partial<GlobalSettings> = { ...updates }; |
| let attemptedProjectWipe = false; |
|
|
| const ignoreEmptyArrayOverwrite = <K extends keyof GlobalSettings>(key: K): void => { |
| const nextVal = sanitizedUpdates[key] as unknown; |
| const curVal = current[key] as unknown; |
| if ( |
| Array.isArray(nextVal) && |
| nextVal.length === 0 && |
| Array.isArray(curVal) && |
| curVal.length > 0 |
| ) { |
| delete sanitizedUpdates[key]; |
| } |
| }; |
|
|
| const currentProjectsLen = Array.isArray(current.projects) ? current.projects.length : 0; |
| |
| const newTrashedProjectsLen = Array.isArray(sanitizedUpdates.trashedProjects) |
| ? sanitizedUpdates.trashedProjects.length |
| : Array.isArray(current.trashedProjects) |
| ? current.trashedProjects.length |
| : 0; |
|
|
| if ( |
| Array.isArray(sanitizedUpdates.projects) && |
| sanitizedUpdates.projects.length === 0 && |
| currentProjectsLen > 0 |
| ) { |
| |
| |
| if (newTrashedProjectsLen === 0) { |
| logger.warn( |
| '[WIPE_PROTECTION] Attempted to set projects to empty array with no trash! Ignoring update.', |
| { |
| currentProjectsLen, |
| newProjectsLen: 0, |
| newTrashedProjectsLen, |
| currentProjects: current.projects?.map((p) => p.name), |
| } |
| ); |
| attemptedProjectWipe = true; |
| delete sanitizedUpdates.projects; |
| } else { |
| logger.info('[LEGITIMATE_REMOVAL] Removing all projects to trash', { |
| currentProjectsLen, |
| newProjectsLen: 0, |
| movedToTrash: newTrashedProjectsLen, |
| }); |
| } |
| } |
|
|
| ignoreEmptyArrayOverwrite('trashedProjects'); |
| ignoreEmptyArrayOverwrite('projectHistory'); |
| ignoreEmptyArrayOverwrite('recentFolders'); |
| ignoreEmptyArrayOverwrite('mcpServers'); |
| ignoreEmptyArrayOverwrite('enabledCursorModels'); |
| ignoreEmptyArrayOverwrite('claudeApiProfiles'); |
| |
|
|
| |
| const allowEmptyEventHooks = |
| (sanitizedUpdates as Record<string, unknown>).__allowEmptyEventHooks === true; |
| |
| delete (sanitizedUpdates as Record<string, unknown>).__allowEmptyEventHooks; |
|
|
| |
| if (!allowEmptyEventHooks) { |
| ignoreEmptyArrayOverwrite('eventHooks'); |
| } |
|
|
| |
| |
| |
| const allowEmptyNtfyEndpoints = |
| (sanitizedUpdates as Record<string, unknown>).__allowEmptyNtfyEndpoints === true; |
| |
| delete (sanitizedUpdates as Record<string, unknown>).__allowEmptyNtfyEndpoints; |
|
|
| if (!allowEmptyNtfyEndpoints) { |
| const currentNtfyLen = Array.isArray(current.ntfyEndpoints) |
| ? current.ntfyEndpoints.length |
| : 0; |
| const newNtfyLen = Array.isArray(sanitizedUpdates.ntfyEndpoints) |
| ? sanitizedUpdates.ntfyEndpoints.length |
| : currentNtfyLen; |
|
|
| if (Array.isArray(sanitizedUpdates.ntfyEndpoints) && newNtfyLen === 0 && currentNtfyLen > 0) { |
| logger.warn( |
| '[WIPE_PROTECTION] Attempted to set ntfyEndpoints to empty array! Ignoring update.', |
| { |
| currentNtfyLen, |
| newNtfyLen, |
| } |
| ); |
| delete sanitizedUpdates.ntfyEndpoints; |
| } |
| } else { |
| logger.info('[INTENTIONAL_CLEAR] Clearing ntfyEndpoints via escape hatch'); |
| } |
|
|
| |
| const ignoreEmptyObjectOverwrite = <K extends keyof GlobalSettings>(key: K): void => { |
| const nextVal = sanitizedUpdates[key] as unknown; |
| const curVal = current[key] as unknown; |
| if ( |
| nextVal && |
| typeof nextVal === 'object' && |
| !Array.isArray(nextVal) && |
| Object.keys(nextVal).length === 0 && |
| curVal && |
| typeof curVal === 'object' && |
| !Array.isArray(curVal) && |
| Object.keys(curVal).length > 0 |
| ) { |
| delete sanitizedUpdates[key]; |
| } |
| }; |
|
|
| ignoreEmptyObjectOverwrite('lastSelectedSessionByProject'); |
| ignoreEmptyObjectOverwrite('autoModeByWorktree'); |
|
|
| |
| if (attemptedProjectWipe) { |
| delete sanitizedUpdates.theme; |
| } |
|
|
| const updated: GlobalSettings = { |
| ...current, |
| ...sanitizedUpdates, |
| version: SETTINGS_VERSION, |
| }; |
|
|
| |
| if (sanitizedUpdates.keyboardShortcuts) { |
| updated.keyboardShortcuts = { |
| ...current.keyboardShortcuts, |
| ...sanitizedUpdates.keyboardShortcuts, |
| }; |
| } |
|
|
| |
| if (sanitizedUpdates.phaseModels) { |
| updated.phaseModels = { |
| ...current.phaseModels, |
| ...sanitizedUpdates.phaseModels, |
| }; |
| } |
|
|
| |
| if (sanitizedUpdates.autoModeByWorktree) { |
| type WorktreeEntry = { maxConcurrency: number; branchName: string | null }; |
| const mergedAutoModeByWorktree: Record<string, WorktreeEntry> = { |
| ...current.autoModeByWorktree, |
| }; |
| for (const [key, value] of Object.entries(sanitizedUpdates.autoModeByWorktree)) { |
| mergedAutoModeByWorktree[key] = { |
| ...mergedAutoModeByWorktree[key], |
| ...value, |
| }; |
| } |
| updated.autoModeByWorktree = mergedAutoModeByWorktree; |
| } |
|
|
| await writeSettingsJson(settingsPath, updated); |
| logger.info('Global settings updated'); |
|
|
| return updated; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| async hasGlobalSettings(): Promise<boolean> { |
| const settingsPath = getGlobalSettingsPath(this.dataDir); |
| return fileExists(settingsPath); |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| async getCredentials(): Promise<Credentials> { |
| const credentialsPath = getCredentialsPath(this.dataDir); |
| const credentials = await readJsonFile<Credentials>(credentialsPath, DEFAULT_CREDENTIALS); |
|
|
| return { |
| ...DEFAULT_CREDENTIALS, |
| ...credentials, |
| apiKeys: { |
| ...DEFAULT_CREDENTIALS.apiKeys, |
| ...credentials.apiKeys, |
| }, |
| }; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| async updateCredentials(updates: Partial<Credentials>): Promise<Credentials> { |
| await ensureDataDir(this.dataDir); |
| const credentialsPath = getCredentialsPath(this.dataDir); |
|
|
| const current = await this.getCredentials(); |
| const updated: Credentials = { |
| ...current, |
| ...updates, |
| version: CREDENTIALS_VERSION, |
| }; |
|
|
| |
| if (updates.apiKeys) { |
| updated.apiKeys = { |
| ...current.apiKeys, |
| ...updates.apiKeys, |
| }; |
| } |
|
|
| await writeSettingsJson(credentialsPath, updated); |
| logger.info('Credentials updated'); |
|
|
| return updated; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| async getMaskedCredentials(): Promise<{ |
| anthropic: { configured: boolean; masked: string }; |
| google: { configured: boolean; masked: string }; |
| openai: { configured: boolean; masked: string }; |
| zai: { configured: boolean; masked: string }; |
| }> { |
| const credentials = await this.getCredentials(); |
|
|
| const maskKey = (key: string): string => { |
| if (!key || key.length < 8) return ''; |
| return `${key.substring(0, 4)}...${key.substring(key.length - 4)}`; |
| }; |
|
|
| return { |
| anthropic: { |
| configured: !!credentials.apiKeys.anthropic, |
| masked: maskKey(credentials.apiKeys.anthropic), |
| }, |
| google: { |
| configured: !!credentials.apiKeys.google, |
| masked: maskKey(credentials.apiKeys.google), |
| }, |
| openai: { |
| configured: !!credentials.apiKeys.openai, |
| masked: maskKey(credentials.apiKeys.openai), |
| }, |
| zai: { |
| configured: !!credentials.apiKeys.zai, |
| masked: maskKey(credentials.apiKeys.zai), |
| }, |
| }; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| async hasCredentials(): Promise<boolean> { |
| const credentialsPath = getCredentialsPath(this.dataDir); |
| return fileExists(credentialsPath); |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| async getProjectSettings(projectPath: string): Promise<ProjectSettings> { |
| const settingsPath = getProjectSettingsPath(projectPath); |
| const settings = await readJsonFile<ProjectSettings>(settingsPath, DEFAULT_PROJECT_SETTINGS); |
|
|
| return { |
| ...DEFAULT_PROJECT_SETTINGS, |
| ...settings, |
| }; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| async updateProjectSettings( |
| projectPath: string, |
| updates: Partial<ProjectSettings> |
| ): Promise<ProjectSettings> { |
| await ensureAutomakerDir(projectPath); |
| const settingsPath = getProjectSettingsPath(projectPath); |
|
|
| const current = await this.getProjectSettings(projectPath); |
| const updated: ProjectSettings = { |
| ...current, |
| ...updates, |
| version: PROJECT_SETTINGS_VERSION, |
| }; |
|
|
| |
| if (updates.boardBackground) { |
| updated.boardBackground = { |
| ...current.boardBackground, |
| ...updates.boardBackground, |
| }; |
| } |
|
|
| |
| |
| |
| |
| if ( |
| 'activeClaudeApiProfileId' in updates && |
| updates.activeClaudeApiProfileId === '__USE_GLOBAL__' |
| ) { |
| delete updated.activeClaudeApiProfileId; |
| } |
|
|
| |
| |
| |
| if ( |
| 'phaseModelOverrides' in updates && |
| (updates as Record<string, unknown>).phaseModelOverrides === '__CLEAR__' |
| ) { |
| delete updated.phaseModelOverrides; |
| } |
|
|
| |
| |
| |
| if ( |
| 'defaultFeatureModel' in updates && |
| (updates as Record<string, unknown>).defaultFeatureModel === '__CLEAR__' |
| ) { |
| delete updated.defaultFeatureModel; |
| } |
|
|
| |
| |
| |
| if ('devCommand' in updates && updates.devCommand === null) { |
| delete updated.devCommand; |
| } |
|
|
| |
| |
| |
| if ('testCommand' in updates && updates.testCommand === null) { |
| delete updated.testCommand; |
| } |
|
|
| await writeSettingsJson(settingsPath, updated); |
| logger.info(`Project settings updated for ${projectPath}`); |
|
|
| return updated; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| async hasProjectSettings(projectPath: string): Promise<boolean> { |
| const settingsPath = getProjectSettingsPath(projectPath); |
| return fileExists(settingsPath); |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| async migrateFromLocalStorage(localStorageData: { |
| 'automaker-storage'?: string; |
| 'automaker-setup'?: string; |
| 'worktree-panel-collapsed'?: string; |
| 'file-browser-recent-folders'?: string; |
| 'automaker:lastProjectDir'?: string; |
| }): Promise<{ |
| success: boolean; |
| migratedGlobalSettings: boolean; |
| migratedCredentials: boolean; |
| migratedProjectCount: number; |
| errors: string[]; |
| }> { |
| const errors: string[] = []; |
| let migratedGlobalSettings = false; |
| let migratedCredentials = false; |
| let migratedProjectCount = 0; |
|
|
| try { |
| |
| let appState: Record<string, unknown> = {}; |
| if (localStorageData['automaker-storage']) { |
| try { |
| const parsed = JSON.parse(localStorageData['automaker-storage']); |
| appState = parsed.state || parsed; |
| } catch (e) { |
| errors.push(`Failed to parse automaker-storage: ${e}`); |
| } |
| } |
|
|
| |
| let setupState: Record<string, unknown> = {}; |
| if (localStorageData['automaker-setup']) { |
| try { |
| const parsed = JSON.parse(localStorageData['automaker-setup']); |
| setupState = parsed.state || parsed; |
| } catch (e) { |
| errors.push(`Failed to parse automaker-setup: ${e}`); |
| } |
| } |
|
|
| |
| const globalSettings: Partial<GlobalSettings> = { |
| setupComplete: |
| setupState.setupComplete !== undefined ? (setupState.setupComplete as boolean) : false, |
| isFirstRun: setupState.isFirstRun !== undefined ? (setupState.isFirstRun as boolean) : true, |
| skipClaudeSetup: |
| setupState.skipClaudeSetup !== undefined |
| ? (setupState.skipClaudeSetup as boolean) |
| : false, |
| theme: (appState.theme as GlobalSettings['theme']) || 'dark', |
| sidebarOpen: appState.sidebarOpen !== undefined ? (appState.sidebarOpen as boolean) : true, |
| chatHistoryOpen: (appState.chatHistoryOpen as boolean) || false, |
| maxConcurrency: (appState.maxConcurrency as number) || DEFAULT_MAX_CONCURRENCY, |
| defaultSkipTests: |
| appState.defaultSkipTests !== undefined ? (appState.defaultSkipTests as boolean) : true, |
| enableDependencyBlocking: |
| appState.enableDependencyBlocking !== undefined |
| ? (appState.enableDependencyBlocking as boolean) |
| : true, |
| skipVerificationInAutoMode: |
| appState.skipVerificationInAutoMode !== undefined |
| ? (appState.skipVerificationInAutoMode as boolean) |
| : false, |
| useWorktrees: |
| appState.useWorktrees !== undefined ? (appState.useWorktrees as boolean) : true, |
| defaultPlanningMode: |
| (appState.defaultPlanningMode as GlobalSettings['defaultPlanningMode']) || 'skip', |
| defaultRequirePlanApproval: (appState.defaultRequirePlanApproval as boolean) || false, |
| muteDoneSound: (appState.muteDoneSound as boolean) || false, |
| enhancementModel: |
| (appState.enhancementModel as GlobalSettings['enhancementModel']) || 'sonnet', |
| keyboardShortcuts: |
| (appState.keyboardShortcuts as KeyboardShortcuts) || |
| DEFAULT_GLOBAL_SETTINGS.keyboardShortcuts, |
| eventHooks: (appState.eventHooks as GlobalSettings['eventHooks']) || [], |
| ntfyEndpoints: (appState.ntfyEndpoints as GlobalSettings['ntfyEndpoints']) || [], |
| projects: (appState.projects as ProjectRef[]) || [], |
| trashedProjects: (appState.trashedProjects as TrashedProjectRef[]) || [], |
| projectHistory: (appState.projectHistory as string[]) || [], |
| projectHistoryIndex: (appState.projectHistoryIndex as number) || -1, |
| lastSelectedSessionByProject: |
| (appState.lastSelectedSessionByProject as Record<string, string>) || {}, |
| }; |
|
|
| |
| if (localStorageData['automaker:lastProjectDir']) { |
| globalSettings.lastProjectDir = localStorageData['automaker:lastProjectDir']; |
| } |
|
|
| if (localStorageData['file-browser-recent-folders']) { |
| try { |
| globalSettings.recentFolders = JSON.parse( |
| localStorageData['file-browser-recent-folders'] |
| ); |
| } catch { |
| globalSettings.recentFolders = []; |
| } |
| } |
|
|
| if (localStorageData['worktree-panel-collapsed']) { |
| globalSettings.worktreePanelCollapsed = |
| localStorageData['worktree-panel-collapsed'] === 'true'; |
| } |
|
|
| |
| await this.updateGlobalSettings(globalSettings); |
| migratedGlobalSettings = true; |
| logger.info('Migrated global settings from localStorage'); |
|
|
| |
| if (appState.apiKeys) { |
| const apiKeys = appState.apiKeys as { |
| anthropic?: string; |
| google?: string; |
| openai?: string; |
| }; |
| await this.updateCredentials({ |
| apiKeys: { |
| anthropic: apiKeys.anthropic || '', |
| google: apiKeys.google || '', |
| openai: apiKeys.openai || '', |
| zai: '', |
| }, |
| }); |
| migratedCredentials = true; |
| logger.info('Migrated credentials from localStorage'); |
| } |
|
|
| |
| const boardBackgroundByProject = appState.boardBackgroundByProject as |
| | Record<string, BoardBackgroundSettings> |
| | undefined; |
| const currentWorktreeByProject = appState.currentWorktreeByProject as |
| | Record<string, { path: string | null; branch: string }> |
| | undefined; |
| const worktreesByProject = appState.worktreesByProject as |
| | Record<string, WorktreeInfo[]> |
| | undefined; |
|
|
| |
| const projectPaths = new Set<string>(); |
| if (boardBackgroundByProject) { |
| Object.keys(boardBackgroundByProject).forEach((p) => projectPaths.add(p)); |
| } |
| if (currentWorktreeByProject) { |
| Object.keys(currentWorktreeByProject).forEach((p) => projectPaths.add(p)); |
| } |
| if (worktreesByProject) { |
| Object.keys(worktreesByProject).forEach((p) => projectPaths.add(p)); |
| } |
|
|
| |
| const projects = (appState.projects as ProjectRef[]) || []; |
| for (const project of projects) { |
| if (project.theme) { |
| projectPaths.add(project.path); |
| } |
| } |
|
|
| |
| for (const projectPath of projectPaths) { |
| try { |
| const projectSettings: Partial<ProjectSettings> = {}; |
|
|
| |
| const project = projects.find((p) => p.path === projectPath); |
| if (project?.theme) { |
| projectSettings.theme = project.theme as ProjectSettings['theme']; |
| } |
|
|
| if (boardBackgroundByProject?.[projectPath]) { |
| projectSettings.boardBackground = boardBackgroundByProject[projectPath]; |
| } |
|
|
| if (currentWorktreeByProject?.[projectPath]) { |
| projectSettings.currentWorktree = currentWorktreeByProject[projectPath]; |
| } |
|
|
| if (worktreesByProject?.[projectPath]) { |
| projectSettings.worktrees = worktreesByProject[projectPath]; |
| } |
|
|
| if (Object.keys(projectSettings).length > 0) { |
| await this.updateProjectSettings(projectPath, projectSettings); |
| migratedProjectCount++; |
| } |
| } catch (e) { |
| errors.push(`Failed to migrate project settings for ${projectPath}: ${e}`); |
| } |
| } |
|
|
| logger.info(`Migration complete: ${migratedProjectCount} projects migrated`); |
|
|
| return { |
| success: errors.length === 0, |
| migratedGlobalSettings, |
| migratedCredentials, |
| migratedProjectCount, |
| errors, |
| }; |
| } catch (error) { |
| logger.error('Migration failed:', error); |
| errors.push(`Migration failed: ${error}`); |
| return { |
| success: false, |
| migratedGlobalSettings, |
| migratedCredentials, |
| migratedProjectCount, |
| errors, |
| }; |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| getDataDir(): string { |
| return this.dataDir; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| private getLegacyElectronUserDataPath(): string { |
| const homeDir = os.homedir(); |
|
|
| switch (process.platform) { |
| case 'darwin': |
| |
| return path.join(homeDir, 'Library', 'Application Support', 'Automaker'); |
| case 'win32': |
| |
| return path.join( |
| process.env.APPDATA || path.join(homeDir, 'AppData', 'Roaming'), |
| 'Automaker' |
| ); |
| default: |
| |
| return path.join(process.env.XDG_CONFIG_HOME || path.join(homeDir, '.config'), 'Automaker'); |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| async migrateFromLegacyElectronPath(): Promise<{ |
| migrated: boolean; |
| migratedFiles: string[]; |
| legacyPath: string; |
| errors: string[]; |
| }> { |
| const legacyPath = this.getLegacyElectronUserDataPath(); |
| const migratedFiles: string[] = []; |
| const errors: string[] = []; |
|
|
| |
| if (path.resolve(legacyPath) === path.resolve(this.dataDir)) { |
| logger.debug('Legacy path same as current data dir, skipping migration'); |
| return { migrated: false, migratedFiles, legacyPath, errors }; |
| } |
|
|
| logger.info(`Checking for legacy data migration from: ${legacyPath}`); |
| logger.info(`Current data directory: ${this.dataDir}`); |
|
|
| |
| const newSettingsPath = getGlobalSettingsPath(this.dataDir); |
| let newSettingsExist = false; |
| try { |
| await fs.access(newSettingsPath); |
| newSettingsExist = true; |
| } catch { |
| |
| } |
|
|
| if (newSettingsExist) { |
| logger.debug('Settings already exist in new location, skipping migration'); |
| return { migrated: false, migratedFiles, legacyPath, errors }; |
| } |
|
|
| |
| const legacySettingsPath = path.join(legacyPath, 'settings.json'); |
| let legacySettingsExist = false; |
| try { |
| await fs.access(legacySettingsPath); |
| legacySettingsExist = true; |
| } catch { |
| |
| } |
|
|
| if (!legacySettingsExist) { |
| logger.debug('No legacy settings found, skipping migration'); |
| return { migrated: false, migratedFiles, legacyPath, errors }; |
| } |
|
|
| |
| |
| logger.info('Found legacy data directory, migrating application data to new location...'); |
|
|
| |
| try { |
| await ensureDataDir(this.dataDir); |
| } catch (error) { |
| const msg = `Failed to create data directory: ${error}`; |
| logger.error(msg); |
| errors.push(msg); |
| return { migrated: false, migratedFiles, legacyPath, errors }; |
| } |
|
|
| |
| const itemsToMigrate = [ |
| 'settings.json', |
| 'credentials.json', |
| 'sessions-metadata.json', |
| 'agent-sessions', |
| '.api-key', |
| '.sessions', |
| ]; |
|
|
| for (const item of itemsToMigrate) { |
| const srcPath = path.join(legacyPath, item); |
| const destPath = path.join(this.dataDir, item); |
|
|
| |
| try { |
| await fs.access(srcPath); |
| } catch { |
| |
| continue; |
| } |
|
|
| |
| try { |
| await fs.access(destPath); |
| logger.debug(`Skipping ${item} - already exists in destination`); |
| continue; |
| } catch { |
| |
| } |
|
|
| |
| try { |
| const stat = await fs.stat(srcPath); |
| if (stat.isDirectory()) { |
| await this.copyDirectory(srcPath, destPath); |
| migratedFiles.push(item + '/'); |
| logger.info(`Migrated directory: ${item}/`); |
| } else { |
| const content = await fs.readFile(srcPath); |
| await fs.writeFile(destPath, content); |
| migratedFiles.push(item); |
| logger.info(`Migrated file: ${item}`); |
| } |
| } catch (error) { |
| const msg = `Failed to migrate ${item}: ${error}`; |
| logger.error(msg); |
| errors.push(msg); |
| } |
| } |
|
|
| if (migratedFiles.length > 0) { |
| logger.info( |
| `Migration complete. Migrated ${migratedFiles.length} item(s): ${migratedFiles.join(', ')}` |
| ); |
| logger.info(`Legacy path: ${legacyPath}`); |
| logger.info(`New path: ${this.dataDir}`); |
| } |
|
|
| return { |
| migrated: migratedFiles.length > 0, |
| migratedFiles, |
| legacyPath, |
| errors, |
| }; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| private async copyDirectory(srcDir: string, destDir: string): Promise<void> { |
| await fs.mkdir(destDir, { recursive: true }); |
| const entries = await fs.readdir(srcDir, { withFileTypes: true }); |
|
|
| for (const entry of entries) { |
| const srcPath = path.join(srcDir, entry.name); |
| const destPath = path.join(destDir, entry.name); |
|
|
| if (entry.isDirectory()) { |
| await this.copyDirectory(srcPath, destPath); |
| } else if (entry.isFile()) { |
| const content = await fs.readFile(srcPath); |
| await fs.writeFile(destPath, content); |
| } |
| } |
| } |
| } |
|
|