Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 162 additions & 0 deletions apps/server/src/routes/updates/common.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 37 additions & 0 deletions apps/server/src/routes/updates/index.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 177 additions & 0 deletions apps/server/src/routes/updates/routes/check.ts
Original file line number Diff line number Diff line change
@@ -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) });
}
};
}
Loading
Loading