From 4ae06dc63bc781e4690b42d3b8b8753469ec9bf6 Mon Sep 17 00:00:00 2001 From: Arkadiusz Moscicki Date: Sat, 27 Dec 2025 20:58:53 +0000 Subject: [PATCH 01/13] fix: improve auto-update check logic and UI refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix false positive 'update available' when local is ahead of remote Uses git merge-base --is-ancestor to properly check ancestry - Fix UI not refreshing after update from toast notification Emit custom event when update pulled, UpdatesSection listens and refetches 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- apps/server/src/index.ts | 2 + apps/server/src/routes/updates/common.ts | 137 +++++++ apps/server/src/routes/updates/index.ts | 29 ++ .../server/src/routes/updates/routes/check.ts | 177 +++++++++ apps/server/src/routes/updates/routes/info.ts | 132 +++++++ apps/server/src/routes/updates/routes/pull.ts | 183 +++++++++ .../ui/src/components/views/settings-view.tsx | 5 + .../views/settings-view/config/navigation.ts | 2 + .../settings-view/hooks/use-settings-view.ts | 1 + .../settings-view/updates/updates-section.tsx | 373 ++++++++++++++++++ apps/ui/src/hooks/use-auto-update.ts | 198 ++++++++++ apps/ui/src/lib/electron.ts | 45 +++ apps/ui/src/lib/http-api-client.ts | 49 +++ apps/ui/src/routes/__root.tsx | 4 + apps/ui/src/store/app-store.ts | 21 + apps/ui/vite.config.mts | 8 + libs/types/src/index.ts | 2 + libs/types/src/settings.ts | 28 ++ 18 files changed, 1396 insertions(+) create mode 100644 apps/server/src/routes/updates/common.ts create mode 100644 apps/server/src/routes/updates/index.ts create mode 100644 apps/server/src/routes/updates/routes/check.ts create mode 100644 apps/server/src/routes/updates/routes/info.ts create mode 100644 apps/server/src/routes/updates/routes/pull.ts create mode 100644 apps/ui/src/components/views/settings-view/updates/updates-section.tsx create mode 100644 apps/ui/src/hooks/use-auto-update.ts diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 0f97255f3..1d40c23a5 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -53,6 +53,7 @@ import { ClaudeUsageService } from './services/claude-usage-service.js'; import { createGitHubRoutes } from './routes/github/index.js'; import { createContextRoutes } from './routes/context/index.js'; import { createBacklogPlanRoutes } from './routes/backlog-plan/index.js'; +import { createUpdatesRoutes } from './routes/updates/index.js'; import { cleanupStaleValidations } from './routes/github/routes/validation-common.js'; import { createMCPRoutes } from './routes/mcp/index.js'; import { MCPTestService } from './services/mcp-test-service.js'; @@ -215,6 +216,7 @@ app.use('/api/context', createContextRoutes(settingsService)); app.use('/api/backlog-plan', createBacklogPlanRoutes(events, settingsService)); app.use('/api/mcp', createMCPRoutes(mcpTestService)); app.use('/api/pipeline', createPipelineRoutes(pipelineService)); +app.use('/api/updates', createUpdatesRoutes(settingsService)); // Create HTTP server const server = createServer(app); diff --git a/apps/server/src/routes/updates/common.ts b/apps/server/src/routes/updates/common.ts new file mode 100644 index 000000000..bb66b2128 --- /dev/null +++ b/apps/server/src/routes/updates/common.ts @@ -0,0 +1,137 @@ +/** + * Common utilities for update routes + */ + +import { createLogger } from '@automaker/utils'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js'; + +const logger = createLogger('Updates'); +export const execAsync = promisify(exec); + +// Re-export shared utilities +export { getErrorMessageShared as getErrorMessage }; +export const logError = createLogError(logger); + +// ============================================================================ +// Extended PATH configuration for Electron apps +// ============================================================================ + +const pathSeparator = process.platform === 'win32' ? ';' : ':'; +const additionalPaths: string[] = []; + +if (process.platform === 'win32') { + // Windows paths + if (process.env.LOCALAPPDATA) { + additionalPaths.push(`${process.env.LOCALAPPDATA}\\Programs\\Git\\cmd`); + } + if (process.env.PROGRAMFILES) { + additionalPaths.push(`${process.env.PROGRAMFILES}\\Git\\cmd`); + } + if (process.env['ProgramFiles(x86)']) { + additionalPaths.push(`${process.env['ProgramFiles(x86)']}\\Git\\cmd`); + } +} else { + // Unix/Mac paths + additionalPaths.push( + '/opt/homebrew/bin', // Homebrew on Apple Silicon + '/usr/local/bin', // Homebrew on Intel Mac, common Linux location + '/home/linuxbrew/.linuxbrew/bin', // Linuxbrew + `${process.env.HOME}/.local/bin` // pipx, other user installs + ); +} + +const extendedPath = [process.env.PATH, ...additionalPaths.filter(Boolean)] + .filter(Boolean) + .join(pathSeparator); + +/** + * Environment variables with extended PATH for executing shell commands. + */ +export const execEnv = { + ...process.env, + PATH: extendedPath, +}; + +// ============================================================================ +// Automaker installation path +// ============================================================================ + +/** + * Get the root directory of the Automaker installation. + * This is the directory containing the package.json (monorepo root). + */ +export function getAutomakerRoot(): string { + // In ESM, we use import.meta.url to get the current file path + // This file is at: apps/server/src/routes/updates/common.ts + // So we need to go up 5 levels to get to the monorepo root + const __filename = fileURLToPath(import.meta.url); + const __dirname = path.dirname(__filename); + + // Go up from: updates -> routes -> src -> server -> apps -> root + return path.resolve(__dirname, '..', '..', '..', '..', '..'); +} + +/** + * Check if git is available on the system + */ +export async function isGitAvailable(): Promise { + try { + await execAsync('git --version', { env: execEnv }); + return true; + } catch { + return false; + } +} + +/** + * Check if a path is a git repository + */ +export async function isGitRepo(repoPath: string): Promise { + try { + await execAsync('git rev-parse --is-inside-work-tree', { cwd: repoPath, env: execEnv }); + return true; + } catch { + return false; + } +} + +/** + * Get the current HEAD commit hash + */ +export async function getCurrentCommit(repoPath: string): Promise { + const { stdout } = await execAsync('git rev-parse HEAD', { cwd: repoPath, env: execEnv }); + return stdout.trim(); +} + +/** + * Get the short version of a commit hash + */ +export async function getShortCommit(repoPath: string): Promise { + const { stdout } = await execAsync('git rev-parse --short HEAD', { cwd: repoPath, env: execEnv }); + return stdout.trim(); +} + +/** + * Check if the repo has local uncommitted changes + */ +export async function hasLocalChanges(repoPath: string): Promise { + const { stdout } = await execAsync('git status --porcelain', { cwd: repoPath, env: execEnv }); + return stdout.trim().length > 0; +} + +/** + * Validate that a URL looks like a valid git remote URL + */ +export function isValidGitUrl(url: string): boolean { + // Allow HTTPS and SSH git URLs + return ( + url.startsWith('https://') || + url.startsWith('git@') || + url.startsWith('git://') || + url.startsWith('ssh://') + ); +} diff --git a/apps/server/src/routes/updates/index.ts b/apps/server/src/routes/updates/index.ts new file mode 100644 index 000000000..0fb0b009f --- /dev/null +++ b/apps/server/src/routes/updates/index.ts @@ -0,0 +1,29 @@ +/** + * Update routes - HTTP API for checking and applying updates + * + * Provides endpoints for: + * - Checking if updates are available from upstream + * - Pulling updates from upstream + * - Getting current installation info + */ + +import { Router } from 'express'; +import type { SettingsService } from '../../services/settings-service.js'; +import { createCheckHandler } from './routes/check.js'; +import { createPullHandler } from './routes/pull.js'; +import { createInfoHandler } from './routes/info.js'; + +export function createUpdatesRoutes(settingsService: SettingsService): Router { + const router = Router(); + + // GET /api/updates/check - Check if updates are available + router.get('/check', createCheckHandler(settingsService)); + + // POST /api/updates/pull - Pull updates from upstream + router.post('/pull', createPullHandler(settingsService)); + + // GET /api/updates/info - Get current installation info + router.get('/info', createInfoHandler(settingsService)); + + return router; +} diff --git a/apps/server/src/routes/updates/routes/check.ts b/apps/server/src/routes/updates/routes/check.ts new file mode 100644 index 000000000..1ba015bad --- /dev/null +++ b/apps/server/src/routes/updates/routes/check.ts @@ -0,0 +1,177 @@ +/** + * GET /check endpoint - Check if updates are available + * + * Compares local HEAD commit with the remote upstream branch. + */ + +import type { Request, Response } from 'express'; +import type { SettingsService } from '../../../services/settings-service.js'; +import { + execAsync, + execEnv, + getAutomakerRoot, + getCurrentCommit, + getShortCommit, + isGitRepo, + isGitAvailable, + getErrorMessage, + logError, +} from '../common.js'; + +export interface UpdateCheckResult { + updateAvailable: boolean; + localCommit: string; + localCommitShort: string; + remoteCommit: string | null; + remoteCommitShort: string | null; + upstreamUrl: string; + automakerPath: string; + error?: string; +} + +export function createCheckHandler(settingsService: SettingsService) { + return async (_req: Request, res: Response): Promise => { + try { + const automakerPath = getAutomakerRoot(); + + // Check if git is available + if (!(await isGitAvailable())) { + res.status(500).json({ + success: false, + error: 'Git is not installed or not available in PATH', + }); + return; + } + + // Check if automaker directory is a git repo + if (!(await isGitRepo(automakerPath))) { + res.status(500).json({ + success: false, + error: 'Automaker installation is not a git repository', + }); + return; + } + + // Get settings for upstream URL + const settings = await settingsService.getGlobalSettings(); + const upstreamUrl = + settings.autoUpdate?.upstreamUrl || 'https://github.com/AutoMaker-Org/automaker.git'; + + // Get local commit + const localCommit = await getCurrentCommit(automakerPath); + const localCommitShort = await getShortCommit(automakerPath); + + // Fetch from upstream (use a temporary remote name to avoid conflicts) + const tempRemoteName = 'automaker-update-check'; + + try { + // Remove temp remote if it exists (ignore errors) + try { + await execAsync(`git remote remove ${tempRemoteName}`, { + cwd: automakerPath, + env: execEnv, + }); + } catch { + // Remote doesn't exist, that's fine + } + + // Add temporary remote + await execAsync(`git remote add ${tempRemoteName} "${upstreamUrl}"`, { + cwd: automakerPath, + env: execEnv, + }); + + // Fetch from the temporary remote + await execAsync(`git fetch ${tempRemoteName} main`, { + cwd: automakerPath, + env: execEnv, + }); + + // Get remote commit + const { stdout: remoteCommitOutput } = await execAsync( + `git rev-parse ${tempRemoteName}/main`, + { cwd: automakerPath, env: execEnv } + ); + const remoteCommit = remoteCommitOutput.trim(); + + // Get short remote commit + const { stdout: remoteCommitShortOutput } = await execAsync( + `git rev-parse --short ${tempRemoteName}/main`, + { cwd: automakerPath, env: execEnv } + ); + const remoteCommitShort = remoteCommitShortOutput.trim(); + + // Clean up temp remote + await execAsync(`git remote remove ${tempRemoteName}`, { + cwd: automakerPath, + env: execEnv, + }); + + // Check if remote is ahead of local (update available) + // git merge-base --is-ancestor returns 0 if commit1 is ancestor of commit2 + let updateAvailable = false; + if (localCommit !== remoteCommit) { + try { + // Check if local is already an ancestor of remote (remote is ahead) + await execAsync(`git merge-base --is-ancestor ${localCommit} ${remoteCommit}`, { + cwd: automakerPath, + env: execEnv, + }); + // If we get here (exit code 0), local is ancestor of remote, so update is available + updateAvailable = true; + } catch { + // Exit code 1 means local is NOT an ancestor of remote + // This means either local is ahead, or branches have diverged + // In either case, we don't show "update available" + updateAvailable = false; + } + } + + const result: UpdateCheckResult = { + updateAvailable, + localCommit, + localCommitShort, + remoteCommit, + remoteCommitShort, + upstreamUrl, + automakerPath, + }; + + res.json({ + success: true, + result, + }); + } catch (fetchError) { + // Clean up temp remote on error + try { + await execAsync(`git remote remove ${tempRemoteName}`, { + cwd: automakerPath, + env: execEnv, + }); + } catch { + // Ignore cleanup errors + } + + const errorMsg = getErrorMessage(fetchError); + logError(fetchError, 'Failed to fetch from upstream'); + + res.json({ + success: true, + result: { + updateAvailable: false, + localCommit, + localCommitShort, + remoteCommit: null, + remoteCommitShort: null, + upstreamUrl, + automakerPath, + error: `Could not fetch from upstream: ${errorMsg}`, + } satisfies UpdateCheckResult, + }); + } + } catch (error) { + logError(error, 'Update check failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/updates/routes/info.ts b/apps/server/src/routes/updates/routes/info.ts new file mode 100644 index 000000000..ca416d8ec --- /dev/null +++ b/apps/server/src/routes/updates/routes/info.ts @@ -0,0 +1,132 @@ +/** + * GET /info endpoint - Get current installation info + * + * Returns current commit, branch, and configuration info. + */ + +import type { Request, Response } from 'express'; +import type { SettingsService } from '../../../services/settings-service.js'; +import { + execAsync, + execEnv, + getAutomakerRoot, + getCurrentCommit, + getShortCommit, + isGitRepo, + isGitAvailable, + hasLocalChanges, + getErrorMessage, + logError, +} from '../common.js'; + +export interface UpdateInfo { + automakerPath: string; + isGitRepo: boolean; + gitAvailable: boolean; + currentCommit: string | null; + currentCommitShort: string | null; + currentBranch: string | null; + hasLocalChanges: boolean; + upstreamUrl: string; + autoUpdateEnabled: boolean; + checkIntervalMinutes: number; +} + +export function createInfoHandler(settingsService: SettingsService) { + return async (_req: Request, res: Response): Promise => { + try { + const automakerPath = getAutomakerRoot(); + + // Get settings + const settings = await settingsService.getGlobalSettings(); + const autoUpdateSettings = settings.autoUpdate || { + enabled: true, + checkIntervalMinutes: 15, + upstreamUrl: 'https://github.com/AutoMaker-Org/automaker.git', + }; + + // Check if git is available + const gitAvailable = await isGitAvailable(); + + if (!gitAvailable) { + const result: UpdateInfo = { + automakerPath, + isGitRepo: false, + gitAvailable: false, + currentCommit: null, + currentCommitShort: null, + currentBranch: null, + hasLocalChanges: false, + upstreamUrl: autoUpdateSettings.upstreamUrl, + autoUpdateEnabled: autoUpdateSettings.enabled, + checkIntervalMinutes: autoUpdateSettings.checkIntervalMinutes, + }; + + res.json({ + success: true, + result, + }); + return; + } + + // Check if it's a git repo + const isRepo = await isGitRepo(automakerPath); + + if (!isRepo) { + const result: UpdateInfo = { + automakerPath, + isGitRepo: false, + gitAvailable: true, + currentCommit: null, + currentCommitShort: null, + currentBranch: null, + hasLocalChanges: false, + upstreamUrl: autoUpdateSettings.upstreamUrl, + autoUpdateEnabled: autoUpdateSettings.enabled, + checkIntervalMinutes: autoUpdateSettings.checkIntervalMinutes, + }; + + res.json({ + success: true, + result, + }); + return; + } + + // Get git info + const currentCommit = await getCurrentCommit(automakerPath); + const currentCommitShort = await getShortCommit(automakerPath); + + // Get current branch + const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', { + cwd: automakerPath, + env: execEnv, + }); + const currentBranch = branchOutput.trim(); + + // Check for local changes + const localChanges = await hasLocalChanges(automakerPath); + + const result: UpdateInfo = { + automakerPath, + isGitRepo: true, + gitAvailable: true, + currentCommit, + currentCommitShort, + currentBranch, + hasLocalChanges: localChanges, + upstreamUrl: autoUpdateSettings.upstreamUrl, + autoUpdateEnabled: autoUpdateSettings.enabled, + checkIntervalMinutes: autoUpdateSettings.checkIntervalMinutes, + }; + + res.json({ + success: true, + result, + }); + } catch (error) { + logError(error, 'Failed to get update info'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/updates/routes/pull.ts b/apps/server/src/routes/updates/routes/pull.ts new file mode 100644 index 000000000..dff56dd0b --- /dev/null +++ b/apps/server/src/routes/updates/routes/pull.ts @@ -0,0 +1,183 @@ +/** + * POST /pull endpoint - Pull updates from upstream + * + * Executes git pull from the configured upstream repository. + */ + +import type { Request, Response } from 'express'; +import type { SettingsService } from '../../../services/settings-service.js'; +import { + execAsync, + execEnv, + getAutomakerRoot, + getCurrentCommit, + getShortCommit, + isGitRepo, + isGitAvailable, + hasLocalChanges, + getErrorMessage, + logError, +} from '../common.js'; + +export interface UpdatePullResult { + success: boolean; + previousCommit: string; + previousCommitShort: string; + newCommit: string; + newCommitShort: string; + alreadyUpToDate: boolean; + message: string; +} + +export function createPullHandler(settingsService: SettingsService) { + return async (_req: Request, res: Response): Promise => { + try { + const automakerPath = getAutomakerRoot(); + + // Check if git is available + if (!(await isGitAvailable())) { + res.status(500).json({ + success: false, + error: 'Git is not installed or not available in PATH', + }); + return; + } + + // Check if automaker directory is a git repo + if (!(await isGitRepo(automakerPath))) { + res.status(500).json({ + success: false, + error: 'Automaker installation is not a git repository', + }); + return; + } + + // Check for local changes + if (await hasLocalChanges(automakerPath)) { + res.status(400).json({ + success: false, + error: 'You have local uncommitted changes. Please commit or stash them before updating.', + }); + return; + } + + // Get settings for upstream URL + const settings = await settingsService.getGlobalSettings(); + const upstreamUrl = + settings.autoUpdate?.upstreamUrl || 'https://github.com/AutoMaker-Org/automaker.git'; + + // Get current commit before pull + const previousCommit = await getCurrentCommit(automakerPath); + const previousCommitShort = await getShortCommit(automakerPath); + + // Use a temporary remote to pull from + const tempRemoteName = 'automaker-update-pull'; + + try { + // Remove temp remote if it exists (ignore errors) + try { + await execAsync(`git remote remove ${tempRemoteName}`, { + cwd: automakerPath, + env: execEnv, + }); + } catch { + // Remote doesn't exist, that's fine + } + + // Add temporary remote + await execAsync(`git remote add ${tempRemoteName} "${upstreamUrl}"`, { + cwd: automakerPath, + env: execEnv, + }); + + // Fetch first + await execAsync(`git fetch ${tempRemoteName} main`, { + cwd: automakerPath, + env: execEnv, + }); + + // Get current branch + const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', { + cwd: automakerPath, + env: execEnv, + }); + const currentBranch = branchOutput.trim(); + + // Merge the fetched changes + const { stdout: mergeOutput } = await execAsync( + `git merge ${tempRemoteName}/main --ff-only`, + { cwd: automakerPath, env: execEnv } + ); + + // Clean up temp remote + await execAsync(`git remote remove ${tempRemoteName}`, { + cwd: automakerPath, + env: execEnv, + }); + + // Get new commit after merge + const newCommit = await getCurrentCommit(automakerPath); + const newCommitShort = await getShortCommit(automakerPath); + + const alreadyUpToDate = + mergeOutput.includes('Already up to date') || previousCommit === newCommit; + + const result: UpdatePullResult = { + success: true, + previousCommit, + previousCommitShort, + newCommit, + newCommitShort, + alreadyUpToDate, + message: alreadyUpToDate + ? 'Already up to date' + : `Updated from ${previousCommitShort} to ${newCommitShort}`, + }; + + res.json({ + success: true, + result, + }); + } catch (pullError) { + // Clean up temp remote on error + try { + await execAsync(`git remote remove ${tempRemoteName}`, { + cwd: automakerPath, + env: execEnv, + }); + } catch { + // Ignore cleanup errors + } + + const errorMsg = getErrorMessage(pullError); + logError(pullError, 'Failed to pull updates'); + + // Check for common errors + if (errorMsg.includes('not possible to fast-forward')) { + res.status(400).json({ + success: false, + error: + 'Cannot fast-forward merge. Your local branch has diverged from upstream. Please resolve manually.', + }); + return; + } + + if (errorMsg.includes('CONFLICT')) { + res.status(400).json({ + success: false, + error: 'Merge conflict detected. Please resolve conflicts manually.', + }); + return; + } + + res.status(500).json({ + success: false, + error: `Failed to pull updates: ${errorMsg}`, + }); + } + } catch (error) { + logError(error, 'Update pull failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/ui/src/components/views/settings-view.tsx b/apps/ui/src/components/views/settings-view.tsx index ce737fee8..7e4b1cdbe 100644 --- a/apps/ui/src/components/views/settings-view.tsx +++ b/apps/ui/src/components/views/settings-view.tsx @@ -18,6 +18,7 @@ import { TerminalSection } from './settings-view/terminal/terminal-section'; import { AudioSection } from './settings-view/audio/audio-section'; import { KeyboardShortcutsSection } from './settings-view/keyboard-shortcuts/keyboard-shortcuts-section'; import { FeatureDefaultsSection } from './settings-view/feature-defaults/feature-defaults-section'; +import { UpdatesSection } from './settings-view/updates/updates-section'; import { DangerZoneSection } from './settings-view/danger-zone/danger-zone-section'; import { MCPServersSection } from './settings-view/mcp-servers'; import { PromptCustomizationSection } from './settings-view/prompts'; @@ -57,6 +58,8 @@ export function SettingsView() { setEnableSandboxMode, promptCustomization, setPromptCustomization, + autoUpdate, + setAutoUpdate, } = useAppStore(); const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus); @@ -179,6 +182,8 @@ export function SettingsView() { onValidationModelChange={setValidationModel} /> ); + case 'updates': + return ; case 'danger': return ( ) => void; +} + +interface UpdateInfo { + automakerPath: string; + isGitRepo: boolean; + gitAvailable: boolean; + currentCommit: string | null; + currentCommitShort: string | null; + currentBranch: string | null; + hasLocalChanges: boolean; + upstreamUrl: string; + autoUpdateEnabled: boolean; + checkIntervalMinutes: number; +} + +export function UpdatesSection({ autoUpdate, onAutoUpdateChange }: UpdatesSectionProps) { + const [isChecking, setIsChecking] = useState(false); + const [isPulling, setIsPulling] = useState(false); + const [updateInfo, setUpdateInfo] = useState(null); + const [updateAvailable, setUpdateAvailable] = useState(false); + const [remoteCommit, setRemoteCommit] = useState(null); + const [error, setError] = useState(null); + + // Fetch update info + const fetchInfo = useCallback(async () => { + try { + const api = getElectronAPI(); + if (!api.updates?.info) return; + + const result = await api.updates.info(); + if (result.success && result.result) { + setUpdateInfo(result.result); + } + } catch (err) { + console.error('Failed to fetch update info:', err); + } + }, []); + + // Fetch update info on mount + useEffect(() => { + fetchInfo(); + }, [fetchInfo]); + + // Listen for update-pulled events from the useAutoUpdate hook + useEffect(() => { + const handleUpdatePulled = () => { + // Refetch info and clear update available state + fetchInfo(); + setUpdateAvailable(false); + setRemoteCommit(null); + }; + + window.addEventListener('automaker:update-pulled', handleUpdatePulled); + return () => window.removeEventListener('automaker:update-pulled', handleUpdatePulled); + }, [fetchInfo]); + + // Check for updates + const handleCheckForUpdates = useCallback(async () => { + setIsChecking(true); + setError(null); + + try { + const api = getElectronAPI(); + if (!api.updates?.check) { + toast.error('Updates API not available'); + return; + } + + const result = await api.updates.check(); + + if (!result.success) { + setError(result.error || 'Failed to check for updates'); + toast.error(result.error || 'Failed to check for updates'); + return; + } + + if (result.result) { + if (result.result.error) { + setError(result.result.error); + toast.error(result.result.error); + } else if (result.result.updateAvailable) { + setUpdateAvailable(true); + setRemoteCommit(result.result.remoteCommitShort); + toast.success('Update available!', { + description: `New version: ${result.result.remoteCommitShort}`, + }); + } else { + setUpdateAvailable(false); + toast.success('You are up to date!'); + } + } + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to check for updates'; + setError(message); + toast.error(message); + } finally { + setIsChecking(false); + } + }, []); + + // Pull updates + const handlePullUpdates = useCallback(async () => { + setIsPulling(true); + setError(null); + + try { + const api = getElectronAPI(); + if (!api.updates?.pull) { + toast.error('Updates API not available'); + return; + } + + const result = await api.updates.pull(); + + if (!result.success) { + setError(result.error || 'Failed to pull updates'); + toast.error(result.error || 'Failed to pull updates'); + return; + } + + if (result.result) { + if (result.result.alreadyUpToDate) { + toast.success('Already up to date!'); + } else { + setUpdateAvailable(false); + // Refresh the info + const infoResult = await api.updates.info(); + if (infoResult.success && infoResult.result) { + setUpdateInfo(infoResult.result); + } + + // Show restart toast + toast.success('Update installed!', { + description: result.result.message, + duration: Infinity, + action: { + label: 'Restart Now', + onClick: () => { + // For Electron, we need to reload the window + // The server will need to be restarted separately + window.location.reload(); + }, + }, + }); + } + } + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to pull updates'; + setError(message); + toast.error(message); + } finally { + setIsPulling(false); + } + }, []); + + // Extract repo name from URL for display + const getRepoDisplayName = (url: string) => { + const match = url.match(/github\.com[/:]([^/]+\/[^/.]+)/); + return match ? match[1] : url; + }; + + return ( +
+
+
+
+ +
+

