import { exec, type ExecException } from "child_process"; import { promisify } from "util"; import { writeFile, readFile, unlink } from "fs/promises"; 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 ): Promise<{ stdout: string; stderr: string }> { return new Promise((resolve, reject) => { const process = exec(cmd, (error: ExecException | null, stdout: string, stderr: string) => { if (error) { reject(error); } else { resolve({ stdout, stderr }); } }); const timer = setTimeout(() => { process.kill("SIGTERM"); reject(new Error(`Command timed out after ${timeoutMs}ms: ${cmd}`)); }, timeoutMs); process.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 { // Get branch first (needed for subsequent commands) const { stdout: branchName } = await execWithTimeout("git rev-parse --abbrev-ref HEAD"); const branch = branchName.trim(); // Parallel execution: get current commit while fetching const [currentResult] = await Promise.all([ execWithTimeout("git rev-parse --short HEAD"), execWithTimeout(`git fetch origin ${branch} --prune`) // 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}`), execWithTimeout(`git log HEAD..origin/${branch} --format="%h|%s|%an" --no-merges`), execWithTimeout(`git diff HEAD..origin/${branch} --name-only`) ]); 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 { try { const { stdout } = await execWithTimeout(`git diff HEAD..origin/${branch} --name-only`); 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 { stdout } = await execWithTimeout("git rev-parse HEAD"); 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 }> { try { const rollbackCommit = await readFile(this.ROLLBACK_FILE, "utf-8"); await execWithTimeout(`git reset --hard ${rollbackCommit.trim()}`); 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 { await execWithTimeout(`git reset --hard origin/${branch}`); } /** * Install dependencies for specified projects * Optimized: Parallel installation */ static async installDependencies(options: { root: boolean; web: boolean }): Promise { const tasks: Promise<{ label: string; output: string }>[] = []; 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 { await writeFile(this.CONTEXT_FILE, JSON.stringify(context)); } /** * Trigger a restart */ static async triggerRestart(): Promise { if (process.env.RESTART_COMMAND) { exec(process.env.RESTART_COMMAND).unref(); } else { 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 contextData = await readFile(this.CONTEXT_FILE, "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 { await unlink(this.CONTEXT_FILE); } catch { // File may not exist, ignore } // Don't clear rollback cache here - rollback file persists } }