mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
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:
206
src/__tests__/main/stores/defaults.test.ts
Normal file
206
src/__tests__/main/stores/defaults.test.ts
Normal 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({});
|
||||
});
|
||||
});
|
||||
});
|
||||
221
src/__tests__/main/stores/getters.test.ts
Normal file
221
src/__tests__/main/stores/getters.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
190
src/__tests__/main/stores/instances.test.ts
Normal file
190
src/__tests__/main/stores/instances.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
231
src/__tests__/main/stores/types.test.ts
Normal file
231
src/__tests__/main/stores/types.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
154
src/__tests__/main/stores/utils.test.ts
Normal file
154
src/__tests__/main/stores/utils.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
94
src/main/stores/defaults.ts
Normal file
94
src/main/stores/defaults.ts
Normal 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
118
src/main/stores/getters.ts
Normal 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
72
src/main/stores/index.ts
Normal 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';
|
||||
175
src/main/stores/instances.ts
Normal file
175
src/main/stores/instances.ts
Normal 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
115
src/main/stores/types.ts
Normal 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
94
src/main/stores/utils.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user