📝 Add docstrings to main

Docstrings generation was requested by @amoscicki.

* https://github.com/AutoMaker-Org/automaker/pull/290#issuecomment-3694458998

The following files were modified:

* `apps/server/src/routes/updates/common.ts`
* `apps/server/src/routes/updates/index.ts`
* `apps/server/src/routes/updates/routes/check.ts`
* `apps/server/src/routes/updates/routes/info.ts`
* `apps/server/src/routes/updates/routes/pull.ts`
* `apps/ui/src/components/updates/update-notifier.tsx`
* `apps/ui/src/components/views/settings-view.tsx`
* `apps/ui/src/components/views/settings-view/updates/updates-section.tsx`
* `apps/ui/src/hooks/use-settings-migration.ts`
* `apps/ui/src/hooks/use-update-polling.ts`
* `apps/ui/src/lib/utils.ts`
* `apps/ui/src/routes/__root.tsx`
This commit is contained in:
coderabbitai[bot]
2025-12-28 05:04:14 +00:00
committed by GitHub
parent 4a708aa305
commit c9f164a1b4
12 changed files with 1307 additions and 12 deletions

View File

@@ -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<boolean> {
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<boolean> {
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<string> {
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<string> {
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<boolean> {
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;
}

View File

@@ -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;
}

View File

@@ -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<void> => {
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 <commit1> <commit2> 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) });
}
};
}

View File

@@ -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<void> => {
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) });
}
};
}

View File

@@ -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<void> => {
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) });
}
};
}