mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-02 20:43:36 +00:00
📝 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:
committed by
GitHub
parent
4a708aa305
commit
c9f164a1b4
162
apps/server/src/routes/updates/common.ts
Normal file
162
apps/server/src/routes/updates/common.ts
Normal 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;
|
||||
}
|
||||
37
apps/server/src/routes/updates/index.ts
Normal file
37
apps/server/src/routes/updates/index.ts
Normal 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;
|
||||
}
|
||||
177
apps/server/src/routes/updates/routes/check.ts
Normal file
177
apps/server/src/routes/updates/routes/check.ts
Normal 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) });
|
||||
}
|
||||
};
|
||||
}
|
||||
136
apps/server/src/routes/updates/routes/info.ts
Normal file
136
apps/server/src/routes/updates/routes/info.ts
Normal 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) });
|
||||
}
|
||||
};
|
||||
}
|
||||
170
apps/server/src/routes/updates/routes/pull.ts
Normal file
170
apps/server/src/routes/updates/routes/pull.ts
Normal 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) });
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user