I'd be happy to help you create a clean update summary for your GitHub project! However, I don't see any input provided after "INPUT:" in your message.

Could you please share the changelog, commit history, pull request information, or any other details about what has changed in your project since the last release? This could include:

- Git commit messages
- Pull request descriptions
- Release notes
- Feature additions
- Bug fixes
- Performance improvements
- Breaking changes
- Dependency updates

Once you provide this information, I'll create an exciting CHANGES section with clean, 10-word bullets and relevant emojis as requested!
This commit is contained in:
Pedram Amini
2025-12-15 23:56:58 -06:00
parent 10db46b8b3
commit 7e412482a7
10 changed files with 1052 additions and 59 deletions

104
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "maestro",
"version": "0.8.5",
"version": "0.8.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "maestro",
"version": "0.8.5",
"version": "0.8.7",
"hasInstallScript": true,
"license": "AGPL 3.0",
"dependencies": {
@@ -26,6 +26,7 @@
"diff": "^8.0.2",
"dompurify": "^3.3.0",
"electron-store": "^8.1.0",
"electron-updater": "^6.6.2",
"fastify": "^4.25.2",
"js-tiktoken": "^1.0.21",
"mermaid": "^11.12.1",
@@ -3767,7 +3768,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true,
"license": "Python-2.0"
},
"node_modules/aria-query": {
@@ -6714,6 +6714,82 @@
"dev": true,
"license": "ISC"
},
"node_modules/electron-updater": {
"version": "6.6.2",
"resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.6.2.tgz",
"integrity": "sha512-Cr4GDOkbAUqRHP5/oeOmH/L2Bn6+FQPxVLZtPbcmKZC63a1F3uu5EefYOssgZXG3u/zBlubbJ5PJdITdMVggbw==",
"license": "MIT",
"dependencies": {
"builder-util-runtime": "9.3.1",
"fs-extra": "^10.1.0",
"js-yaml": "^4.1.0",
"lazy-val": "^1.0.5",
"lodash.escaperegexp": "^4.1.2",
"lodash.isequal": "^4.5.0",
"semver": "^7.6.3",
"tiny-typed-emitter": "^2.1.0"
}
},
"node_modules/electron-updater/node_modules/builder-util-runtime": {
"version": "9.3.1",
"resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.3.1.tgz",
"integrity": "sha512-2/egrNDDnRaxVwK3A+cJq6UOlqOdedGA7JPqCeJjN2Zjk1/QB/6QUi3b714ScIGS7HafFXTyzJEOr5b44I3kvQ==",
"license": "MIT",
"dependencies": {
"debug": "^4.3.4",
"sax": "^1.2.4"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/electron-updater/node_modules/fs-extra": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
"integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
"universalify": "^2.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/electron-updater/node_modules/jsonfile": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz",
"integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==",
"license": "MIT",
"dependencies": {
"universalify": "^2.0.0"
},
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/electron-updater/node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/electron-updater/node_modules/universalify": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
"license": "MIT",
"engines": {
"node": ">= 10.0.0"
}
},
"node_modules/electron/node_modules/@types/node": {
"version": "18.19.130",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz",
@@ -8644,7 +8720,6 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"dev": true,
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
@@ -8876,7 +8951,6 @@
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz",
"integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==",
"dev": true,
"license": "MIT"
},
"node_modules/lazystream": {
@@ -9008,6 +9082,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/lodash.escaperegexp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz",
"integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==",
"license": "MIT"
},
"node_modules/lodash.flatten": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz",
@@ -9015,6 +9095,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/lodash.isequal": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
"integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
"deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
"license": "MIT"
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
@@ -12559,7 +12646,6 @@
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.4.3.tgz",
"integrity": "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==",
"dev": true,
"license": "BlueOak-1.0.0"
},
"node_modules/saxes": {
@@ -13412,6 +13498,12 @@
"real-require": "^0.2.0"
}
},
"node_modules/tiny-typed-emitter": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz",
"integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==",
"license": "MIT"
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",

View File

