cyrus-ai
Version:
AI-powered Linear issue automation using Claude
294 lines ⢠11.9 kB
JavaScript
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