Updates

+
+

+ Check for and install updates from the upstream repository. +

+
+ +
+ {/* Current Version Info */} + {updateInfo && ( +
+
+ + Current Installation +
+
+
+ Commit: + + {updateInfo.currentCommitShort || 'Unknown'} + +
+
+ Branch: + {updateInfo.currentBranch || 'Unknown'} +
+ {updateInfo.hasLocalChanges && ( +
+ + Local changes detected +
+ )} +
+
+ )} + + {/* Update Status */} + {updateAvailable && remoteCommit && ( +
+
+
+ + Update Available +
+ {remoteCommit} +
+
+ )} + + {/* Error Display */} + {error && ( +
+
+ + {error} +
+
+ )} + + {/* Auto-Update Toggle */} +
+ onAutoUpdateChange({ enabled: !!checked })} + className="mt-1" + /> +
+ +

+ Periodically check for new updates from the upstream repository. +

+
+
+ + {/* Check Interval */} +
+ + { + const value = parseInt(e.target.value, 10); + if (!isNaN(value) && value >= 1 && value <= 60) { + onAutoUpdateChange({ checkIntervalMinutes: value }); + } + }} + className="w-32" + disabled={!autoUpdate.enabled} + /> +

+ How often to check for updates (1-60 minutes). +

+
+ + {/* Upstream URL */} +
+ +
+ onAutoUpdateChange({ upstreamUrl: e.target.value })} + placeholder="https://github.com/AutoMaker-Org/automaker.git" + className="flex-1 font-mono text-sm" + /> + +
+

