From b2c7fa6e83547762283bd2f12d63c319643bc78e Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Thu, 8 Jan 2026 14:13:24 +0100 Subject: [PATCH] feat: improvements to update command --- src/commands/admin/update.ts | 227 +++++++++++++++++--------- src/modules/admin/update.service.ts | 212 +++++++++++++++++++++--- src/modules/admin/update.view.ts | 244 ++++++++++++++++++++++++---- 3 files changed, 547 insertions(+), 136 deletions(-) diff --git a/src/commands/admin/update.ts b/src/commands/admin/update.ts index f54416e..c98b630 100644 --- a/src/commands/admin/update.ts +++ b/src/commands/admin/update.ts @@ -9,91 +9,168 @@ import { getUpdatingEmbed, getCancelledEmbed, getTimeoutEmbed, - getErrorEmbed + getErrorEmbed, + getRollbackSuccessEmbed, + getRollbackFailedEmbed } from "@/modules/admin/update.view"; export const update = createCommand({ data: new SlashCommandBuilder() .setName("update") .setDescription("Check for updates and restart the bot") - .addBooleanOption(option => - option.setName("force") - .setDescription("Force update even if checks fail (not recommended)") - .setRequired(false) + .addSubcommand(sub => + sub.setName("check") + .setDescription("Check for and apply available updates") + .addBooleanOption(option => + option.setName("force") + .setDescription("Force update even if no changes detected") + .setRequired(false) + ) + ) + .addSubcommand(sub => + sub.setName("rollback") + .setDescription("Rollback to the previous version") ) .setDefaultMemberPermissions(PermissionFlagsBits.Administrator), + execute: async (interaction) => { - await interaction.deferReply({ flags: MessageFlags.Ephemeral }); - const force = interaction.options.getBoolean("force") || false; + const subcommand = interaction.options.getSubcommand(); - try { - await interaction.editReply({ embeds: [getCheckingEmbed()] }); - - const { hasUpdates, log, branch } = await UpdateService.checkForUpdates(); - - if (!hasUpdates && !force) { - await interaction.editReply({ embeds: [getNoUpdatesEmbed()] }); - return; - } - - const { embeds, components } = getUpdatesAvailableMessage(branch, log, force); - const response = await interaction.editReply({ embeds, components }); - - try { - const confirmation = await response.awaitMessageComponent({ - filter: (i) => i.user.id === interaction.user.id, - componentType: ComponentType.Button, - time: 30000 - }); - - if (confirmation.customId === "confirm_update") { - await confirmation.update({ - embeds: [getPreparingEmbed()], - components: [] - }); - - // 1. Check what the update requires - const { needsInstall, needsMigrations } = await UpdateService.checkUpdateRequirements(branch); - - // 2. Prepare context BEFORE update - await UpdateService.prepareRestartContext({ - channelId: interaction.channelId, - userId: interaction.user.id, - timestamp: Date.now(), - runMigrations: needsMigrations, - installDependencies: needsInstall - }); - - // 3. Update UI to "Restarting" state - await interaction.editReply({ embeds: [getUpdatingEmbed(needsInstall)] }); - - // 4. Perform Update (Danger Zone) - await UpdateService.performUpdate(branch); - - // 5. Trigger Restart (if we are still alive) - await UpdateService.triggerRestart(); - - } else { - await confirmation.update({ - embeds: [getCancelledEmbed()], - components: [] - }); - } - - } catch (e) { - if (e instanceof Error && e.message.includes("time")) { - await interaction.editReply({ - embeds: [getTimeoutEmbed()], - components: [] - }); - } else { - throw e; - } - } - - } catch (error) { - console.error("Update failed:", error); - await interaction.editReply({ embeds: [getErrorEmbed(error)] }); + if (subcommand === "rollback") { + await handleRollback(interaction); + } else { + await handleUpdate(interaction); } } }); + +async function handleUpdate(interaction: any) { + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + const force = interaction.options.getBoolean("force") || false; + + try { + // 1. Check for updates + await interaction.editReply({ embeds: [getCheckingEmbed()] }); + const updateInfo = await UpdateService.checkForUpdates(); + + if (!updateInfo.hasUpdates && !force) { + await interaction.editReply({ + embeds: [getNoUpdatesEmbed(updateInfo.currentCommit)] + }); + return; + } + + // 2. Analyze requirements + const requirements = await UpdateService.checkUpdateRequirements(updateInfo.branch); + const categories = UpdateService.categorizeChanges(requirements.changedFiles); + + // 3. Show confirmation with details + const { embeds, components } = getUpdatesAvailableMessage( + updateInfo, + requirements, + categories, + force + ); + const response = await interaction.editReply({ embeds, components }); + + // 4. Wait for confirmation + try { + const confirmation = await response.awaitMessageComponent({ + filter: (i: any) => i.user.id === interaction.user.id, + componentType: ComponentType.Button, + time: 30000 + }); + + if (confirmation.customId === "confirm_update") { + await confirmation.update({ + embeds: [getPreparingEmbed()], + components: [] + }); + + // 5. Save rollback point + const previousCommit = await UpdateService.saveRollbackPoint(); + + // 6. Prepare restart context + await UpdateService.prepareRestartContext({ + channelId: interaction.channelId, + userId: interaction.user.id, + timestamp: Date.now(), + runMigrations: requirements.needsMigrations, + installDependencies: requirements.needsRootInstall || requirements.needsWebInstall, + previousCommit: previousCommit.substring(0, 7), + newCommit: updateInfo.latestCommit + }); + + // 7. Show updating status + await interaction.editReply({ + embeds: [getUpdatingEmbed(requirements)] + }); + + // 8. Perform update + await UpdateService.performUpdate(updateInfo.branch); + + // 9. Trigger restart + await UpdateService.triggerRestart(); + + } else { + await confirmation.update({ + embeds: [getCancelledEmbed()], + components: [] + }); + } + + } catch (e) { + if (e instanceof Error && e.message.includes("time")) { + await interaction.editReply({ + embeds: [getTimeoutEmbed()], + components: [] + }); + } else { + throw e; + } + } + + } catch (error) { + console.error("Update failed:", error); + await interaction.editReply({ + embeds: [getErrorEmbed(error)], + components: [] + }); + } +} + +async function handleRollback(interaction: any) { + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + try { + const hasRollback = await UpdateService.hasRollbackPoint(); + + if (!hasRollback) { + await interaction.editReply({ + embeds: [getRollbackFailedEmbed("No rollback point available. Rollback is only possible after a recent update.")] + }); + return; + } + + const result = await UpdateService.rollback(); + + if (result.success) { + await interaction.editReply({ + embeds: [getRollbackSuccessEmbed(result.message.split(" ").pop() || "unknown")] + }); + + // Restart after rollback + setTimeout(() => UpdateService.triggerRestart(), 1000); + } else { + await interaction.editReply({ + embeds: [getRollbackFailedEmbed(result.message)] + }); + } + + } catch (error) { + console.error("Rollback failed:", error); + await interaction.editReply({ + embeds: [getErrorEmbed(error)] + }); + } +} diff --git a/src/modules/admin/update.service.ts b/src/modules/admin/update.service.ts index 38fe614..3b49cde 100644 --- a/src/modules/admin/update.service.ts +++ b/src/modules/admin/update.service.ts @@ -2,7 +2,7 @@ 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 } from "./update.view"; +import { getPostRestartEmbed, getInstallingDependenciesEmbed, getRunningMigrationsEmbed } from "./update.view"; import type { PostRestartResult } from "./update.view"; const execAsync = promisify(exec); @@ -16,72 +16,221 @@ export interface RestartContext { timestamp: number; runMigrations: boolean; installDependencies: boolean; + previousCommit: string; + newCommit: string; } export interface UpdateCheckResult { - needsInstall: boolean; + needsRootInstall: boolean; + needsWebInstall: boolean; needsMigrations: boolean; + changedFiles: string[]; error?: Error; } +export interface UpdateInfo { + hasUpdates: boolean; + branch: string; + currentCommit: string; + latestCommit: string; + commitCount: number; + commits: CommitInfo[]; +} + +export interface CommitInfo { + hash: string; + message: string; + author: string; +} + export class UpdateService { private static readonly CONTEXT_FILE = ".restart_context.json"; + private static readonly ROLLBACK_FILE = ".rollback_commit.txt"; - static async checkForUpdates(): Promise<{ hasUpdates: boolean; log: string; branch: string }> { + /** + * 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: logOutput } = await execAsync(`git log HEAD..origin/${branch} --oneline`); + + 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: !!logOutput.trim(), - log: logOutput.trim(), - branch + hasUpdates: commits.length > 0, + branch, + currentCommit: currentCommit.trim(), + latestCommit: latestCommit.trim(), + commitCount: commits.length, + commits }; } - static async performUpdate(branch: string): Promise { - await execAsync(`git reset --hard origin/${branch}`); - } - + /** + * 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 === "src/web/package.json" || file === "src/web/bun.lock" + ); + + const needsMigrations = changedFiles.some(file => + file.includes("schema.ts") || file.startsWith("drizzle/") + ); + return { - needsInstall: stdout.includes("package.json"), - needsMigrations: stdout.includes("schema.ts") || stdout.includes("drizzle/") + needsRootInstall, + needsWebInstall, + needsMigrations, + changedFiles }; } catch (e) { console.error("Failed to check update requirements:", e); return { - needsInstall: false, + needsRootInstall: false, + needsWebInstall: false, needsMigrations: false, + changedFiles: [], error: e instanceof Error ? e : new Error(String(e)) }; } } - static async installDependencies(): Promise { - const { stdout } = await execAsync("bun install"); - return stdout; + /** + * 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("src/commands/")) category = "Commands"; + else if (file.startsWith("src/modules/")) category = "Modules"; + else if (file.startsWith("src/web/")) category = "Web Dashboard"; + else if (file.startsWith("src/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 src/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) { - // Run without awaiting - it may kill the process immediately exec(process.env.RESTART_COMMAND).unref(); } else { - // Fallback: exit the process and let Docker/PM2/systemd restart it - // Small delay to allow any pending I/O to complete setTimeout(() => process.exit(0), 100); } } + /** + * Handle post-restart tasks + */ static async handlePostRestart(client: Client): Promise { try { const context = await this.loadRestartContext(); @@ -99,7 +248,7 @@ export class UpdateService { } const result = await this.executePostRestartTasks(context, channel); - await this.notifyPostRestartResult(channel, result); + await this.notifyPostRestartResult(channel, result, context); await this.cleanupContext(); } catch (e) { console.error("Failed to handle post-restart context:", e); @@ -143,15 +292,20 @@ export class UpdateService { migrationSuccess: true, migrationOutput: "", ranInstall: context.installDependencies, - ranMigrations: context.runMigrations + 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 } = await execAsync("bun install"); - result.installOutput = stdout; + + const { stdout: rootOutput } = await execAsync("bun install"); + const { stdout: webOutput } = await execAsync("cd src/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); @@ -162,6 +316,7 @@ export class UpdateService { // 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) { @@ -174,8 +329,13 @@ export class UpdateService { return result; } - private static async notifyPostRestartResult(channel: TextChannel, result: PostRestartResult): Promise { - await channel.send({ embeds: [getPostRestartEmbed(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 { diff --git a/src/modules/admin/update.view.ts b/src/modules/admin/update.view.ts index 001e81d..cc7bc6b 100644 --- a/src/modules/admin/update.view.ts +++ b/src/modules/admin/update.view.ts @@ -1,31 +1,100 @@ -import { ActionRowBuilder, ButtonBuilder, ButtonStyle } from "discord.js"; +import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } from "discord.js"; import { createInfoEmbed, createSuccessEmbed, createWarningEmbed, createErrorEmbed } from "@lib/embeds"; +import type { UpdateInfo, UpdateCheckResult } from "./update.service"; // Constants for UI -const LOG_TRUNCATE_LENGTH = 1000; -const OUTPUT_TRUNCATE_LENGTH = 500; +const LOG_TRUNCATE_LENGTH = 800; +const OUTPUT_TRUNCATE_LENGTH = 400; function truncate(text: string, maxLength: number): string { - return text.length > maxLength ? `${text.substring(0, maxLength)}\n...and more` : text; + if (!text) return ""; + return text.length > maxLength ? `${text.substring(0, maxLength)}...` : text; } +// ============ Pre-Update Embeds ============ + export function getCheckingEmbed() { - return createInfoEmbed("Checking for updates...", "System Update"); + return createInfoEmbed("šŸ” Fetching latest changes from remote...", "Checking for Updates"); } -export function getNoUpdatesEmbed() { - return createSuccessEmbed("The bot is already up to date.", "No Updates Found"); -} - -export function getUpdatesAvailableMessage(branch: string, log: string, force: boolean) { - const embed = createInfoEmbed( - `**Branch:** \`${branch}\`\n\n**Pending Changes:**\n\`\`\`\n${truncate(log, LOG_TRUNCATE_LENGTH)}\n\`\`\`\n**Do you want to proceed?**`, - "Updates Available" +export function getNoUpdatesEmbed(currentCommit: string) { + return createSuccessEmbed( + `You're running the latest version.\n\n**Current:** \`${currentCommit}\``, + "āœ… Already Up to Date" ); +} + +export function getUpdatesAvailableMessage( + updateInfo: UpdateInfo, + requirements: UpdateCheckResult, + changeCategories: Record, + force: boolean +) { + const { branch, currentCommit, latestCommit, commitCount, commits } = updateInfo; + const { needsRootInstall, needsWebInstall, needsMigrations } = requirements; + + // Build commit list (max 5) + const commitList = commits + .slice(0, 5) + .map(c => `\`${c.hash}\` ${truncate(c.message, 50)}`) + .join("\n"); + + const moreCommits = commitCount > 5 ? `\n*...and ${commitCount - 5} more*` : ""; + + // Build change categories + const categoryList = Object.entries(changeCategories) + .map(([cat, count]) => `• ${cat}: ${count} file${count > 1 ? "s" : ""}`) + .join("\n"); + + // Build requirements list + const reqs: string[] = []; + if (needsRootInstall) reqs.push("šŸ“¦ Install root dependencies"); + if (needsWebInstall) reqs.push("🌐 Install web dependencies"); + if (needsMigrations) reqs.push("šŸ—ƒļø Run database migrations"); + if (reqs.length === 0) reqs.push("⚔ Quick update (no extra steps)"); + + const embed = new EmbedBuilder() + .setTitle("šŸ“„ Updates Available") + .setColor(force ? 0xFF6B6B : 0x5865F2) + .addFields( + { + name: "Version", + value: `\`${currentCommit}\` → \`${latestCommit}\``, + inline: true + }, + { + name: "Branch", + value: `\`${branch}\``, + inline: true + }, + { + name: "Commits", + value: `${commitCount} new commit${commitCount > 1 ? "s" : ""}`, + inline: true + }, + { + name: "Recent Changes", + value: commitList + moreCommits || "No commits", + inline: false + }, + { + name: "Files Changed", + value: categoryList || "Unknown", + inline: true + }, + { + name: "Update Actions", + value: reqs.join("\n"), + inline: true + } + ) + .setFooter({ text: force ? "āš ļø Force mode enabled" : "This will restart the bot" }) + .setTimestamp(); const confirmButton = new ButtonBuilder() .setCustomId("confirm_update") - .setLabel(force ? "Force Update & Restart" : "Update & Restart") + .setLabel(force ? "Force Update" : "Update Now") + .setEmoji(force ? "āš ļø" : "šŸš€") .setStyle(force ? ButtonStyle.Danger : ButtonStyle.Success); const cancelButton = new ButtonBuilder() @@ -33,34 +102,58 @@ export function getUpdatesAvailableMessage(branch: string, log: string, force: b .setLabel("Cancel") .setStyle(ButtonStyle.Secondary); - const row = new ActionRowBuilder() - .addComponents(confirmButton, cancelButton); + const row = new ActionRowBuilder().addComponents(confirmButton, cancelButton); return { embeds: [embed], components: [row] }; } +// ============ Update Progress Embeds ============ + export function getPreparingEmbed() { - return createInfoEmbed("ā³ Preparing update...", "Update In Progress"); + return createInfoEmbed( + "šŸ”’ Saving rollback point...\nšŸ“„ Preparing to download updates...", + "ā³ Preparing Update" + ); } -export function getUpdatingEmbed(needsDependencyInstall: boolean) { - const message = `Downloading and applying updates...${needsDependencyInstall ? `\nExpect a slightly longer startup for dependency installation.` : ""}\nThe system will restart automatically.`; - return createWarningEmbed(message, "Updating & Restarting"); +export function getUpdatingEmbed(requirements: UpdateCheckResult) { + const steps: string[] = ["āœ… Rollback point saved"]; + + steps.push("šŸ“„ Downloading updates..."); + + if (requirements.needsRootInstall || requirements.needsWebInstall) { + steps.push("šŸ“¦ Dependencies will be installed after restart"); + } + if (requirements.needsMigrations) { + steps.push("šŸ—ƒļø Migrations will run after restart"); + } + + steps.push("\nšŸ”„ **Restarting now...**"); + + return createWarningEmbed(steps.join("\n"), "šŸš€ Updating"); } export function getCancelledEmbed() { - return createInfoEmbed("Update cancelled.", "Cancelled"); + return createInfoEmbed("Update cancelled. No changes were made.", "āŒ Cancelled"); } export function getTimeoutEmbed() { - return createWarningEmbed("Update confirmation timed out.", "Timed Out"); + return createWarningEmbed( + "No response received within 30 seconds.\nRun `/update` again when ready.", + "ā° Timed Out" + ); } export function getErrorEmbed(error: unknown) { const message = error instanceof Error ? error.message : String(error); - return createErrorEmbed(`Failed to update:\n\`\`\`\n${message}\n\`\`\``, "Update Failed"); + return createErrorEmbed( + `The update could not be completed:\n\`\`\`\n${truncate(message, 500)}\n\`\`\``, + "āŒ Update Failed" + ); } +// ============ Post-Restart Embeds ============ + export interface PostRestartResult { installSuccess: boolean; installOutput: string; @@ -68,33 +161,114 @@ export interface PostRestartResult { migrationOutput: string; ranInstall: boolean; ranMigrations: boolean; + previousCommit?: string; + newCommit?: string; } -export function getPostRestartEmbed(result: PostRestartResult) { - const parts: string[] = ["System updated successfully."]; +export function getPostRestartEmbed(result: PostRestartResult, hasRollback: boolean) { + const isSuccess = result.installSuccess && result.migrationSuccess; + + const embed = new EmbedBuilder() + .setTitle(isSuccess ? "āœ… Update Complete" : "āš ļø Update Completed with Issues") + .setColor(isSuccess ? 0x57F287 : 0xFEE75C) + .setTimestamp(); + + // Version info + if (result.previousCommit && result.newCommit) { + embed.addFields({ + name: "Version", + value: `\`${result.previousCommit}\` → \`${result.newCommit}\``, + inline: false + }); + } + + // Results summary + const results: string[] = []; if (result.ranInstall) { - parts.push(`**Dependencies:** ${result.installSuccess ? "āœ… Installed" : "āŒ Failed"}`); + results.push(result.installSuccess + ? "āœ… Dependencies installed" + : "āŒ Dependency installation failed" + ); } if (result.ranMigrations) { - parts.push(`**Migrations:** ${result.migrationSuccess ? "āœ… Applied" : "āŒ Failed"}`); + results.push(result.migrationSuccess + ? "āœ… Migrations applied" + : "āŒ Migration failed" + ); } - if (result.installOutput) { - parts.push(`\n**Install Output:**\n\`\`\`\n${truncate(result.installOutput, OUTPUT_TRUNCATE_LENGTH)}\n\`\`\``); + if (results.length > 0) { + embed.addFields({ + name: "Actions Performed", + value: results.join("\n"), + inline: false + }); } - if (result.migrationOutput) { - parts.push(`\n**Migration Output:**\n\`\`\`\n${truncate(result.migrationOutput, OUTPUT_TRUNCATE_LENGTH)}\n\`\`\``); + // Output details (collapsed if too long) + if (result.installOutput && !result.installSuccess) { + embed.addFields({ + name: "Install Output", + value: `\`\`\`\n${truncate(result.installOutput, OUTPUT_TRUNCATE_LENGTH)}\n\`\`\``, + inline: false + }); } - const isSuccess = result.installSuccess && result.migrationSuccess; - const title = isSuccess ? "Update Complete" : "Update Completed with Errors"; + if (result.migrationOutput && !result.migrationSuccess) { + embed.addFields({ + name: "Migration Output", + value: `\`\`\`\n${truncate(result.migrationOutput, OUTPUT_TRUNCATE_LENGTH)}\n\`\`\``, + inline: false + }); + } - return createSuccessEmbed(parts.join("\n"), title); + // Footer with rollback hint + if (!isSuccess && hasRollback) { + embed.setFooter({ text: "šŸ’” Use /update rollback to revert if needed" }); + } + + // Build components + const components: ActionRowBuilder[] = []; + + if (!isSuccess && hasRollback) { + const rollbackButton = new ButtonBuilder() + .setCustomId("rollback_update") + .setLabel("Rollback") + .setEmoji("ā†©ļø") + .setStyle(ButtonStyle.Danger); + + components.push(new ActionRowBuilder().addComponents(rollbackButton)); + } + + return { embeds: [embed], components }; } export function getInstallingDependenciesEmbed() { - return createSuccessEmbed("Installing dependencies...", "Post-Update Action"); + return createInfoEmbed( + "šŸ“¦ Installing dependencies for root and web projects...\nThis may take a moment.", + "ā³ Installing Dependencies" + ); +} + +export function getRunningMigrationsEmbed() { + return createInfoEmbed( + "šŸ—ƒļø Applying database migrations...", + "ā³ Running Migrations" + ); +} + +export function getRollbackSuccessEmbed(commit: string) { + return createSuccessEmbed( + `Successfully rolled back to commit \`${commit}\`.\nThe bot will restart now.`, + "ā†©ļø Rollback Complete" + ); +} + +export function getRollbackFailedEmbed(error: string) { + return createErrorEmbed( + `Could not rollback:\n\`\`\`\n${error}\n\`\`\``, + "āŒ Rollback Failed" + ); }