From e2aa5ee7608ed2352d2a5ba29e79f0c237c4ae14 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Wed, 24 Dec 2025 14:03:15 +0100 Subject: [PATCH] feat: Implement stateful admin update with post-restart context, database migrations, and dedicated view components. --- src/commands/admin/update.ts | 72 +++---- src/modules/admin/update.service.test.ts | 239 +++++++++++++++++++---- src/modules/admin/update.service.ts | 179 +++++++++++------ src/modules/admin/update.view.ts | 100 ++++++++++ 4 files changed, 436 insertions(+), 154 deletions(-) create mode 100644 src/modules/admin/update.view.ts diff --git a/src/commands/admin/update.ts b/src/commands/admin/update.ts index accf253..6351841 100644 --- a/src/commands/admin/update.ts +++ b/src/commands/admin/update.ts @@ -1,7 +1,16 @@ import { createCommand } from "@lib/utils"; -import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, ButtonBuilder, ButtonStyle, ActionRowBuilder, ComponentType } from "discord.js"; -import { createErrorEmbed, createSuccessEmbed, createWarningEmbed, createInfoEmbed } from "@lib/embeds"; +import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, ComponentType } from "discord.js"; import { UpdateService } from "@/modules/admin/update.service"; +import { + getCheckingEmbed, + getNoUpdatesEmbed, + getUpdatesAvailableMessage, + getPreparingEmbed, + getUpdatingEmbed, + getCancelledEmbed, + getTimeoutEmbed, + getErrorEmbed +} from "@/modules/admin/update.view"; export const update = createCommand({ data: new SlashCommandBuilder() @@ -18,42 +27,17 @@ export const update = createCommand({ const force = interaction.options.getBoolean("force") || false; try { - await interaction.editReply({ - embeds: [createInfoEmbed("Checking for updates...", "System Update")] - }); + await interaction.editReply({ embeds: [getCheckingEmbed()] }); const { hasUpdates, log, branch } = await UpdateService.checkForUpdates(); if (!hasUpdates && !force) { - await interaction.editReply({ - embeds: [createSuccessEmbed("The bot is already up to date.", "No Updates Found")] - }); + await interaction.editReply({ embeds: [getNoUpdatesEmbed()] }); return; } - // Prepare confirmation UI - const confirmButton = new ButtonBuilder() - .setCustomId("confirm_update") - .setLabel(force ? "Force Update & Restart" : "Update & Restart") - .setStyle(force ? ButtonStyle.Danger : ButtonStyle.Success); - - const cancelButton = new ButtonBuilder() - .setCustomId("cancel_update") - .setLabel("Cancel") - .setStyle(ButtonStyle.Secondary); - - const row = new ActionRowBuilder() - .addComponents(confirmButton, cancelButton); - - const updateEmbed = createInfoEmbed( - `**Branch:** \`${branch}\`\n\n**Pending Changes:**\n\`\`\`\n${log.substring(0, 1000)}${log.length > 1000 ? "\n...and more" : ""}\n\`\`\`\n**Do you want to proceed?**`, - "Updates Available" - ); - - const response = await interaction.editReply({ - embeds: [updateEmbed], - components: [row] - }); + const { embeds, components } = getUpdatesAvailableMessage(branch, log, force); + const response = await interaction.editReply({ embeds, components }); try { const confirmation = await response.awaitMessageComponent({ @@ -64,40 +48,34 @@ export const update = createCommand({ if (confirmation.customId === "confirm_update") { await confirmation.update({ - embeds: [createInfoEmbed("⏳ Preparing update...", "Update In Progress")], + embeds: [getPreparingEmbed()], components: [] }); // 1. Check dependencies - const needsDependencyInstall = await UpdateService.checkDependencies(branch); + const { needsInstall } = await UpdateService.checkDependencies(branch); - // 2. Prepare context BEFORE update, as update might kill the process (git reset on watched files) + // 2. Prepare context BEFORE update await UpdateService.prepareRestartContext({ channelId: interaction.channelId, userId: interaction.user.id, timestamp: Date.now(), runMigrations: true, - installDependencies: needsDependencyInstall + installDependencies: needsInstall }); - // 3. Update UI to "Restarting" state now, because we might not get a chance later - await interaction.editReply({ - embeds: [createWarningEmbed( - `Downloading and applying updates...\n${needsDependencyInstall ? `Expect a slightly longer startup for dependency installation.\n` : ""}The system will restart automatically.`, - "Updating & Restarting" - )] - }); + // 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) - // If git reset didn't kill us (e.g. no watched files changed), we assume we need to restart manually. await UpdateService.triggerRestart(); } else { await confirmation.update({ - embeds: [createInfoEmbed("Update cancelled.", "Cancelled")], + embeds: [getCancelledEmbed()], components: [] }); } @@ -105,7 +83,7 @@ export const update = createCommand({ } catch (e) { if (e instanceof Error && e.message.includes("time")) { await interaction.editReply({ - embeds: [createWarningEmbed("Update confirmation timed out.", "Timed Out")], + embeds: [getTimeoutEmbed()], components: [] }); } else { @@ -115,9 +93,7 @@ export const update = createCommand({ } catch (error) { console.error("Update failed:", error); - await interaction.editReply({ - embeds: [createErrorEmbed(`Failed to update:\n\`\`\`\n${error instanceof Error ? error.message : String(error)}\n\`\`\``, "Update Failed")] - }); + await interaction.editReply({ embeds: [getErrorEmbed(error)] }); } } }); diff --git a/src/modules/admin/update.service.test.ts b/src/modules/admin/update.service.test.ts index 592e588..c916850 100644 --- a/src/modules/admin/update.service.test.ts +++ b/src/modules/admin/update.service.test.ts @@ -1,9 +1,13 @@ -import { describe, expect, test, mock, beforeEach, afterAll } from "bun:test"; -import { appendFile } from "fs/promises"; +import { describe, expect, test, mock, beforeEach, afterAll, spyOn } from "bun:test"; +import * as fs from "fs/promises"; // Mock child_process BEFORE importing the service -const mockExec = mock((cmd: string, callback: any) => { - // console.log("Mock Exec Called with:", cmd); +const mockExec = mock((cmd: string, callback?: any) => { + // Handle calls without callback (like exec().unref()) + if (!callback) { + return { unref: () => { } }; + } + if (cmd.includes("git rev-parse")) { callback(null, { stdout: "main\n" }); } else if (cmd.includes("git fetch")) { @@ -12,8 +16,12 @@ const mockExec = mock((cmd: string, callback: any) => { callback(null, { stdout: "abcdef Update 1\n123456 Update 2" }); } else if (cmd.includes("git diff")) { callback(null, { stdout: "package.json\nsrc/index.ts" }); + } else if (cmd.includes("git reset")) { + callback(null, { stdout: "HEAD is now at abcdef Update 1" }); } else if (cmd.includes("bun install")) { callback(null, { stdout: "Installed dependencies" }); + } else if (cmd.includes("drizzle-kit migrate")) { + callback(null, { stdout: "Migrations applied" }); } else { callback(null, { stdout: "" }); } @@ -23,12 +31,21 @@ mock.module("child_process", () => ({ exec: mockExec })); -// We need to mock fs/promises appendFile to check triggerRestart fallback +// Mock fs/promises +const mockWriteFile = mock((path: string, content: string) => Promise.resolve()); +const mockReadFile = mock((path: string, encoding: string) => Promise.resolve("{}")); +const mockUnlink = mock((path: string) => Promise.resolve()); + mock.module("fs/promises", () => ({ - writeFile: mock(() => Promise.resolve()), - readFile: mock(() => Promise.resolve()), - unlink: mock(() => Promise.resolve()), - appendFile: mock(() => Promise.resolve()) + writeFile: mockWriteFile, + readFile: mockReadFile, + unlink: mockUnlink +})); + +// Mock view module to avoid import issues +mock.module("./update.view", () => ({ + getPostRestartEmbed: () => ({ title: "Update Complete" }), + getInstallingDependenciesEmbed: () => ({ title: "Installing..." }), })); describe("UpdateService", () => { @@ -36,6 +53,10 @@ describe("UpdateService", () => { beforeEach(async () => { mockExec.mockClear(); + mockWriteFile.mockClear(); + mockReadFile.mockClear(); + mockUnlink.mockClear(); + // Dynamically import to ensure mock is used const module = await import("./update.service"); UpdateService = module.UpdateService; @@ -45,46 +66,182 @@ describe("UpdateService", () => { mock.restore(); }); - test("checkForUpdates should return updates if log is not empty", async () => { - const result = await UpdateService.checkForUpdates(); - expect(result.hasUpdates).toBe(true); - expect(result.branch).toBe("main"); - expect(mockExec).toHaveBeenCalledTimes(3); + describe("checkForUpdates", () => { + test("should return updates if git log has output", async () => { + const result = await UpdateService.checkForUpdates(); + + expect(result.hasUpdates).toBe(true); + expect(result.branch).toBe("main"); + expect(result.log).toContain("Update 1"); + }); + + test("should call git rev-parse, fetch, and log commands", async () => { + await UpdateService.checkForUpdates(); + + const calls = mockExec.mock.calls.map((c: any) => c[0]); + expect(calls.some((cmd: string) => cmd.includes("git rev-parse"))).toBe(true); + expect(calls.some((cmd: string) => cmd.includes("git fetch"))).toBe(true); + expect(calls.some((cmd: string) => cmd.includes("git log"))).toBe(true); + }); }); - test("checkDependencies should detect package.json change", async () => { - const changed = await UpdateService.checkDependencies("main"); - expect(changed).toBe(true); - const lastCall = mockExec.mock.lastCall; - expect(lastCall).toBeDefined(); - if (lastCall) { - expect(lastCall[0]).toContain("git diff"); - } + describe("performUpdate", () => { + test("should run git reset --hard with correct branch", async () => { + await UpdateService.performUpdate("main"); + + const lastCall = mockExec.mock.lastCall; + expect(lastCall).toBeDefined(); + expect(lastCall![0]).toContain("git reset --hard origin/main"); + }); }); - test("installDependencies should run bun install", async () => { - await UpdateService.installDependencies(); - const lastCall = mockExec.mock.lastCall; - expect(lastCall).toBeDefined(); - if (lastCall) { - expect(lastCall[0]).toContain("bun install"); - } + describe("checkDependencies", () => { + test("should detect package.json change", async () => { + const result = await UpdateService.checkDependencies("main"); + + expect(result.needsInstall).toBe(true); + expect(result.error).toBeUndefined(); + }); + + test("should call git diff with correct branch", async () => { + await UpdateService.checkDependencies("develop"); + + const lastCall = mockExec.mock.lastCall; + expect(lastCall).toBeDefined(); + expect(lastCall![0]).toContain("git diff HEAD..origin/develop"); + }); }); - test("triggerRestart should use appendFile (touch) if no env var", async () => { - // Ensure no env var - const originalEnv = process.env.RESTART_COMMAND; - delete process.env.RESTART_COMMAND; + describe("installDependencies", () => { + test("should run bun install and return output", async () => { + const output = await UpdateService.installDependencies(); - await UpdateService.triggerRestart(); + expect(output).toBe("Installed dependencies"); + const lastCall = mockExec.mock.lastCall; + expect(lastCall![0]).toBe("bun install"); + }); + }); - // Cannot easily spy on fs mocks via module import in Bun unless they are exposed or we use a different strategy. - // But since we mocked it above, we can assume it doesn't crash. - // To verify it, we can check that exec was NOT called with a custom command? - // But exec is called by other things. - // Let's at least ensure it runs without error. - expect(true).toBe(true); + describe("prepareRestartContext", () => { + test("should write context to file", async () => { + const context = { + channelId: "123", + userId: "456", + timestamp: Date.now(), + runMigrations: true, + installDependencies: false + }; - process.env.RESTART_COMMAND = originalEnv; + await UpdateService.prepareRestartContext(context); + + expect(mockWriteFile).toHaveBeenCalled(); + const lastCall = mockWriteFile.mock.lastCall as [string, string] | undefined; + expect(lastCall).toBeDefined(); + expect(lastCall![0]).toContain("restart_context"); + expect(JSON.parse(lastCall![1])).toEqual(context); + }); + }); + + describe("triggerRestart", () => { + test("should use RESTART_COMMAND env var when set", async () => { + const originalEnv = process.env.RESTART_COMMAND; + process.env.RESTART_COMMAND = "pm2 restart bot"; + + await UpdateService.triggerRestart(); + + const lastCall = mockExec.mock.lastCall; + expect(lastCall).toBeDefined(); + expect(lastCall![0]).toBe("pm2 restart bot"); + + process.env.RESTART_COMMAND = originalEnv; + }); + + test("should write to trigger file when no env var", async () => { + const originalEnv = process.env.RESTART_COMMAND; + delete process.env.RESTART_COMMAND; + + await UpdateService.triggerRestart(); + + expect(mockWriteFile).toHaveBeenCalled(); + const lastCall = mockWriteFile.mock.lastCall as [string, string] | undefined; + expect(lastCall).toBeDefined(); + expect(lastCall![0]).toContain("restart_trigger"); + + process.env.RESTART_COMMAND = originalEnv; + }); + }); + + describe("handlePostRestart", () => { + const createMockClient = (channel: any = null) => ({ + channels: { + fetch: mock(() => Promise.resolve(channel)) + } + }); + + const createMockChannel = () => ({ + isSendable: () => true, + send: mock(() => Promise.resolve()) + }); + + test("should ignore stale context (>10 mins old)", async () => { + const staleContext = { + channelId: "123", + userId: "456", + timestamp: Date.now() - (15 * 60 * 1000), // 15 mins ago + runMigrations: true, + installDependencies: true + }; + + mockReadFile.mockImplementationOnce(() => Promise.resolve(JSON.stringify(staleContext))); + + const mockChannel = createMockChannel(); + // Create mock with instanceof support + const channel = Object.assign(mockChannel, { constructor: { name: "TextChannel" } }); + Object.setPrototypeOf(channel, Object.create({ constructor: { name: "TextChannel" } })); + + const mockClient = createMockClient(channel); + + await UpdateService.handlePostRestart(mockClient); + + // Should not send any message for stale context + expect(mockChannel.send).not.toHaveBeenCalled(); + // Should clean up the context file + expect(mockUnlink).toHaveBeenCalled(); + }); + + test("should do nothing if no context file exists", async () => { + mockReadFile.mockImplementationOnce(() => Promise.reject(new Error("ENOENT"))); + + const mockClient = createMockClient(); + + await UpdateService.handlePostRestart(mockClient); + + // Should not throw and not try to clean up + expect(mockUnlink).not.toHaveBeenCalled(); + }); + + test("should clean up context file after processing", async () => { + const validContext = { + channelId: "123", + userId: "456", + timestamp: Date.now(), + runMigrations: false, + installDependencies: false + }; + + mockReadFile.mockImplementationOnce(() => Promise.resolve(JSON.stringify(validContext))); + + // Create a proper TextChannel mock + const { TextChannel } = await import("discord.js"); + const mockChannel = Object.create(TextChannel.prototype); + mockChannel.isSendable = () => true; + mockChannel.send = mock(() => Promise.resolve()); + + const mockClient = createMockClient(mockChannel); + + await UpdateService.handlePostRestart(mockClient); + + expect(mockUnlink).toHaveBeenCalled(); + }); }); }); diff --git a/src/modules/admin/update.service.ts b/src/modules/admin/update.service.ts index f019f98..5ba602c 100644 --- a/src/modules/admin/update.service.ts +++ b/src/modules/admin/update.service.ts @@ -1,11 +1,16 @@ import { exec } from "child_process"; import { promisify } from "util"; -import { writeFile, readFile, unlink, appendFile } from "fs/promises"; +import { writeFile, readFile, unlink } from "fs/promises"; import { Client, TextChannel } from "discord.js"; -import { createSuccessEmbed } from "@lib/embeds"; +import { getPostRestartEmbed, getInstallingDependenciesEmbed } from "./update.view"; +import type { PostRestartResult } from "./update.view"; const execAsync = promisify(exec); +// Constants +const STALE_CONTEXT_MS = 10 * 60 * 1000; // 10 minutes +const RESTART_TRIGGER_FILE = ".restart_trigger"; + export interface RestartContext { channelId: string; userId: string; @@ -14,6 +19,11 @@ export interface RestartContext { installDependencies: boolean; } +export interface DependencyCheckResult { + needsInstall: boolean; + error?: Error; +} + export class UpdateService { private static readonly CONTEXT_FILE = ".restart_context.json"; @@ -35,14 +45,16 @@ export class UpdateService { await execAsync(`git reset --hard origin/${branch}`); } - static async checkDependencies(branch: string): Promise { + static async checkDependencies(branch: string): Promise { try { - // Check if package.json has changed between HEAD and upstream const { stdout } = await execAsync(`git diff HEAD..origin/${branch} --name-only`); - return stdout.includes("package.json"); + return { needsInstall: stdout.includes("package.json") }; } catch (e) { console.error("Failed to check dependencies:", e); - return false; + return { + needsInstall: false, + error: e instanceof Error ? e : new Error(String(e)) + }; } } @@ -56,83 +68,120 @@ export class UpdateService { } static async triggerRestart(): Promise { - // Use custom restart command if available, otherwise fallback to touch if (process.env.RESTART_COMMAND) { - // We run this without awaiting because it might kill the process immediately + // Run without awaiting - it may kill the process immediately exec(process.env.RESTART_COMMAND).unref(); } else { - // Fallback to touch + // Fallback to writing a trigger file (avoids polluting source code) try { - await appendFile("src/index.ts", " "); + await writeFile(RESTART_TRIGGER_FILE, Date.now().toString()); } catch (err) { - console.error("Failed to touch trigger:", err); + console.error("Failed to write restart trigger:", err); } } } static async handlePostRestart(client: Client): Promise { try { - const contextData = await readFile(this.CONTEXT_FILE, "utf-8"); - const context: RestartContext = JSON.parse(contextData); + const context = await this.loadRestartContext(); + if (!context) return; - if (Date.now() - context.timestamp > 10 * 60 * 1000) { - // Ignore stale contexts (> 10 mins) + if (this.isContextStale(context)) { + await this.cleanupContext(); return; } - const channel = await client.channels.fetch(context.channelId); - if (channel && channel.isSendable() && channel instanceof TextChannel) { - let migrationOutput = ""; - let migrationSuccess = true; - let installOutput = ""; - let installSuccess = true; - - // 1. Install Dependencies if needed (Post-Restart) - if (context.installDependencies) { - try { - await channel.send({ - embeds: [createSuccessEmbed("Installing dependencies...", "Post-Update Action")] - }); - const { stdout } = await execAsync("bun install"); - installOutput = stdout; - } catch (err: any) { - installSuccess = false; - installOutput = err.message; - console.error("Dependency Install Failed:", err); - } - } - - // 2. Run Migrations - if (context.runMigrations) { - try { - // Use drizzle-kit migrate - // Apply migrations using drizzle-kit - // We use `bun x` to run the local binary directly, avoiding docker-in-docker issues - const { stdout: migOut } = await execAsync("bun x drizzle-kit migrate"); - migrationOutput = migOut; - } catch (err: any) { - migrationSuccess = false; - migrationOutput = err.message; - } - } - - await channel.send({ - embeds: [ - createSuccessEmbed( - `System updated successfully. -${context.installDependencies ? `**Dependencies:** ${installSuccess ? "✅ Installed" : "❌ Failed"}\n` : ""} -${context.runMigrations ? `**Migrations:** ${migrationSuccess ? "✅ Applied" : "❌ Failed"}\n` : ""} -${installOutput ? `\n**Install Output:**\n\`\`\`\n${installOutput.substring(0, 500)}\n\`\`\`` : ""} -${migrationOutput ? `\n**Migration Output:**\n\`\`\`\n${migrationOutput.substring(0, 500)}\n\`\`\`` : ""}`, - (migrationSuccess && installSuccess) ? "Update Complete" : "Update Completed with Errors" - ) - ] - }); + const channel = await this.fetchNotificationChannel(client, context.channelId); + if (!channel) { + await this.cleanupContext(); + return; } - await unlink(this.CONTEXT_FILE); + const result = await this.executePostRestartTasks(context, channel); + await this.notifyPostRestartResult(channel, result); + await this.cleanupContext(); } catch (e) { - // No context or read error, ignore + 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 + }; + + // 1. Install Dependencies if needed + if (context.installDependencies) { + try { + await channel.send({ embeds: [getInstallingDependenciesEmbed()] }); + const { stdout } = await execAsync("bun install"); + result.installOutput = stdout; + } 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 { + 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): Promise { + await channel.send({ embeds: [getPostRestartEmbed(result)] }); + } + + private static async cleanupContext(): Promise { + try { + await unlink(this.CONTEXT_FILE); + } catch { + // File may not exist, ignore } } } diff --git a/src/modules/admin/update.view.ts b/src/modules/admin/update.view.ts new file mode 100644 index 0000000..001e81d --- /dev/null +++ b/src/modules/admin/update.view.ts @@ -0,0 +1,100 @@ +import { ActionRowBuilder, ButtonBuilder, ButtonStyle } from "discord.js"; +import { createInfoEmbed, createSuccessEmbed, createWarningEmbed, createErrorEmbed } from "@lib/embeds"; + +// Constants for UI +const LOG_TRUNCATE_LENGTH = 1000; +const OUTPUT_TRUNCATE_LENGTH = 500; + +function truncate(text: string, maxLength: number): string { + return text.length > maxLength ? `${text.substring(0, maxLength)}\n...and more` : text; +} + +export function getCheckingEmbed() { + return createInfoEmbed("Checking for updates...", "System Update"); +} + +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" + ); + + const confirmButton = new ButtonBuilder() + .setCustomId("confirm_update") + .setLabel(force ? "Force Update & Restart" : "Update & Restart") + .setStyle(force ? ButtonStyle.Danger : ButtonStyle.Success); + + const cancelButton = new ButtonBuilder() + .setCustomId("cancel_update") + .setLabel("Cancel") + .setStyle(ButtonStyle.Secondary); + + const row = new ActionRowBuilder() + .addComponents(confirmButton, cancelButton); + + return { embeds: [embed], components: [row] }; +} + +export function getPreparingEmbed() { + return createInfoEmbed("⏳ Preparing update...", "Update In Progress"); +} + +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 getCancelledEmbed() { + return createInfoEmbed("Update cancelled.", "Cancelled"); +} + +export function getTimeoutEmbed() { + return createWarningEmbed("Update confirmation timed out.", "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"); +} + +export interface PostRestartResult { + installSuccess: boolean; + installOutput: string; + migrationSuccess: boolean; + migrationOutput: string; + ranInstall: boolean; + ranMigrations: boolean; +} + +export function getPostRestartEmbed(result: PostRestartResult) { + const parts: string[] = ["System updated successfully."]; + + if (result.ranInstall) { + parts.push(`**Dependencies:** ${result.installSuccess ? "✅ Installed" : "❌ Failed"}`); + } + + if (result.ranMigrations) { + parts.push(`**Migrations:** ${result.migrationSuccess ? "✅ Applied" : "❌ Failed"}`); + } + + if (result.installOutput) { + parts.push(`\n**Install Output:**\n\`\`\`\n${truncate(result.installOutput, OUTPUT_TRUNCATE_LENGTH)}\n\`\`\``); + } + + if (result.migrationOutput) { + parts.push(`\n**Migration Output:**\n\`\`\`\n${truncate(result.migrationOutput, OUTPUT_TRUNCATE_LENGTH)}\n\`\`\``); + } + + const isSuccess = result.installSuccess && result.migrationSuccess; + const title = isSuccess ? "Update Complete" : "Update Completed with Errors"; + + return createSuccessEmbed(parts.join("\n"), title); +} + +export function getInstallingDependenciesEmbed() { + return createSuccessEmbed("Installing dependencies...", "Post-Update Action"); +}