import { exec, type ExecException } from "child_process"; import { promisify } from "util"; import { writeFile, readFile, unlink } from "fs/promises"; import * as path from "path"; import { Client, TextChannel } from "discord.js"; import { getPostRestartEmbed, getPostRestartProgressEmbed, type PostRestartProgress } from "@/modules/admin/update.view"; import type { PostRestartResult } from "@/modules/admin/update.view"; import type { RestartContext, UpdateCheckResult, UpdateInfo, CommitInfo } from "@/modules/admin/update.types"; const execAsync = promisify(exec); // Constants const STALE_CONTEXT_MS = 10 * 60 * 1000; // 10 minutes const DEFAULT_TIMEOUT_MS = 30_000; // 30 seconds for git commands const INSTALL_TIMEOUT_MS = 120_000; // 2 minutes for dependency installation const BUILD_TIMEOUT_MS = 180_000; // 3 minutes for web build /** * Execute a command with timeout protection */ async function execWithTimeout( cmd: string, timeoutMs: number = DEFAULT_TIMEOUT_MS, options: { cwd?: string } = {} ): Promise<{ stdout: string; stderr: string }> { return new Promise((resolve, reject) => { const child = exec(cmd, { cwd: options.cwd, env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_SSH_COMMAND: "ssh -o BatchMode=yes" } }, (error: ExecException | null, stdout: string, stderr: string) => { if (error) { reject(error); } else { resolve({ stdout, stderr }); } }); const timer = setTimeout(() => { child.kill("SIGTERM"); reject(new Error(`Command timed out after ${timeoutMs}ms: ${cmd}`)); }, timeoutMs); child.on("exit", () => clearTimeout(timer)); }); } export class UpdateService { private static readonly CONTEXT_FILE = ".restart_context.json"; private static readonly ROLLBACK_FILE = ".rollback_commit.txt"; // Cache for rollback state (set when we save, cleared on cleanup) private static rollbackPointExists: boolean | null = null; /** * Check for available updates with detailed commit information * Optimized: Parallel git commands and combined requirements check */ static async checkForUpdates(): Promise { const cwd = this.getDeployDir(); // Get branch first (needed for subsequent commands) const { stdout: branchName } = await execWithTimeout("git rev-parse --abbrev-ref HEAD", DEFAULT_TIMEOUT_MS, { cwd }); const branch = branchName.trim(); // Parallel execution: get current commit while fetching const [currentResult] = await Promise.all([ execWithTimeout("git rev-parse --short HEAD", DEFAULT_TIMEOUT_MS, { cwd }), execWithTimeout(`git fetch origin ${branch} --prune`, DEFAULT_TIMEOUT_MS, { cwd }) // Only fetch current branch ]); const currentCommit = currentResult.stdout.trim(); // After fetch completes, get remote info in parallel const [latestResult, logResult, diffResult] = await Promise.all([ execWithTimeout(`git rev-parse --short origin/${branch}`, DEFAULT_TIMEOUT_MS, { cwd }), execWithTimeout(`git log HEAD..origin/${branch} --format="%h|%s|%an" --no-merges`, DEFAULT_TIMEOUT_MS, { cwd }), execWithTimeout(`git diff HEAD..origin/${branch} --name-only`, DEFAULT_TIMEOUT_MS, { cwd }) ]); const latestCommit = latestResult.stdout.trim(); // Parse commit log const commits: CommitInfo[] = logResult.stdout .trim() .split("\n") .filter(line => line.length > 0) .map(line => { const [hash, message, author] = line.split("|"); return { hash: hash || "", message: message || "", author: author || "" }; }); // Parse changed files and analyze requirements in one pass const changedFiles = diffResult.stdout.trim().split("\n").filter(f => f.length > 0); const requirements = this.analyzeChangedFiles(changedFiles); return { hasUpdates: commits.length > 0, branch, currentCommit, latestCommit, commitCount: commits.length, commits, requirements }; } /** * Analyze changed files to determine update requirements * Extracted for reuse and clarity */ private static analyzeChangedFiles(changedFiles: string[]): UpdateCheckResult { const needsRootInstall = changedFiles.some(file => file === "package.json" || file === "bun.lock" ); const needsWebInstall = changedFiles.some(file => file === "web/package.json" || file === "web/bun.lock" ); // Only rebuild web if essential source files changed const needsWebBuild = changedFiles.some(file => file.match(/^web\/src\/(components|pages|lib|index)/) || file === "web/build.ts" || file === "web/tailwind.config.ts" || file === "web/tsconfig.json" ); const needsMigrations = changedFiles.some(file => file.includes("schema.ts") || file.startsWith("drizzle/") ); return { needsRootInstall, needsWebInstall, needsWebBuild, needsMigrations, changedFiles }; } /** * @deprecated Use checkForUpdates() which now includes requirements * Kept for backwards compatibility */ static async checkUpdateRequirements(branch: string): Promise { const cwd = this.getDeployDir(); try { const { stdout } = await execWithTimeout(`git diff HEAD..origin/${branch} --name-only`, DEFAULT_TIMEOUT_MS, { cwd }); const changedFiles = stdout.trim().split("\n").filter(f => f.length > 0); return this.analyzeChangedFiles(changedFiles); } catch (e) { console.error("Failed to check update requirements:", e); return { needsRootInstall: false, needsWebInstall: false, needsWebBuild: false, needsMigrations: false, changedFiles: [], error: e instanceof Error ? e : new Error(String(e)) }; } } /** * Get a summary of changed file categories */ static categorizeChanges(changedFiles: string[]): Record { const categories: Record = {}; for (const file of changedFiles) { let category = "Other"; if (file.startsWith("bot/commands/")) category = "Commands"; else if (file.startsWith("bot/modules/")) category = "Modules"; else if (file.startsWith("web/")) category = "Web Dashboard"; else if (file.startsWith("bot/lib/") || file.startsWith("shared/lib/")) category = "Library"; else if (file.startsWith("drizzle/") || file.includes("schema")) category = "Database"; else if (file.endsWith(".test.ts")) category = "Tests"; else if (file.includes("package.json") || file.includes("lock")) category = "Dependencies"; categories[category] = (categories[category] || 0) + 1; } return categories; } /** * Save the current commit for potential rollback */ static async saveRollbackPoint(): Promise { const cwd = this.getDeployDir(); const { stdout } = await execWithTimeout("git rev-parse HEAD", DEFAULT_TIMEOUT_MS, { cwd }); const commit = stdout.trim(); await writeFile(this.ROLLBACK_FILE, commit); this.rollbackPointExists = true; // Cache the state return commit; } /** * Rollback to the previous commit */ static async rollback(): Promise<{ success: boolean; message: string }> { const cwd = this.getDeployDir(); try { const rollbackCommit = await readFile(this.ROLLBACK_FILE, "utf-8"); await execWithTimeout(`git reset --hard ${rollbackCommit.trim()}`, DEFAULT_TIMEOUT_MS, { cwd }); await unlink(this.ROLLBACK_FILE); this.rollbackPointExists = false; return { success: true, message: `Rolled back to ${rollbackCommit.trim().substring(0, 7)}` }; } catch (e) { return { success: false, message: e instanceof Error ? e.message : "No rollback point available" }; } } /** * Check if a rollback point exists * Uses cache when available to avoid file system access */ static async hasRollbackPoint(): Promise { if (this.rollbackPointExists !== null) { return this.rollbackPointExists; } try { await readFile(this.ROLLBACK_FILE, "utf-8"); this.rollbackPointExists = true; return true; } catch { this.rollbackPointExists = false; return false; } } /** * Perform the git update */ static async performUpdate(branch: string): Promise { const cwd = this.getDeployDir(); await execWithTimeout(`git reset --hard origin/${branch}`, DEFAULT_TIMEOUT_MS, { cwd }); } /** * Install dependencies for specified projects * Optimized: Parallel installation */ static async installDependencies(options: { root: boolean; web: boolean }): Promise { const tasks: Promise<{ label: string; output: string }>[] = []; // Install dependencies in the App directory (not deploy dir) because we are updating the RUNNING app // NOTE: If we hot-reload, we want to install in the current directory. // If we restart, dependencies should be in the image. // Assuming this method is used for hot-patching/minor updates without container rebuild. if (options.root) { tasks.push( execWithTimeout("bun install", INSTALL_TIMEOUT_MS) .then(({ stdout }) => ({ label: "šŸ“¦ Root", output: stdout.trim() || "Done" })) ); } if (options.web) { tasks.push( execWithTimeout("cd web && bun install", INSTALL_TIMEOUT_MS) .then(({ stdout }) => ({ label: "🌐 Web", output: stdout.trim() || "Done" })) ); } const results = await Promise.all(tasks); return results.map(r => `${r.label}: ${r.output}`).join("\n"); } /** * Prepare restart context with rollback info */ static async prepareRestartContext(context: RestartContext): Promise { const filePath = path.join(this.getDeployDir(), this.CONTEXT_FILE); await writeFile(filePath, JSON.stringify(context)); } /** * Trigger a restart * * In production Docker (with restart: unless-stopped), exiting the process * will cause Docker to restart the container. For a full rebuild with code changes, * use the deploy.sh script or GitHub Actions CI/CD instead of this command. * * Note: The /update command works for hot-reloading in development and minor * restarts in production, but for production deployments with new code, * use: `cd ~/Aurora && git pull && docker compose -f docker-compose.prod.yml up -d --build` */ static async triggerRestart(): Promise { if (process.env.RESTART_COMMAND) { // Custom restart command from environment exec(process.env.RESTART_COMMAND).unref(); } else { // Exit process - Docker will restart container, dev mode will hot-reload setTimeout(() => process.exit(0), 100); } } /** * Handle post-restart tasks */ static async handlePostRestart(client: Client): Promise { try { const context = await this.loadRestartContext(); if (!context) return; if (this.isContextStale(context)) { await this.cleanupContext(); return; } const channel = await this.fetchNotificationChannel(client, context.channelId); if (!channel) { await this.cleanupContext(); return; } const result = await this.executePostRestartTasks(context, channel); await this.notifyPostRestartResult(channel, result); await this.cleanupContext(); } catch (e) { console.error("Failed to handle post-restart context:", e); } } // --- Private Helper Methods --- private static async loadRestartContext(): Promise { try { const filePath = path.join(this.getDeployDir(), this.CONTEXT_FILE); const contextData = await readFile(filePath, "utf-8"); return JSON.parse(contextData) as RestartContext; } catch { return null; } } private static isContextStale(context: RestartContext): boolean { return Date.now() - context.timestamp > STALE_CONTEXT_MS; } private static async fetchNotificationChannel(client: Client, channelId: string): Promise { try { const channel = await client.channels.fetch(channelId); if (channel && channel.isSendable() && channel instanceof TextChannel) { return channel; } return null; } catch { return null; } } private static async executePostRestartTasks( context: RestartContext, channel: TextChannel ): Promise { const result: PostRestartResult = { installSuccess: true, installOutput: "", webBuildSuccess: true, webBuildOutput: "", migrationSuccess: true, migrationOutput: "", ranInstall: context.installDependencies, ranWebBuild: context.buildWebAssets, ranMigrations: context.runMigrations, previousCommit: context.previousCommit, newCommit: context.newCommit }; // Track progress for consolidated message const progress: PostRestartProgress = { installDeps: context.installDependencies, buildWeb: context.buildWebAssets, runMigrations: context.runMigrations, currentStep: "starting" }; // Only send progress message if there are tasks to run const hasTasks = context.installDependencies || context.buildWebAssets || context.runMigrations; let progressMessage = hasTasks ? await channel.send({ embeds: [getPostRestartProgressEmbed(progress)] }) : null; // Helper to update progress message const updateProgress = async () => { if (progressMessage) { await progressMessage.edit({ embeds: [getPostRestartProgressEmbed(progress)] }); } }; // 1. Install Dependencies if needed (PARALLELIZED) if (context.installDependencies) { try { progress.currentStep = "install"; await updateProgress(); // Parallel installation of root and web dependencies const [rootResult, webResult] = await Promise.all([ execWithTimeout("bun install", INSTALL_TIMEOUT_MS) .then(({ stdout }) => ({ success: true, output: stdout.trim() || "Done" })) .catch(err => ({ success: false, output: err instanceof Error ? err.message : String(err) })), execWithTimeout("cd web && bun install", INSTALL_TIMEOUT_MS) .then(({ stdout }) => ({ success: true, output: stdout.trim() || "Done" })) .catch(err => ({ success: false, output: err instanceof Error ? err.message : String(err) })) ]); result.installSuccess = rootResult.success && webResult.success; result.installOutput = `šŸ“¦ Root: ${rootResult.output}\n🌐 Web: ${webResult.output}`; progress.installDone = true; if (!result.installSuccess) { console.error("Dependency Install Failed:", result.installOutput); } } catch (err: unknown) { result.installSuccess = false; result.installOutput = err instanceof Error ? err.message : String(err); progress.installDone = true; console.error("Dependency Install Failed:", err); } } // 2. Build Web Assets if needed if (context.buildWebAssets) { try { progress.currentStep = "build"; await updateProgress(); const { stdout } = await execWithTimeout("cd web && bun run build", BUILD_TIMEOUT_MS); result.webBuildOutput = stdout.trim() || "Build completed successfully"; progress.buildDone = true; } catch (err: unknown) { result.webBuildSuccess = false; result.webBuildOutput = err instanceof Error ? err.message : String(err); progress.buildDone = true; console.error("Web Build Failed:", err); } } // 3. Run Migrations if (context.runMigrations) { try { progress.currentStep = "migrate"; await updateProgress(); const { stdout } = await execWithTimeout("bun x drizzle-kit migrate", DEFAULT_TIMEOUT_MS); result.migrationOutput = stdout; progress.migrateDone = true; } catch (err: unknown) { result.migrationSuccess = false; result.migrationOutput = err instanceof Error ? err.message : String(err); progress.migrateDone = true; console.error("Migration Failed:", err); } } // Delete progress message before final result if (progressMessage) { try { await progressMessage.delete(); } catch { // Message may already be deleted, ignore } } return result; } private static async notifyPostRestartResult( channel: TextChannel, result: PostRestartResult ): Promise { // Use cached rollback state - we just saved it before restart const hasRollback = await this.hasRollbackPoint(); await channel.send(getPostRestartEmbed(result, hasRollback)); } private static async cleanupContext(): Promise { try { const filePath = path.join(this.getDeployDir(), this.CONTEXT_FILE); await unlink(filePath); } catch { // File may not exist, ignore } // Don't clear rollback cache here - rollback file persists } // ========================================================================= // Bot-Triggered Deployment Methods // ========================================================================= private static readonly DEPLOY_TIMEOUT_MS = 300_000; // 5 minutes for full deploy /** * Get the deploy directory path from environment or default */ static getDeployDir(): string { return process.env.DEPLOY_DIR || "/app/deploy"; } /** * Check if deployment is available (docker socket accessible) */ static async isDeployAvailable(): Promise { try { await execWithTimeout("docker --version", 5000); return true; } catch { return false; } } /** * Perform a full deployment: git pull + docker compose rebuild * This will restart the container with the new code * * @param onProgress - Callback for progress updates * @returns Object with success status, output, and commit info */ static async performDeploy(onProgress?: (step: string) => void): Promise<{ success: boolean; previousCommit: string; newCommit: string; output: string; error?: string; }> { const deployDir = this.getDeployDir(); let previousCommit = ""; let newCommit = ""; let output = ""; try { // 1. Get current commit onProgress?.("Getting current version..."); const { stdout: currentSha } = await execWithTimeout( `cd ${deployDir} && git rev-parse --short HEAD`, DEFAULT_TIMEOUT_MS ); previousCommit = currentSha.trim(); output += `šŸ“ Current: ${previousCommit}\n`; // 2. Pull latest changes onProgress?.("Pulling latest code..."); const { stdout: pullOutput } = await execWithTimeout( `cd ${deployDir} && git pull origin main`, DEFAULT_TIMEOUT_MS ); output += `šŸ“„ Pull: ${pullOutput.includes("Already up to date") ? "Already up to date" : "Updated"}\n`; // 3. Get new commit const { stdout: newSha } = await execWithTimeout( `cd ${deployDir} && git rev-parse --short HEAD`, DEFAULT_TIMEOUT_MS ); newCommit = newSha.trim(); output += `šŸ“ New: ${newCommit}\n`; // 4. Rebuild and restart container (this will kill the current process) onProgress?.("Rebuilding and restarting..."); // Use spawn with detached mode so the command continues after we exit const { spawn } = await import("child_process"); const deployProcess = spawn( "sh", ["-c", `cd ${deployDir} && docker compose -f docker-compose.prod.yml up -d --build`], { detached: true, stdio: "ignore" } ); deployProcess.unref(); output += `šŸš€ Deploy triggered - container will restart\n`; console.log("Deploy triggered successfully:", output); return { success: true, previousCommit, newCommit, output }; } catch (error) { return { success: false, previousCommit, newCommit, output, error: error instanceof Error ? error.message : String(error) }; } } }