diff --git a/README.md b/README.md index c8e1b84ea..7cd3104c3 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ - [Security & Isolation](#security--isolation) - [Data Storage](#data-storage) - [Learn More](#learn-more) +- [Auto-Updates](#auto-updates) - [License](#license) @@ -630,6 +631,7 @@ data/ - [Contributing Guide](./CONTRIBUTING.md) - How to contribute to Automaker - [Project Documentation](./docs/) - Architecture guides, patterns, and developer docs +- [Auto-Updates Guide](./docs/auto-updates.md) - Update system architecture and customization - [Docker Isolation Guide](./docs/docker-isolation.md) - Security-focused Docker deployment - [Shared Packages Guide](./docs/llm-shared-packages.md) - Using monorepo packages @@ -662,3 +664,13 @@ 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. + +## Auto-Updates + +Automaker includes a built-in auto-update system with one-click updates from the upstream repository. + +- **Automatic Checking** - Periodic background checks (configurable interval) +- **Toast Notifications** - "Update Available" notifications with "Update Now" button +- **One-Click Updates** - Pull updates directly from Settings > Updates + +**[Read the full Auto-Updates documentation](./docs/auto-updates.md)** - includes architecture details and how to swap the update mechanism (git → releases, etc.). diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 0f97255f3..9d579efde 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, events)); // 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..a8cbd791a --- /dev/null +++ b/apps/server/src/routes/updates/common.ts @@ -0,0 +1,196 @@ +/** + * Common utilities for update routes + */ + +import { createLogger } from '@automaker/utils'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import path from 'path'; +import fs from 'fs'; +import crypto from 'crypto'; +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 + ); + // pipx, other user installs - only add if HOME is defined + if (process.env.HOME) { + additionalPaths.push(`${process.env.HOME}/.local/bin`); + } +} + +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. + * Traverses up from the current file looking for a package.json with name "automaker". + * This approach is more robust than using fixed relative paths. + */ +export function getAutomakerRoot(): string { + const __filename = fileURLToPath(import.meta.url); + let currentDir = path.dirname(__filename); + const root = path.parse(currentDir).root; + + while (currentDir !== root) { + const packageJsonPath = path.join(currentDir, 'package.json'); + if (fs.existsSync(packageJsonPath)) { + try { + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')); + // Look for the monorepo root package.json with name "automaker" + if (packageJson.name === 'automaker') { + return currentDir; + } + } catch { + // Ignore JSON parse errors, continue searching + } + } + currentDir = path.dirname(currentDir); + } + + // Fallback to fixed path if marker not found (shouldn't happen in normal usage) + const fallbackDir = path.dirname(__filename); + return path.resolve(fallbackDir, '..', '..', '..', '..', '..'); +} + +/** + * 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. + * Also blocks shell metacharacters to prevent command injection. + */ +export function isValidGitUrl(url: string): boolean { + // Allow HTTPS, SSH, and git protocols + const startsWithValidProtocol = + url.startsWith('https://') || + url.startsWith('git@') || + url.startsWith('git://') || + url.startsWith('ssh://'); + + // Block shell metacharacters to prevent command injection + const hasShellChars = /[;`|&<>()$!\\[\] ]/.test(url); + + return startsWithValidProtocol && !hasShellChars; +} + +/** + * Execute a callback with a temporary git remote, ensuring cleanup. + * Centralizes the pattern of adding a temp remote, doing work, and removing it. + */ +export async function withTempGitRemote( + installPath: string, + sourceUrl: string, + callback: (tempRemoteName: string) => Promise +): Promise { + // Defense-in-depth: validate URL even though callers should already validate + if (!isValidGitUrl(sourceUrl)) { + throw new Error('Invalid git URL format'); + } + + const tempRemoteName = `automaker-temp-remote-${crypto.randomBytes(8).toString('hex')}`; + try { + await execAsync(`git remote add ${tempRemoteName} "${sourceUrl}"`, { + cwd: installPath, + env: execEnv, + }); + return await callback(tempRemoteName); + } finally { + try { + await execAsync(`git remote remove ${tempRemoteName}`, { + cwd: installPath, + env: execEnv, + }); + } catch { + // Ignore cleanup errors + } + } +} diff --git a/apps/server/src/routes/updates/index.ts b/apps/server/src/routes/updates/index.ts new file mode 100644 index 000000000..fb8696057 --- /dev/null +++ b/apps/server/src/routes/updates/index.ts @@ -0,0 +1,33 @@ +/** + * 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 type { EventEmitter } from '../../lib/events.js'; +import { createCheckHandler } from './routes/check.js'; +import { createPullHandler } from './routes/pull.js'; +import { createInfoHandler } from './routes/info.js'; + +export function createUpdatesRoutes( + settingsService: SettingsService, + events: EventEmitter +): 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 (events for progress streaming) + router.post('/pull', createPullHandler(settingsService, events)); + + // 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..bdde5401b --- /dev/null +++ b/apps/server/src/routes/updates/routes/check.ts @@ -0,0 +1,143 @@ +/** + * GET /check endpoint - Check if updates are available + * + * 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, + getAutomakerRoot, + getCurrentCommit, + getShortCommit, + isGitRepo, + isGitAvailable, + isValidGitUrl, + withTempGitRemote, + getErrorMessage, + logError, +} from '../common.js'; + +export function createCheckHandler(settingsService: SettingsService) { + return async (_req: Request, res: Response): Promise => { + try { + const installPath = 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(installPath))) { + 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 sourceUrl = + settings.autoUpdate?.upstreamUrl || 'https://github.com/AutoMaker-Org/automaker.git'; + + // Validate URL to prevent command injection + if (!isValidGitUrl(sourceUrl)) { + res.status(400).json({ + success: false, + error: 'Invalid upstream URL format', + }); + return; + } + + // Get local version + const localVersion = await getCurrentCommit(installPath); + const localVersionShort = await getShortCommit(installPath); + + try { + const result = await withTempGitRemote(installPath, sourceUrl, async (tempRemoteName) => { + // Fetch from the temporary remote + await execAsync(`git fetch ${tempRemoteName} main`, { + cwd: installPath, + env: execEnv, + }); + + // Get remote version + const { stdout: remoteVersionOutput } = await execAsync( + `git rev-parse ${tempRemoteName}/main`, + { cwd: installPath, env: execEnv } + ); + const remoteVersion = remoteVersionOutput.trim(); + + // Get short remote version + const { stdout: remoteVersionShortOutput } = await execAsync( + `git rev-parse --short ${tempRemoteName}/main`, + { cwd: installPath, env: execEnv } + ); + const remoteVersionShort = remoteVersionShortOutput.trim(); + + // Check if remote is ahead of local (update available) + let updateAvailable = false; + if (localVersion !== remoteVersion) { + try { + // Check if local is already an ancestor of remote (remote is ahead) + 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 + updateAvailable = true; + } catch { + // Exit code 1 means local is NOT an ancestor of remote + // This means either local is ahead, or branches have diverged + updateAvailable = false; + } + } + + return { + updateAvailable, + localVersion, + localVersionShort, + remoteVersion, + remoteVersionShort, + sourceUrl, + installPath, + } satisfies UpdateCheckResult; + }); + + res.json({ + success: true, + result, + }); + } catch (fetchError) { + const errorMsg = getErrorMessage(fetchError); + logError(fetchError, 'Failed to fetch from upstream'); + + res.json({ + success: true, + result: { + updateAvailable: false, + localVersion, + localVersionShort, + remoteVersion: null, + remoteVersionShort: null, + sourceUrl, + installPath, + 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..ed4869b82 --- /dev/null +++ b/apps/server/src/routes/updates/routes/info.ts @@ -0,0 +1,125 @@ +/** + * GET /info endpoint - Get current installation info + * + * Returns current version, branch, and configuration info. + */ + +import type { Request, Response } from 'express'; +import type { SettingsService } from '../../../services/settings-service.js'; +import { DEFAULT_AUTO_UPDATE_SETTINGS, type UpdateInfo } from '@automaker/types'; +import { + execAsync, + execEnv, + getAutomakerRoot, + getCurrentCommit, + getShortCommit, + isGitRepo, + isGitAvailable, + hasLocalChanges, + getErrorMessage, + logError, +} from '../common.js'; + +export function createInfoHandler(settingsService: SettingsService) { + return async (_req: Request, res: Response): Promise => { + try { + const installPath = getAutomakerRoot(); + + // Get settings + const settings = await settingsService.getGlobalSettings(); + const autoUpdateSettings = settings.autoUpdate || DEFAULT_AUTO_UPDATE_SETTINGS; + + // Check if git is available + const gitAvailable = await isGitAvailable(); + + if (!gitAvailable) { + const result: UpdateInfo = { + installPath, + currentVersion: null, + currentVersionShort: null, + currentBranch: null, + hasLocalChanges: false, + sourceUrl: autoUpdateSettings.upstreamUrl, + autoUpdateEnabled: autoUpdateSettings.enabled, + checkIntervalMinutes: autoUpdateSettings.checkIntervalMinutes, + updateType: 'git', + mechanismInfo: { + isGitRepo: false, + gitAvailable: false, + }, + }; + + res.json({ + success: true, + result, + }); + return; + } + + // Check if it's a git repo + const isRepo = await isGitRepo(installPath); + + if (!isRepo) { + const result: UpdateInfo = { + installPath, + currentVersion: null, + currentVersionShort: null, + currentBranch: null, + hasLocalChanges: false, + sourceUrl: autoUpdateSettings.upstreamUrl, + autoUpdateEnabled: autoUpdateSettings.enabled, + checkIntervalMinutes: autoUpdateSettings.checkIntervalMinutes, + updateType: 'git', + mechanismInfo: { + isGitRepo: false, + gitAvailable: true, + }, + }; + + res.json({ + success: true, + result, + }); + return; + } + + // Get git info + 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: installPath, + env: execEnv, + }); + const currentBranch = branchOutput.trim(); + + // Check for local changes + const localChanges = await hasLocalChanges(installPath); + + const result: UpdateInfo = { + installPath, + currentVersion, + currentVersionShort, + currentBranch, + hasLocalChanges: localChanges, + sourceUrl: autoUpdateSettings.upstreamUrl, + autoUpdateEnabled: autoUpdateSettings.enabled, + checkIntervalMinutes: autoUpdateSettings.checkIntervalMinutes, + updateType: 'git', + mechanismInfo: { + isGitRepo: true, + gitAvailable: true, + }, + }; + + 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..24223909f --- /dev/null +++ b/apps/server/src/routes/updates/routes/pull.ts @@ -0,0 +1,149 @@ +/** + * 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 type { EventEmitter } from '../../../lib/events.js'; +import type { UpdatePullResult } from '@automaker/types'; +import { + execAsync, + execEnv, + getAutomakerRoot, + getCurrentCommit, + getShortCommit, + isGitRepo, + isGitAvailable, + isValidGitUrl, + hasLocalChanges, + withTempGitRemote, + getErrorMessage, + logError, +} from '../common.js'; + +export function createPullHandler( + settingsService: SettingsService, + _events: EventEmitter // Available for future progress streaming via WebSocket +) { + return async (_req: Request, res: Response): Promise => { + try { + const installPath = 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(installPath))) { + res.status(500).json({ + success: false, + error: 'Automaker installation is not a git repository', + }); + return; + } + + // Check for local changes + if (await hasLocalChanges(installPath)) { + 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 sourceUrl = + settings.autoUpdate?.upstreamUrl || 'https://github.com/AutoMaker-Org/automaker.git'; + + // Validate URL to prevent command injection + if (!isValidGitUrl(sourceUrl)) { + res.status(400).json({ + success: false, + error: 'Invalid upstream URL format', + }); + return; + } + + try { + const result = await withTempGitRemote(installPath, sourceUrl, async (tempRemoteName) => { + // Get current version before pull + const previousVersion = await getCurrentCommit(installPath); + const previousVersionShort = await getShortCommit(installPath); + + // Fetch first + await execAsync(`git fetch ${tempRemoteName} main`, { + cwd: installPath, + env: execEnv, + }); + + // Merge the fetched changes + const { stdout: mergeOutput } = await execAsync( + `git merge ${tempRemoteName}/main --ff-only`, + { cwd: installPath, env: execEnv } + ); + + // Get new version after merge + const newVersion = await getCurrentCommit(installPath); + const newVersionShort = await getShortCommit(installPath); + + const alreadyUpToDate = + mergeOutput.includes('Already up to date') || previousVersion === newVersion; + + return { + success: true, + previousVersion, + previousVersionShort, + newVersion, + newVersionShort, + alreadyUpToDate, + message: alreadyUpToDate + ? 'Already up to date' + : `Updated from ${previousVersionShort} to ${newVersionShort}`, + } satisfies UpdatePullResult; + }); + + res.json({ + success: true, + result, + }); + } catch (pullError) { + 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/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..d0cafa3b5 --- /dev/null +++ b/apps/ui/src/components/updates/update-notifier.tsx @@ -0,0 +1,170 @@ +/** + * 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'; +import { getRepoDisplayName } from '@/lib/utils'; + +// ============================================================================ +// 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 repoName = getRepoDisplayName(autoUpdate.upstreamUrl); + + // 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, + }, + cancel: { + label: 'Later', + onClick: () => { + // Dismiss toast - won't show again for this version until a new version appears + shownToastForCommitRef.current = remoteVersionShort; + }, + }, + }); + }, [ + 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.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; +} + +// ============================================================================ +// Component +// ============================================================================ + +export function UpdatesSection({ autoUpdate, onAutoUpdateChange }: UpdatesSectionProps) { + // Use centralized store + const { + info, + updateAvailable, + remoteVersionShort, + isChecking, + isPulling, + isLoadingInfo, + error, + fetchInfo, + checkForUpdates, + pullUpdates, + clearError, + } = useUpdatesStore(); + + // Fetch info on mount + useEffect(() => { + fetchInfo(); + }, [fetchInfo]); + + // Handle check for updates with toast notifications + const handleCheckForUpdates = async () => { + clearError(); + const hasUpdate = await checkForUpdates(); + + 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'); + } + }; + + // Handle pull updates with toast notifications + const handlePullUpdates = async () => { + clearError(); + const result = await pullUpdates(); + + 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 + }, + }, + }); + } + } else if (useUpdatesStore.getState().error) { + toast.error(useUpdatesStore.getState().error || 'Failed to pull updates'); + } + }; + + const isLoading = isChecking || isPulling || isLoadingInfo; + + return ( +
+
+
+
+ +
+

Updates

+
+

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

+
+ +
+ {/* Current Version Info */} + {info && ( +
+
+ + Current Installation +
+
+
+ Version: + + {info.currentVersionShort || 'Unknown'} + +
+
+ Branch: + {info.currentBranch || 'Unknown'} +
+ {info.hasLocalChanges && ( +
+ + Local changes detected +
+ )} +
+
+ )} + + {/* Update Status */} + {updateAvailable && remoteVersionShort && ( +
+
+
+ + Update Available +
+ {remoteVersionShort} +
+
+ )} + + {/* 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-settings-migration.ts b/apps/ui/src/hooks/use-settings-migration.ts index 54b24b244..f8273c136 100644 --- a/apps/ui/src/hooks/use-settings-migration.ts +++ b/apps/ui/src/hooks/use-settings-migration.ts @@ -237,6 +237,7 @@ export async function syncSettingsToServer(): Promise { projectHistory: state.projectHistory, projectHistoryIndex: state.projectHistoryIndex, lastSelectedSessionByProject: state.lastSelectedSessionByProject, + autoUpdate: state.autoUpdate, }; const result = await api.settings.updateGlobal(updates); diff --git a/apps/ui/src/hooks/use-update-polling.ts b/apps/ui/src/hooks/use-update-polling.ts new file mode 100644 index 000000000..40309bc4e --- /dev/null +++ b/apps/ui/src/hooks/use-update-polling.ts @@ -0,0 +1,99 @@ +/** + * Update Polling Hook + * + * Handles the background polling logic for checking updates. + * Separated from the store to follow single responsibility principle. + * + * This hook only manages WHEN to check, not HOW to check. + * The actual check logic lives in the updates-store. + */ + +import { useEffect, useRef, useCallback } from 'react'; +import { useAppStore } from '@/store/app-store'; +import { useUpdatesStore } from '@/store/updates-store'; + +// ============================================================================ +// Types +// ============================================================================ + +export interface UseUpdatePollingOptions { + /** Override the check function (for testing/DI) */ + onCheck?: () => Promise; + + /** Override enabled state (for testing) */ + enabled?: boolean; + + /** Override interval in minutes (for testing) */ + intervalMinutes?: number; +} + +export interface UseUpdatePollingResult { + /** Whether polling is currently active */ + isPollingActive: boolean; + + /** Manually trigger a check */ + checkNow: () => Promise; + + /** Last check timestamp */ + lastChecked: Date | null; +} + +// ============================================================================ +// Hook +// ============================================================================ + +/** + * Hook for background update polling. + * + * @param options - Optional overrides for testing/DI + * @returns Polling state and controls + */ +export function useUpdatePolling(options: UseUpdatePollingOptions = {}): UseUpdatePollingResult { + const { autoUpdate } = useAppStore(); + const { checkForUpdates, lastChecked } = useUpdatesStore(); + + // Allow overrides for testing + const isEnabled = options.enabled ?? autoUpdate.enabled; + const intervalMinutes = options.intervalMinutes ?? autoUpdate.checkIntervalMinutes; + + // Stabilize the check function reference to prevent interval resets + const onCheckRef = useRef(options.onCheck ?? checkForUpdates); + onCheckRef.current = options.onCheck ?? checkForUpdates; + + const stableOnCheck = useCallback(() => onCheckRef.current(), []); + + const intervalRef = useRef(null); + + useEffect(() => { + // Clear any existing interval + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + + // Don't set up polling if disabled + if (!isEnabled) { + return; + } + + // Check immediately on enable + stableOnCheck(); + + // Set up interval + const intervalMs = intervalMinutes * 60 * 1000; + intervalRef.current = setInterval(stableOnCheck, intervalMs); + + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }; + }, [isEnabled, intervalMinutes, stableOnCheck]); + + return { + isPollingActive: isEnabled, + checkNow: stableOnCheck, + lastChecked, + }; +} diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index 58125806c..52ce60da4 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; + localVersion: string; + localVersionShort: string; + remoteVersion: string | null; + remoteVersionShort: string | null; + sourceUrl: string; + installPath: string; + error?: string; + }; + error?: string; + }>; + pull: () => Promise<{ + success: boolean; + result?: { + success: boolean; + previousVersion: string; + previousVersionShort: string; + newVersion: string; + newVersionShort: string; + alreadyUpToDate: boolean; + message: string; + }; + error?: string; + }>; + info: () => Promise<{ + success: boolean; + result?: { + installPath: string; + isGitRepo: boolean; + gitAvailable: boolean; + currentVersion: string | null; + currentVersionShort: string | null; + currentBranch: string | null; + hasLocalChanges: boolean; + sourceUrl: 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..9eb1934af 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; + localVersion: string; + localVersionShort: string; + remoteVersion: string | null; + remoteVersionShort: string | null; + sourceUrl: string; + installPath: string; + error?: string; + }; + error?: string; + }> => this.get('/api/updates/check'), + + pull: (): Promise<{ + success: boolean; + result?: { + success: boolean; + previousVersion: string; + previousVersionShort: string; + newVersion: string; + newVersionShort: string; + alreadyUpToDate: boolean; + message: string; + }; + error?: string; + }> => this.post('/api/updates/pull', {}), + + info: (): Promise<{ + success: boolean; + result?: { + installPath: string; + isGitRepo: boolean; + gitAvailable: boolean; + currentVersion: string | null; + currentVersionShort: string | null; + currentBranch: string | null; + hasLocalChanges: boolean; + sourceUrl: string; + autoUpdateEnabled: boolean; + checkIntervalMinutes: number; + }; + error?: string; + }> => this.get('/api/updates/info'), + }; + // Backlog Plan API backlogPlan = { generate: ( diff --git a/apps/ui/src/lib/utils.ts b/apps/ui/src/lib/utils.ts index 82ad7452d..a9317eabd 100644 --- a/apps/ui/src/lib/utils.ts +++ b/apps/ui/src/lib/utils.ts @@ -63,3 +63,13 @@ export const isMac = : typeof navigator !== 'undefined' && (/Mac/.test(navigator.userAgent) || (navigator.platform ? navigator.platform.toLowerCase().includes('mac') : false)); + +/** + * Extract a display name from a git repository URL. + * Handles GitHub URLs and returns the owner/repo format. + * Falls back to 'upstream' for unrecognized URLs. + */ +export function getRepoDisplayName(url: string): string { + const match = url.match(/github\.com[/:]([^/]+\/[^/.]+)/); + return match ? match[1] : 'upstream'; +} diff --git a/apps/ui/src/routes/__root.tsx b/apps/ui/src/routes/__root.tsx index 3608334d2..69b93d72b 100644 --- a/apps/ui/src/routes/__root.tsx +++ b/apps/ui/src/routes/__root.tsx @@ -20,6 +20,8 @@ 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'; +import { UpdateNotifier } from '@/components/updates'; // Session storage key for sandbox risk acknowledgment const SANDBOX_RISK_ACKNOWLEDGED_KEY = 'automaker-sandbox-risk-acknowledged'; @@ -53,6 +55,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; @@ -372,6 +377,7 @@ function RootLayoutContent() { onConfirm={handleSandboxConfirm} onDeny={handleSandboxDeny} /> + ); } diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index c04daa8f0..c045b15d0 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -12,7 +12,9 @@ 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 +497,8 @@ 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 +785,9 @@ export interface AppActions { // Prompt Customization actions setPromptCustomization: (customization: PromptCustomization) => Promise; + // Auto-Update Settings actions + setAutoUpdate: (settings: Partial) => Promise; + // AI Profile actions addAIProfile: (profile: Omit) => void; updateAIProfile: (id: string, updates: Partial) => void; @@ -980,6 +987,8 @@ 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 +1653,16 @@ export const useAppStore = create()( await syncSettingsToServer(); }, + // Auto-Update Settings actions + setAutoUpdate: async (settings) => { + set((state) => ({ + autoUpdate: { ...state.autoUpdate, ...settings }, + })); + // Sync to server settings file + const { syncSettingsToServer } = await import('@/hooks/use-settings-migration'); + await syncSettingsToServer(); + }, + // AI Profile actions addAIProfile: (profile) => { const id = `profile-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; @@ -2927,6 +2946,7 @@ 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/src/store/updates-store.ts b/apps/ui/src/store/updates-store.ts new file mode 100644 index 000000000..e6fa475ce --- /dev/null +++ b/apps/ui/src/store/updates-store.ts @@ -0,0 +1,334 @@ +/** + * Updates Store + * + * Centralized state management for the auto-update feature. + * Single source of truth for update status, operations, and state. + * + * Uses interface -> implementation pattern for easy DI in the future. + */ + +import { create } from 'zustand'; +import { getElectronAPI } from '@/lib/electron'; +import type { + UpdateInfo, + UpdateCheckResult, + UpdatePullResult, + UpdateApiResponse, +} from '@automaker/types'; + +// ============================================================================ +// Interfaces (for DI) +// ============================================================================ + +/** + * Interface for the updates API client. + * Implement this interface to swap the update mechanism (e.g., git -> HTTP downloads). + */ +export interface IUpdatesApiClient { + /** Fetch current installation info */ + getInfo(): Promise>; + + /** Check if updates are available */ + checkForUpdates(): Promise>; + + /** Pull/download updates */ + pullUpdates(): Promise>; +} + +/** + * Interface for update event emitter. + * Allows decoupling event emission from the store. + */ +export interface IUpdateEventEmitter { + emitUpdatePulled(detail: { newVersion: string; alreadyUpToDate: boolean }): void; +} + +// ============================================================================ +// Default Implementations +// ============================================================================ + +/** + * Default API client using the Electron/HTTP API + */ +const createDefaultApiClient = (): IUpdatesApiClient => ({ + async getInfo() { + const api = getElectronAPI(); + if (!api.updates?.info) { + return { success: false, error: 'Updates API not available' }; + } + return api.updates.info(); + }, + + async checkForUpdates() { + const api = getElectronAPI(); + if (!api.updates?.check) { + return { success: false, error: 'Updates API not available' }; + } + return api.updates.check(); + }, + + async pullUpdates() { + const api = getElectronAPI(); + if (!api.updates?.pull) { + return { success: false, error: 'Updates API not available' }; + } + return api.updates.pull(); + }, +}); + +/** + * Default event emitter using window custom events + */ +const createDefaultEventEmitter = (): IUpdateEventEmitter => ({ + emitUpdatePulled(detail) { + window.dispatchEvent(new CustomEvent('automaker:update-pulled', { detail })); + }, +}); + +// ============================================================================ +// State Types +// ============================================================================ + +interface UpdatesState { + // Installation info from /api/updates/info + info: UpdateInfo | null; + + // Update availability + updateAvailable: boolean; + remoteVersion: string | null; + remoteVersionShort: string | null; + + // Loading states + isLoadingInfo: boolean; + isChecking: boolean; + isPulling: boolean; + + // Error state + error: string | null; + + // Timestamps + lastChecked: Date | null; + lastPulled: Date | null; +} + +interface UpdatesActions { + /** Fetch current installation info */ + fetchInfo: () => Promise; + + /** Check for updates (returns true if update available) */ + checkForUpdates: () => Promise; + + /** Pull updates (returns result) */ + pullUpdates: () => Promise; + + /** Clear error */ + clearError: () => void; + + /** Reset state */ + reset: () => void; + + /** Set custom API client (for DI/testing) */ + setApiClient: (client: IUpdatesApiClient) => void; + + /** Set custom event emitter (for DI/testing) */ + setEventEmitter: (emitter: IUpdateEventEmitter) => void; +} + +export type UpdatesStore = UpdatesState & UpdatesActions; + +// ============================================================================ +// Initial State +// ============================================================================ + +const initialState: UpdatesState = { + info: null, + updateAvailable: false, + remoteVersion: null, + remoteVersionShort: null, + isLoadingInfo: false, + isChecking: false, + isPulling: false, + error: null, + lastChecked: null, + lastPulled: null, +}; + +// ============================================================================ +// Store Factory +// ============================================================================ + +/** + * Create the updates store with optional dependency injection. + * + * @param apiClient - Custom API client (defaults to Electron/HTTP API) + * @param eventEmitter - Custom event emitter (defaults to window events) + */ +export const createUpdatesStore = ( + apiClient: IUpdatesApiClient = createDefaultApiClient(), + eventEmitter: IUpdateEventEmitter = createDefaultEventEmitter() +) => { + // Mutable references for DI + let _apiClient = apiClient; + let _eventEmitter = eventEmitter; + + return create((set, get) => ({ + ...initialState, + + setApiClient: (client: IUpdatesApiClient) => { + _apiClient = client; + }, + + setEventEmitter: (emitter: IUpdateEventEmitter) => { + _eventEmitter = emitter; + }, + + fetchInfo: async () => { + const { isLoadingInfo } = get(); + if (isLoadingInfo) return null; + + set({ isLoadingInfo: true, error: null }); + + try { + const response = await _apiClient.getInfo(); + + if (!response.success || !response.result) { + set({ + isLoadingInfo: false, + error: response.error || 'Failed to fetch update info', + }); + return null; + } + + set({ isLoadingInfo: false, info: response.result }); + return response.result; + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to fetch update info'; + set({ isLoadingInfo: false, error: message }); + return null; + } + }, + + checkForUpdates: async () => { + const { isChecking, isPulling } = get(); + if (isChecking || isPulling) return false; + + set({ isChecking: true, error: null }); + + try { + const response = await _apiClient.checkForUpdates(); + + if (!response.success || !response.result) { + set({ + isChecking: false, + error: response.error || 'Failed to check for updates', + lastChecked: new Date(), + }); + return false; + } + + const result = response.result; + + // Handle error from the check itself (e.g., network issues) + if (result.error) { + set({ + isChecking: false, + error: result.error, + lastChecked: new Date(), + }); + return false; + } + + set({ + isChecking: false, + updateAvailable: result.updateAvailable, + remoteVersion: result.remoteVersion, + remoteVersionShort: result.remoteVersionShort, + lastChecked: new Date(), + }); + + return result.updateAvailable; + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to check for updates'; + set({ isChecking: false, error: message, lastChecked: new Date() }); + return false; + } + }, + + pullUpdates: async () => { + const { isPulling, isChecking } = get(); + if (isPulling || isChecking) return null; + + set({ isPulling: true, error: null }); + + try { + const response = await _apiClient.pullUpdates(); + + if (!response.success || !response.result) { + set({ + isPulling: false, + error: response.error || 'Failed to pull updates', + }); + return null; + } + + const result = response.result; + + // Update state after successful pull + set({ + isPulling: false, + updateAvailable: false, + remoteVersion: null, + remoteVersionShort: null, + lastPulled: new Date(), + }); + + // Refresh info to get new commit (await to ensure state is updated before emitting) + await get() + .fetchInfo() + .catch(() => { + // Ignore fetchInfo errors - pull was successful, info refresh is best-effort + }); + + // Emit event for other components + _eventEmitter.emitUpdatePulled({ + newVersion: result.newVersionShort, + alreadyUpToDate: result.alreadyUpToDate, + }); + + return result; + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to pull updates'; + set({ isPulling: false, error: message }); + return null; + } + }, + + clearError: () => { + set({ error: null }); + }, + + reset: () => { + set(initialState); + }, + })); +}; + +// ============================================================================ +// Default Store Instance +// ============================================================================ + +/** + * Default store instance using the default API client. + * Use createUpdatesStore() for custom DI. + */ +export const useUpdatesStore = createUpdatesStore(); + +// ============================================================================ +// Selectors +// ============================================================================ + +export const selectUpdateInfo = (state: UpdatesStore) => state.info; +export const selectUpdateAvailable = (state: UpdatesStore) => state.updateAvailable; +export const selectIsLoading = (state: UpdatesStore) => + state.isLoadingInfo || state.isChecking || state.isPulling; +export const selectError = (state: UpdatesStore) => state.error; 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/docs/auto-updates.md b/docs/auto-updates.md new file mode 100644 index 000000000..21a879841 --- /dev/null +++ b/docs/auto-updates.md @@ -0,0 +1,304 @@ +# Auto-Updates + +Automaker includes a built-in auto-update system that checks for and installs updates from an upstream repository. + +## Features + +- **Automatic Checking** - Periodic background checks for new versions +- **Toast Notifications** - Non-intrusive notifications when updates are available +- **One-Click Updates** - Pull updates directly from the UI +- **Restart Prompts** - Option to restart immediately or later after updates + +## Configuration + +Access update settings in **Settings > Updates**: + +| Setting | Description | Default | +| ------------------------------ | --------------------------------- | ------------------------------------------------ | +| Enable automatic update checks | Toggle periodic checking | `true` | +| Check interval | How often to check (1-60 minutes) | `15` | +| Upstream repository URL | Source repository for updates | `https://github.com/AutoMaker-Org/automaker.git` | + +## Architecture + +The update system is designed with **dependency injection** in mind, making it easy to swap the update mechanism (e.g., from git-based to GitHub releases). + +### Component Overview + +```text +┌─────────────────────────────────────────────────────────────────┐ +│ UI Layer │ +├─────────────────────────────────────────────────────────────────┤ +│ UpdatesSection │ UpdateNotifier │ +│ (Settings UI) │ (Toast notifications) │ +│ │ │ +│ - Display current │ - Subscribe to store │ +│ version info │ - Show "Update Available" toast │ +│ - Manual check/update │ - Show "Update Installed" toast │ +│ buttons │ - Handle "Update Now" clicks │ +└──────────────┬───────────┴──────────────┬───────────────────────┘ + │ │ + ▼ ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Updates Store (Zustand) │ +├─────────────────────────────────────────────────────────────────┤ +│ State: │ Actions: │ +│ - info │ - fetchInfo() │ +│ - updateAvailable │ - checkForUpdates() │ +│ - remoteVersion │ - pullUpdates() │ +│ - isChecking/isPulling │ - clearError() │ +│ - error │ - reset() │ +│ - lastChecked │ │ +├─────────────────────────────────────────────────────────────────┤ +│ Injected Dependencies: │ +│ - IUpdatesApiClient (how to fetch/check/pull) │ +│ - IUpdateEventEmitter (how to notify other components) │ +└──────────────┬───────────────────────────┬──────────────────────┘ + │ │ + ▼ ▼ +┌──────────────────────────┐ ┌───────────────────────────────────┐ +│ useUpdatePolling() │ │ Server API │ +│ (Polling Hook) │ │ /api/updates/* │ +├──────────────────────────┤ ├───────────────────────────────────┤ +│ - Respects settings │ │ GET /info - Installation info │ +│ - Interval-based │ │ GET /check - Check for updates │ +│ - Pauses when disabled │ │ POST /pull - Pull updates │ +└──────────────────────────┘ └───────────────────────────────────┘ +``` + +### Key Files + +| File | Purpose | +| ------------------------------------------------------------------------ | ----------------------------------------------- | +| `libs/types/src/updates.ts` | Type definitions (abstract, mechanism-agnostic) | +| `apps/ui/src/store/updates-store.ts` | Zustand store with DI interfaces | +| `apps/ui/src/hooks/use-update-polling.ts` | Polling logic (when to check) | +| `apps/ui/src/components/updates/update-notifier.tsx` | Toast notifications | +| `apps/ui/src/components/views/settings-view/updates/updates-section.tsx` | Settings UI | +| `apps/server/src/routes/updates/` | Server-side update logic | + +## Swapping the Update Mechanism + +The update system uses **interface-based dependency injection** to allow swapping the update mechanism without changing UI code. + +### Interfaces + +```typescript +// How to communicate with the update backend +interface IUpdatesApiClient { + getInfo(): Promise>; + checkForUpdates(): Promise>; + pullUpdates(): Promise>; +} + +// How to notify other components about updates +interface IUpdateEventEmitter { + emitUpdatePulled(detail: { newVersion: string; alreadyUpToDate: boolean }): void; +} +``` + +### Example: Custom API Client + +To use a different update mechanism (e.g., GitHub Releases API): + +```typescript +// my-custom-api-client.ts +import type { + IUpdatesApiClient, + UpdateApiResponse, + UpdateInfo, + UpdateCheckResult, + UpdatePullResult, +} from '@automaker/types'; + +export const createGitHubReleasesClient = (): IUpdatesApiClient => ({ + async getInfo(): Promise> { + // Read current version from package.json or version file + const currentVersion = await readCurrentVersion(); + + return { + success: true, + result: { + installPath: process.cwd(), + currentVersion, + currentVersionShort: currentVersion, + currentBranch: null, + hasLocalChanges: false, + sourceUrl: 'https://github.com/AutoMaker-Org/automaker/releases', + autoUpdateEnabled: true, + checkIntervalMinutes: 5, + updateType: 'release', + }, + }; + }, + + async checkForUpdates(): Promise> { + // Fetch latest release from GitHub API + const response = await fetch( + 'https://api.github.com/repos/AutoMaker-Org/automaker/releases/latest' + ); + const release = await response.json(); + + const currentVersion = await readCurrentVersion(); + const latestVersion = release.tag_name; + + return { + success: true, + result: { + updateAvailable: latestVersion !== currentVersion, + localVersion: currentVersion, + localVersionShort: currentVersion, + remoteVersion: latestVersion, + remoteVersionShort: latestVersion, + sourceUrl: release.html_url, + installPath: process.cwd(), + }, + }; + }, + + async pullUpdates(): Promise> { + // Download and extract release asset + // This is where you'd implement the actual update logic + // ... + + return { + success: true, + result: { + success: true, + previousVersion: '1.0.0', + previousVersionShort: '1.0.0', + newVersion: '1.1.0', + newVersionShort: '1.1.0', + alreadyUpToDate: false, + message: 'Updated from 1.0.0 to 1.1.0', + }, + }; + }, +}); +``` + +### Injecting Custom Client + +```typescript +// In your app initialization +import { createUpdatesStore } from '@/store/updates-store'; +import { createGitHubReleasesClient } from './my-custom-api-client'; + +// Create store with custom client +const customClient = createGitHubReleasesClient(); +const useCustomUpdatesStore = createUpdatesStore(customClient); + +// Or inject at runtime +import { useUpdatesStore } from '@/store/updates-store'; +useUpdatesStore.getState().setApiClient(customClient); +``` + +## Type Definitions + +The types are intentionally **abstract** to support different update mechanisms: + +```typescript +interface UpdateInfo { + installPath: string; // Where Automaker is installed + currentVersion: string | null; // Current version (commit, semver, etc.) + currentVersionShort: string | null; + currentBranch: string | null; // Branch or release channel + hasLocalChanges: boolean; + sourceUrl: string; // Where updates come from + autoUpdateEnabled: boolean; + checkIntervalMinutes: number; + updateType?: 'git' | 'release' | 'custom'; + mechanismInfo?: Record; // Mechanism-specific data +} + +interface UpdateCheckResult { + updateAvailable: boolean; + localVersion: string; + localVersionShort: string; + remoteVersion: string | null; + remoteVersionShort: string | null; + sourceUrl: string; + installPath: string; + error?: string; +} + +interface UpdatePullResult { + success: boolean; + previousVersion: string; + previousVersionShort: string; + newVersion: string; + newVersionShort: string; + alreadyUpToDate: boolean; + message: string; +} +``` + +## Server-Side Implementation + +The default implementation uses **git** to check for and pull updates: + +### Check Logic (`/api/updates/check`) + +1. Add temporary remote pointing to upstream URL +2. Fetch latest from remote +3. Compare local commit with remote using `git merge-base --is-ancestor` +4. Return whether remote is ahead of local + +### Pull Logic (`/api/updates/pull`) + +1. Verify no local uncommitted changes +2. Add temporary remote +3. Fetch and merge with `--ff-only` (fast-forward only) +4. Return version change info + +### Implementing Alternative Backends + +To implement a different update mechanism on the server: + +1. Create new route handlers in `apps/server/src/routes/updates/routes/` +2. Implement the same response format as defined in `UpdateInfo`, `UpdateCheckResult`, `UpdatePullResult` +3. The UI will work without changes + +## Events + +The update system emits a custom event when updates are pulled: + +```typescript +// Listen for update events +window.addEventListener('automaker:update-pulled', (event) => { + const { newVersion, alreadyUpToDate } = event.detail; + console.log(`Updated to ${newVersion}, already up to date: ${alreadyUpToDate}`); +}); +``` + +## Troubleshooting + +### "Git is not installed" + +The default update mechanism requires git. Either install git or implement a custom `IUpdatesApiClient`. + +### "Local uncommitted changes" + +Updates require a clean working directory. Commit or stash changes before updating. + +### "Cannot fast-forward merge" + +Your local branch has diverged from upstream. Manual intervention required: + +```bash +git fetch origin main +git rebase origin/main +# or +git reset --hard origin/main # Warning: discards local changes +``` + +### Updates not appearing + +1. Check that auto-update is enabled in Settings +2. Verify the upstream URL is correct +3. Check browser console for errors +4. Try manual "Check for Updates" button + +--- + +Last updated: December 2025 diff --git a/libs/types/src/index.ts b/libs/types/src/index.ts index be7148776..3be0d2ed6 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, @@ -143,3 +145,10 @@ export type { // Port configuration export { STATIC_PORT, SERVER_PORT, RESERVED_PORTS } from './ports.js'; +// Updates types +export type { + UpdateInfo, + UpdateCheckResult, + UpdatePullResult, + UpdateApiResponse, +} from './updates.js'; diff --git a/libs/types/src/settings.ts b/libs/types/src/settings.ts index 990c2ff6c..7ac231705 100644 --- a/libs/types/src/settings.ts +++ b/libs/types/src/settings.ts @@ -365,6 +365,9 @@ 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 +504,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 +560,8 @@ 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) */ diff --git a/libs/types/src/updates.ts b/libs/types/src/updates.ts new file mode 100644 index 000000000..0a90673ea --- /dev/null +++ b/libs/types/src/updates.ts @@ -0,0 +1,107 @@ +/** + * Types for the auto-update feature + * + * These types are intentionally abstract to support different update mechanisms: + * - Git-based (commits) + * - Release-based (GitHub releases, semantic versions) + * - Custom (any versioning scheme) + */ + +/** + * Information about the current Automaker installation + */ +export interface UpdateInfo { + /** Path to the Automaker installation */ + installPath: string; + + /** Current version identifier (commit hash, semver, etc.) */ + currentVersion: string | null; + + /** Short/display version of current version */ + currentVersionShort: string | null; + + /** Current branch (for git) or channel (for releases) */ + currentBranch: string | null; + + /** Whether there are local modifications */ + hasLocalChanges: boolean; + + /** URL of the update source */ + sourceUrl: string; + + /** Whether auto-update is enabled */ + autoUpdateEnabled: boolean; + + /** Check interval in minutes */ + checkIntervalMinutes: number; + + /** Update mechanism type (optional for backwards compatibility) */ + updateType?: 'git' | 'release' | 'custom'; + + /** Mechanism-specific info (e.g., isGitRepo, gitAvailable for git) */ + mechanismInfo?: Record; +} + +/** + * Result of checking for updates + */ +export interface UpdateCheckResult { + /** Whether an update is available */ + updateAvailable: boolean; + + /** Current local version */ + localVersion: string; + + /** Short/display version of local version */ + localVersionShort: string; + + /** Available remote version (null if check failed) */ + remoteVersion: string | null; + + /** Short/display version of remote version */ + remoteVersionShort: string | null; + + /** URL of the update source */ + sourceUrl: string; + + /** Path to the installation */ + installPath: string; + + /** Error message if check failed */ + error?: string; +} + +/** + * Result of pulling/installing updates + */ +export interface UpdatePullResult { + /** Whether the update succeeded */ + success: boolean; + + /** Version before update */ + previousVersion: string; + + /** Short/display version before update */ + previousVersionShort: string; + + /** Version after update */ + newVersion: string; + + /** Short/display version after update */ + newVersionShort: string; + + /** Whether already at the latest version */ + alreadyUpToDate: boolean; + + /** Human-readable message about the update */ + message: string; +} + +/** + * API response wrapper for update operations + */ +export interface UpdateApiResponse { + success: boolean; + result?: T; + error?: string; +}