+ Repository to check for updates. Default: {getRepoDisplayName(autoUpdate.upstreamUrl)} +

+
+ + {/* Action Buttons */} +
+ + + {updateAvailable && ( + + )} +
+
+
+ ); +} diff --git a/apps/ui/src/hooks/use-auto-update.ts b/apps/ui/src/hooks/use-auto-update.ts new file mode 100644 index 000000000..1e3998162 --- /dev/null +++ b/apps/ui/src/hooks/use-auto-update.ts @@ -0,0 +1,198 @@ +import { useEffect, useRef, useCallback, useState } from 'react'; +import { useAppStore } from '@/store/app-store'; +import { getElectronAPI } from '@/lib/electron'; +import { toast } from 'sonner'; + +/** + * Hook for automatic update checking. + * + * Polls the server at the configured interval to check for updates. + * Shows a persistent toast when an update is available. + */ +export function useAutoUpdate() { + const { autoUpdate } = useAppStore(); + const [isChecking, setIsChecking] = useState(false); + const [updateAvailable, setUpdateAvailable] = useState(false); + const [remoteCommit, setRemoteCommit] = useState(null); + const [isPulling, setIsPulling] = useState(false); + + // Track if we've already shown the toast for this update + const shownToastForCommitRef = useRef(null); + const toastIdRef = useRef(null); + const intervalRef = useRef(null); + + // Pull updates + const pullUpdate = useCallback(async () => { + setIsPulling(true); + + try { + const api = getElectronAPI(); + if (!api.updates?.pull) { + toast.error('Updates API not available'); + return false; + } + + const result = await api.updates.pull(); + + if (!result.success) { + toast.error(result.error || 'Failed to pull updates'); + return false; + } + + if (result.result) { + if (result.result.alreadyUpToDate) { + toast.success('Already up to date!'); + return true; + } + + setUpdateAvailable(false); + shownToastForCommitRef.current = null; + + // Dismiss the update available toast if it's showing + if (toastIdRef.current) { + toast.dismiss(toastIdRef.current); + toastIdRef.current = null; + } + + // Emit event so other components (like UpdatesSection) can refresh + window.dispatchEvent( + new CustomEvent('automaker:update-pulled', { + detail: { newCommit: result.result.newCommitShort }, + }) + ); + + // Show restart toast + toast.success('Update installed!', { + description: result.result.message, + duration: Infinity, + action: { + label: 'Restart Now', + onClick: () => { + window.location.reload(); + }, + }, + }); + + return true; + } + + return false; + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to pull updates'; + toast.error(message); + return false; + } finally { + setIsPulling(false); + } + }, []); + + // Check for updates + const checkForUpdates = useCallback(async () => { + if (isChecking || isPulling) return; + + setIsChecking(true); + + try { + const api = getElectronAPI(); + if (!api.updates?.check) { + return; + } + + const result = await api.updates.check(); + + if (!result.success || !result.result) { + return; + } + + if (result.result.error) { + // Network error or similar - silently ignore, will retry next interval + console.warn('Update check failed:', result.result.error); + return; + } + + if (result.result.updateAvailable && result.result.remoteCommitShort) { + setUpdateAvailable(true); + setRemoteCommit(result.result.remoteCommitShort); + + // Only show toast if we haven't shown it for this commit yet + if (shownToastForCommitRef.current !== result.result.remoteCommit) { + shownToastForCommitRef.current = result.result.remoteCommit; + + // Dismiss any existing toast + if (toastIdRef.current) { + toast.dismiss(toastIdRef.current); + } + + // Extract repo name for display + const repoMatch = result.result.upstreamUrl.match(/github\.com[/:]([^/]+\/[^/.]+)/); + const repoName = repoMatch ? repoMatch[1] : 'upstream'; + + // Show persistent toast with update button + toastIdRef.current = toast.info('Update Available', { + description: `New version (${result.result.remoteCommitShort}) available from ${repoName}`, + duration: Infinity, + action: { + label: 'Update Now', + onClick: async () => { + await pullUpdate(); + }, + }, + }); + } + } else { + setUpdateAvailable(false); + setRemoteCommit(null); + } + } catch (err) { + console.warn('Update check failed:', err); + } finally { + setIsChecking(false); + } + }, [isChecking, isPulling, pullUpdate]); + + // Set up polling interval + useEffect(() => { + // Clear any existing interval + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + + // Don't set up polling if auto-update is disabled + if (!autoUpdate.enabled) { + return; + } + + // Check immediately on mount/enable + checkForUpdates(); + + // Set up interval + const intervalMs = autoUpdate.checkIntervalMinutes * 60 * 1000; + intervalRef.current = setInterval(checkForUpdates, intervalMs); + + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }; + }, [autoUpdate.enabled, autoUpdate.checkIntervalMinutes, checkForUpdates]); + + // Clean up toast on unmount + useEffect(() => { + return () => { + if (toastIdRef.current) { + toast.dismiss(toastIdRef.current); + } + }; + }, []); + + return { + isChecking, + updateAvailable, + remoteCommit, + isPulling, + checkNow: checkForUpdates, + pullUpdate, + }; +} diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index 58125806c..22ee15cd9 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -647,6 +647,51 @@ export interface ElectronAPI { error?: string; }>; }; + updates?: { + check: () => Promise<{ + success: boolean; + result?: { + updateAvailable: boolean; + localCommit: string; + localCommitShort: string; + remoteCommit: string | null; + remoteCommitShort: string | null; + upstreamUrl: string; + automakerPath: string; + error?: string; + }; + error?: string; + }>; + pull: () => Promise<{ + success: boolean; + result?: { + success: boolean; + previousCommit: string; + previousCommitShort: string; + newCommit: string; + newCommitShort: string; + alreadyUpToDate: boolean; + message: string; + }; + error?: string; + }>; + info: () => Promise<{ + success: boolean; + result?: { + automakerPath: string; + isGitRepo: boolean; + gitAvailable: boolean; + currentCommit: string | null; + currentCommitShort: string | null; + currentBranch: string | null; + hasLocalChanges: boolean; + upstreamUrl: string; + autoUpdateEnabled: boolean; + checkIntervalMinutes: number; + }; + error?: string; + }>; + }; } // Note: Window interface is declared in @/types/electron.d.ts diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index 953423499..59a2c300c 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -1571,6 +1571,55 @@ export class HttpApiClient implements ElectronAPI { }> => this.post('/api/context/describe-file', { filePath }), }; + // Updates API + updates = { + check: (): Promise<{ + success: boolean; + result?: { + updateAvailable: boolean; + localCommit: string; + localCommitShort: string; + remoteCommit: string | null; + remoteCommitShort: string | null; + upstreamUrl: string; + automakerPath: string; + error?: string; + }; + error?: string; + }> => this.get('/api/updates/check'), + + pull: (): Promise<{ + success: boolean; + result?: { + success: boolean; + previousCommit: string; + previousCommitShort: string; + newCommit: string; + newCommitShort: string; + alreadyUpToDate: boolean; + message: string; + }; + error?: string; + }> => this.post('/api/updates/pull', {}), + + info: (): Promise<{ + success: boolean; + result?: { + automakerPath: string; + isGitRepo: boolean; + gitAvailable: boolean; + currentCommit: string | null; + currentCommitShort: string | null; + currentBranch: string | null; + hasLocalChanges: boolean; + upstreamUrl: string; + autoUpdateEnabled: boolean; + checkIntervalMinutes: number; + }; + error?: string; + }> => this.get('/api/updates/info'), + }; + // Backlog Plan API backlogPlan = { generate: ( diff --git a/apps/ui/src/routes/__root.tsx b/apps/ui/src/routes/__root.tsx index 3608334d2..4bd5b50c4 100644 --- a/apps/ui/src/routes/__root.tsx +++ b/apps/ui/src/routes/__root.tsx @@ -20,6 +20,7 @@ import { Toaster } from 'sonner'; import { ThemeOption, themeOptions } from '@/config/theme-options'; import { SandboxRiskDialog } from '@/components/dialogs/sandbox-risk-dialog'; import { SandboxRejectionScreen } from '@/components/dialogs/sandbox-rejection-screen'; +import { useAutoUpdate } from '@/hooks/use-auto-update'; // Session storage key for sandbox risk acknowledgment const SANDBOX_RISK_ACKNOWLEDGED_KEY = 'automaker-sandbox-risk-acknowledged'; @@ -53,6 +54,9 @@ function RootLayoutContent() { return 'pending'; }); + // Auto-update polling - checks for updates at configured interval + useAutoUpdate(); + // Hidden streamer panel - opens with "\" key const handleStreamerPanelShortcut = useCallback((event: KeyboardEvent) => { const activeElement = document.activeElement; diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index c04daa8f0..85b988dff 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -12,7 +12,10 @@ import type { PipelineConfig, PipelineStep, PromptCustomization, +======= + AutoUpdateSettings, } from '@automaker/types'; +import { DEFAULT_AUTO_UPDATE_SETTINGS } from '@automaker/types'; // Re-export ThemeMode for convenience export type { ThemeMode }; @@ -495,6 +498,9 @@ export interface AppState { // Prompt Customization promptCustomization: PromptCustomization; // Custom prompts for Auto Mode, Agent, Backlog Plan, Enhancement +======= + // Auto-Update Settings + autoUpdate: AutoUpdateSettings; // Configuration for automatic update checking // Project Analysis projectAnalysis: ProjectAnalysis | null; @@ -781,6 +787,9 @@ export interface AppActions { // Prompt Customization actions setPromptCustomization: (customization: PromptCustomization) => Promise; + // Auto-Update Settings actions + setAutoUpdate: (settings: Partial) => void; + // AI Profile actions addAIProfile: (profile: Omit) => void; updateAIProfile: (id: string, updates: Partial) => void; @@ -980,6 +989,9 @@ const initialState: AppState = { mcpAutoApproveTools: true, // Default to enabled - bypass permission prompts for MCP tools mcpUnrestrictedTools: true, // Default to enabled - don't filter allowedTools when MCP enabled promptCustomization: {}, // Empty by default - all prompts use built-in defaults +======= + enableSandboxMode: true, // Default to enabled for security (can be disabled if issues occur) + autoUpdate: DEFAULT_AUTO_UPDATE_SETTINGS, // Default auto-update settings aiProfiles: DEFAULT_AI_PROFILES, projectAnalysis: null, isAnalyzing: false, @@ -1644,6 +1656,13 @@ export const useAppStore = create()( await syncSettingsToServer(); }, + // Auto-Update Settings actions + setAutoUpdate: (settings) => { + set((state) => ({ + autoUpdate: { ...state.autoUpdate, ...settings }, + })); + }, + // AI Profile actions addAIProfile: (profile) => { const id = `profile-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; @@ -2927,6 +2946,8 @@ export const useAppStore = create()( mcpUnrestrictedTools: state.mcpUnrestrictedTools, // Prompt customization promptCustomization: state.promptCustomization, +======= + autoUpdate: state.autoUpdate, // Profiles and sessions aiProfiles: state.aiProfiles, chatSessions: state.chatSessions, diff --git a/apps/ui/vite.config.mts b/apps/ui/vite.config.mts index 12243c8e0..4d5f7ca74 100644 --- a/apps/ui/vite.config.mts +++ b/apps/ui/vite.config.mts @@ -66,6 +66,14 @@ export default defineConfig(({ command }) => { }, server: { port: parseInt(process.env.TEST_PORT || '3007', 10), + proxy: { + // Proxy API requests to the backend server + '/api': { + target: `http://localhost:${process.env.VITE_SERVER_PORT || '3008'}`, + changeOrigin: true, + ws: true, // Enable WebSocket proxying + }, + }, }, build: { outDir: 'dist', diff --git a/libs/types/src/index.ts b/libs/types/src/index.ts index be7148776..72487496c 100644 --- a/libs/types/src/index.ts +++ b/libs/types/src/index.ts @@ -83,12 +83,14 @@ export type { BoardBackgroundSettings, WorktreeInfo, ProjectSettings, + AutoUpdateSettings, } from './settings.js'; export { DEFAULT_KEYBOARD_SHORTCUTS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_CREDENTIALS, DEFAULT_PROJECT_SETTINGS, + DEFAULT_AUTO_UPDATE_SETTINGS, SETTINGS_VERSION, CREDENTIALS_VERSION, PROJECT_SETTINGS_VERSION, diff --git a/libs/types/src/settings.ts b/libs/types/src/settings.ts index 990c2ff6c..a7747ac21 100644 --- a/libs/types/src/settings.ts +++ b/libs/types/src/settings.ts @@ -365,6 +365,10 @@ export interface GlobalSettings { // Prompt Customization /** Custom prompts for Auto Mode, Agent Runner, Backlog Planning, and Enhancements */ promptCustomization?: PromptCustomization; +======= + // Auto-Update Settings + /** Configuration for automatic update checking */ + autoUpdate: AutoUpdateSettings; } /** @@ -501,6 +505,27 @@ export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = { closeTerminal: 'Alt+W', }; +/** + * AutoUpdateSettings - Configuration for automatic update checking + * + * Controls how the app checks for and applies updates from the upstream repository. + */ +export interface AutoUpdateSettings { + /** Whether automatic update checking is enabled */ + enabled: boolean; + /** How often to check for updates (in minutes) */ + checkIntervalMinutes: number; + /** URL of the upstream repository to check for updates */ + upstreamUrl: string; +} + +/** Default auto-update settings */ +export const DEFAULT_AUTO_UPDATE_SETTINGS: AutoUpdateSettings = { + enabled: true, + checkIntervalMinutes: 15, + upstreamUrl: 'https://github.com/AutoMaker-Org/automaker.git', +}; + /** Default global settings used when no settings file exists */ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = { version: SETTINGS_VERSION, @@ -536,6 +561,9 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = { // via the security warning dialog that explains the risks. mcpAutoApproveTools: true, mcpUnrestrictedTools: true, +======= + enableSandboxMode: true, + autoUpdate: DEFAULT_AUTO_UPDATE_SETTINGS, }; /** Default credentials (empty strings - user must provide API keys) */ From 21538cf2396fa9b5c1c0378425f2d6e43346d277 Mon Sep 17 00:00:00 2001 From: Arkadiusz Moscicki Date: Sat, 27 Dec 2025 20:59:25 +0000 Subject: [PATCH 02/13] test: verify auto-update detection --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index c8e1b84ea..a7d4a1546 100644 --- a/README.md +++ b/README.md @@ -662,3 +662,5 @@ This project is licensed under the **Automaker License Agreement**. See [LICENSE - By contributing to this repository, you grant the Core Contributors full, irrevocable rights to your code (copyright assignment). **Core Contributors** (Cody Seibert (webdevcody), SuperComboGamer (SCG), Kacper Lachowicz (Shironex, Shirone), and Ben Scott (trueheads)) are granted perpetual, royalty-free licenses for any use, including monetization. + +# Test update 1766869165 From 78a434fffc9b22017134bbf154e103961a622c66 Mon Sep 17 00:00:00 2001 From: Arkadiusz Moscicki Date: Sun, 28 Dec 2025 00:45:02 +0000 Subject: [PATCH 03/13] refactor: abstract update types for future flexibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename commit-related fields to version-related (localVersion, remoteVersion, etc.) - Add centralized updates-store with DI support (IUpdatesApiClient, IUpdateEventEmitter) - Separate concerns: store (state), polling hook (when), notifier (toasts), section (UI) - Add UpdateNotifier component for toast notifications - Make updateType optional for backwards compatibility 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../server/src/routes/updates/routes/check.ts | 84 ++--- apps/server/src/routes/updates/routes/info.ts | 73 ++-- apps/server/src/routes/updates/routes/pull.ts | 59 ++-- apps/ui/src/components/updates/index.ts | 8 + .../components/updates/update-notifier.tsx | 164 +++++++++ .../settings-view/updates/updates-section.tsx | 234 +++++-------- apps/ui/src/hooks/use-auto-update.ts | 198 ----------- apps/ui/src/hooks/use-update-polling.ts | 94 +++++ apps/ui/src/lib/electron.ts | 28 +- apps/ui/src/lib/http-api-client.ts | 28 +- apps/ui/src/routes/__root.tsx | 5 + apps/ui/src/store/updates-store.ts | 330 ++++++++++++++++++ libs/types/src/index.ts | 10 + libs/types/src/updates.ts | 107 ++++++ 14 files changed, 924 insertions(+), 498 deletions(-) create mode 100644 apps/ui/src/components/updates/index.ts create mode 100644 apps/ui/src/components/updates/update-notifier.tsx delete mode 100644 apps/ui/src/hooks/use-auto-update.ts create mode 100644 apps/ui/src/hooks/use-update-polling.ts create mode 100644 apps/ui/src/store/updates-store.ts create mode 100644 libs/types/src/updates.ts diff --git a/apps/server/src/routes/updates/routes/check.ts b/apps/server/src/routes/updates/routes/check.ts index 1ba015bad..3fba78829 100644 --- a/apps/server/src/routes/updates/routes/check.ts +++ b/apps/server/src/routes/updates/routes/check.ts @@ -1,11 +1,12 @@ /** * GET /check endpoint - Check if updates are available * - * Compares local HEAD commit with the remote upstream branch. + * Compares local version with the remote upstream version. */ import type { Request, Response } from 'express'; import type { SettingsService } from '../../../services/settings-service.js'; +import type { UpdateCheckResult } from '@automaker/types'; import { execAsync, execEnv, @@ -18,21 +19,10 @@ import { logError, } from '../common.js'; -export interface UpdateCheckResult { - updateAvailable: boolean; - localCommit: string; - localCommitShort: string; - remoteCommit: string | null; - remoteCommitShort: string | null; - upstreamUrl: string; - automakerPath: string; - error?: string; -} - export function createCheckHandler(settingsService: SettingsService) { return async (_req: Request, res: Response): Promise => { try { - const automakerPath = getAutomakerRoot(); + const installPath = getAutomakerRoot(); // Check if git is available if (!(await isGitAvailable())) { @@ -44,7 +34,7 @@ export function createCheckHandler(settingsService: SettingsService) { } // Check if automaker directory is a git repo - if (!(await isGitRepo(automakerPath))) { + if (!(await isGitRepo(installPath))) { res.status(500).json({ success: false, error: 'Automaker installation is not a git repository', @@ -54,12 +44,12 @@ export function createCheckHandler(settingsService: SettingsService) { // Get settings for upstream URL const settings = await settingsService.getGlobalSettings(); - const upstreamUrl = + const sourceUrl = settings.autoUpdate?.upstreamUrl || 'https://github.com/AutoMaker-Org/automaker.git'; - // Get local commit - const localCommit = await getCurrentCommit(automakerPath); - const localCommitShort = await getShortCommit(automakerPath); + // Get local version + const localVersion = await getCurrentCommit(installPath); + const localVersionShort = await getShortCommit(installPath); // Fetch from upstream (use a temporary remote name to avoid conflicts) const tempRemoteName = 'automaker-update-check'; @@ -68,7 +58,7 @@ export function createCheckHandler(settingsService: SettingsService) { // Remove temp remote if it exists (ignore errors) try { await execAsync(`git remote remove ${tempRemoteName}`, { - cwd: automakerPath, + cwd: installPath, env: execEnv, }); } catch { @@ -76,45 +66,45 @@ export function createCheckHandler(settingsService: SettingsService) { } // Add temporary remote - await execAsync(`git remote add ${tempRemoteName} "${upstreamUrl}"`, { - cwd: automakerPath, + await execAsync(`git remote add ${tempRemoteName} "${sourceUrl}"`, { + cwd: installPath, env: execEnv, }); // Fetch from the temporary remote await execAsync(`git fetch ${tempRemoteName} main`, { - cwd: automakerPath, + cwd: installPath, env: execEnv, }); - // Get remote commit - const { stdout: remoteCommitOutput } = await execAsync( + // Get remote version + const { stdout: remoteVersionOutput } = await execAsync( `git rev-parse ${tempRemoteName}/main`, - { cwd: automakerPath, env: execEnv } + { cwd: installPath, env: execEnv } ); - const remoteCommit = remoteCommitOutput.trim(); + const remoteVersion = remoteVersionOutput.trim(); - // Get short remote commit - const { stdout: remoteCommitShortOutput } = await execAsync( + // Get short remote version + const { stdout: remoteVersionShortOutput } = await execAsync( `git rev-parse --short ${tempRemoteName}/main`, - { cwd: automakerPath, env: execEnv } + { cwd: installPath, env: execEnv } ); - const remoteCommitShort = remoteCommitShortOutput.trim(); + const remoteVersionShort = remoteVersionShortOutput.trim(); // Clean up temp remote await execAsync(`git remote remove ${tempRemoteName}`, { - cwd: automakerPath, + cwd: installPath, env: execEnv, }); // Check if remote is ahead of local (update available) // git merge-base --is-ancestor returns 0 if commit1 is ancestor of commit2 let updateAvailable = false; - if (localCommit !== remoteCommit) { + if (localVersion !== remoteVersion) { try { // Check if local is already an ancestor of remote (remote is ahead) - await execAsync(`git merge-base --is-ancestor ${localCommit} ${remoteCommit}`, { - cwd: automakerPath, + await execAsync(`git merge-base --is-ancestor ${localVersion} ${remoteVersion}`, { + cwd: installPath, env: execEnv, }); // If we get here (exit code 0), local is ancestor of remote, so update is available @@ -129,12 +119,12 @@ export function createCheckHandler(settingsService: SettingsService) { const result: UpdateCheckResult = { updateAvailable, - localCommit, - localCommitShort, - remoteCommit, - remoteCommitShort, - upstreamUrl, - automakerPath, + localVersion, + localVersionShort, + remoteVersion, + remoteVersionShort, + sourceUrl, + installPath, }; res.json({ @@ -145,7 +135,7 @@ export function createCheckHandler(settingsService: SettingsService) { // Clean up temp remote on error try { await execAsync(`git remote remove ${tempRemoteName}`, { - cwd: automakerPath, + cwd: installPath, env: execEnv, }); } catch { @@ -159,12 +149,12 @@ export function createCheckHandler(settingsService: SettingsService) { success: true, result: { updateAvailable: false, - localCommit, - localCommitShort, - remoteCommit: null, - remoteCommitShort: null, - upstreamUrl, - automakerPath, + localVersion, + localVersionShort, + remoteVersion: null, + remoteVersionShort: null, + sourceUrl, + installPath, error: `Could not fetch from upstream: ${errorMsg}`, } satisfies UpdateCheckResult, }); diff --git a/apps/server/src/routes/updates/routes/info.ts b/apps/server/src/routes/updates/routes/info.ts index ca416d8ec..bb0b47412 100644 --- a/apps/server/src/routes/updates/routes/info.ts +++ b/apps/server/src/routes/updates/routes/info.ts @@ -1,11 +1,12 @@ /** * GET /info endpoint - Get current installation info * - * Returns current commit, branch, and configuration info. + * Returns current version, branch, and configuration info. */ import type { Request, Response } from 'express'; import type { SettingsService } from '../../../services/settings-service.js'; +import type { UpdateInfo } from '@automaker/types'; import { execAsync, execEnv, @@ -19,23 +20,10 @@ import { logError, } from '../common.js'; -export interface UpdateInfo { - automakerPath: string; - isGitRepo: boolean; - gitAvailable: boolean; - currentCommit: string | null; - currentCommitShort: string | null; - currentBranch: string | null; - hasLocalChanges: boolean; - upstreamUrl: string; - autoUpdateEnabled: boolean; - checkIntervalMinutes: number; -} - export function createInfoHandler(settingsService: SettingsService) { return async (_req: Request, res: Response): Promise => { try { - const automakerPath = getAutomakerRoot(); + const installPath = getAutomakerRoot(); // Get settings const settings = await settingsService.getGlobalSettings(); @@ -50,16 +38,19 @@ export function createInfoHandler(settingsService: SettingsService) { if (!gitAvailable) { const result: UpdateInfo = { - automakerPath, - isGitRepo: false, - gitAvailable: false, - currentCommit: null, - currentCommitShort: null, + installPath, + currentVersion: null, + currentVersionShort: null, currentBranch: null, hasLocalChanges: false, - upstreamUrl: autoUpdateSettings.upstreamUrl, + sourceUrl: autoUpdateSettings.upstreamUrl, autoUpdateEnabled: autoUpdateSettings.enabled, checkIntervalMinutes: autoUpdateSettings.checkIntervalMinutes, + updateType: 'git', + mechanismInfo: { + isGitRepo: false, + gitAvailable: false, + }, }; res.json({ @@ -70,20 +61,23 @@ export function createInfoHandler(settingsService: SettingsService) { } // Check if it's a git repo - const isRepo = await isGitRepo(automakerPath); + const isRepo = await isGitRepo(installPath); if (!isRepo) { const result: UpdateInfo = { - automakerPath, - isGitRepo: false, - gitAvailable: true, - currentCommit: null, - currentCommitShort: null, + installPath, + currentVersion: null, + currentVersionShort: null, currentBranch: null, hasLocalChanges: false, - upstreamUrl: autoUpdateSettings.upstreamUrl, + sourceUrl: autoUpdateSettings.upstreamUrl, autoUpdateEnabled: autoUpdateSettings.enabled, checkIntervalMinutes: autoUpdateSettings.checkIntervalMinutes, + updateType: 'git', + mechanismInfo: { + isGitRepo: false, + gitAvailable: true, + }, }; res.json({ @@ -94,30 +88,33 @@ export function createInfoHandler(settingsService: SettingsService) { } // Get git info - const currentCommit = await getCurrentCommit(automakerPath); - const currentCommitShort = await getShortCommit(automakerPath); + const currentVersion = await getCurrentCommit(installPath); + const currentVersionShort = await getShortCommit(installPath); // Get current branch const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', { - cwd: automakerPath, + cwd: installPath, env: execEnv, }); const currentBranch = branchOutput.trim(); // Check for local changes - const localChanges = await hasLocalChanges(automakerPath); + const localChanges = await hasLocalChanges(installPath); const result: UpdateInfo = { - automakerPath, - isGitRepo: true, - gitAvailable: true, - currentCommit, - currentCommitShort, + installPath, + currentVersion, + currentVersionShort, currentBranch, hasLocalChanges: localChanges, - upstreamUrl: autoUpdateSettings.upstreamUrl, + sourceUrl: autoUpdateSettings.upstreamUrl, autoUpdateEnabled: autoUpdateSettings.enabled, checkIntervalMinutes: autoUpdateSettings.checkIntervalMinutes, + updateType: 'git', + mechanismInfo: { + isGitRepo: true, + gitAvailable: true, + }, }; res.json({ diff --git a/apps/server/src/routes/updates/routes/pull.ts b/apps/server/src/routes/updates/routes/pull.ts index dff56dd0b..e51a62077 100644 --- a/apps/server/src/routes/updates/routes/pull.ts +++ b/apps/server/src/routes/updates/routes/pull.ts @@ -6,6 +6,7 @@ import type { Request, Response } from 'express'; import type { SettingsService } from '../../../services/settings-service.js'; +import type { UpdatePullResult } from '@automaker/types'; import { execAsync, execEnv, @@ -19,20 +20,10 @@ import { logError, } from '../common.js'; -export interface UpdatePullResult { - success: boolean; - previousCommit: string; - previousCommitShort: string; - newCommit: string; - newCommitShort: string; - alreadyUpToDate: boolean; - message: string; -} - export function createPullHandler(settingsService: SettingsService) { return async (_req: Request, res: Response): Promise => { try { - const automakerPath = getAutomakerRoot(); + const installPath = getAutomakerRoot(); // Check if git is available if (!(await isGitAvailable())) { @@ -44,7 +35,7 @@ export function createPullHandler(settingsService: SettingsService) { } // Check if automaker directory is a git repo - if (!(await isGitRepo(automakerPath))) { + if (!(await isGitRepo(installPath))) { res.status(500).json({ success: false, error: 'Automaker installation is not a git repository', @@ -53,7 +44,7 @@ export function createPullHandler(settingsService: SettingsService) { } // Check for local changes - if (await hasLocalChanges(automakerPath)) { + if (await hasLocalChanges(installPath)) { res.status(400).json({ success: false, error: 'You have local uncommitted changes. Please commit or stash them before updating.', @@ -63,12 +54,12 @@ export function createPullHandler(settingsService: SettingsService) { // Get settings for upstream URL const settings = await settingsService.getGlobalSettings(); - const upstreamUrl = + const sourceUrl = settings.autoUpdate?.upstreamUrl || 'https://github.com/AutoMaker-Org/automaker.git'; - // Get current commit before pull - const previousCommit = await getCurrentCommit(automakerPath); - const previousCommitShort = await getShortCommit(automakerPath); + // Get current version before pull + const previousVersion = await getCurrentCommit(installPath); + const previousVersionShort = await getShortCommit(installPath); // Use a temporary remote to pull from const tempRemoteName = 'automaker-update-pull'; @@ -77,7 +68,7 @@ export function createPullHandler(settingsService: SettingsService) { // Remove temp remote if it exists (ignore errors) try { await execAsync(`git remote remove ${tempRemoteName}`, { - cwd: automakerPath, + cwd: installPath, env: execEnv, }); } catch { @@ -85,20 +76,20 @@ export function createPullHandler(settingsService: SettingsService) { } // Add temporary remote - await execAsync(`git remote add ${tempRemoteName} "${upstreamUrl}"`, { - cwd: automakerPath, + await execAsync(`git remote add ${tempRemoteName} "${sourceUrl}"`, { + cwd: installPath, env: execEnv, }); // Fetch first await execAsync(`git fetch ${tempRemoteName} main`, { - cwd: automakerPath, + cwd: installPath, env: execEnv, }); // Get current branch const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', { - cwd: automakerPath, + cwd: installPath, env: execEnv, }); const currentBranch = branchOutput.trim(); @@ -106,32 +97,32 @@ export function createPullHandler(settingsService: SettingsService) { // Merge the fetched changes const { stdout: mergeOutput } = await execAsync( `git merge ${tempRemoteName}/main --ff-only`, - { cwd: automakerPath, env: execEnv } + { cwd: installPath, env: execEnv } ); // Clean up temp remote await execAsync(`git remote remove ${tempRemoteName}`, { - cwd: automakerPath, + cwd: installPath, env: execEnv, }); - // Get new commit after merge - const newCommit = await getCurrentCommit(automakerPath); - const newCommitShort = await getShortCommit(automakerPath); + // Get new version after merge + const newVersion = await getCurrentCommit(installPath); + const newVersionShort = await getShortCommit(installPath); const alreadyUpToDate = - mergeOutput.includes('Already up to date') || previousCommit === newCommit; + mergeOutput.includes('Already up to date') || previousVersion === newVersion; const result: UpdatePullResult = { success: true, - previousCommit, - previousCommitShort, - newCommit, - newCommitShort, + previousVersion, + previousVersionShort, + newVersion, + newVersionShort, alreadyUpToDate, message: alreadyUpToDate ? 'Already up to date' - : `Updated from ${previousCommitShort} to ${newCommitShort}`, + : `Updated from ${previousVersionShort} to ${newVersionShort}`, }; res.json({ @@ -142,7 +133,7 @@ export function createPullHandler(settingsService: SettingsService) { // Clean up temp remote on error try { await execAsync(`git remote remove ${tempRemoteName}`, { - cwd: automakerPath, + cwd: installPath, env: execEnv, }); } catch { diff --git a/apps/ui/src/components/updates/index.ts b/apps/ui/src/components/updates/index.ts new file mode 100644 index 000000000..166cc4579 --- /dev/null +++ b/apps/ui/src/components/updates/index.ts @@ -0,0 +1,8 @@ +/** + * Updates Components + * + * Export all update-related components for easy imports. + */ + +export { UpdateNotifier } from './update-notifier'; +export type { UpdateNotifierProps } from './update-notifier'; diff --git a/apps/ui/src/components/updates/update-notifier.tsx b/apps/ui/src/components/updates/update-notifier.tsx new file mode 100644 index 000000000..859c1742d --- /dev/null +++ b/apps/ui/src/components/updates/update-notifier.tsx @@ -0,0 +1,164 @@ +/** + * Update Notifier Component + * + * Responsible for displaying toast notifications related to updates. + * Subscribes to the updates store and reacts to state changes. + * + * This component handles the UI notifications, keeping them separate + * from the business logic in the store. + */ + +import { useEffect, useRef, useCallback } from 'react'; +import { toast } from 'sonner'; +import { useUpdatesStore } from '@/store/updates-store'; +import { useUpdatePolling } from '@/hooks/use-update-polling'; +import { useAppStore } from '@/store/app-store'; + +// ============================================================================ +// Types +// ============================================================================ + +export interface UpdateNotifierProps { + /** Custom handler for update available (for testing/DI) */ + onUpdateAvailable?: (remoteVersion: string) => void; + + /** Custom handler for update installed (for testing/DI) */ + onUpdateInstalled?: (newVersion: string, alreadyUpToDate: boolean) => void; +} + +// ============================================================================ +// Component +// ============================================================================ + +/** + * Update Notifier - handles toast notifications for updates. + * + * Place this component at the app root level to enable update notifications. + * It subscribes to the updates store and shows toasts when: + * - An update is available (persistent toast with "Update Now" button) + * - An update is installed (toast with "Restart Now" / "Later" buttons) + */ +export function UpdateNotifier({ onUpdateAvailable, onUpdateInstalled }: UpdateNotifierProps = {}) { + // Store state + const { updateAvailable, remoteVersionShort, pullUpdates, isPulling } = useUpdatesStore(); + + const { autoUpdate } = useAppStore(); + + // Start polling + useUpdatePolling(); + + // Track shown toasts to avoid duplicates + const shownToastForCommitRef = useRef(null); + const toastIdRef = useRef(null); + + // Handle "Update Now" click + const handleUpdateNow = useCallback(async () => { + const result = await pullUpdates(); + + if (result) { + // Dismiss the "update available" toast + if (toastIdRef.current) { + toast.dismiss(toastIdRef.current); + toastIdRef.current = null; + } + + // Call custom handler if provided + if (onUpdateInstalled) { + onUpdateInstalled(result.newVersionShort, result.alreadyUpToDate); + return; + } + + // Show appropriate toast based on result + if (result.alreadyUpToDate) { + toast.success('Already up to date!'); + } else { + toast.success('Update installed!', { + description: result.message, + duration: Infinity, + action: { + label: 'Restart Now', + onClick: () => { + window.location.reload(); + }, + }, + cancel: { + label: 'Later', + onClick: () => { + // Just dismiss - user will restart manually later + }, + }, + }); + } + } + }, [pullUpdates, onUpdateInstalled]); + + // Show toast when update becomes available + useEffect(() => { + if (!updateAvailable || !remoteVersionShort) { + return; + } + + // Don't show toast if we've already shown it for this version + if (shownToastForCommitRef.current === remoteVersionShort) { + return; + } + + shownToastForCommitRef.current = remoteVersionShort; + + // Call custom handler if provided + if (onUpdateAvailable) { + onUpdateAvailable(remoteVersionShort); + return; + } + + // Dismiss any existing toast + if (toastIdRef.current) { + toast.dismiss(toastIdRef.current); + } + + // Extract repo name for display + const upstreamUrl = autoUpdate.upstreamUrl; + const repoMatch = upstreamUrl.match(/github\.com[/:]([^/]+\/[^/.]+)/); + const repoName = repoMatch ? repoMatch[1] : 'upstream'; + + // Show persistent toast with update button + toastIdRef.current = toast.info('Update Available', { + description: `New version (${remoteVersionShort}) available from ${repoName}`, + duration: Infinity, + action: { + label: isPulling ? 'Updating...' : 'Update Now', + onClick: handleUpdateNow, + }, + }); + }, [ + updateAvailable, + remoteVersionShort, + autoUpdate.upstreamUrl, + isPulling, + handleUpdateNow, + onUpdateAvailable, + ]); + + // Clean up toast on unmount + useEffect(() => { + return () => { + if (toastIdRef.current) { + toast.dismiss(toastIdRef.current); + } + }; + }, []); + + // Reset shown toast when update is no longer available + useEffect(() => { + if (!updateAvailable) { + shownToastForCommitRef.current = null; + if (toastIdRef.current) { + toast.dismiss(toastIdRef.current); + toastIdRef.current = null; + } + } + }, [updateAvailable]); + + // This component doesn't render anything visible + return null; +} diff --git a/apps/ui/src/components/views/settings-view/updates/updates-section.tsx b/apps/ui/src/components/views/settings-view/updates/updates-section.tsx index 5ea6ce1fd..7f340af4b 100644 --- a/apps/ui/src/components/views/settings-view/updates/updates-section.tsx +++ b/apps/ui/src/components/views/settings-view/updates/updates-section.tsx @@ -1,4 +1,11 @@ -import { useState, useEffect, useCallback } from 'react'; +/** + * Updates Section Component + * + * Settings panel for configuring and managing auto-updates. + * Uses the centralized updates-store for state and actions. + */ + +import { useEffect } from 'react'; import { Label } from '@/components/ui/label'; import { Checkbox } from '@/components/ui/checkbox'; import { Input } from '@/components/ui/input'; @@ -12,167 +19,90 @@ import { AlertCircle, } from 'lucide-react'; import { cn } from '@/lib/utils'; -import { getElectronAPI } from '@/lib/electron'; import { toast } from 'sonner'; +import { useUpdatesStore } from '@/store/updates-store'; import type { AutoUpdateSettings } from '@automaker/types'; +// ============================================================================ +// Types +// ============================================================================ + interface UpdatesSectionProps { autoUpdate: AutoUpdateSettings; onAutoUpdateChange: (settings: Partial) => void; } -interface UpdateInfo { - automakerPath: string; - isGitRepo: boolean; - gitAvailable: boolean; - currentCommit: string | null; - currentCommitShort: string | null; - currentBranch: string | null; - hasLocalChanges: boolean; - upstreamUrl: string; - autoUpdateEnabled: boolean; - checkIntervalMinutes: number; -} +// ============================================================================ +// Component +// ============================================================================ export function UpdatesSection({ autoUpdate, onAutoUpdateChange }: UpdatesSectionProps) { - const [isChecking, setIsChecking] = useState(false); - const [isPulling, setIsPulling] = useState(false); - const [updateInfo, setUpdateInfo] = useState(null); - const [updateAvailable, setUpdateAvailable] = useState(false); - const [remoteCommit, setRemoteCommit] = useState(null); - const [error, setError] = useState(null); - - // Fetch update info - const fetchInfo = useCallback(async () => { - try { - const api = getElectronAPI(); - if (!api.updates?.info) return; - - const result = await api.updates.info(); - if (result.success && result.result) { - setUpdateInfo(result.result); - } - } catch (err) { - console.error('Failed to fetch update info:', err); - } - }, []); + // Use centralized store + const { + info, + updateAvailable, + remoteVersionShort, + isChecking, + isPulling, + isLoadingInfo, + error, + fetchInfo, + checkForUpdates, + pullUpdates, + clearError, + } = useUpdatesStore(); - // Fetch update info on mount + // Fetch info on mount useEffect(() => { fetchInfo(); }, [fetchInfo]); - // Listen for update-pulled events from the useAutoUpdate hook - useEffect(() => { - const handleUpdatePulled = () => { - // Refetch info and clear update available state - fetchInfo(); - setUpdateAvailable(false); - setRemoteCommit(null); - }; - - window.addEventListener('automaker:update-pulled', handleUpdatePulled); - return () => window.removeEventListener('automaker:update-pulled', handleUpdatePulled); - }, [fetchInfo]); - - // Check for updates - const handleCheckForUpdates = useCallback(async () => { - setIsChecking(true); - setError(null); - - try { - const api = getElectronAPI(); - if (!api.updates?.check) { - toast.error('Updates API not available'); - return; - } - - const result = await api.updates.check(); + // Handle check for updates with toast notifications + const handleCheckForUpdates = async () => { + clearError(); + const hasUpdate = await checkForUpdates(); - if (!result.success) { - setError(result.error || 'Failed to check for updates'); - toast.error(result.error || 'Failed to check for updates'); - return; - } - - if (result.result) { - if (result.result.error) { - setError(result.result.error); - toast.error(result.result.error); - } else if (result.result.updateAvailable) { - setUpdateAvailable(true); - setRemoteCommit(result.result.remoteCommitShort); - toast.success('Update available!', { - description: `New version: ${result.result.remoteCommitShort}`, - }); - } else { - setUpdateAvailable(false); - toast.success('You are up to date!'); - } - } - } catch (err) { - const message = err instanceof Error ? err.message : 'Failed to check for updates'; - setError(message); - toast.error(message); - } finally { - setIsChecking(false); + if (hasUpdate) { + toast.success('Update available!', { + description: `New version: ${useUpdatesStore.getState().remoteVersionShort}`, + }); + } else if (!useUpdatesStore.getState().error) { + toast.success('You are up to date!'); + } else { + toast.error(useUpdatesStore.getState().error || 'Failed to check for updates'); } - }, []); - - // Pull updates - const handlePullUpdates = useCallback(async () => { - setIsPulling(true); - setError(null); - - try { - const api = getElectronAPI(); - if (!api.updates?.pull) { - toast.error('Updates API not available'); - return; - } - - const result = await api.updates.pull(); - - if (!result.success) { - setError(result.error || 'Failed to pull updates'); - toast.error(result.error || 'Failed to pull updates'); - return; - } + }; - if (result.result) { - if (result.result.alreadyUpToDate) { - toast.success('Already up to date!'); - } else { - setUpdateAvailable(false); - // Refresh the info - const infoResult = await api.updates.info(); - if (infoResult.success && infoResult.result) { - setUpdateInfo(infoResult.result); - } + // Handle pull updates with toast notifications + const handlePullUpdates = async () => { + clearError(); + const result = await pullUpdates(); - // Show restart toast - toast.success('Update installed!', { - description: result.result.message, - duration: Infinity, - action: { - label: 'Restart Now', - onClick: () => { - // For Electron, we need to reload the window - // The server will need to be restarted separately - window.location.reload(); - }, + if (result) { + if (result.alreadyUpToDate) { + toast.success('Already up to date!'); + } else { + toast.success('Update installed!', { + description: result.message, + duration: Infinity, + action: { + label: 'Restart Now', + onClick: () => { + window.location.reload(); }, - }); - } + }, + cancel: { + label: 'Later', + onClick: () => { + // Just dismiss - user will restart manually later + }, + }, + }); } - } catch (err) { - const message = err instanceof Error ? err.message : 'Failed to pull updates'; - setError(message); - toast.error(message); - } finally { - setIsPulling(false); + } else if (useUpdatesStore.getState().error) { + toast.error(useUpdatesStore.getState().error || 'Failed to pull updates'); } - }, []); + }; // Extract repo name from URL for display const getRepoDisplayName = (url: string) => { @@ -180,6 +110,8 @@ export function UpdatesSection({ autoUpdate, onAutoUpdateChange }: UpdatesSectio return match ? match[1] : url; }; + const isLoading = isChecking || isPulling || isLoadingInfo; + return (
{/* Current Version Info */} - {updateInfo && ( + {info && (
@@ -211,16 +143,16 @@ export function UpdatesSection({ autoUpdate, onAutoUpdateChange }: UpdatesSectio
- Commit: + Version: - {updateInfo.currentCommitShort || 'Unknown'} + {info.currentVersionShort || 'Unknown'}
Branch: - {updateInfo.currentBranch || 'Unknown'} + {info.currentBranch || 'Unknown'}
- {updateInfo.hasLocalChanges && ( + {info.hasLocalChanges && (
Local changes detected @@ -231,14 +163,14 @@ export function UpdatesSection({ autoUpdate, onAutoUpdateChange }: UpdatesSectio )} {/* Update Status */} - {updateAvailable && remoteCommit && ( + {updateAvailable && remoteVersionShort && (
Update Available
- {remoteCommit} + {remoteVersionShort}
)} @@ -333,11 +265,7 @@ export function UpdatesSection({ autoUpdate, onAutoUpdateChange }: UpdatesSectio {/* Action Buttons */}
- {updateAvailable && ( -