import { exec } from "child_process"; import { promisify } from "util"; import { writeFile, readFile, unlink } from "fs/promises"; import { Client, TextChannel } from "discord.js"; import { getPostRestartEmbed, getInstallingDependenciesEmbed, getRunningMigrationsEmbed } 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 export class UpdateService { private static readonly CONTEXT_FILE = ".restart_context.json"; private static readonly ROLLBACK_FILE = ".rollback_commit.txt"; /** * Check for available updates with detailed commit information */ static async checkForUpdates(): Promise { const { stdout: branchName } = await execAsync("git rev-parse --abbrev-ref HEAD"); const branch = branchName.trim(); const { stdout: currentCommit } = await execAsync("git rev-parse --short HEAD"); await execAsync("git fetch --all"); const { stdout: latestCommit } = await execAsync(`git rev-parse --short origin/${branch}`); // Get commit log with author info const { stdout: logOutput } = await execAsync( `git log HEAD..origin/${branch} --format="%h|%s|%an" --no-merges` ); const commits: CommitInfo[] = logOutput .trim() .split("\n") .filter(line => line.length > 0) .map(line => { const [hash, message, author] = line.split("|"); return { hash: hash || "", message: message || "", author: author || "" }; }); return { hasUpdates: commits.length > 0, branch, currentCommit: currentCommit.trim(), latestCommit: latestCommit.trim(), commitCount: commits.length, commits }; } /** * Analyze what the update requires */ static async checkUpdateRequirements(branch: string): Promise { try { const { stdout } = await execAsync(`git diff HEAD..origin/${branch} --name-only`); const changedFiles = stdout.trim().split("\n").filter(f => f.length > 0); const needsRootInstall = changedFiles.some(file => file === "package.json" || file === "bun.lock" ); const needsWebInstall = changedFiles.some(file => file === "web/package.json" || file === "web/bun.lock" ); const needsMigrations = changedFiles.some(file => file.includes("schema.ts") || file.startsWith("drizzle/") ); return { needsRootInstall, needsWebInstall, needsMigrations, changedFiles }; } catch (e) { console.error("Failed to check update requirements:", e); return { needsRootInstall: false, needsWebInstall: 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 execAsync("git rev-parse HEAD"); const commit = stdout.trim(); await writeFile(this.ROLLBACK_FILE, commit); 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 execAsync(`git reset --hard ${rollbackCommit.trim()}`); await unlink(this.ROLLBACK_FILE); 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 */ static async hasRollbackPoint(): Promise { try { await readFile(this.ROLLBACK_FILE, "utf-8"); return true; } catch { return false; } } /** * Perform the git update */ static async performUpdate(branch: string): Promise { await execAsync(`git reset --hard origin/${branch}`); } /** * Install dependencies for specified projects */ static async installDependencies(options: { root: boolean; web: boolean }): Promise { const outputs: string[] = []; if (options.root) { const { stdout } = await execAsync("bun install"); outputs.push(`šŸ“¦ Root: ${stdout.trim() || "Done"}`); } if (options.web) { const { stdout } = await execAsync("cd web && bun install"); outputs.push(`🌐 Web: ${stdout.trim() || "Done"}`); } return outputs.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, context); 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: "", migrationSuccess: true, migrationOutput: "", ranInstall: context.installDependencies, ranMigrations: context.runMigrations, previousCommit: context.previousCommit, newCommit: context.newCommit }; // 1. Install Dependencies if needed if (context.installDependencies) { try { await channel.send({ embeds: [getInstallingDependenciesEmbed()] }); const { stdout: rootOutput } = await execAsync("bun install"); const { stdout: webOutput } = await execAsync("cd web && bun install"); result.installOutput = `šŸ“¦ Root: ${rootOutput.trim() || "Done"}\n🌐 Web: ${webOutput.trim() || "Done"}`; } catch (err: unknown) { result.installSuccess = false; result.installOutput = err instanceof Error ? err.message : String(err); console.error("Dependency Install Failed:", err); } } // 2. Run Migrations if (context.runMigrations) { try { await channel.send({ embeds: [getRunningMigrationsEmbed()] }); const { stdout } = await execAsync("bun x drizzle-kit migrate"); result.migrationOutput = stdout; } catch (err: unknown) { result.migrationSuccess = false; result.migrationOutput = err instanceof Error ? err.message : String(err); console.error("Migration Failed:", err); } } return result; } private static async notifyPostRestartResult( channel: TextChannel, result: PostRestartResult, context: RestartContext ): Promise { 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 } } }