diff --git a/src/__tests__/main/stores/defaults.test.ts b/src/__tests__/main/stores/defaults.test.ts new file mode 100644 index 00000000..03bfb6b5 --- /dev/null +++ b/src/__tests__/main/stores/defaults.test.ts @@ -0,0 +1,206 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +import { + getDefaultShell, + SETTINGS_DEFAULTS, + SESSIONS_DEFAULTS, + GROUPS_DEFAULTS, + AGENT_CONFIGS_DEFAULTS, + WINDOW_STATE_DEFAULTS, + CLAUDE_SESSION_ORIGINS_DEFAULTS, + AGENT_SESSION_ORIGINS_DEFAULTS, +} from '../../../main/stores/defaults'; + +describe('stores/defaults', () => { + describe('getDefaultShell', () => { + const originalPlatform = process.platform; + const originalShell = process.env.SHELL; + + afterEach(() => { + // Restore original values + Object.defineProperty(process, 'platform', { value: originalPlatform }); + process.env.SHELL = originalShell; + }); + + it('should return powershell on Windows', () => { + Object.defineProperty(process, 'platform', { value: 'win32' }); + + expect(getDefaultShell()).toBe('powershell'); + }); + + it('should return zsh when SHELL is /bin/zsh', () => { + Object.defineProperty(process, 'platform', { value: 'darwin' }); + process.env.SHELL = '/bin/zsh'; + + expect(getDefaultShell()).toBe('zsh'); + }); + + it('should return bash when SHELL is /bin/bash', () => { + Object.defineProperty(process, 'platform', { value: 'linux' }); + process.env.SHELL = '/bin/bash'; + + expect(getDefaultShell()).toBe('bash'); + }); + + it('should return fish when SHELL is /usr/bin/fish', () => { + Object.defineProperty(process, 'platform', { value: 'darwin' }); + process.env.SHELL = '/usr/bin/fish'; + + expect(getDefaultShell()).toBe('fish'); + }); + + it('should return sh when SHELL is /bin/sh', () => { + Object.defineProperty(process, 'platform', { value: 'linux' }); + process.env.SHELL = '/bin/sh'; + + expect(getDefaultShell()).toBe('sh'); + }); + + it('should return tcsh when SHELL is /bin/tcsh', () => { + Object.defineProperty(process, 'platform', { value: 'darwin' }); + process.env.SHELL = '/bin/tcsh'; + + expect(getDefaultShell()).toBe('tcsh'); + }); + + it('should return bash when SHELL is an unsupported shell', () => { + Object.defineProperty(process, 'platform', { value: 'linux' }); + process.env.SHELL = '/bin/unsupported'; + + expect(getDefaultShell()).toBe('bash'); + }); + + it('should return bash when SHELL is not set', () => { + Object.defineProperty(process, 'platform', { value: 'linux' }); + delete process.env.SHELL; + + expect(getDefaultShell()).toBe('bash'); + }); + + it('should handle full path with nested directories', () => { + Object.defineProperty(process, 'platform', { value: 'darwin' }); + process.env.SHELL = '/opt/homebrew/bin/zsh'; + + expect(getDefaultShell()).toBe('zsh'); + }); + }); + + describe('SETTINGS_DEFAULTS', () => { + it('should have correct default theme', () => { + expect(SETTINGS_DEFAULTS.activeThemeId).toBe('dracula'); + }); + + it('should have correct default llmProvider', () => { + expect(SETTINGS_DEFAULTS.llmProvider).toBe('openrouter'); + }); + + it('should have correct default modelSlug', () => { + expect(SETTINGS_DEFAULTS.modelSlug).toBe('anthropic/claude-3.5-sonnet'); + }); + + it('should have empty apiKey by default', () => { + expect(SETTINGS_DEFAULTS.apiKey).toBe(''); + }); + + it('should have empty shortcuts by default', () => { + expect(SETTINGS_DEFAULTS.shortcuts).toEqual({}); + }); + + it('should have correct default fontSize', () => { + expect(SETTINGS_DEFAULTS.fontSize).toBe(14); + }); + + it('should have correct default fontFamily', () => { + expect(SETTINGS_DEFAULTS.fontFamily).toBe('Roboto Mono, Menlo, "Courier New", monospace'); + }); + + it('should have empty customFonts by default', () => { + expect(SETTINGS_DEFAULTS.customFonts).toEqual([]); + }); + + it('should have info as default logLevel', () => { + expect(SETTINGS_DEFAULTS.logLevel).toBe('info'); + }); + + it('should have webAuthEnabled disabled by default', () => { + expect(SETTINGS_DEFAULTS.webAuthEnabled).toBe(false); + }); + + it('should have null webAuthToken by default', () => { + expect(SETTINGS_DEFAULTS.webAuthToken).toBeNull(); + }); + + it('should have webInterfaceUseCustomPort disabled by default', () => { + expect(SETTINGS_DEFAULTS.webInterfaceUseCustomPort).toBe(false); + }); + + it('should have 8080 as default webInterfaceCustomPort', () => { + expect(SETTINGS_DEFAULTS.webInterfaceCustomPort).toBe(8080); + }); + + it('should have empty sshRemotes by default', () => { + expect(SETTINGS_DEFAULTS.sshRemotes).toEqual([]); + }); + + it('should have null defaultSshRemoteId by default', () => { + expect(SETTINGS_DEFAULTS.defaultSshRemoteId).toBeNull(); + }); + + it('should have null installationId by default', () => { + expect(SETTINGS_DEFAULTS.installationId).toBeNull(); + }); + }); + + describe('SESSIONS_DEFAULTS', () => { + it('should have empty sessions array', () => { + expect(SESSIONS_DEFAULTS.sessions).toEqual([]); + }); + }); + + describe('GROUPS_DEFAULTS', () => { + it('should have empty groups array', () => { + expect(GROUPS_DEFAULTS.groups).toEqual([]); + }); + }); + + describe('AGENT_CONFIGS_DEFAULTS', () => { + it('should have empty configs object', () => { + expect(AGENT_CONFIGS_DEFAULTS.configs).toEqual({}); + }); + }); + + describe('WINDOW_STATE_DEFAULTS', () => { + it('should have correct default width', () => { + expect(WINDOW_STATE_DEFAULTS.width).toBe(1400); + }); + + it('should have correct default height', () => { + expect(WINDOW_STATE_DEFAULTS.height).toBe(900); + }); + + it('should have isMaximized false by default', () => { + expect(WINDOW_STATE_DEFAULTS.isMaximized).toBe(false); + }); + + it('should have isFullScreen false by default', () => { + expect(WINDOW_STATE_DEFAULTS.isFullScreen).toBe(false); + }); + + it('should not have x/y position by default', () => { + expect(WINDOW_STATE_DEFAULTS.x).toBeUndefined(); + expect(WINDOW_STATE_DEFAULTS.y).toBeUndefined(); + }); + }); + + describe('CLAUDE_SESSION_ORIGINS_DEFAULTS', () => { + it('should have empty origins object', () => { + expect(CLAUDE_SESSION_ORIGINS_DEFAULTS.origins).toEqual({}); + }); + }); + + describe('AGENT_SESSION_ORIGINS_DEFAULTS', () => { + it('should have empty origins object', () => { + expect(AGENT_SESSION_ORIGINS_DEFAULTS.origins).toEqual({}); + }); + }); +}); diff --git a/src/__tests__/main/stores/getters.test.ts b/src/__tests__/main/stores/getters.test.ts new file mode 100644 index 00000000..5cbcfa40 --- /dev/null +++ b/src/__tests__/main/stores/getters.test.ts @@ -0,0 +1,221 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// Mock the instances module +vi.mock('../../../main/stores/instances', () => ({ + isInitialized: vi.fn(), + getStoreInstances: vi.fn(), + getCachedPaths: vi.fn(), +})); + +import { + getBootstrapStore, + getSettingsStore, + getSessionsStore, + getGroupsStore, + getAgentConfigsStore, + getWindowStateStore, + getClaudeSessionOriginsStore, + getAgentSessionOriginsStore, + getSyncPath, + getProductionDataPath, + getSshRemoteById, +} from '../../../main/stores/getters'; + +import { isInitialized, getStoreInstances, getCachedPaths } from '../../../main/stores/instances'; + +const mockedIsInitialized = vi.mocked(isInitialized); +const mockedGetStoreInstances = vi.mocked(getStoreInstances); +const mockedGetCachedPaths = vi.mocked(getCachedPaths); + +describe('stores/getters', () => { + const mockStores = { + bootstrapStore: { get: vi.fn(), set: vi.fn() }, + settingsStore: { get: vi.fn(), set: vi.fn() }, + sessionsStore: { get: vi.fn(), set: vi.fn() }, + groupsStore: { get: vi.fn(), set: vi.fn() }, + agentConfigsStore: { get: vi.fn(), set: vi.fn() }, + windowStateStore: { get: vi.fn(), set: vi.fn() }, + claudeSessionOriginsStore: { get: vi.fn(), set: vi.fn() }, + agentSessionOriginsStore: { get: vi.fn(), set: vi.fn() }, + }; + + const mockPaths = { + syncPath: '/test/sync/path', + productionDataPath: '/test/production/path', + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockedIsInitialized.mockReturnValue(true); + mockedGetStoreInstances.mockReturnValue(mockStores as any); + mockedGetCachedPaths.mockReturnValue(mockPaths); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('error handling when not initialized', () => { + beforeEach(() => { + mockedIsInitialized.mockReturnValue(false); + mockedGetStoreInstances.mockReturnValue({ + bootstrapStore: null, + settingsStore: null, + sessionsStore: null, + groupsStore: null, + agentConfigsStore: null, + windowStateStore: null, + claudeSessionOriginsStore: null, + agentSessionOriginsStore: null, + } as any); + mockedGetCachedPaths.mockReturnValue({ + syncPath: null, + productionDataPath: null, + }); + }); + + it('getBootstrapStore should throw when not initialized', () => { + expect(() => getBootstrapStore()).toThrow( + 'Stores not initialized. Call initializeStores() first.' + ); + }); + + it('getSettingsStore should throw when not initialized', () => { + expect(() => getSettingsStore()).toThrow( + 'Stores not initialized. Call initializeStores() first.' + ); + }); + + it('getSessionsStore should throw when not initialized', () => { + expect(() => getSessionsStore()).toThrow( + 'Stores not initialized. Call initializeStores() first.' + ); + }); + + it('getGroupsStore should throw when not initialized', () => { + expect(() => getGroupsStore()).toThrow( + 'Stores not initialized. Call initializeStores() first.' + ); + }); + + it('getAgentConfigsStore should throw when not initialized', () => { + expect(() => getAgentConfigsStore()).toThrow( + 'Stores not initialized. Call initializeStores() first.' + ); + }); + + it('getWindowStateStore should throw when not initialized', () => { + expect(() => getWindowStateStore()).toThrow( + 'Stores not initialized. Call initializeStores() first.' + ); + }); + + it('getClaudeSessionOriginsStore should throw when not initialized', () => { + expect(() => getClaudeSessionOriginsStore()).toThrow( + 'Stores not initialized. Call initializeStores() first.' + ); + }); + + it('getAgentSessionOriginsStore should throw when not initialized', () => { + expect(() => getAgentSessionOriginsStore()).toThrow( + 'Stores not initialized. Call initializeStores() first.' + ); + }); + + it('getSyncPath should throw when not initialized', () => { + expect(() => getSyncPath()).toThrow('Stores not initialized. Call initializeStores() first.'); + }); + + it('getProductionDataPath should throw when not initialized', () => { + expect(() => getProductionDataPath()).toThrow( + 'Stores not initialized. Call initializeStores() first.' + ); + }); + }); + + describe('successful getters when initialized', () => { + it('getBootstrapStore should return bootstrap store', () => { + const result = getBootstrapStore(); + expect(result).toBe(mockStores.bootstrapStore); + }); + + it('getSettingsStore should return settings store', () => { + const result = getSettingsStore(); + expect(result).toBe(mockStores.settingsStore); + }); + + it('getSessionsStore should return sessions store', () => { + const result = getSessionsStore(); + expect(result).toBe(mockStores.sessionsStore); + }); + + it('getGroupsStore should return groups store', () => { + const result = getGroupsStore(); + expect(result).toBe(mockStores.groupsStore); + }); + + it('getAgentConfigsStore should return agent configs store', () => { + const result = getAgentConfigsStore(); + expect(result).toBe(mockStores.agentConfigsStore); + }); + + it('getWindowStateStore should return window state store', () => { + const result = getWindowStateStore(); + expect(result).toBe(mockStores.windowStateStore); + }); + + it('getClaudeSessionOriginsStore should return claude session origins store', () => { + const result = getClaudeSessionOriginsStore(); + expect(result).toBe(mockStores.claudeSessionOriginsStore); + }); + + it('getAgentSessionOriginsStore should return agent session origins store', () => { + const result = getAgentSessionOriginsStore(); + expect(result).toBe(mockStores.agentSessionOriginsStore); + }); + + it('getSyncPath should return sync path', () => { + const result = getSyncPath(); + expect(result).toBe('/test/sync/path'); + }); + + it('getProductionDataPath should return production data path', () => { + const result = getProductionDataPath(); + expect(result).toBe('/test/production/path'); + }); + }); + + describe('getSshRemoteById', () => { + it('should find SSH remote by ID', () => { + const mockSshRemotes = [ + { id: 'remote-1', name: 'Server 1', host: 'server1.com', username: 'user1' }, + { id: 'remote-2', name: 'Server 2', host: 'server2.com', username: 'user2' }, + ]; + + mockStores.settingsStore.get.mockReturnValue(mockSshRemotes); + + const result = getSshRemoteById('remote-2'); + + expect(result).toEqual(mockSshRemotes[1]); + expect(mockStores.settingsStore.get).toHaveBeenCalledWith('sshRemotes', []); + }); + + it('should return undefined for non-existent ID', () => { + mockStores.settingsStore.get.mockReturnValue([ + { id: 'remote-1', name: 'Server 1', host: 'server1.com', username: 'user1' }, + ]); + + const result = getSshRemoteById('non-existent'); + + expect(result).toBeUndefined(); + }); + + it('should return undefined when no remotes configured', () => { + mockStores.settingsStore.get.mockReturnValue([]); + + const result = getSshRemoteById('remote-1'); + + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/src/__tests__/main/stores/instances.test.ts b/src/__tests__/main/stores/instances.test.ts new file mode 100644 index 00000000..f5cc79cf --- /dev/null +++ b/src/__tests__/main/stores/instances.test.ts @@ -0,0 +1,190 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// Mock electron +vi.mock('electron', () => ({ + app: { + getPath: vi.fn().mockReturnValue('/mock/user/data'), + }, +})); + +// Track mock store constructor calls +const mockStoreConstructorCalls: Array> = []; + +// Mock electron-store with a class +vi.mock('electron-store', () => { + return { + default: class MockStore { + options: Record; + constructor(options: Record) { + this.options = options; + mockStoreConstructorCalls.push(options); + } + get() { + return undefined; + } + set() {} + }, + }; +}); + +// Mock utils +vi.mock('../../../main/stores/utils', () => ({ + getCustomSyncPath: vi.fn(), +})); + +import { + initializeStores, + isInitialized, + getStoreInstances, + getCachedPaths, +} from '../../../main/stores/instances'; +import { getCustomSyncPath } from '../../../main/stores/utils'; + +const mockedGetCustomSyncPath = vi.mocked(getCustomSyncPath); + +describe('stores/instances', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockStoreConstructorCalls.length = 0; // Clear tracked constructor calls + mockedGetCustomSyncPath.mockReturnValue(undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('initializeStores', () => { + it('should initialize all stores', () => { + const result = initializeStores({ productionDataPath: '/mock/production/path' }); + + // Should create 8 stores + expect(mockStoreConstructorCalls).toHaveLength(8); + + // Should return syncPath and bootstrapStore + expect(result.syncPath).toBe('/mock/user/data'); + expect(result.bootstrapStore).toBeDefined(); + }); + + it('should use custom sync path when available', () => { + const customSyncPath = '/custom/sync/path'; + mockedGetCustomSyncPath.mockReturnValue(customSyncPath); + + const result = initializeStores({ productionDataPath: '/mock/production/path' }); + + expect(result.syncPath).toBe(customSyncPath); + }); + + it('should create bootstrap store with userData path', () => { + initializeStores({ productionDataPath: '/mock/production/path' }); + + // First store created should be bootstrap + expect(mockStoreConstructorCalls[0]).toEqual({ + name: 'maestro-bootstrap', + cwd: '/mock/user/data', + defaults: {}, + }); + }); + + it('should create settings store with sync path', () => { + initializeStores({ productionDataPath: '/mock/production/path' }); + + // Second store created should be settings + expect(mockStoreConstructorCalls[1]).toMatchObject({ + name: 'maestro-settings', + cwd: '/mock/user/data', + }); + }); + + it('should create agent configs store with production path', () => { + const productionPath = '/mock/production/path'; + initializeStores({ productionDataPath: productionPath }); + + // Agent configs store should use production path + const agentConfigsCall = mockStoreConstructorCalls.find( + (call) => call.name === 'maestro-agent-configs' + ); + expect(agentConfigsCall).toMatchObject({ + name: 'maestro-agent-configs', + cwd: productionPath, + }); + }); + + it('should create window state store without cwd (local only)', () => { + initializeStores({ productionDataPath: '/mock/production/path' }); + + // Window state store should not have cwd + const windowStateCall = mockStoreConstructorCalls.find( + (call) => call.name === 'maestro-window-state' + ); + expect(windowStateCall).toMatchObject({ + name: 'maestro-window-state', + defaults: { + width: 1400, + height: 900, + isMaximized: false, + isFullScreen: false, + }, + }); + // Window state should NOT have cwd + expect(windowStateCall).not.toHaveProperty('cwd'); + }); + + it('should log startup paths', () => { + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + initializeStores({ productionDataPath: '/mock/production/path' }); + + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('[STARTUP] userData path:')); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('[STARTUP] syncPath')); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('[STARTUP] productionDataPath') + ); + }); + }); + + describe('isInitialized', () => { + it('should return true after initialization', () => { + initializeStores({ productionDataPath: '/mock/production/path' }); + + expect(isInitialized()).toBe(true); + }); + }); + + describe('getStoreInstances', () => { + it('should return all store instances after initialization', () => { + initializeStores({ productionDataPath: '/mock/production/path' }); + + const instances = getStoreInstances(); + + expect(instances.bootstrapStore).toBeDefined(); + expect(instances.settingsStore).toBeDefined(); + expect(instances.sessionsStore).toBeDefined(); + expect(instances.groupsStore).toBeDefined(); + expect(instances.agentConfigsStore).toBeDefined(); + expect(instances.windowStateStore).toBeDefined(); + expect(instances.claudeSessionOriginsStore).toBeDefined(); + expect(instances.agentSessionOriginsStore).toBeDefined(); + }); + }); + + describe('getCachedPaths', () => { + it('should return cached paths after initialization', () => { + initializeStores({ productionDataPath: '/mock/production/path' }); + + const paths = getCachedPaths(); + + expect(paths.syncPath).toBe('/mock/user/data'); + expect(paths.productionDataPath).toBe('/mock/production/path'); + }); + + it('should return custom sync path when configured', () => { + mockedGetCustomSyncPath.mockReturnValue('/custom/sync'); + + initializeStores({ productionDataPath: '/mock/production/path' }); + + const paths = getCachedPaths(); + + expect(paths.syncPath).toBe('/custom/sync'); + }); + }); +}); diff --git a/src/__tests__/main/stores/types.test.ts b/src/__tests__/main/stores/types.test.ts new file mode 100644 index 00000000..dd9e8544 --- /dev/null +++ b/src/__tests__/main/stores/types.test.ts @@ -0,0 +1,231 @@ +import { describe, it, expect } from 'vitest'; + +import type { + BootstrapSettings, + MaestroSettings, + SessionsData, + GroupsData, + AgentConfigsData, + WindowState, + ClaudeSessionOrigin, + ClaudeSessionOriginInfo, + ClaudeSessionOriginsData, + AgentSessionOriginsData, +} from '../../../main/stores/types'; + +/** + * Type-level tests to ensure type definitions are correct. + * These tests verify that the types can be used as expected. + */ +describe('stores/types', () => { + describe('BootstrapSettings', () => { + it('should allow optional customSyncPath', () => { + const settings: BootstrapSettings = {}; + expect(settings.customSyncPath).toBeUndefined(); + }); + + it('should allow customSyncPath string', () => { + const settings: BootstrapSettings = { + customSyncPath: '/Users/test/iCloud/Maestro', + }; + expect(settings.customSyncPath).toBe('/Users/test/iCloud/Maestro'); + }); + + it('should allow legacy iCloudSyncEnabled', () => { + const settings: BootstrapSettings = { + iCloudSyncEnabled: true, + }; + expect(settings.iCloudSyncEnabled).toBe(true); + }); + }); + + describe('MaestroSettings', () => { + it('should have all required fields', () => { + const settings: MaestroSettings = { + activeThemeId: 'dracula', + llmProvider: 'openrouter', + modelSlug: 'test-model', + apiKey: 'test-key', + shortcuts: { 'ctrl+s': 'save' }, + fontSize: 14, + fontFamily: 'monospace', + customFonts: ['CustomFont'], + logLevel: 'info', + defaultShell: 'zsh', + webAuthEnabled: false, + webAuthToken: null, + webInterfaceUseCustomPort: false, + webInterfaceCustomPort: 8080, + sshRemotes: [], + defaultSshRemoteId: null, + installationId: null, + }; + + expect(settings.activeThemeId).toBe('dracula'); + expect(settings.logLevel).toBe('info'); + }); + + it('should allow all valid logLevel values', () => { + const logLevels: Array<'debug' | 'info' | 'warn' | 'error'> = [ + 'debug', + 'info', + 'warn', + 'error', + ]; + + logLevels.forEach((level) => { + const settings: Partial = { logLevel: level }; + expect(settings.logLevel).toBe(level); + }); + }); + }); + + describe('SessionsData', () => { + it('should have sessions array', () => { + const data: SessionsData = { + sessions: [{ id: '1', name: 'Test' }], + }; + expect(data.sessions).toHaveLength(1); + }); + }); + + describe('GroupsData', () => { + it('should have groups array', () => { + const data: GroupsData = { + groups: [{ id: '1', name: 'Group 1' }], + }; + expect(data.groups).toHaveLength(1); + }); + }); + + describe('AgentConfigsData', () => { + it('should support nested config structure', () => { + const data: AgentConfigsData = { + configs: { + 'claude-code': { + customPath: '/usr/local/bin/claude', + customArgs: ['--verbose'], + }, + codex: { + customEnv: { API_KEY: 'test' }, + }, + }, + }; + + expect(data.configs['claude-code'].customPath).toBe('/usr/local/bin/claude'); + expect(data.configs['codex'].customEnv.API_KEY).toBe('test'); + }); + }); + + describe('WindowState', () => { + it('should have required fields', () => { + const state: WindowState = { + width: 1400, + height: 900, + isMaximized: false, + isFullScreen: false, + }; + + expect(state.width).toBe(1400); + expect(state.x).toBeUndefined(); + }); + + it('should allow optional x and y', () => { + const state: WindowState = { + x: 100, + y: 200, + width: 1400, + height: 900, + isMaximized: false, + isFullScreen: false, + }; + + expect(state.x).toBe(100); + expect(state.y).toBe(200); + }); + }); + + describe('ClaudeSessionOrigin', () => { + it('should allow user or auto values', () => { + const userOrigin: ClaudeSessionOrigin = 'user'; + const autoOrigin: ClaudeSessionOrigin = 'auto'; + + expect(userOrigin).toBe('user'); + expect(autoOrigin).toBe('auto'); + }); + }); + + describe('ClaudeSessionOriginInfo', () => { + it('should have required origin field', () => { + const info: ClaudeSessionOriginInfo = { + origin: 'user', + }; + expect(info.origin).toBe('user'); + }); + + it('should allow optional fields', () => { + const info: ClaudeSessionOriginInfo = { + origin: 'auto', + sessionName: 'My Session', + starred: true, + contextUsage: 75, + }; + + expect(info.sessionName).toBe('My Session'); + expect(info.starred).toBe(true); + expect(info.contextUsage).toBe(75); + }); + }); + + describe('ClaudeSessionOriginsData', () => { + it('should support nested origin structure', () => { + const data: ClaudeSessionOriginsData = { + origins: { + '/path/to/project': { + 'session-1': 'user', + 'session-2': { + origin: 'auto', + sessionName: 'Auto Session', + starred: false, + }, + }, + }, + }; + + expect(data.origins['/path/to/project']['session-1']).toBe('user'); + expect( + (data.origins['/path/to/project']['session-2'] as ClaudeSessionOriginInfo).sessionName + ).toBe('Auto Session'); + }); + }); + + describe('AgentSessionOriginsData', () => { + it('should support multi-agent nested structure', () => { + const data: AgentSessionOriginsData = { + origins: { + codex: { + '/path/to/project': { + 'session-1': { + origin: 'user', + sessionName: 'Codex Session', + starred: true, + }, + }, + }, + opencode: { + '/another/project': { + 'session-2': { + origin: 'auto', + }, + }, + }, + }, + }; + + expect(data.origins['codex']['/path/to/project']['session-1'].sessionName).toBe( + 'Codex Session' + ); + expect(data.origins['opencode']['/another/project']['session-2'].origin).toBe('auto'); + }); + }); +}); diff --git a/src/__tests__/main/stores/utils.test.ts b/src/__tests__/main/stores/utils.test.ts new file mode 100644 index 00000000..eb5924c6 --- /dev/null +++ b/src/__tests__/main/stores/utils.test.ts @@ -0,0 +1,154 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import fsSync from 'fs'; + +// Mock electron-store with a class +vi.mock('electron-store', () => { + return { + default: class MockStore { + options: Record; + constructor(options: Record) { + this.options = options; + } + get(_key: string, defaultValue?: unknown) { + return defaultValue; + } + set() {} + }, + }; +}); + +// Mock fs +vi.mock('fs', () => ({ + default: { + existsSync: vi.fn(), + mkdirSync: vi.fn(), + }, + existsSync: vi.fn(), + mkdirSync: vi.fn(), +})); + +import { getCustomSyncPath, getEarlySettings, findSshRemoteById } from '../../../main/stores/utils'; +import type { BootstrapSettings } from '../../../main/stores/types'; +import type Store from 'electron-store'; + +describe('stores/utils', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('getCustomSyncPath', () => { + it('should return undefined when no custom path is configured', () => { + const mockStore = { + get: vi.fn().mockReturnValue(undefined), + } as unknown as Store; + + const result = getCustomSyncPath(mockStore); + + expect(result).toBeUndefined(); + expect(mockStore.get).toHaveBeenCalledWith('customSyncPath'); + }); + + it('should return the custom path when it exists', () => { + const customPath = '/Users/test/iCloud/Maestro'; + const mockStore = { + get: vi.fn().mockReturnValue(customPath), + } as unknown as Store; + + vi.mocked(fsSync.existsSync).mockReturnValue(true); + + const result = getCustomSyncPath(mockStore); + + expect(result).toBe(customPath); + expect(fsSync.existsSync).toHaveBeenCalledWith(customPath); + }); + + it('should create directory when custom path does not exist', () => { + const customPath = '/Users/test/iCloud/Maestro'; + const mockStore = { + get: vi.fn().mockReturnValue(customPath), + } as unknown as Store; + + vi.mocked(fsSync.existsSync).mockReturnValue(false); + vi.mocked(fsSync.mkdirSync).mockReturnValue(undefined); + + const result = getCustomSyncPath(mockStore); + + expect(result).toBe(customPath); + expect(fsSync.mkdirSync).toHaveBeenCalledWith(customPath, { recursive: true }); + }); + + it('should return undefined when directory creation fails', () => { + const customPath = '/invalid/path'; + const mockStore = { + get: vi.fn().mockReturnValue(customPath), + } as unknown as Store; + + vi.mocked(fsSync.existsSync).mockReturnValue(false); + vi.mocked(fsSync.mkdirSync).mockImplementation(() => { + throw new Error('Permission denied'); + }); + + // Spy on console.error to verify it's called + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const result = getCustomSyncPath(mockStore); + + expect(result).toBeUndefined(); + expect(consoleSpy).toHaveBeenCalledWith( + `Failed to create custom sync path: ${customPath}, using default` + ); + }); + }); + + describe('getEarlySettings', () => { + it('should return default values when settings are not set', () => { + const result = getEarlySettings('/test/path'); + + expect(result).toEqual({ + crashReportingEnabled: true, + disableGpuAcceleration: false, + }); + }); + }); + + describe('findSshRemoteById', () => { + const mockSshRemotes = [ + { id: 'remote-1', name: 'Server 1', host: 'server1.example.com', username: 'user1' }, + { id: 'remote-2', name: 'Server 2', host: 'server2.example.com', username: 'user2' }, + { id: 'remote-3', name: 'Server 3', host: 'server3.example.com', username: 'user3' }, + ]; + + it('should find remote by id', () => { + const result = findSshRemoteById(mockSshRemotes as any, 'remote-2'); + + expect(result).toEqual(mockSshRemotes[1]); + }); + + it('should return undefined for non-existent id', () => { + const result = findSshRemoteById(mockSshRemotes as any, 'non-existent'); + + expect(result).toBeUndefined(); + }); + + it('should return undefined for empty array', () => { + const result = findSshRemoteById([], 'remote-1'); + + expect(result).toBeUndefined(); + }); + + it('should find first matching remote when duplicates exist', () => { + const remotesWithDuplicates = [ + { id: 'remote-1', name: 'First', host: 'first.example.com', username: 'user1' }, + { id: 'remote-1', name: 'Second', host: 'second.example.com', username: 'user2' }, + ]; + + const result = findSshRemoteById(remotesWithDuplicates as any, 'remote-1'); + + expect(result?.name).toBe('First'); + }); + }); +}); diff --git a/src/main/index.ts b/src/main/index.ts index efe0ec37..0712cd4a 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -13,8 +13,19 @@ import { logger } from './utils/logger'; import { tunnelManager } from './tunnel-manager'; import { powerManager } from './power-manager'; import { getThemeById } from './themes'; -import Store from 'electron-store'; import { getHistoryManager } from './history-manager'; +import { + initializeStores, + getEarlySettings, + getSettingsStore, + getSessionsStore, + getGroupsStore, + getAgentConfigsStore, + getWindowStateStore, + getClaudeSessionOriginsStore, + getAgentSessionOriginsStore, + getSshRemoteById, +} from './stores'; import { registerGitHandlers, registerAutorunHandlers, @@ -58,7 +69,6 @@ import { initializeSessionStorages } from './storage'; import { initializeOutputParsers, getOutputParser } from './parsers'; import { calculateContextTokens } from './parsers/usage-aggregator'; import { DEMO_MODE, DEMO_DATA_PATH } from './constants'; -import type { SshRemoteConfig } from '../shared/types'; import { initAutoUpdater } from './auto-updater'; import { readDirRemote, @@ -105,19 +115,10 @@ function debugLog(prefix: string, message: string, ...args: any[]): void { } } -// ============================================================================ -// Custom Storage Location Configuration -// ============================================================================ -// This bootstrap store is ALWAYS local - it tells us where to find the main data -// Users can choose a custom folder (e.g., iCloud Drive, Dropbox, OneDrive) to sync settings -interface BootstrapSettings { - customSyncPath?: string; - iCloudSyncEnabled?: boolean; // Legacy - kept for backwards compatibility during migration -} - // ============================================================================ // Data Directory Configuration (MUST happen before any Store initialization) // ============================================================================ +// Store type definitions are imported from ./stores/types.ts const isDevelopment = process.env.NODE_ENV === 'development'; // Capture the production data path before any modification @@ -144,66 +145,22 @@ if (isDevelopment && !DEMO_MODE && !process.env.USE_PROD_DATA) { // ============================================================================ // Store Initialization (after userData path is configured) // ============================================================================ +// All stores are initialized via initializeStores() from ./stores module -const bootstrapStore = new Store({ - name: 'maestro-bootstrap', - cwd: app.getPath('userData'), - defaults: {}, -}); +const { syncPath, bootstrapStore } = initializeStores({ productionDataPath }); -/** - * Get the custom sync path if configured. - * Returns undefined if using default path. - */ -function getSyncPath(): string | undefined { - const customPath = bootstrapStore.get('customSyncPath'); - - if (customPath) { - // Ensure the directory exists - if (!fsSync.existsSync(customPath)) { - try { - fsSync.mkdirSync(customPath, { recursive: true }); - } catch { - // If we can't create the directory, fall back to default - console.error(`Failed to create custom sync path: ${customPath}, using default`); - return undefined; - } - } - return customPath; - } - - return undefined; // Use default path -} - -// Get the sync path once at startup -// If no custom sync path, use the current userData path (dev or prod depending on mode) -const syncPath = getSyncPath() || app.getPath('userData'); - -// Log the paths being used for debugging session persistence issues -console.log(`[STARTUP] userData path: ${app.getPath('userData')}`); -console.log(`[STARTUP] syncPath (sessions/settings): ${syncPath}`); -console.log(`[STARTUP] productionDataPath (agent configs): ${productionDataPath}`); - -// Initialize Sentry for crash reporting -// Only enable in production - skip during development to avoid noise from hot-reload artifacts -// Check if crash reporting is enabled (default: true for opt-out behavior) -const earlySettingsStore = new Store<{ - crashReportingEnabled: boolean; - disableGpuAcceleration: boolean; -}>({ - name: 'maestro-settings', - cwd: syncPath, // Use same path as main settings store -}); -const crashReportingEnabled = earlySettingsStore.get('crashReportingEnabled', true); +// Get early settings before Sentry init (for crash reporting and GPU acceleration) +const { crashReportingEnabled, disableGpuAcceleration } = getEarlySettings(syncPath); // Disable GPU hardware acceleration if user has opted out // Must be called before app.ready event -const disableGpuAcceleration = earlySettingsStore.get('disableGpuAcceleration', false); if (disableGpuAcceleration) { app.disableHardwareAcceleration(); console.log('[STARTUP] GPU hardware acceleration disabled by user preference'); } +// Initialize Sentry for crash reporting +// Only enable in production - skip during development to avoid noise from hot-reload artifacts if (crashReportingEnabled && !isDevelopment) { Sentry.init({ dsn: 'https://2303c5f787f910863d83ed5d27ce8ed2@o4510554134740992.ingest.us.sentry.io/4510554135789568', @@ -226,73 +183,9 @@ if (crashReportingEnabled && !isDevelopment) { }); } -// Type definitions -interface MaestroSettings { - activeThemeId: string; - llmProvider: string; - modelSlug: string; - apiKey: string; - shortcuts: Record; - fontSize: number; - fontFamily: string; - customFonts: string[]; - logLevel: 'debug' | 'info' | 'warn' | 'error'; - defaultShell: string; - // Web interface authentication - webAuthEnabled: boolean; - webAuthToken: string | null; - // Web interface custom port - webInterfaceUseCustomPort: boolean; - webInterfaceCustomPort: number; - // SSH remote execution - sshRemotes: SshRemoteConfig[]; - defaultSshRemoteId: string | null; - // Unique installation identifier (generated once on first run) - installationId: string | null; -} - -const store = new Store({ - name: 'maestro-settings', - cwd: syncPath, // Use iCloud/custom sync path if configured - defaults: { - activeThemeId: 'dracula', - llmProvider: 'openrouter', - modelSlug: 'anthropic/claude-3.5-sonnet', - apiKey: '', - shortcuts: {}, - fontSize: 14, - fontFamily: 'Roboto Mono, Menlo, "Courier New", monospace', - customFonts: [], - logLevel: 'info', - defaultShell: (() => { - // Windows: $SHELL doesn't exist; default to PowerShell - if (process.platform === 'win32') { - return 'powershell'; - } - // Unix: Respect user's configured login shell from $SHELL - const shellPath = process.env.SHELL; - if (shellPath) { - const shellName = path.basename(shellPath); - // Valid Unix shell IDs from shellDetector.ts (lines 27-34) - if (['bash', 'zsh', 'fish', 'sh', 'tcsh'].includes(shellName)) { - return shellName; - } - } - // Fallback to bash (more portable than zsh on older Unix systems) - return 'bash'; - })(), - webAuthEnabled: false, - webAuthToken: null, - webInterfaceUseCustomPort: false, - webInterfaceCustomPort: 8080, - sshRemotes: [], - defaultSshRemoteId: null, - installationId: null, - }, -}); - // Generate installation ID on first run (one-time generation) // This creates a unique identifier per Maestro installation for telemetry differentiation +const store = getSettingsStore(); let installationId = store.get('installationId'); if (!installationId) { installationId = crypto.randomUUID(); @@ -305,123 +198,19 @@ if (crashReportingEnabled && !isDevelopment) { Sentry.setTag('installationId', installationId); } -// Sessions store -interface SessionsData { - sessions: any[]; -} - -const sessionsStore = new Store({ - name: 'maestro-sessions', - cwd: syncPath, // Use iCloud/custom sync path if configured - defaults: { - sessions: [], - }, -}); - -// Groups store -interface GroupsData { - groups: any[]; -} - -const groupsStore = new Store({ - name: 'maestro-groups', - cwd: syncPath, // Use iCloud/custom sync path if configured - defaults: { - groups: [], - }, -}); - -interface AgentConfigsData { - configs: Record>; // agentId -> config key-value pairs -} - -// Agent configs are ALWAYS stored in the production path, even in dev mode -// This ensures agent paths, custom args, and env vars are shared between dev and prod -// (They represent machine-level configuration, not session/project data) -const agentConfigsStore = new Store({ - name: 'maestro-agent-configs', - cwd: productionDataPath, - defaults: { - configs: {}, - }, -}); - -// Window state store (for remembering window size/position) -// NOTE: This is intentionally NOT synced - window state is per-device -interface WindowState { - x?: number; - y?: number; - width: number; - height: number; - isMaximized: boolean; - isFullScreen: boolean; -} - -const windowStateStore = new Store({ - name: 'maestro-window-state', - // No cwd - always local, not synced (window position is device-specific) - defaults: { - width: 1400, - height: 900, - isMaximized: false, - isFullScreen: false, - }, -}); +// Create local references to stores for use throughout this module +// These are convenience variables - the actual stores are managed by ./stores module +const sessionsStore = getSessionsStore(); +const groupsStore = getGroupsStore(); +const agentConfigsStore = getAgentConfigsStore(); +const windowStateStore = getWindowStateStore(); +const claudeSessionOriginsStore = getClaudeSessionOriginsStore(); +const agentSessionOriginsStore = getAgentSessionOriginsStore(); // Note: History storage is now handled by HistoryManager which uses per-session files // in the history/ directory. The legacy maestro-history.json file is migrated automatically. // See src/main/history-manager.ts for details. -// Claude session origins store - tracks which Claude sessions were created by Maestro -// and their origin type (user-initiated vs auto/batch) -type ClaudeSessionOrigin = 'user' | 'auto'; -interface ClaudeSessionOriginInfo { - origin: ClaudeSessionOrigin; - sessionName?: string; // User-defined session name from Maestro - starred?: boolean; // Whether the session is starred - contextUsage?: number; // Last known context window usage percentage (0-100) -} -interface ClaudeSessionOriginsData { - // Map of projectPath -> { agentSessionId -> origin info } - origins: Record>; -} - -const claudeSessionOriginsStore = new Store({ - name: 'maestro-claude-session-origins', - cwd: syncPath, // Use iCloud/custom sync path if configured - defaults: { - origins: {}, - }, -}); - -// Generic agent session origins store - supports all agents (Codex, OpenCode, etc.) -// Structure: { [agentId]: { [projectPath]: { [sessionId]: { origin, sessionName, starred } } } } -interface AgentSessionOriginsData { - origins: Record< - string, - Record< - string, - Record - > - >; -} -const agentSessionOriginsStore = new Store({ - name: 'maestro-agent-session-origins', - cwd: syncPath, // Use iCloud/custom sync path if configured - defaults: { - origins: {}, - }, -}); - -/** - * Get SSH remote configuration by ID. - * Returns undefined if not found. - */ -function getSshRemoteById(sshRemoteId: string): SshRemoteConfig | undefined { - const sshRemotes = store.get('sshRemotes', []) as SshRemoteConfig[]; - return sshRemotes.find((r) => r.id === sshRemoteId); -} - let mainWindow: BrowserWindow | null = null; let processManager: ProcessManager | null = null; let webServer: WebServer | null = null; diff --git a/src/main/stores/defaults.ts b/src/main/stores/defaults.ts new file mode 100644 index 00000000..ee28d6c5 --- /dev/null +++ b/src/main/stores/defaults.ts @@ -0,0 +1,94 @@ +/** + * Store Default Values + * + * Centralized default values for all stores. + * Separated for easy modification and testing. + */ + +import path from 'path'; + +import type { + MaestroSettings, + SessionsData, + GroupsData, + AgentConfigsData, + WindowState, + ClaudeSessionOriginsData, + AgentSessionOriginsData, +} from './types'; + +// ============================================================================ +// Utility Functions for Defaults +// ============================================================================ + +/** + * Get the default shell based on the current platform. + */ +export function getDefaultShell(): string { + // Windows: $SHELL doesn't exist; default to PowerShell + if (process.platform === 'win32') { + return 'powershell'; + } + // Unix: Respect user's configured login shell from $SHELL + const shellPath = process.env.SHELL; + if (shellPath) { + const shellName = path.basename(shellPath); + // Valid Unix shell IDs from shellDetector.ts + if (['bash', 'zsh', 'fish', 'sh', 'tcsh'].includes(shellName)) { + return shellName; + } + } + // Fallback to bash (more portable than zsh on older Unix systems) + return 'bash'; +} + +// ============================================================================ +// Store Defaults +// ============================================================================ + +export const SETTINGS_DEFAULTS: MaestroSettings = { + activeThemeId: 'dracula', + llmProvider: 'openrouter', + modelSlug: 'anthropic/claude-3.5-sonnet', + apiKey: '', + shortcuts: {}, + fontSize: 14, + fontFamily: 'Roboto Mono, Menlo, "Courier New", monospace', + customFonts: [], + logLevel: 'info', + defaultShell: getDefaultShell(), + webAuthEnabled: false, + webAuthToken: null, + webInterfaceUseCustomPort: false, + webInterfaceCustomPort: 8080, + sshRemotes: [], + defaultSshRemoteId: null, + installationId: null, +}; + +export const SESSIONS_DEFAULTS: SessionsData = { + sessions: [], +}; + +export const GROUPS_DEFAULTS: GroupsData = { + groups: [], +}; + +export const AGENT_CONFIGS_DEFAULTS: AgentConfigsData = { + configs: {}, +}; + +export const WINDOW_STATE_DEFAULTS: WindowState = { + width: 1400, + height: 900, + isMaximized: false, + isFullScreen: false, +}; + +export const CLAUDE_SESSION_ORIGINS_DEFAULTS: ClaudeSessionOriginsData = { + origins: {}, +}; + +export const AGENT_SESSION_ORIGINS_DEFAULTS: AgentSessionOriginsData = { + origins: {}, +}; diff --git a/src/main/stores/getters.ts b/src/main/stores/getters.ts new file mode 100644 index 00000000..84be7071 --- /dev/null +++ b/src/main/stores/getters.ts @@ -0,0 +1,118 @@ +/** + * Store Getters + * + * Public getter functions for accessing store instances. + * All getters throw if stores haven't been initialized. + */ + +import type Store from 'electron-store'; + +import type { + BootstrapSettings, + MaestroSettings, + SessionsData, + GroupsData, + AgentConfigsData, + WindowState, + ClaudeSessionOriginsData, + AgentSessionOriginsData, +} from './types'; +import type { SshRemoteConfig } from '../../shared/types'; + +import { isInitialized, getStoreInstances, getCachedPaths } from './instances'; + +// ============================================================================ +// Initialization Check +// ============================================================================ + +function ensureInitialized(): void { + if (!isInitialized()) { + throw new Error('Stores not initialized. Call initializeStores() first.'); + } +} + +// ============================================================================ +// Store Getters +// ============================================================================ + +export function getBootstrapStore(): Store { + const { bootstrapStore } = getStoreInstances(); + if (!bootstrapStore) { + throw new Error('Stores not initialized. Call initializeStores() first.'); + } + return bootstrapStore; +} + +export function getSettingsStore(): Store { + ensureInitialized(); + return getStoreInstances().settingsStore!; +} + +export function getSessionsStore(): Store { + ensureInitialized(); + return getStoreInstances().sessionsStore!; +} + +export function getGroupsStore(): Store { + ensureInitialized(); + return getStoreInstances().groupsStore!; +} + +export function getAgentConfigsStore(): Store { + ensureInitialized(); + return getStoreInstances().agentConfigsStore!; +} + +export function getWindowStateStore(): Store { + ensureInitialized(); + return getStoreInstances().windowStateStore!; +} + +export function getClaudeSessionOriginsStore(): Store { + ensureInitialized(); + return getStoreInstances().claudeSessionOriginsStore!; +} + +export function getAgentSessionOriginsStore(): Store { + ensureInitialized(); + return getStoreInstances().agentSessionOriginsStore!; +} + +// ============================================================================ +// Path Getters +// ============================================================================ + +/** + * Get the sync path. Must be called after initializeStores(). + */ +export function getSyncPath(): string { + const { syncPath } = getCachedPaths(); + if (syncPath === null) { + throw new Error('Stores not initialized. Call initializeStores() first.'); + } + return syncPath; +} + +/** + * Get the production data path. Must be called after initializeStores(). + */ +export function getProductionDataPath(): string { + const { productionDataPath } = getCachedPaths(); + if (productionDataPath === null) { + throw new Error('Stores not initialized. Call initializeStores() first.'); + } + return productionDataPath; +} + +// ============================================================================ +// Convenience Functions +// ============================================================================ + +/** + * Get SSH remote configuration by ID from the settings store. + * Returns undefined if not found. + */ +export function getSshRemoteById(sshRemoteId: string): SshRemoteConfig | undefined { + const sshRemotes = getSettingsStore().get('sshRemotes', []); + return sshRemotes.find((r) => r.id === sshRemoteId); +} diff --git a/src/main/stores/index.ts b/src/main/stores/index.ts new file mode 100644 index 00000000..f4748659 --- /dev/null +++ b/src/main/stores/index.ts @@ -0,0 +1,72 @@ +/** + * Centralized Store Management + * + * This module provides the public API for all store operations: + * - Type definitions (from ./types) + * - Store initialization (from ./instances) + * - Store getters (from ./getters) + * - Utility functions (from ./utils) + * - Default values (from ./defaults) + * + * IMPORTANT: initializeStores() MUST be called before accessing any store. + * The app.setPath('userData', ...) calls MUST happen before initialization. + * + * Directory structure: + * ├── index.ts - Public API (this file) + * ├── types.ts - Type definitions for all stores + * ├── defaults.ts - Default values for all stores + * ├── instances.ts - Store instance management and initialization + * ├── getters.ts - Public getter functions + * └── utils.ts - Utility functions + */ + +// ============================================================================ +// Type Definitions +// ============================================================================ + +export * from './types'; + +// ============================================================================ +// Store Initialization +// ============================================================================ + +export { initializeStores } from './instances'; +export type { StoreInitOptions } from './instances'; + +// ============================================================================ +// Store Getters +// ============================================================================ + +export { + getBootstrapStore, + getSettingsStore, + getSessionsStore, + getGroupsStore, + getAgentConfigsStore, + getWindowStateStore, + getClaudeSessionOriginsStore, + getAgentSessionOriginsStore, + getSyncPath, + getProductionDataPath, + getSshRemoteById, +} from './getters'; + +// ============================================================================ +// Utility Functions +// ============================================================================ + +export { getDefaultShell, getCustomSyncPath, getEarlySettings } from './utils'; + +// ============================================================================ +// Default Values (for testing or external use) +// ============================================================================ + +export { + SETTINGS_DEFAULTS, + SESSIONS_DEFAULTS, + GROUPS_DEFAULTS, + AGENT_CONFIGS_DEFAULTS, + WINDOW_STATE_DEFAULTS, + CLAUDE_SESSION_ORIGINS_DEFAULTS, + AGENT_SESSION_ORIGINS_DEFAULTS, +} from './defaults'; diff --git a/src/main/stores/instances.ts b/src/main/stores/instances.ts new file mode 100644 index 00000000..a3895079 --- /dev/null +++ b/src/main/stores/instances.ts @@ -0,0 +1,175 @@ +/** + * Store Instances + * + * Manages store instance lifecycle: + * - Store instance variables (private) + * - Initialization function + * - Path caching + * + * The actual getter functions are in getters.ts to keep this file focused + * on initialization logic only. + */ + +import { app } from 'electron'; +import Store from 'electron-store'; + +import type { + BootstrapSettings, + MaestroSettings, + SessionsData, + GroupsData, + AgentConfigsData, + WindowState, + ClaudeSessionOriginsData, + AgentSessionOriginsData, +} from './types'; + +import { + SETTINGS_DEFAULTS, + SESSIONS_DEFAULTS, + GROUPS_DEFAULTS, + AGENT_CONFIGS_DEFAULTS, + WINDOW_STATE_DEFAULTS, + CLAUDE_SESSION_ORIGINS_DEFAULTS, + AGENT_SESSION_ORIGINS_DEFAULTS, +} from './defaults'; + +import { getCustomSyncPath } from './utils'; + +// ============================================================================ +// Store Instance Variables +// ============================================================================ + +let _bootstrapStore: Store | null = null; +let _settingsStore: Store | null = null; +let _sessionsStore: Store | null = null; +let _groupsStore: Store | null = null; +let _agentConfigsStore: Store | null = null; +let _windowStateStore: Store | null = null; +let _claudeSessionOriginsStore: Store | null = null; +let _agentSessionOriginsStore: Store | null = null; + +// Cached paths after initialization +let _syncPath: string | null = null; +let _productionDataPath: string | null = null; + +// ============================================================================ +// Initialization +// ============================================================================ + +export interface StoreInitOptions { + /** The production userData path (before any dev mode modifications) */ + productionDataPath: string; +} + +/** + * Initialize all stores. Must be called once during app startup, + * after app.setPath('userData', ...) has been configured. + * + * @returns Object containing syncPath and bootstrapStore for further initialization + */ +export function initializeStores(options: StoreInitOptions): { + syncPath: string; + bootstrapStore: Store; +} { + const { productionDataPath } = options; + _productionDataPath = productionDataPath; + + // 1. Initialize bootstrap store first (determines sync path) + _bootstrapStore = new Store({ + name: 'maestro-bootstrap', + cwd: app.getPath('userData'), + defaults: {}, + }); + + // 2. Determine sync path + _syncPath = getCustomSyncPath(_bootstrapStore) || app.getPath('userData'); + + // Log paths for debugging + console.log(`[STARTUP] userData path: ${app.getPath('userData')}`); + console.log(`[STARTUP] syncPath (sessions/settings): ${_syncPath}`); + console.log(`[STARTUP] productionDataPath (agent configs): ${_productionDataPath}`); + + // 3. Initialize all other stores + _settingsStore = new Store({ + name: 'maestro-settings', + cwd: _syncPath, + defaults: SETTINGS_DEFAULTS, + }); + + _sessionsStore = new Store({ + name: 'maestro-sessions', + cwd: _syncPath, + defaults: SESSIONS_DEFAULTS, + }); + + _groupsStore = new Store({ + name: 'maestro-groups', + cwd: _syncPath, + defaults: GROUPS_DEFAULTS, + }); + + // Agent configs are ALWAYS stored in the production path, even in dev mode + // This ensures agent paths, custom args, and env vars are shared between dev and prod + _agentConfigsStore = new Store({ + name: 'maestro-agent-configs', + cwd: _productionDataPath, + defaults: AGENT_CONFIGS_DEFAULTS, + }); + + // Window state is intentionally NOT synced - it's per-device + _windowStateStore = new Store({ + name: 'maestro-window-state', + defaults: WINDOW_STATE_DEFAULTS, + }); + + // Claude session origins - tracks which sessions were created by Maestro + _claudeSessionOriginsStore = new Store({ + name: 'maestro-claude-session-origins', + cwd: _syncPath, + defaults: CLAUDE_SESSION_ORIGINS_DEFAULTS, + }); + + // Generic agent session origins - supports all agents (Codex, OpenCode, etc.) + _agentSessionOriginsStore = new Store({ + name: 'maestro-agent-session-origins', + cwd: _syncPath, + defaults: AGENT_SESSION_ORIGINS_DEFAULTS, + }); + + return { + syncPath: _syncPath, + bootstrapStore: _bootstrapStore, + }; +} + +// ============================================================================ +// Internal Accessors (used by getters.ts) +// ============================================================================ + +/** Check if stores have been initialized */ +export function isInitialized(): boolean { + return _settingsStore !== null; +} + +/** Get raw store instances (for getters.ts) */ +export function getStoreInstances() { + return { + bootstrapStore: _bootstrapStore, + settingsStore: _settingsStore, + sessionsStore: _sessionsStore, + groupsStore: _groupsStore, + agentConfigsStore: _agentConfigsStore, + windowStateStore: _windowStateStore, + claudeSessionOriginsStore: _claudeSessionOriginsStore, + agentSessionOriginsStore: _agentSessionOriginsStore, + }; +} + +/** Get cached paths (for getters.ts) */ +export function getCachedPaths() { + return { + syncPath: _syncPath, + productionDataPath: _productionDataPath, + }; +} diff --git a/src/main/stores/types.ts b/src/main/stores/types.ts new file mode 100644 index 00000000..a4eb3a2f --- /dev/null +++ b/src/main/stores/types.ts @@ -0,0 +1,115 @@ +/** + * Store type definitions + * + * Centralized type definitions for all electron-store instances. + * These types are used across the main process for type-safe store access. + */ + +import type { SshRemoteConfig } from '../../shared/types'; + +// ============================================================================ +// Bootstrap Store (local-only, determines sync path) +// ============================================================================ + +export interface BootstrapSettings { + customSyncPath?: string; + iCloudSyncEnabled?: boolean; // Legacy - kept for backwards compatibility during migration +} + +// ============================================================================ +// Settings Store +// ============================================================================ + +export interface MaestroSettings { + activeThemeId: string; + llmProvider: string; + modelSlug: string; + apiKey: string; + shortcuts: Record; + fontSize: number; + fontFamily: string; + customFonts: string[]; + logLevel: 'debug' | 'info' | 'warn' | 'error'; + defaultShell: string; + // Web interface authentication + webAuthEnabled: boolean; + webAuthToken: string | null; + // Web interface custom port + webInterfaceUseCustomPort: boolean; + webInterfaceCustomPort: number; + // SSH remote execution + sshRemotes: SshRemoteConfig[]; + defaultSshRemoteId: string | null; + // Unique installation identifier (generated once on first run) + installationId: string | null; +} + +// ============================================================================ +// Sessions Store +// ============================================================================ + +export interface SessionsData { + sessions: any[]; +} + +// ============================================================================ +// Groups Store +// ============================================================================ + +export interface GroupsData { + groups: any[]; +} + +// ============================================================================ +// Agent Configs Store +// ============================================================================ + +export interface AgentConfigsData { + configs: Record>; // agentId -> config key-value pairs +} + +// ============================================================================ +// Window State Store (local-only, per-device) +// ============================================================================ + +export interface WindowState { + x?: number; + y?: number; + width: number; + height: number; + isMaximized: boolean; + isFullScreen: boolean; +} + +// ============================================================================ +// Claude Session Origins Store +// ============================================================================ + +export type ClaudeSessionOrigin = 'user' | 'auto'; + +export interface ClaudeSessionOriginInfo { + origin: ClaudeSessionOrigin; + sessionName?: string; // User-defined session name from Maestro + starred?: boolean; // Whether the session is starred + contextUsage?: number; // Last known context window usage percentage (0-100) +} + +export interface ClaudeSessionOriginsData { + // Map of projectPath -> { agentSessionId -> origin info } + origins: Record>; +} + +// ============================================================================ +// Agent Session Origins Store (generic, for non-Claude agents) +// ============================================================================ + +export interface AgentSessionOriginsData { + // Structure: { [agentId]: { [projectPath]: { [sessionId]: { origin, sessionName, starred } } } } + origins: Record< + string, + Record< + string, + Record + > + >; +} diff --git a/src/main/stores/utils.ts b/src/main/stores/utils.ts new file mode 100644 index 00000000..be75ce56 --- /dev/null +++ b/src/main/stores/utils.ts @@ -0,0 +1,94 @@ +/** + * Store Utilities + * + * Helper functions for store operations including: + * - Sync path resolution + * - Early settings access (before app.ready) + * - SSH remote configuration lookup + */ + +import Store from 'electron-store'; +import fsSync from 'fs'; + +import type { BootstrapSettings } from './types'; +import type { SshRemoteConfig } from '../../shared/types'; + +// Re-export getDefaultShell from defaults for backward compatibility +export { getDefaultShell } from './defaults'; + +// ============================================================================ +// Sync Path Utilities +// ============================================================================ + +/** + * Get the custom sync path from the bootstrap store. + * Creates the directory if it doesn't exist. + * Returns undefined if no custom path is configured or if creation fails. + */ +export function getCustomSyncPath(bootstrapStore: Store): string | undefined { + const customPath = bootstrapStore.get('customSyncPath'); + + if (customPath) { + // Ensure the directory exists + if (!fsSync.existsSync(customPath)) { + try { + fsSync.mkdirSync(customPath, { recursive: true }); + } catch { + // If we can't create the directory, fall back to default + console.error(`Failed to create custom sync path: ${customPath}, using default`); + return undefined; + } + } + return customPath; + } + + return undefined; +} + +// ============================================================================ +// Early Settings Access +// ============================================================================ + +/** + * Get early settings that need to be read before app.ready. + * Used for crash reporting and GPU acceleration settings. + * + * This creates a temporary store instance just for reading these values + * before the full store initialization happens. + */ +export function getEarlySettings(syncPath: string): { + crashReportingEnabled: boolean; + disableGpuAcceleration: boolean; +} { + const earlyStore = new Store<{ + crashReportingEnabled: boolean; + disableGpuAcceleration: boolean; + }>({ + name: 'maestro-settings', + cwd: syncPath, + }); + + return { + crashReportingEnabled: earlyStore.get('crashReportingEnabled', true), + disableGpuAcceleration: earlyStore.get('disableGpuAcceleration', false), + }; +} + +// ============================================================================ +// SSH Remote Utilities +// ============================================================================ + +/** + * Get SSH remote configuration by ID from a settings store. + * Returns undefined if not found. + * + * Note: This is a lower-level function that takes a store instance. + * For convenience, use getSshRemoteById() from the main stores module + * which automatically uses the initialized settings store. + */ +export function findSshRemoteById( + sshRemotes: SshRemoteConfig[], + sshRemoteId: string +): SshRemoteConfig | undefined { + return sshRemotes.find((r) => r.id === sshRemoteId); +}