UNPKG

cyrus-ai

Version:

AI-powered Linear issue automation using Claude

294 lines • 11.9 kB
import { existsSync, mkdirSync, watch } from "node:fs"; import { dirname, join } from "node:path"; import { DEFAULT_PROXY_URL, NoopErrorReporter, } from "cyrus-core"; import { GitService, SharedApplicationServer } from "cyrus-edge-worker"; import dotenv from "dotenv"; import { DEFAULT_SERVER_PORT, parsePort } from "./config/constants.js"; import { ConfigService } from "./services/ConfigService.js"; import { Logger } from "./services/Logger.js"; import { WorkerService } from "./services/WorkerService.js"; import { getDefaultReposDir } from "./utils/getDefaultReposDir.js"; import { getDefaultWorktreesDir } from "./utils/getDefaultWorktreesDir.js"; /** * Main application context providing access to services */ export class Application { cyrusHome; config; git; worker; logger; version; errorReporter; envWatcher; configWatcher; isInSetupWaitingMode = false; isInIdleMode = false; envFilePath; constructor(cyrusHome, customEnvPath, version, errorReporter = new NoopErrorReporter()) { this.cyrusHome = cyrusHome; // Initialize logger first this.logger = new Logger(); // Store version this.version = version || "unknown"; // Error reporter (Sentry or noop). Injected so tests can supply a fake. this.errorReporter = errorReporter; // Determine the env file path: use custom path if provided, otherwise default to ~/.cyrus/.env this.envFilePath = customEnvPath || join(cyrusHome, ".env"); // Ensure required directories exist this.ensureRequiredDirectories(); // Load environment variables from the determined env file path this.loadEnvFile(); // Watch .env file for changes and reload this.setupEnvFileWatcher(); // Initialize services this.config = new ConfigService(cyrusHome, this.logger); this.git = new GitService({ cyrusHome }, this.logger); this.worker = new WorkerService(this.config, this.git, cyrusHome, this.logger, this.version); } /** * Load environment variables from the configured env file path */ loadEnvFile() { if (existsSync(this.envFilePath)) { dotenv.config({ path: this.envFilePath, override: true }); this.logger.info(`šŸ”§ Loaded environment variables from ${this.envFilePath}`); } } /** * Setup file watcher for .env file to reload on changes */ setupEnvFileWatcher() { // Only watch if file exists if (!existsSync(this.envFilePath)) { return; } try { this.envWatcher = watch(this.envFilePath, (eventType) => { if (eventType === "change") { this.logger.info("šŸ”„ .env file changed, reloading..."); this.loadEnvFile(); } }); this.logger.info(`šŸ‘€ Watching .env file for changes: ${this.envFilePath}`); } catch (error) { this.logger.error(`āŒ Failed to watch .env file: ${error}`); } } /** * Ensure required Cyrus directories exist * Creates repos dir (CYRUS_REPOS_DIR or ~/.cyrus/repos), * worktrees dir (CYRUS_WORKTREES_DIR or ~/.cyrus/worktrees), * and ~/.cyrus/mcp-configs */ ensureRequiredDirectories() { const requiredDirs = [ getDefaultReposDir(this.cyrusHome), getDefaultWorktreesDir(this.cyrusHome), join(this.cyrusHome, "mcp-configs"), ]; for (const dirPath of requiredDirs) { if (!existsSync(dirPath)) { try { mkdirSync(dirPath, { recursive: true }); this.logger.info(`šŸ“ Created directory: ${dirPath}`); } catch (error) { this.logger.error(`āŒ Failed to create directory ${dirPath}: ${error}`); throw error; } } } } /** * Get proxy URL from environment or use default */ getProxyUrl() { return process.env.PROXY_URL || DEFAULT_PROXY_URL; } /** * Check if using default proxy */ isUsingDefaultProxy() { return this.getProxyUrl() === DEFAULT_PROXY_URL; } /** * Create a temporary SharedApplicationServer for OAuth */ async createTempServer() { const serverPort = parsePort(process.env.CYRUS_SERVER_PORT, DEFAULT_SERVER_PORT); return new SharedApplicationServer(serverPort); } /** * Enable setup waiting mode and start watching config.json for repositories */ enableSetupWaitingMode() { this.isInSetupWaitingMode = true; this.startConfigWatcher(); } /** * Enable idle mode (post-onboarding, no repositories) and start watching config.json */ enableIdleMode() { this.isInIdleMode = true; this.startConfigWatcher(); } /** * Setup file watcher for config.json to detect when repositories are added */ startConfigWatcher() { const configPath = this.config.getConfigPath(); // Create empty config file if it doesn't exist if (!existsSync(configPath)) { try { const configDir = dirname(configPath); if (!existsSync(configDir)) { mkdirSync(configDir, { recursive: true }); } // Create empty config with empty repositories array this.config.save({ repositories: [] }); this.logger.info(`šŸ“ Created empty config file: ${configPath}`); } catch (error) { this.logger.error(`āŒ Failed to create config file: ${error}`); return; } } try { this.configWatcher = watch(configPath, async (eventType) => { if (eventType === "change" && (this.isInSetupWaitingMode || this.isInIdleMode)) { this.logger.info("šŸ”„ Configuration file changed, checking for repositories..."); // Reload config and check if repositories were added const edgeConfig = this.config.load(); const repositories = edgeConfig.repositories || []; if (repositories.length > 0) { this.logger.success("āœ… Configuration received!"); this.logger.info(`šŸ“¦ Starting edge worker with ${repositories.length} repository(ies)...`); // Remove CYRUS_SETUP_PENDING flag from .env (only in setup waiting mode) if (this.isInSetupWaitingMode) { await this.removeSetupPendingFlag(); } // Transition to normal operation mode await this.transitionToNormalMode(repositories); } } }); this.logger.info(`šŸ‘€ Watching config.json for repository configuration: ${configPath}`); } catch (error) { this.logger.error(`āŒ Failed to watch config.json: ${error}`); } } /** * Remove CYRUS_SETUP_PENDING flag from .env file */ async removeSetupPendingFlag() { const { readFile, writeFile } = await import("node:fs/promises"); const envPath = join(this.cyrusHome, ".env"); if (!existsSync(envPath)) { return; } try { const envContent = await readFile(envPath, "utf-8"); const updatedContent = envContent .split("\n") .filter((line) => !line.startsWith("CYRUS_SETUP_PENDING=")) .join("\n"); await writeFile(envPath, updatedContent, "utf-8"); this.logger.info("āœ… Removed CYRUS_SETUP_PENDING flag from .env"); // Reload environment variables this.loadEnvFile(); } catch (error) { this.logger.error(`āŒ Failed to remove CYRUS_SETUP_PENDING flag: ${error}`); } } /** * Transition from setup waiting mode to normal operation */ async transitionToNormalMode(repositories) { try { this.isInSetupWaitingMode = false; this.isInIdleMode = false; // Close config watcher if (this.configWatcher) { this.configWatcher.close(); this.configWatcher = undefined; } // Stop the setup waiting mode or idle mode server before starting EdgeWorker await this.worker.stopWaitingServer(); // Start the EdgeWorker with the new configuration await this.worker.startEdgeWorker({ repositories, }); // Display server information const serverPort = this.worker.getServerPort(); this.logger.raw(""); this.logger.divider(70); this.logger.success("Edge worker started successfully"); this.logger.info(`šŸ“Œ Version: ${this.version}`); this.logger.info(`šŸ”— Server running on port ${serverPort}`); if (process.env.CLOUDFLARE_TOKEN) { this.logger.info("šŸŒ©ļø Cloudflare tunnel: Active"); } this.logger.info(`\nšŸ“¦ Managing ${repositories.length} repositories:`); repositories.forEach((repo) => { this.logger.info(` • ${repo.name} (${repo.repositoryPath})`); }); this.logger.divider(70); } catch (error) { this.logger.error(`āŒ Failed to transition to normal mode: ${error}`); process.exit(1); } } /** * Handle graceful shutdown */ async shutdown() { // Close .env file watcher if (this.envWatcher) { this.envWatcher.close(); } // Close config file watcher if (this.configWatcher) { this.configWatcher.close(); } await this.worker.stop(); // Flush any buffered Sentry events before exiting await this.errorReporter.flush(2000).catch(() => false); process.exit(0); } /** * Setup process signal handlers */ setupSignalHandlers() { process.on("SIGINT", () => { this.logger.info("\nReceived SIGINT, shutting down gracefully..."); void this.shutdown(); }); process.on("SIGTERM", () => { this.logger.info("\nReceived SIGTERM, shutting down gracefully..."); void this.shutdown(); }); // Handle uncaught exceptions and unhandled promise rejections. // Logger.error forwards the Error arg to the global ErrorReporter, so we // don't need to call captureException explicitly here. process.on("uncaughtException", (error) => { this.logger.error(`🚨 Uncaught Exception: ${error.message} (type: ${error.constructor.name}). This error was caught by the global handler, preventing application crash.`, error); // Attempt graceful shutdown but don't wait indefinitely this.shutdown().finally(() => { this.logger.error("Process exiting due to uncaught exception"); process.exit(1); }); }); process.on("unhandledRejection", (reason, promise) => { const error = reason instanceof Error ? reason : new Error(String(reason)); this.logger.error(`🚨 Unhandled Promise Rejection at: ${promise}. This rejection was caught by the global handler, continuing operation.`, error); // Don't exit for promise rejections — they might be recoverable. }); } } //# sourceMappingURL=Application.js.map