Skip to content
Open
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
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
- [Security & Isolation](#security--isolation)
- [Data Storage](#data-storage)
- [Learn More](#learn-more)
- [Auto-Updates](#auto-updates)
- [License](#license)

</details>
Expand Down Expand Up @@ -630,6 +631,7 @@ data/

- [Contributing Guide](./CONTRIBUTING.md) - How to contribute to Automaker
- [Project Documentation](./docs/) - Architecture guides, patterns, and developer docs
- [Auto-Updates Guide](./docs/auto-updates.md) - Update system architecture and customization
- [Docker Isolation Guide](./docs/docker-isolation.md) - Security-focused Docker deployment
- [Shared Packages Guide](./docs/llm-shared-packages.md) - Using monorepo packages

Expand Down Expand Up @@ -662,3 +664,13 @@ This project is licensed under the **Automaker License Agreement**. See [LICENSE
- By contributing to this repository, you grant the Core Contributors full, irrevocable rights to your code (copyright assignment).

**Core Contributors** (Cody Seibert (webdevcody), SuperComboGamer (SCG), Kacper Lachowicz (Shironex, Shirone), and Ben Scott (trueheads)) are granted perpetual, royalty-free licenses for any use, including monetization.

## Auto-Updates

Automaker includes a built-in auto-update system with one-click updates from the upstream repository.

- **Automatic Checking** - Periodic background checks (configurable interval)
- **Toast Notifications** - "Update Available" notifications with "Update Now" button
- **One-Click Updates** - Pull updates directly from Settings > Updates

**[Read the full Auto-Updates documentation](./docs/auto-updates.md)** - includes architecture details and how to swap the update mechanism (git → releases, etc.).
2 changes: 2 additions & 0 deletions apps/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import { ClaudeUsageService } from './services/claude-usage-service.js';
import { createGitHubRoutes } from './routes/github/index.js';
import { createContextRoutes } from './routes/context/index.js';
import { createBacklogPlanRoutes } from './routes/backlog-plan/index.js';
import { createUpdatesRoutes } from './routes/updates/index.js';
import { cleanupStaleValidations } from './routes/github/routes/validation-common.js';
import { createMCPRoutes } from './routes/mcp/index.js';
import { MCPTestService } from './services/mcp-test-service.js';
Expand Down Expand Up @@ -215,6 +216,7 @@ app.use('/api/context', createContextRoutes(settingsService));
app.use('/api/backlog-plan', createBacklogPlanRoutes(events, settingsService));
app.use('/api/mcp', createMCPRoutes(mcpTestService));
app.use('/api/pipeline', createPipelineRoutes(pipelineService));
app.use('/api/updates', createUpdatesRoutes(settingsService, events));

// Create HTTP server
const server = createServer(app);
Expand Down
196 changes: 196 additions & 0 deletions apps/server/src/routes/updates/common.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
/**
* Common utilities for update routes
*/

import { createLogger } from '@automaker/utils';
import { exec } from 'child_process';
import { promisify } from 'util';
import path from 'path';
import fs from 'fs';
import crypto from 'crypto';
import { fileURLToPath } from 'url';
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';

const logger = createLogger('Updates');
export const execAsync = promisify(exec);

// Re-export shared utilities
export { getErrorMessageShared as getErrorMessage };
export const logError = createLogError(logger);

// ============================================================================
// Extended PATH configuration for Electron apps
// ============================================================================

const pathSeparator = process.platform === 'win32' ? ';' : ':';
const additionalPaths: string[] = [];

if (process.platform === 'win32') {
// Windows paths
if (process.env.LOCALAPPDATA) {
additionalPaths.push(`${process.env.LOCALAPPDATA}\\Programs\\Git\\cmd`);
}
if (process.env.PROGRAMFILES) {
additionalPaths.push(`${process.env.PROGRAMFILES}\\Git\\cmd`);
}
if (process.env['ProgramFiles(x86)']) {
additionalPaths.push(`${process.env['ProgramFiles(x86)']}\\Git\\cmd`);
}
} else {
// Unix/Mac paths
additionalPaths.push(
'/opt/homebrew/bin', // Homebrew on Apple Silicon
'/usr/local/bin', // Homebrew on Intel Mac, common Linux location
'/home/linuxbrew/.linuxbrew/bin' // Linuxbrew
);
// pipx, other user installs - only add if HOME is defined
if (process.env.HOME) {
additionalPaths.push(`${process.env.HOME}/.local/bin`);
}
}

const extendedPath = [process.env.PATH, ...additionalPaths.filter(Boolean)]
.filter(Boolean)
.join(pathSeparator);

/**
* Environment variables with extended PATH for executing shell commands.
*/
export const execEnv = {
...process.env,
PATH: extendedPath,
};

// ============================================================================
// Automaker installation path
// ============================================================================

/**
* Get the root directory of the Automaker installation.
* Traverses up from the current file looking for a package.json with name "automaker".
* This approach is more robust than using fixed relative paths.
*/
export function getAutomakerRoot(): string {
const __filename = fileURLToPath(import.meta.url);
let currentDir = path.dirname(__filename);
const root = path.parse(currentDir).root;

while (currentDir !== root) {
const packageJsonPath = path.join(currentDir, 'package.json');
if (fs.existsSync(packageJsonPath)) {
try {
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
// Look for the monorepo root package.json with name "automaker"
if (packageJson.name === 'automaker') {
return currentDir;
}
} catch {
// Ignore JSON parse errors, continue searching
}
}
currentDir = path.dirname(currentDir);
}

// Fallback to fixed path if marker not found (shouldn't happen in normal usage)
const fallbackDir = path.dirname(__filename);
return path.resolve(fallbackDir, '..', '..', '..', '..', '..');
}

/**
* Check if git is available on the system
*/
export async function isGitAvailable(): Promise<boolean> {
try {
await execAsync('git --version', { env: execEnv });
return true;
} catch {
return false;
}
}

/**
* Check if a path is a git repository
*/
export async function isGitRepo(repoPath: string): Promise<boolean> {
try {
await execAsync('git rev-parse --is-inside-work-tree', { cwd: repoPath, env: execEnv });
return true;
} catch {
return false;
}
}

/**
* Get the current HEAD commit hash
*/
export async function getCurrentCommit(repoPath: string): Promise<string> {
const { stdout } = await execAsync('git rev-parse HEAD', { cwd: repoPath, env: execEnv });
return stdout.trim();
}

/**
* Get the short version of a commit hash
*/
export async function getShortCommit(repoPath: string): Promise<string> {
const { stdout } = await execAsync('git rev-parse --short HEAD', { cwd: repoPath, env: execEnv });
return stdout.trim();
}

/**
* Check if the repo has local uncommitted changes
*/
export async function hasLocalChanges(repoPath: string): Promise<boolean> {
const { stdout } = await execAsync('git status --porcelain', { cwd: repoPath, env: execEnv });
return stdout.trim().length > 0;
}

/**
* Validate that a URL looks like a valid git remote URL.
* Also blocks shell metacharacters to prevent command injection.
*/
export function isValidGitUrl(url: string): boolean {
// Allow HTTPS, SSH, and git protocols
const startsWithValidProtocol =
url.startsWith('https://') ||
url.startsWith('git@') ||
url.startsWith('git://') ||
url.startsWith('ssh://');

// Block shell metacharacters to prevent command injection
const hasShellChars = /[;`|&<>()$!\\[\] ]/.test(url);

return startsWithValidProtocol && !hasShellChars;
}

/**
* Execute a callback with a temporary git remote, ensuring cleanup.
* Centralizes the pattern of adding a temp remote, doing work, and removing it.
*/
export async function withTempGitRemote<T>(
installPath: string,
sourceUrl: string,
callback: (tempRemoteName: string) => Promise<T>
): Promise<T> {
// Defense-in-depth: validate URL even though callers should already validate
if (!isValidGitUrl(sourceUrl)) {
throw new Error('Invalid git URL format');
}

const tempRemoteName = `automaker-temp-remote-${crypto.randomBytes(8).toString('hex')}`;
try {
await execAsync(`git remote add ${tempRemoteName} "${sourceUrl}"`, {
cwd: installPath,
env: execEnv,
});
return await callback(tempRemoteName);
} finally {
try {
await execAsync(`git remote remove ${tempRemoteName}`, {
cwd: installPath,
env: execEnv,
});
} catch {
// Ignore cleanup errors
}
}
}
33 changes: 33 additions & 0 deletions apps/server/src/routes/updates/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* Update routes - HTTP API for checking and applying updates
*
* Provides endpoints for:
* - Checking if updates are available from upstream
* - Pulling updates from upstream
* - Getting current installation info
*/

import { Router } from 'express';
import type { SettingsService } from '../../services/settings-service.js';
import type { EventEmitter } from '../../lib/events.js';
import { createCheckHandler } from './routes/check.js';
import { createPullHandler } from './routes/pull.js';
import { createInfoHandler } from './routes/info.js';

export function createUpdatesRoutes(
settingsService: SettingsService,
events: EventEmitter
): Router {
const router = Router();

// GET /api/updates/check - Check if updates are available
router.get('/check', createCheckHandler(settingsService));

// POST /api/updates/pull - Pull updates from upstream (events for progress streaming)
router.post('/pull', createPullHandler(settingsService, events));

// GET /api/updates/info - Get current installation info
router.get('/info', createInfoHandler(settingsService));

return router;
}
Loading