diff --git a/apps/server/src/routes/updates/common.ts b/apps/server/src/routes/updates/common.ts new file mode 100644 index 000000000..96033f980 --- /dev/null +++ b/apps/server/src/routes/updates/common.ts @@ -0,0 +1,162 @@ +/** + * 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 + ); + // 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 +// ============================================================================ + +/** + * Locate the Automaker monorepo root directory. + * + * @returns Absolute path to the monorepo root directory (the directory containing the top-level `package.json`) + */ +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, '..', '..', '..', '..', '..'); +} + +/** + * Determines whether Git is available on the system. + * + * @returns `true` if the `git` command is executable in the current environment, `false` otherwise. + */ +export async function isGitAvailable(): Promise { + try { + await execAsync('git --version', { env: execEnv }); + return true; + } catch { + return false; + } +} + +/** + * Determine whether the given filesystem path is a Git repository. + * + * @param repoPath - Filesystem path to check + * @returns `true` if the path is inside a Git working tree, `false` otherwise. + */ +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; + } +} + +/** + * Retrieves the full commit hash pointed to by HEAD in the given repository. + * + * @param repoPath - Filesystem path of the Git repository to query + * @returns The full commit hash for HEAD as a trimmed string + */ +export async function getCurrentCommit(repoPath: string): Promise { + const { stdout } = await execAsync('git rev-parse HEAD', { cwd: repoPath, env: execEnv }); + return stdout.trim(); +} + +/** + * Retrieve the short commit hash of HEAD for the repository at the given path. + * + * @param repoPath - Filesystem path to the git repository + * @returns The short commit hash for `HEAD` + */ +export async function getShortCommit(repoPath: string): Promise { + const { stdout } = await execAsync('git rev-parse --short HEAD', { cwd: repoPath, env: execEnv }); + return stdout.trim(); +} + +/** + * Determine whether the repository contains uncommitted local changes. + * + * @param repoPath - Filesystem path to the Git repository to check + * @returns `true` if the repository has any uncommitted changes, `false` otherwise + */ +export async function hasLocalChanges(repoPath: string): Promise { + const { stdout } = await execAsync('git status --porcelain', { cwd: repoPath, env: execEnv }); + return stdout.trim().length > 0; +} + +/** + * Determine whether a string is a well-formed git remote URL and contains no shell metacharacters. + * + * @param url - The URL to validate + * @returns `true` if `url` starts with a common git protocol (`https://`, `git@`, `git://`, `ssh://`) and does not contain shell metacharacters, `false` otherwise. + */ +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; +} \ No newline at end of file diff --git a/apps/server/src/routes/updates/index.ts b/apps/server/src/routes/updates/index.ts new file mode 100644 index 000000000..a1a2702b8 --- /dev/null +++ b/apps/server/src/routes/updates/index.ts @@ -0,0 +1,37 @@ +/** + * 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'; + +/** + * Create an Express Router that exposes API endpoints for update operations. + * + * @returns An Express Router with the routes: + * - GET `/check` — checks for available updates + * - POST `/pull` — pulls updates from upstream + * - GET `/info` — returns current installation info + */ +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; +} \ No newline at end of file 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..d862b6994 --- /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 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 crypto from 'crypto'; +import { + execAsync, + execEnv, + getAutomakerRoot, + getCurrentCommit, + getShortCommit, + isGitRepo, + isGitAvailable, + isValidGitUrl, + getErrorMessage, + logError, +} from '../common.js'; + +/** + * Create an Express handler for the update check endpoint that compares the local Git commit + * against a configured upstream to determine whether an update is available. + * + * The handler validates Git availability and repository state, reads the upstream URL from + * global settings (with a default), attempts to fetch the upstream main branch using a + * temporary remote, and returns a structured result describing local and remote commits and + * whether the remote is ahead. + * + * @param settingsService - Service used to read global settings (used to obtain `autoUpdate.upstreamUrl`) + * @returns An Express request handler that responds with JSON. On success the response is + * `{ success: true, result }` where `result` is an `UpdateCheckResult`. On error the response + * is `{ success: false, error }`. If fetching the upstream fails the handler still responds + * with `{ success: true, result }` where `result` indicates no update and includes an `error` message. + */ +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); + + // Use a random remote name to avoid conflicts with concurrent checks + const tempRemoteName = `automaker-update-check-${crypto.randomBytes(8).toString('hex')}`; + + try { + // Add temporary remote + await execAsync(`git remote add ${tempRemoteName} "${sourceUrl}"`, { + cwd: installPath, + env: execEnv, + }); + + // 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) + // git merge-base --is-ancestor returns 0 if commit1 is ancestor of commit2 + 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 + // In either case, we don't show "update available" + updateAvailable = false; + } + } + + const result: UpdateCheckResult = { + updateAvailable, + localVersion, + localVersionShort, + remoteVersion, + remoteVersionShort, + sourceUrl, + installPath, + }; + + 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, + }); + } finally { + // Always clean up temp remote + try { + await execAsync(`git remote remove ${tempRemoteName}`, { + cwd: installPath, + env: execEnv, + }); + } catch { + // Ignore cleanup errors + } + } + } catch (error) { + logError(error, 'Update check failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} \ No newline at end of file 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..63c2e2b6c --- /dev/null +++ b/apps/server/src/routes/updates/routes/info.ts @@ -0,0 +1,136 @@ +/** + * 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'; + +/** + * Creates an Express handler that returns update information for the application installation. + * + * The produced handler responds with a JSON payload containing an UpdateInfo result describing + * installation path, git-based version and branch data (when available), local change status, + * and configured auto-update settings. On failure the handler responds with HTTP 500 and a JSON + * error message. + * + * @returns An Express request handler that sends `{ success: true, result: UpdateInfo }` on success + * or `{ success: false, error: string }` with HTTP 500 on error. + */ +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) }); + } + }; +} \ No newline at end of file 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..4ea3ca6f9 --- /dev/null +++ b/apps/server/src/routes/updates/routes/pull.ts @@ -0,0 +1,170 @@ +/** + * 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 { UpdatePullResult } from '@automaker/types'; +import crypto from 'crypto'; +import { + execAsync, + execEnv, + getAutomakerRoot, + getCurrentCommit, + getShortCommit, + isGitRepo, + isGitAvailable, + isValidGitUrl, + hasLocalChanges, + getErrorMessage, + logError, +} from '../common.js'; + +/** + * Create an Express handler for POST /pull that updates the local Automaker installation by pulling from the configured upstream Git repository. + * + * The handler validates Git availability and that the install directory is a git repository, ensures there are no local uncommitted changes, validates the upstream URL from global settings, and performs a fast-forward-only pull using a temporary remote. It returns a JSON UpdatePullResult on success, or an error JSON with appropriate HTTP status codes for invalid input, merge conflicts, non-fast-forward divergence, or unexpected failures. + * + * @param settingsService - Service used to read global settings (used to obtain the upstream URL) + * @returns An Express request handler that performs the safe fast-forward pull and sends a JSON response describing the result or error + */ +export function createPullHandler(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; + } + + // 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; + } + + // Get current version before pull + const previousVersion = await getCurrentCommit(installPath); + const previousVersionShort = await getShortCommit(installPath); + + // Use a random remote name to avoid conflicts with concurrent pulls + const tempRemoteName = `automaker-update-pull-${crypto.randomBytes(8).toString('hex')}`; + + try { + // Add temporary remote + await execAsync(`git remote add ${tempRemoteName} "${sourceUrl}"`, { + cwd: installPath, + env: execEnv, + }); + + // 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; + + const result: UpdatePullResult = { + success: true, + previousVersion, + previousVersionShort, + newVersion, + newVersionShort, + alreadyUpToDate, + message: alreadyUpToDate + ? 'Already up to date' + : `Updated from ${previousVersionShort} to ${newVersionShort}`, + }; + + 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}`, + }); + } finally { + // Always clean up temp remote + try { + await execAsync(`git remote remove ${tempRemoteName}`, { + cwd: installPath, + env: execEnv, + }); + } catch { + // Ignore cleanup errors + } + } + } catch (error) { + logError(error, 'Update pull failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} \ No newline at end of file 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..076c16e4b --- /dev/null +++ b/apps/ui/src/components/updates/update-notifier.tsx @@ -0,0 +1,173 @@ +/** + * 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 +// ============================================================================ + +/** + * Displays persistent toasts for available and installed application updates. + * + * Shows a persistent "Update Available" toast when a new remote version is detected and, + * after initiating an update, shows success toasts for either "Already up to date!" or + * "Update installed!" with actions to restart now or later. + * + * @param onUpdateAvailable - Optional callback invoked with `remoteVersion` when an update is detected; providing this prevents the default availability toast. + * @param onUpdateInstalled - Optional callback invoked with `(newVersion, alreadyUpToDate)` after attempting to install updates; providing this prevents the default installation toasts. + * @returns Null (this component renders no visible UI; it manages global toast notifications). + */ +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; +} \ No newline at end of file diff --git a/apps/ui/src/components/views/settings-view.tsx b/apps/ui/src/components/views/settings-view.tsx index 6ea52add6..fbf9ccf34 100644 --- a/apps/ui/src/components/views/settings-view.tsx +++ b/apps/ui/src/components/views/settings-view.tsx @@ -17,10 +17,18 @@ 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 type { Project as SettingsProject, Theme } from './settings-view/shared/types'; import type { Project as ElectronProject } from '@/lib/electron'; +/** + * Render the application settings view, including navigation, section panels, theme and project preferences, CLI status, and global dialogs. + * + * Renders the appropriate settings subsection based on the active view, wires UI controls to the app store for reading and updating settings (theme, defaults, features, updates, audio, keyboard, API keys, Claude/CLI settings, etc.), and manages visibility of the keyboard map and delete-project dialogs. + * + * @returns The settings view JSX element + */ export function SettingsView() { const { theme, @@ -52,6 +60,8 @@ export function SettingsView() { setAutoLoadClaudeMd, enableSandboxMode, setEnableSandboxMode, + autoUpdate, + setAutoUpdate, } = useAppStore(); // Hide usage tracking when using API key (only show for Claude Code CLI users) @@ -158,6 +168,8 @@ export function SettingsView() { onValidationModelChange={setValidationModel} /> ); + case 'updates': + return ; case 'danger': return ( ); -} +} \ No newline at end of file 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 new file mode 100644 index 000000000..232882ccb --- /dev/null +++ b/apps/ui/src/components/views/settings-view/updates/updates-section.tsx @@ -0,0 +1,305 @@ +/** + * 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'; +import { Button } from '@/components/ui/button'; +import { + RefreshCw, + GitBranch, + ExternalLink, + Loader2, + CheckCircle2, + AlertCircle, +} from 'lucide-react'; +import { cn, getRepoDisplayName } from '@/lib/utils'; +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; +} + +// ============================================================================ +// Component +/** + * Renders the Updates settings panel and manages update-related actions and UI. + * + * Fetches current update info on mount, exposes controls for auto-update settings + * (enabled, check interval, upstream URL), and provides actions to check for and + * pull updates with user-facing notifications. + * + * @param autoUpdate - Current auto-update configuration (enabled, checkIntervalMinutes, upstreamUrl). + * @param onAutoUpdateChange - Callback invoked with partial updates to apply to the auto-update configuration. + * @returns The Updates settings React element. + */ + +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 && ( + + )} +
+
+
+ ); +} \ No newline at end of file diff --git a/apps/ui/src/hooks/use-settings-migration.ts b/apps/ui/src/hooks/use-settings-migration.ts index 1e989060d..4b974c045 100644 --- a/apps/ui/src/hooks/use-settings-migration.ts +++ b/apps/ui/src/hooks/use-settings-migration.ts @@ -180,17 +180,12 @@ export function useSettingsMigration(): MigrationState { } /** - * Sync current global settings to file-based server storage + * Persist selected global settings from localStorage to the file-based server storage. * - * Reads the current Zustand state from localStorage and sends all global settings - * to the server to be written to {dataDir}/settings.json. + * Reads the `automaker-storage` entry (Zustand state) and sends the relevant global settings + * to the server so they are written to the application's settings file. * - * Call this when important global settings change (theme, UI preferences, profiles, etc.) - * Safe to call from store subscribers or change handlers. - * - * Only functions in Electron mode. Returns false if not in Electron or on error. - * - * @returns Promise resolving to true if sync succeeded, false otherwise + * @returns `true` if the server acknowledged the update, `false` otherwise. */ export async function syncSettingsToServer(): Promise { if (!isElectron()) return false; @@ -232,6 +227,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); @@ -315,4 +311,4 @@ export async function syncProjectSettingsToServer( console.error('[Settings Sync] Failed to sync project settings:', error); return false; } -} +} \ No newline at end of file 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..df11c642b --- /dev/null +++ b/apps/ui/src/hooks/use-update-polling.ts @@ -0,0 +1,105 @@ +/** + * 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 +// ============================================================================ + +/** + * Manages periodic background checks for updates and exposes controls and status. + * + * @param options - Optional overrides for testing or dependency injection: + * - `onCheck`: override the function used to perform an update check + * - `enabled`: force polling enabled or disabled + * - `intervalMinutes`: override the polling interval in minutes + * @returns An object with polling status and controls: + * - `isPollingActive`: `true` when polling is enabled, `false` otherwise + * - `checkNow`: a function that triggers an immediate update check and returns `true` if an update was found, `false` otherwise + * - `lastChecked`: timestamp of the last performed check, or `null` if never checked + */ +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, + }; +} \ No newline at end of file diff --git a/apps/ui/src/lib/utils.ts b/apps/ui/src/lib/utils.ts index 82ad7452d..665c833a7 100644 --- a/apps/ui/src/lib/utils.ts +++ b/apps/ui/src/lib/utils.ts @@ -63,3 +63,14 @@ export const isMac = : typeof navigator !== 'undefined' && (/Mac/.test(navigator.userAgent) || (navigator.platform ? navigator.platform.toLowerCase().includes('mac') : false)); + +/** + * Get the owner/repo display name from a GitHub repository URL. + * + * @param url - A repository URL (common GitHub formats like `https://github.com/owner/repo`, `git@github.com:owner/repo.git`, or `github.com/owner/repo`) + * @returns The `owner/repo` string when the URL is a GitHub repository, otherwise `'upstream'` + */ +export function getRepoDisplayName(url: string): string { + const match = url.match(/github\.com[/:]([^/]+\/[^/.]+)/); + return match ? match[1] : 'upstream'; +} \ No newline at end of file diff --git a/apps/ui/src/routes/__root.tsx b/apps/ui/src/routes/__root.tsx index 9802fd80e..b9cbff4c9 100644 --- a/apps/ui/src/routes/__root.tsx +++ b/apps/ui/src/routes/__root.tsx @@ -11,7 +11,17 @@ import { useSetupStore } from '@/store/setup-store'; import { getElectronAPI } from '@/lib/electron'; import { Toaster } from 'sonner'; import { ThemeOption, themeOptions } from '@/config/theme-options'; - +import { UpdateNotifier } from '@/components/updates'; + +/** + * Render the application's root layout and manage global UI state, routing, and integrations. + * + * This component provides the main application shell: sidebar, route Outlet, a hidden streamer panel, + * theme application, first-run/setup routing handling, file browser binding, IPC connection testing, + * global keyboard shortcut handling, and global UI utilities like the Toaster and UpdateNotifier. + * + * @returns The root layout element containing the sidebar, main content Outlet, hidden streamer panel, Toaster, and UpdateNotifier. + */ function RootLayoutContent() { const location = useLocation(); const { setIpcConnected, currentProject, getEffectiveTheme } = useAppStore(); @@ -175,6 +185,7 @@ function RootLayoutContent() { }`} /> + ); } @@ -189,4 +200,4 @@ function RootLayout() { export const Route = createRootRoute({ component: RootLayout, -}); +}); \ No newline at end of file