refactor: extract stores module from main/index.ts with comprehensive tests

Split monolithic store code from index.ts into dedicated src/main/stores/ module
following single responsibility principle:

- types.ts (112 lines) - Centralized type definitions
- defaults.ts (86 lines) - Default values and getDefaultShell()
- instances.ts (175 lines) - Store instance lifecycle management
- getters.ts (118 lines) - Public store accessor functions
- utils.ts (94 lines) - Utility functions (getCustomSyncPath, getEarlySettings)
- index.ts (72 lines) - Public API re-exports
This commit is contained in:
Raza Rauf
2026-01-20 23:47:34 +05:00
parent ff176e4dcb
commit edde5d7a8f
12 changed files with 1698 additions and 239 deletions

View File

@@ -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({});
});
});
});

View File

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

View File

@@ -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<Record<string, unknown>> = [];
// Mock electron-store with a class
vi.mock('electron-store', () => {
return {
default: class MockStore {
options: Record<string, unknown>;
constructor(options: Record<string, unknown>) {
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');
});
});
});

View File

@@ -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<MaestroSettings> = { 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');
});
});
});

View File

@@ -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<string, unknown>;
constructor(options: Record<string, unknown>) {
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<BootstrapSettings>;
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<BootstrapSettings>;
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<BootstrapSettings>;
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<BootstrapSettings>;
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');
});
});
});

View File

@@ -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<BootstrapSettings>({
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<string, any>;
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<MaestroSettings>({
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<SessionsData>({
name: 'maestro-sessions',
cwd: syncPath, // Use iCloud/custom sync path if configured
defaults: {
sessions: [],
},
});
// Groups store
interface GroupsData {
groups: any[];
}
const groupsStore = new Store<GroupsData>({
name: 'maestro-groups',
cwd: syncPath, // Use iCloud/custom sync path if configured
defaults: {
groups: [],
},
});
interface AgentConfigsData {
configs: Record<string, Record<string, any>>; // 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<AgentConfigsData>({
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<WindowState>({
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<string, Record<string, ClaudeSessionOrigin | ClaudeSessionOriginInfo>>;
}
const claudeSessionOriginsStore = new Store<ClaudeSessionOriginsData>({
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<string, { origin?: 'user' | 'auto'; sessionName?: string; starred?: boolean }>
>
>;
}
const agentSessionOriginsStore = new Store<AgentSessionOriginsData>({
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;

View File

@@ -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: {},
};

118
src/main/stores/getters.ts Normal file
View File

@@ -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<BootstrapSettings> {
const { bootstrapStore } = getStoreInstances();
if (!bootstrapStore) {
throw new Error('Stores not initialized. Call initializeStores() first.');
}
return bootstrapStore;
}
export function getSettingsStore(): Store<MaestroSettings> {
ensureInitialized();
return getStoreInstances().settingsStore!;
}
export function getSessionsStore(): Store<SessionsData> {
ensureInitialized();
return getStoreInstances().sessionsStore!;
}
export function getGroupsStore(): Store<GroupsData> {
ensureInitialized();
return getStoreInstances().groupsStore!;
}
export function getAgentConfigsStore(): Store<AgentConfigsData> {
ensureInitialized();
return getStoreInstances().agentConfigsStore!;
}
export function getWindowStateStore(): Store<WindowState> {
ensureInitialized();
return getStoreInstances().windowStateStore!;
}
export function getClaudeSessionOriginsStore(): Store<ClaudeSessionOriginsData> {
ensureInitialized();
return getStoreInstances().claudeSessionOriginsStore!;
}
export function getAgentSessionOriginsStore(): Store<AgentSessionOriginsData> {
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);
}

72
src/main/stores/index.ts Normal file
View File

@@ -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';

View File

@@ -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<BootstrapSettings> | null = null;
let _settingsStore: Store<MaestroSettings> | null = null;
let _sessionsStore: Store<SessionsData> | null = null;
let _groupsStore: Store<GroupsData> | null = null;
let _agentConfigsStore: Store<AgentConfigsData> | null = null;
let _windowStateStore: Store<WindowState> | null = null;
let _claudeSessionOriginsStore: Store<ClaudeSessionOriginsData> | null = null;
let _agentSessionOriginsStore: Store<AgentSessionOriginsData> | 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<BootstrapSettings>;
} {
const { productionDataPath } = options;
_productionDataPath = productionDataPath;
// 1. Initialize bootstrap store first (determines sync path)
_bootstrapStore = new Store<BootstrapSettings>({
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<MaestroSettings>({
name: 'maestro-settings',
cwd: _syncPath,
defaults: SETTINGS_DEFAULTS,
});
_sessionsStore = new Store<SessionsData>({
name: 'maestro-sessions',
cwd: _syncPath,
defaults: SESSIONS_DEFAULTS,
});
_groupsStore = new Store<GroupsData>({
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<AgentConfigsData>({
name: 'maestro-agent-configs',
cwd: _productionDataPath,
defaults: AGENT_CONFIGS_DEFAULTS,
});
// Window state is intentionally NOT synced - it's per-device
_windowStateStore = new Store<WindowState>({
name: 'maestro-window-state',
defaults: WINDOW_STATE_DEFAULTS,
});
// Claude session origins - tracks which sessions were created by Maestro
_claudeSessionOriginsStore = new Store<ClaudeSessionOriginsData>({
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<AgentSessionOriginsData>({
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,
};
}

115
src/main/stores/types.ts Normal file
View File

@@ -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<string, any>;
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<string, Record<string, any>>; // 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<string, Record<string, ClaudeSessionOrigin | ClaudeSessionOriginInfo>>;
}
// ============================================================================
// 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<string, { origin?: 'user' | 'auto'; sessionName?: string; starred?: boolean }>
>
>;
}

94
src/main/stores/utils.ts Normal file
View File

@@ -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<BootstrapSettings>): 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);
}