@@ -43,6 +43,11 @@
"build": {
"appId": "com.maestro.app",
"productName": "Maestro",
"publish": {
"provider": "github",
"owner": "pedramamini",
"repo": "Maestro"
},
"directories": {
"output": "release",
"buildResources": "build"
@@ -143,6 +148,7 @@
"diff": "^8.0.2",
"dompurify": "^3.3.0",
"electron-store": "^8.1.0",
"electron-updater": "^6.6.2",
"fastify": "^4.25.2",
"js-tiktoken": "^1.0.21",
"mermaid": "^11.12.1",

View File

@@ -17,6 +17,10 @@ import React from 'react';
if (!(window.maestro as any).updates) {
(window.maestro as any).updates = {
check: vi.fn(),
download: vi.fn(),
install: vi.fn(),
getStatus: vi.fn(),
onStatus: vi.fn(() => vi.fn()), // Returns an unsubscribe function
};
}
@@ -212,19 +216,6 @@ describe('UpdateCheckModal', () => {
});
});
it('shows upgrade instructions', async () => {
render(
<UpdateCheckModal
theme={createMockTheme()}
onClose={vi.fn()}
/>
);
await waitFor(() => {
expect(screen.getByText('How to Upgrade')).toBeInTheDocument();
});
});
it('shows release notes section', async () => {
render(
<UpdateCheckModal
@@ -247,11 +238,13 @@ describe('UpdateCheckModal', () => {
);
await waitFor(() => {
expect(screen.getByText('Download Latest Release')).toBeInTheDocument();
expect(screen.getByText('Download and Install Update')).toBeInTheDocument();
});
});
it('opens releases URL when download button clicked', async () => {
it('starts download when download button clicked', async () => {
(window.maestro.updates.download as any).mockResolvedValue({ success: true });
render(
<UpdateCheckModal
theme={createMockTheme()}
@@ -260,14 +253,25 @@ describe('UpdateCheckModal', () => {
);
await waitFor(() => {
expect(screen.getByText('Download Latest Release')).toBeInTheDocument();
expect(screen.getByText('Download and Install Update')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Download Latest Release'));
fireEvent.click(screen.getByText('Download and Install Update'));
expect(window.maestro.shell.openExternal).toHaveBeenCalledWith(
'https://github.com/pedramamini/Maestro/releases'
expect(window.maestro.updates.download).toHaveBeenCalled();
});
it('shows fallback link to GitHub', async () => {
render(
<UpdateCheckModal
theme={createMockTheme()}
onClose={vi.fn()}
/>
);
await waitFor(() => {
expect(screen.getByText('Or download manually from GitHub')).toBeInTheDocument();
});
});
});
@@ -890,4 +894,557 @@ describe('UpdateCheckModal', () => {
expect(screen.queryByTestId('react-markdown')).not.toBeInTheDocument();
});
});
// =============================================================================
// AUTO-UPDATE DOWNLOAD FLOW
// =============================================================================
describe('auto-update download flow', () => {
beforeEach(() => {
// Reset download mock
(window.maestro.updates.download as any).mockReset();
(window.maestro.updates.install as any).mockReset();
});
it('calls download API when download button is clicked', async () => {
(window.maestro.updates.download as any).mockResolvedValue({ success: true });
render(
<UpdateCheckModal
theme={createMockTheme()}
onClose={vi.fn()}
/>
);
await waitFor(() => {
expect(screen.getByText('Download and Install Update')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Download and Install Update'));
expect(window.maestro.updates.download).toHaveBeenCalledTimes(1);
});
it('shows downloading state when download starts', async () => {
// Make download take some time
(window.maestro.updates.download as any).mockImplementation(
() => new Promise((resolve) => setTimeout(() => resolve({ success: true }), 100))
);
render(
<UpdateCheckModal
theme={createMockTheme()}
onClose={vi.fn()}
/>
);
await waitFor(() => {
expect(screen.getByText('Download and Install Update')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Download and Install Update'));
// Should show downloading state
await waitFor(() => {
expect(screen.getByText('Downloading...')).toBeInTheDocument();
});
});
it('disables download button while downloading', async () => {
(window.maestro.updates.download as any).mockImplementation(
() => new Promise((resolve) => setTimeout(() => resolve({ success: true }), 100))
);
render(
<UpdateCheckModal
theme={createMockTheme()}
onClose={vi.fn()}
/>
);
await waitFor(() => {
expect(screen.getByText('Download and Install Update')).toBeInTheDocument();
});
const downloadButton = screen.getByText('Download and Install Update').closest('button');
fireEvent.click(downloadButton!);
await waitFor(() => {
const downloadingButton = screen.getByText('Downloading...').closest('button');
expect(downloadingButton).toBeDisabled();
});
});
it('disables refresh button while downloading', async () => {
(window.maestro.updates.download as any).mockImplementation(
() => new Promise((resolve) => setTimeout(() => resolve({ success: true }), 100))
);
render(
<UpdateCheckModal
theme={createMockTheme()}
onClose={vi.fn()}
/>
);
await waitFor(() => {
expect(screen.getByText('Download and Install Update')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Download and Install Update'));
await waitFor(() => {
expect(screen.getByText('Downloading...')).toBeInTheDocument();
});
const refreshButton = screen.getByTitle('Refresh');
expect(refreshButton).toBeDisabled();
});
it('shows download error when download fails', async () => {
(window.maestro.updates.download as any).mockResolvedValue({
success: false,
error: 'Download failed: network error',
});
render(
<UpdateCheckModal
theme={createMockTheme()}
onClose={vi.fn()}
/>
);
await waitFor(() => {
expect(screen.getByText('Download and Install Update')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Download and Install Update'));
await waitFor(() => {
expect(screen.getByText('Download failed')).toBeInTheDocument();
expect(screen.getByText('Download failed: network error')).toBeInTheDocument();
});
});
it('shows manual download link on error', async () => {
(window.maestro.updates.download as any).mockResolvedValue({
success: false,
error: 'Some error',
});
render(
<UpdateCheckModal
theme={createMockTheme()}
onClose={vi.fn()}
/>
);
await waitFor(() => {
expect(screen.getByText('Download and Install Update')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Download and Install Update'));
await waitFor(() => {
expect(screen.getByText('Download manually from GitHub')).toBeInTheDocument();
});
});
it('opens GitHub releases when manual download link is clicked after error', async () => {
(window.maestro.updates.download as any).mockResolvedValue({
success: false,
error: 'Some error',
});
render(
<UpdateCheckModal
theme={createMockTheme()}
onClose={vi.fn()}
/>
);
await waitFor(() => {
expect(screen.getByText('Download and Install Update')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Download and Install Update'));
await waitFor(() => {
expect(screen.getByText('Download manually from GitHub')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Download manually from GitHub'));
expect(window.maestro.shell.openExternal).toHaveBeenCalledWith(
'https://github.com/pedramamini/Maestro/releases'
);
});
it('subscribes to status updates on mount', async () => {
render(
<UpdateCheckModal
theme={createMockTheme()}
onClose={vi.fn()}
/>
);
expect(window.maestro.updates.onStatus).toHaveBeenCalled();
});
it('unsubscribes from status updates on unmount', async () => {
const mockUnsubscribe = vi.fn();
(window.maestro.updates.onStatus as any).mockReturnValue(mockUnsubscribe);
const { unmount } = render(
<UpdateCheckModal
theme={createMockTheme()}
onClose={vi.fn()}
/>
);
unmount();
expect(mockUnsubscribe).toHaveBeenCalled();
});
});
// =============================================================================
// AUTO-UPDATE INSTALL FLOW
// =============================================================================
describe('auto-update install flow', () => {
it('calls install API when restart button is clicked', async () => {
// Simulate downloaded state via onStatus callback
let statusCallback: ((status: any) => void) | null = null;
(window.maestro.updates.onStatus as any).mockImplementation((cb: any) => {
statusCallback = cb;
return vi.fn();
});
render(
<UpdateCheckModal
theme={createMockTheme()}
onClose={vi.fn()}
/>
);
await waitFor(() => {
expect(screen.getByText('Download and Install Update')).toBeInTheDocument();
});
// Simulate update downloaded status
statusCallback!({ status: 'downloaded', info: { version: '1.1.0' } });
await waitFor(() => {
expect(screen.getByText('Restart to Update')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Restart to Update'));
expect(window.maestro.updates.install).toHaveBeenCalled();
});
it('shows restart button after download completes', async () => {
let statusCallback: ((status: any) => void) | null = null;
(window.maestro.updates.onStatus as any).mockImplementation((cb: any) => {
statusCallback = cb;
return vi.fn();
});
render(
<UpdateCheckModal
theme={createMockTheme()}
onClose={vi.fn()}
/>
);
await waitFor(() => {
expect(screen.getByText('Download and Install Update')).toBeInTheDocument();
});
// Simulate download completed
statusCallback!({ status: 'downloaded', info: { version: '1.1.0' } });
await waitFor(() => {
expect(screen.getByText('Restart to Update')).toBeInTheDocument();
});
// Download button should be replaced
expect(screen.queryByText('Download and Install Update')).not.toBeInTheDocument();
});
it('restart button has success styling', async () => {
let statusCallback: ((status: any) => void) | null = null;
(window.maestro.updates.onStatus as any).mockImplementation((cb: any) => {
statusCallback = cb;
return vi.fn();
});
const theme = createMockTheme();
render(
<UpdateCheckModal
theme={theme}
onClose={vi.fn()}
/>
);
await waitFor(() => {
expect(screen.getByText('Download and Install Update')).toBeInTheDocument();
});
statusCallback!({ status: 'downloaded', info: { version: '1.1.0' } });
await waitFor(() => {
const restartButton = screen.getByText('Restart to Update').closest('button');
expect(restartButton).toHaveStyle({ backgroundColor: theme.colors.success });
});
});
});
// =============================================================================
// DOWNLOAD PROGRESS
// =============================================================================
describe('download progress', () => {
it('shows progress bar during download', async () => {
let statusCallback: ((status: any) => void) | null = null;
(window.maestro.updates.onStatus as any).mockImplementation((cb: any) => {
statusCallback = cb;
return vi.fn();
});
render(
<UpdateCheckModal
theme={createMockTheme()}
onClose={vi.fn()}
/>
);
await waitFor(() => {
expect(screen.getByText('Download and Install Update')).toBeInTheDocument();
});
// Simulate downloading with progress
statusCallback!({
status: 'downloading',
progress: { percent: 50, bytesPerSecond: 1000000, total: 10000000, transferred: 5000000 },
});
await waitFor(() => {
expect(screen.getByText('Downloading update...')).toBeInTheDocument();
expect(screen.getByText('50%')).toBeInTheDocument();
});
});
it('shows download speed', async () => {
let statusCallback: ((status: any) => void) | null = null;
(window.maestro.updates.onStatus as any).mockImplementation((cb: any) => {
statusCallback = cb;
return vi.fn();
});
render(
<UpdateCheckModal
theme={createMockTheme()}
onClose={vi.fn()}
/>
);
await waitFor(() => {
expect(screen.getByText('Download and Install Update')).toBeInTheDocument();
});
statusCallback!({
status: 'downloading',
progress: { percent: 25, bytesPerSecond: 1048576, total: 10485760, transferred: 2621440 },
});
await waitFor(() => {
expect(screen.getByText('1 MB/s')).toBeInTheDocument();
});
});
it('shows transferred and total bytes', async () => {
let statusCallback: ((status: any) => void) | null = null;
(window.maestro.updates.onStatus as any).mockImplementation((cb: any) => {
statusCallback = cb;
return vi.fn();
});
render(
<UpdateCheckModal
theme={createMockTheme()}
onClose={vi.fn()}
/>
);
await waitFor(() => {
expect(screen.getByText('Download and Install Update')).toBeInTheDocument();
});
statusCallback!({
status: 'downloading',
progress: { percent: 50, bytesPerSecond: 500000, total: 10485760, transferred: 5242880 },
});
await waitFor(() => {
expect(screen.getByText('5 MB / 10 MB')).toBeInTheDocument();
});
});
it('updates progress bar width based on percent', async () => {
let statusCallback: ((status: any) => void) | null = null;
(window.maestro.updates.onStatus as any).mockImplementation((cb: any) => {
statusCallback = cb;
return vi.fn();
});
render(
<UpdateCheckModal
theme={createMockTheme()}
onClose={vi.fn()}
/>
);
await waitFor(() => {
expect(screen.getByText('Download and Install Update')).toBeInTheDocument();
});
statusCallback!({
status: 'downloading',
progress: { percent: 75, bytesPerSecond: 1000000, total: 10000000, transferred: 7500000 },
});
await waitFor(() => {
expect(screen.getByText('75%')).toBeInTheDocument();
});
// Check progress bar width
const progressContainer = screen.getByText('75%').closest('div')?.parentElement;
const progressBar = progressContainer?.querySelector('[class*="h-full"]');
expect(progressBar).toHaveStyle({ width: '75%' });
});
it('handles error status from onStatus callback', async () => {
let statusCallback: ((status: any) => void) | null = null;
(window.maestro.updates.onStatus as any).mockImplementation((cb: any) => {
statusCallback = cb;
return vi.fn();
});
render(
<UpdateCheckModal
theme={createMockTheme()}
onClose={vi.fn()}
/>
);
await waitFor(() => {
expect(screen.getByText('Download and Install Update')).toBeInTheDocument();
});
// Simulate error during download
statusCallback!({
status: 'error',
error: 'Connection timed out',
});
await waitFor(() => {
expect(screen.getByText('Download failed')).toBeInTheDocument();
expect(screen.getByText('Connection timed out')).toBeInTheDocument();
});
});
});
// =============================================================================
// FALLBACK LINK
// =============================================================================
describe('fallback link', () => {
it('always shows fallback link when update is available', async () => {
render(
<UpdateCheckModal
theme={createMockTheme()}
onClose={vi.fn()}
/>
);
await waitFor(() => {
expect(screen.getByText('Or download manually from GitHub')).toBeInTheDocument();
});
});
it('opens GitHub when fallback link is clicked', async () => {
render(
<UpdateCheckModal
theme={createMockTheme()}
onClose={vi.fn()}
/>
);
await waitFor(() => {
expect(screen.getByText('Or download manually from GitHub')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Or download manually from GitHub'));
expect(window.maestro.shell.openExternal).toHaveBeenCalledWith(
'https://github.com/pedramamini/Maestro/releases'
);
});
it('fallback link is visible during download', async () => {
let statusCallback: ((status: any) => void) | null = null;
(window.maestro.updates.onStatus as any).mockImplementation((cb: any) => {
statusCallback = cb;
return vi.fn();
});
render(
<UpdateCheckModal
theme={createMockTheme()}
onClose={vi.fn()}
/>
);
await waitFor(() => {
expect(screen.getByText('Download and Install Update')).toBeInTheDocument();
});
statusCallback!({
status: 'downloading',
progress: { percent: 50, bytesPerSecond: 1000000, total: 10000000, transferred: 5000000 },
});
await waitFor(() => {
expect(screen.getByText('Or download manually from GitHub')).toBeInTheDocument();
});
});
it('fallback link is visible after download completes', async () => {
let statusCallback: ((status: any) => void) | null = null;
(window.maestro.updates.onStatus as any).mockImplementation((cb: any) => {
statusCallback = cb;
return vi.fn();
});
render(
<UpdateCheckModal
theme={createMockTheme()}
onClose={vi.fn()}
/>
);
await waitFor(() => {
expect(screen.getByText('Download and Install Update')).toBeInTheDocument();
});
statusCallback!({ status: 'downloaded', info: { version: '1.1.0' } });
await waitFor(() => {
expect(screen.getByText('Or download manually from GitHub')).toBeInTheDocument();
});
});
});
});

132
src/main/auto-updater.ts Normal file
View File

@@ -0,0 +1,132 @@
/**
* Auto-updater module for Maestro
* Uses electron-updater to download and install updates from GitHub releases
*/
import { autoUpdater, UpdateInfo, ProgressInfo } from 'electron-updater';
import { BrowserWindow, ipcMain } from 'electron';
import { logger } from './utils/logger';
// Don't auto-download - we want user to initiate
autoUpdater.autoDownload = false;
autoUpdater.autoInstallOnAppQuit = true;
export interface UpdateStatus {
status: 'idle' | 'checking' | 'available' | 'not-available' | 'downloading' | 'downloaded' | 'error';
info?: UpdateInfo;
progress?: ProgressInfo;
error?: string;
}
let mainWindow: BrowserWindow | null = null;
let currentStatus: UpdateStatus = { status: 'idle' };
/**
* Initialize the auto-updater and set up event handlers
*/
export function initAutoUpdater(window: BrowserWindow): void {
mainWindow = window;
// Update available
autoUpdater.on('update-available', (info: UpdateInfo) => {
logger.info(`Update available: ${info.version}`, 'AutoUpdater');
currentStatus = { status: 'available', info };
sendStatusToRenderer();
});
// No update available
autoUpdater.on('update-not-available', (info: UpdateInfo) => {
logger.debug(`No update available (current: ${info.version})`, 'AutoUpdater');
currentStatus = { status: 'not-available', info };
sendStatusToRenderer();
});
// Download progress
autoUpdater.on('download-progress', (progress: ProgressInfo) => {
currentStatus = { ...currentStatus, status: 'downloading', progress };
sendStatusToRenderer();
});
// Update downloaded
autoUpdater.on('update-downloaded', (info: UpdateInfo) => {
logger.info(`Update downloaded: ${info.version}`, 'AutoUpdater');
currentStatus = { status: 'downloaded', info };
sendStatusToRenderer();
});
// Error
autoUpdater.on('error', (err: Error) => {
logger.error(`Auto-update error: ${err.message}`, 'AutoUpdater');
currentStatus = { status: 'error', error: err.message };
sendStatusToRenderer();
});
// Set up IPC handlers
setupIpcHandlers();
}
/**
* Send current status to renderer
*/
function sendStatusToRenderer(): void {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('updates:status', currentStatus);
}
}
/**
* Set up IPC handlers for update operations
*/
function setupIpcHandlers(): void {
// Check for updates using electron-updater (different from manual GitHub API check)
ipcMain.handle('updates:checkAutoUpdater', async () => {
try {
currentStatus = { status: 'checking' };
sendStatusToRenderer();
const result = await autoUpdater.checkForUpdates();
return { success: true, updateInfo: result?.updateInfo };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
currentStatus = { status: 'error', error: errorMessage };
sendStatusToRenderer();
return { success: false, error: errorMessage };
}
});
// Download update
ipcMain.handle('updates:download', async () => {
try {
currentStatus = { status: 'downloading', progress: { percent: 0, bytesPerSecond: 0, total: 0, transferred: 0, delta: 0 } };
sendStatusToRenderer();
await autoUpdater.downloadUpdate();
return { success: true };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
currentStatus = { status: 'error', error: errorMessage };
sendStatusToRenderer();
return { success: false, error: errorMessage };
}
});
// Install update (quit and install)
ipcMain.handle('updates:install', () => {
autoUpdater.quitAndInstall(false, true);
});
// Get current status
ipcMain.handle('updates:getStatus', () => {
return currentStatus;
});
}
/**
* Manually trigger update check (can be called from main process)
*/
export async function checkForUpdatesManual(): Promise<UpdateInfo | null> {
try {
const result = await autoUpdater.checkForUpdates();
return result?.updateInfo || null;
} catch {
return null;
}
}

View File

@@ -412,6 +412,42 @@ export class HistoryManager {
return paginateEntries(entries, options);
}
/**
* Update sessionName for all entries matching a given claudeSessionId.
* This is used when a tab is renamed to retroactively update past history entries.
*/
updateSessionNameByClaudeSessionId(claudeSessionId: string, sessionName: string): number {
const sessions = this.listSessionsWithHistory();
let updatedCount = 0;
for (const sessionId of sessions) {
const filePath = this.getSessionFilePath(sessionId);
if (!fs.existsSync(filePath)) continue;
try {
const data: HistoryFileData = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
let modified = false;
for (const entry of data.entries) {
if (entry.claudeSessionId === claudeSessionId && entry.sessionName !== sessionName) {
entry.sessionName = sessionName;
modified = true;
updatedCount++;
}
}
if (modified) {
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
logger.debug(`Updated ${updatedCount} entries for claudeSessionId ${claudeSessionId} in session ${sessionId}`, LOG_CONTEXT);
}
} catch (error) {
logger.warn(`Failed to update sessionName in session ${sessionId}: ${error}`, LOG_CONTEXT);
}
}
return updatedCount;
}
/**
* Clear all sessions for a specific project
*/

View File

@@ -13,6 +13,7 @@ import Store from 'electron-store';
import { getHistoryManager } from './history-manager';
import { registerGitHandlers, registerAutorunHandlers, registerPlaybooksHandlers, registerHistoryHandlers, registerAgentsHandlers, registerProcessHandlers, registerPersistenceHandlers, registerSystemHandlers, setupLoggerEventForwarding } from './ipc/handlers';
import { DEMO_MODE, DEMO_DATA_PATH, CLAUDE_SESSION_PARSE_LIMITS, CLAUDE_PRICING } from './constants';
import { initAutoUpdater } from './auto-updater';
import {
SessionStatsCache,
GlobalStatsCache,
@@ -533,6 +534,12 @@ function createWindow() {
logger.info('Browser window closed', 'Window');
mainWindow = null;
});
// Initialize auto-updater (only in production)
if (process.env.NODE_ENV !== 'development') {
initAutoUpdater(mainWindow);
logger.info('Auto-updater initialized', 'Window');
}
}
// Set up global error handlers for uncaught exceptions

View File

@@ -166,6 +166,13 @@ export function registerHistoryHandlers(): void {
return false;
});
// Update sessionName for all entries matching a claudeSessionId (used when renaming tabs)
ipcMain.handle('history:updateSessionName', async (_event, claudeSessionId: string, sessionName: string) => {
const count = historyManager.updateSessionNameByClaudeSessionId(claudeSessionId, sessionName);
logger.info(`Updated sessionName for ${count} history entries with claudeSessionId ${claudeSessionId}`, LOG_CONTEXT);
return count;
});
// NEW: Get history file path for AI context integration
ipcMain.handle('history:getFilePath', async (_event, sessionId: string) => {
return historyManager.getHistoryFilePath(sessionId);

View File

@@ -375,6 +375,26 @@ contextBridge.exposeInMainWorld('maestro', {
releasesUrl: string;
error?: string;
}>,
// Auto-updater APIs (electron-updater)
download: () => ipcRenderer.invoke('updates:download') as Promise<{ success: boolean; error?: string }>,
install: () => ipcRenderer.invoke('updates:install') as Promise<void>,
getStatus: () => ipcRenderer.invoke('updates:getStatus') as Promise<{
status: 'idle' | 'checking' | 'available' | 'not-available' | 'downloading' | 'downloaded' | 'error';
info?: { version: string };
progress?: { percent: number; bytesPerSecond: number; total: number; transferred: number };
error?: string;
}>,
// Subscribe to update status changes
onStatus: (callback: (status: {
status: 'idle' | 'checking' | 'available' | 'not-available' | 'downloading' | 'downloaded' | 'error';
info?: { version: string };
progress?: { percent: number; bytesPerSecond: number; total: number; transferred: number };
error?: string;
}) => void) => {
const handler = (_: any, status: any) => callback(status);
ipcRenderer.on('updates:status', handler);
return () => ipcRenderer.removeListener('updates:status', handler);
},
},
// Logger API
@@ -523,6 +543,9 @@ contextBridge.exposeInMainWorld('maestro', {
ipcRenderer.invoke('history:delete', entryId, sessionId),
update: (entryId: string, updates: { validated?: boolean }, sessionId?: string) =>
ipcRenderer.invoke('history:update', entryId, updates, sessionId),
// Update sessionName for all entries matching a claudeSessionId (used when renaming tabs)
updateSessionName: (claudeSessionId: string, sessionName: string) =>
ipcRenderer.invoke('history:updateSessionName', claudeSessionId, sessionName),
// NEW: Get history file path for AI context integration
getFilePath: (sessionId: string) =>
ipcRenderer.invoke('history:getFilePath', sessionId),
@@ -876,6 +899,20 @@ export interface MaestroAPI {
releasesUrl: string;
error?: string;
}>;
download: () => Promise<{ success: boolean; error?: string }>;
install: () => Promise<void>;
getStatus: () => Promise<{
status: 'idle' | 'checking' | 'available' | 'not-available' | 'downloading' | 'downloaded' | 'error';
info?: { version: string };
progress?: { percent: number; bytesPerSecond: number; total: number; transferred: number };
error?: string;
}>;
onStatus: (callback: (status: {
status: 'idle' | 'checking' | 'available' | 'not-available' | 'downloading' | 'downloaded' | 'error';
info?: { version: string };
progress?: { percent: number; bytesPerSecond: number; total: number; transferred: number };
error?: string;
}) => void) => () => void;
};
logger: {
log: (level: string, message: string, context?: string, data?: unknown) => Promise<void>;
@@ -1079,6 +1116,8 @@ export interface MaestroAPI {
clear: (projectPath?: string) => Promise<boolean>;
delete: (entryId: string, sessionId?: string) => Promise<boolean>;
update: (entryId: string, updates: { validated?: boolean }, sessionId?: string) => Promise<boolean>;
// Update sessionName for all entries matching a claudeSessionId (used when renaming tabs)
updateSessionName: (claudeSessionId: string, sessionName: string) => Promise<number>;
onExternalChange: (handler: () => void) => () => void;
reload: () => Promise<boolean>;
// NEW: Get history file path for AI context integration

View File

@@ -4745,6 +4745,11 @@ export default function MaestroConsole() {
tab.claudeSessionId,
newName || ''
).catch(err => console.error('Failed to persist tab name:', err));
// Also update past history entries with this claudeSessionId
window.maestro.history.updateSessionName(
tab.claudeSessionId,
newName || ''
).catch(err => console.error('Failed to update history session names:', err));
}
return {
...s,

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useRef, useState, useCallback } from 'react';
import { X, Download, ExternalLink, Loader2, CheckCircle2, AlertCircle, RefreshCw, ChevronDown, ChevronRight } from 'lucide-react';
import React, { useEffect, useRef, useState } from 'react';
import { X, Download, ExternalLink, Loader2, CheckCircle2, AlertCircle, RefreshCw, ChevronDown, ChevronRight, RotateCcw } from 'lucide-react';
import type { Theme } from '../types';
import { useLayerStack } from '../contexts/LayerStackContext';
import { MODAL_PRIORITIES } from '../constants/modalPriorities';
@@ -23,6 +23,13 @@ interface UpdateCheckResult {
error?: string;
}
interface UpdateStatus {
status: 'idle' | 'checking' | 'available' | 'not-available' | 'downloading' | 'downloaded' | 'error';
info?: { version: string };
progress?: { percent: number; bytesPerSecond: number; total: number; transferred: number };
error?: string;
}
interface UpdateCheckModalProps {
theme: Theme;
onClose: () => void;
@@ -36,6 +43,10 @@ export function UpdateCheckModal({ theme, onClose }: UpdateCheckModalProps) {
const [result, setResult] = useState<UpdateCheckResult | null>(null);
const [expandedReleases, setExpandedReleases] = useState<Set<string>>(new Set());
// Auto-updater state
const [downloadStatus, setDownloadStatus] = useState<UpdateStatus>({ status: 'idle' });
const [downloadError, setDownloadError] = useState<string | null>(null);
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
@@ -44,8 +55,20 @@ export function UpdateCheckModal({ theme, onClose }: UpdateCheckModalProps) {
checkForUpdates();
}, []);
// Subscribe to update status changes
useEffect(() => {
const unsubscribe = window.maestro.updates.onStatus((status) => {
setDownloadStatus(status);
if (status.status === 'error' && status.error) {
setDownloadError(status.error);
}
});
return () => unsubscribe();
}, []);
const checkForUpdates = async () => {
setLoading(true);
setDownloadError(null);
try {
const updateResult = await window.maestro.updates.check();
setResult(updateResult);
@@ -90,6 +113,29 @@ export function UpdateCheckModal({ theme, onClose }: UpdateCheckModalProps) {
});
};
const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
};
const handleDownloadUpdate = async () => {
setDownloadError(null);
setDownloadStatus({ status: 'downloading', progress: { percent: 0, bytesPerSecond: 0, total: 0, transferred: 0 } });
const downloadResult = await window.maestro.updates.download();
if (!downloadResult.success && downloadResult.error) {
setDownloadError(downloadResult.error);
setDownloadStatus({ status: 'error', error: downloadResult.error });
}
};
const handleInstallUpdate = () => {
window.maestro.updates.install();
};
// Register layer on mount
useEffect(() => {
const id = registerLayer({
@@ -112,6 +158,9 @@ export function UpdateCheckModal({ theme, onClose }: UpdateCheckModalProps) {
};
}, [registerLayer, unregisterLayer]);
const isDownloading = downloadStatus.status === 'downloading';
const isDownloaded = downloadStatus.status === 'downloaded';
return (
<div
ref={containerRef}
@@ -139,7 +188,7 @@ export function UpdateCheckModal({ theme, onClose }: UpdateCheckModalProps) {
<div className="flex items-center gap-2">
<button
onClick={checkForUpdates}
disabled={loading}
disabled={loading || isDownloading}
className="p-1 rounded hover:bg-white/10 transition-colors disabled:opacity-50"
title="Refresh"
>
@@ -210,27 +259,6 @@ export function UpdateCheckModal({ theme, onClose }: UpdateCheckModalProps) {
</div>
</div>
{/* Upgrade Instructions */}
<div
className="p-4 rounded border"
style={{ borderColor: theme.colors.border, backgroundColor: theme.colors.bgActivity }}
>
<div className="text-sm font-bold mb-2" style={{ color: theme.colors.textMain }}>
How to Upgrade
</div>
<div className="text-xs space-y-1" style={{ color: theme.colors.textDim }}>
<p>Upgrading is simple - just download and replace the app binary:</p>
<ol className="list-decimal list-inside ml-2 space-y-1">
<li>Download the latest release for your platform</li>
<li>Replace the existing Maestro app</li>
<li>Restart Maestro</li>
</ol>
<p className="mt-2 pt-2 border-t" style={{ borderColor: theme.colors.border, color: theme.colors.success }}>
All your data (sessions, settings, history) will persist automatically.
</p>
</div>
</div>
{/* Release Notes */}
<div>
<div className="text-sm font-bold mb-3" style={{ color: theme.colors.textMain }}>
@@ -313,16 +341,100 @@ export function UpdateCheckModal({ theme, onClose }: UpdateCheckModalProps) {
</div>
</div>
{/* Download Button */}
<button
onClick={() => window.maestro.shell.openExternal(result.releasesUrl)}
className="w-full flex items-center justify-center gap-2 p-3 rounded-lg font-bold text-sm transition-colors hover:opacity-90"
style={{ backgroundColor: theme.colors.accent, color: theme.colors.bgMain }}
>
<Download className="w-4 h-4" />
Download Latest Release
<ExternalLink className="w-3 h-3" />
</button>
{/* Download Error */}
{downloadError && (
<div
className="p-3 rounded border text-xs"
style={{
backgroundColor: `${theme.colors.error}15`,
borderColor: theme.colors.error,
color: theme.colors.error
}}
>
<div className="flex items-center gap-2 mb-1">
<AlertCircle className="w-4 h-4" />
<span className="font-bold">Download failed</span>
</div>
<p style={{ color: theme.colors.textDim }}>{downloadError}</p>
<button
onClick={() => window.maestro.shell.openExternal(result.releasesUrl)}
className="flex items-center gap-1 mt-2 hover:underline"
style={{ color: theme.colors.accent }}
>
Download manually from GitHub
<ExternalLink className="w-3 h-3" />
</button>
</div>
)}
{/* Download Progress */}
{isDownloading && downloadStatus.progress && (
<div className="space-y-2">
<div className="flex items-center justify-between text-xs" style={{ color: theme.colors.textDim }}>
<span>Downloading update...</span>
<span>{Math.round(downloadStatus.progress.percent)}%</span>
</div>
<div
className="h-2 rounded-full overflow-hidden"
style={{ backgroundColor: theme.colors.bgActivity }}
>
<div
className="h-full transition-all duration-300 rounded-full"
style={{
width: `${downloadStatus.progress.percent}%`,
backgroundColor: theme.colors.accent
}}
/>
</div>
<div className="flex items-center justify-between text-xs" style={{ color: theme.colors.textDim }}>
<span>{formatBytes(downloadStatus.progress.transferred)} / {formatBytes(downloadStatus.progress.total)}</span>
<span>{formatBytes(downloadStatus.progress.bytesPerSecond)}/s</span>
</div>
</div>
)}
{/* Action Buttons */}
<div className="space-y-2">
{isDownloaded ? (
<button
onClick={handleInstallUpdate}
className="w-full flex items-center justify-center gap-2 p-3 rounded-lg font-bold text-sm transition-colors hover:opacity-90"
style={{ backgroundColor: theme.colors.success, color: theme.colors.bgMain }}
>
<RotateCcw className="w-4 h-4" />
Restart to Update
</button>
) : (
<button
onClick={handleDownloadUpdate}
disabled={isDownloading}
className="w-full flex items-center justify-center gap-2 p-3 rounded-lg font-bold text-sm transition-colors hover:opacity-90 disabled:opacity-50"
style={{ backgroundColor: theme.colors.accent, color: theme.colors.bgMain }}
>
{isDownloading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Downloading...
</>
) : (
<>
<Download className="w-4 h-4" />
Download and Install Update
</>
)}
</button>
)}
{/* Fallback link */}
<button
onClick={() => window.maestro.shell.openExternal(result.releasesUrl)}
className="w-full flex items-center justify-center gap-2 p-2 rounded text-xs transition-colors hover:bg-white/5"
style={{ color: theme.colors.textDim }}
>
Or download manually from GitHub
<ExternalLink className="w-3 h-3" />
</button>
</div>
</>
) : (
<div className="flex flex-col items-center justify-center py-8 gap-3">