diff --git a/src/commands/admin/update.ts b/src/commands/admin/update.ts index c0a2b33..335c4b5 100644 --- a/src/commands/admin/update.ts +++ b/src/commands/admin/update.ts @@ -1,36 +1,30 @@ 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 { UpdateService } from "@/modules/admin/update.service"; 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) + ) .setDefaultMemberPermissions(PermissionFlagsBits.Administrator), execute: async (interaction) => { await interaction.deferReply({ flags: MessageFlags.Ephemeral }); - - const { exec } = await import("child_process"); - const { promisify } = await import("util"); - const { writeFile, appendFile } = await import("fs/promises"); - const execAsync = promisify(exec); + const force = interaction.options.getBoolean("force") || false; try { - // Get current branch - const { stdout: branchName } = await execAsync("git rev-parse --abbrev-ref HEAD"); - const branch = branchName.trim(); - await interaction.editReply({ - embeds: [createInfoEmbed("Fetching latest changes...", "Checking for Updates")] + embeds: [createInfoEmbed("Checking for updates...", "System Update")] }); - // Fetch remote - await execAsync("git fetch --all"); + const { hasUpdates, log, branch } = await UpdateService.checkForUpdates(); - // Check for potential changes - const { stdout: logOutput } = await execAsync(`git log HEAD..origin/${branch} --oneline`); - - if (!logOutput.trim()) { + if (!hasUpdates && !force) { await interaction.editReply({ embeds: [createSuccessEmbed("The bot is already up to date.", "No Updates Found")] }); @@ -40,8 +34,8 @@ export const update = createCommand({ // Prepare confirmation UI const confirmButton = new ButtonBuilder() .setCustomId("confirm_update") - .setLabel("Update & Restart") - .setStyle(ButtonStyle.Success); + .setLabel(force ? "Force Update & Restart" : "Update & Restart") + .setStyle(force ? ButtonStyle.Danger : ButtonStyle.Success); const cancelButton = new ButtonBuilder() .setCustomId("cancel_update") @@ -52,7 +46,7 @@ export const update = createCommand({ .addComponents(confirmButton, cancelButton); const updateEmbed = createInfoEmbed( - `The following changes are available:\n\`\`\`\n${logOutput.substring(0, 1000)}${logOutput.length > 1000 ? "\n...and more" : ""}\n\`\`\`\n**Do you want to update and restart?**`, + `**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" ); @@ -65,39 +59,51 @@ export const update = createCommand({ const confirmation = await response.awaitMessageComponent({ filter: (i) => i.user.id === interaction.user.id, componentType: ComponentType.Button, - time: 30000 // 30 seconds timeout + time: 30000 }); if (confirmation.customId === "confirm_update") { await confirmation.update({ - embeds: [createWarningEmbed("Applying updates and restarting...\nThe bot will run database migrations on next startup.", "Update In Progress")], + embeds: [createInfoEmbed("⏳ Pulling latest changes...", "Update In Progress")], components: [] }); - // Write context BEFORE reset, because reset -> watcher restart - await writeFile(".restart_context.json", JSON.stringify({ + // 1. Check dependencies before pulling to know if we need to install + // Actually, we need to pull first to get the new package.json, then check diff? + // UpdateService.checkDependencies uses git diff HEAD..origin/branch. + // This works BEFORE we pull/reset. + const needsDependencyInstall = await UpdateService.checkDependencies(branch); + + // 2. Perform Update + await UpdateService.performUpdate(branch); + + let installLog = ""; + if (needsDependencyInstall) { + await interaction.editReply({ + embeds: [createInfoEmbed("⏳ Installing dependencies...", "Update In Progress")] + }); + try { + installLog = await UpdateService.installDependencies(); + } catch (e: any) { + if (!force) throw new Error(`Dependency installation failed: ${e.message}`); + installLog = `Failed: ${e.message}`; + } + } + + // 3. Schedule Restart + await interaction.editReply({ + embeds: [createWarningEmbed( + `Update applied successfully.\n${needsDependencyInstall ? `Dependencies installed.\n` : ""}Restarting system...`, + "Restarting" + )] + }); + + await UpdateService.scheduleRestart({ channelId: interaction.channelId, userId: interaction.user.id, timestamp: Date.now(), runMigrations: true - })); - - const { stdout } = await execAsync(`git reset --hard origin/${branch}`); - - // In case we are not running with a watcher, or if no files were changed (unlikely given log check), - // we might need to manually trigger restart. - // But if files changed, watcher kicks in here or slightly after. - // If we are here, we can try to force a touch or just exit. - - // Trigger restart just in case watcher didn't catch it or we are in a mode without watcher (though update implies source change) - try { - await appendFile("src/index.ts", " "); - } catch (err) { - console.error("Failed to touch triggers:", err); - } - - // The process should die now or soon. - // We do NOT run migrations here anymore. + }); } else { await confirmation.update({ @@ -107,17 +113,20 @@ export const update = createCommand({ } } catch (e) { - // Timeout - await interaction.editReply({ - embeds: [createWarningEmbed("Update confirmation timed out.", "Timed Out")], - components: [] - }); + if (e instanceof Error && e.message.includes("time")) { + await interaction.editReply({ + embeds: [createWarningEmbed("Update confirmation timed out.", "Timed Out")], + components: [] + }); + } else { + throw e; + } } } catch (error) { - console.error(error); + console.error("Update failed:", error); await interaction.editReply({ - embeds: [createErrorEmbed(`Failed to check for updates:\n\`\`\`\n${error instanceof Error ? error.message : String(error)}\n\`\`\``, "Update Check Failed")] + embeds: [createErrorEmbed(`Failed to update:\n\`\`\`\n${error instanceof Error ? error.message : String(error)}\n\`\`\``, "Update Failed")] }); } } diff --git a/src/events/ready.ts b/src/events/ready.ts index b5f23ce..0786e9f 100644 --- a/src/events/ready.ts +++ b/src/events/ready.ts @@ -9,54 +9,9 @@ const event: Event = { console.log(`Ready! Logged in as ${c.user.tag}`); schedulerService.start(); - // Check for restart context - const { readFile, unlink } = await import("fs/promises"); - const { createSuccessEmbed } = await import("@lib/embeds"); - - try { - const contextData = await readFile(".restart_context.json", "utf-8"); - const context = JSON.parse(contextData); - - // Validate context freshness (e.g., ignore if older than 5 minutes) - if (Date.now() - context.timestamp < 5 * 60 * 1000) { - const channel = await c.channels.fetch(context.channelId); - - if (channel && channel.isSendable()) { - let migrationOutput = ""; - let success = true; - - if (context.runMigrations) { - try { - const { exec } = await import("child_process"); - const { promisify } = await import("util"); - const execAsync = promisify(exec); - - // Send intermediate update if possible, though ready event should be fast. - const { stdout: dbOut } = await execAsync("bun run db:push:local"); - migrationOutput = dbOut; - } catch (dbErr: any) { - success = false; - migrationOutput = `Migration Failed: ${dbErr.message}`; - console.error("Migration Error:", dbErr); - } - } - - if (context.runMigrations) { - await channel.send({ - embeds: [createSuccessEmbed(`Bot is back online!\n\n**DB Migration Output:**\n\`\`\`\n${migrationOutput}\n\`\`\``, success ? "Update Successful" : "Update Completed with/Errors")] - }); - } else { - await channel.send({ - embeds: [createSuccessEmbed("Bot is back online! Redeploy successful.", "System Online")] - }); - } - } - } - - await unlink(".restart_context.json"); - } catch (error) { - // Ignore errors (file not found, etc.) - } + // Handle post-update tasks + const { UpdateService } = await import("@/modules/admin/update.service"); + await UpdateService.handlePostRestart(c); }, }; diff --git a/src/modules/admin/update.service.test.ts b/src/modules/admin/update.service.test.ts new file mode 100644 index 0000000..0cba9a7 --- /dev/null +++ b/src/modules/admin/update.service.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, test, mock, beforeEach, afterAll } from "bun:test"; + +// Mock child_process BEFORE importing the service +const mockExec = mock((cmd: string, callback: any) => { + // console.log("Mock Exec Called with:", cmd); + if (cmd.includes("git rev-parse")) { + callback(null, { stdout: "main\n" }); + } else if (cmd.includes("git fetch")) { + callback(null, { stdout: "" }); + } else if (cmd.includes("git log")) { + 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("bun install")) { + callback(null, { stdout: "Installed dependencies" }); + } else { + callback(null, { stdout: "" }); + } +}); + +mock.module("child_process", () => ({ + exec: mockExec +})); + +describe("UpdateService", () => { + let UpdateService: any; + + beforeEach(async () => { + mockExec.mockClear(); + // Dynamically import to ensure mock is used + const module = await import("./update.service"); + UpdateService = module.UpdateService; + }); + + afterAll(() => { + 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"); + // Check calls. Note: promisify wraps exec, so expecting specific arguments might be tricky if promisify adds options. + // But the command string should be there. + // calls[0] -> rev-parse + // calls[1] -> fetch + // calls[2] -> log + expect(mockExec).toHaveBeenCalledTimes(3); + }); + + test("checkDependencies should detect package.json change", async () => { + const changed = await UpdateService.checkDependencies("main"); + expect(changed).toBe(true); + // Note: checking args on mockExec when called via promisify: + // promisify passes (command, callback) or (command, options, callback). + // call arguments: [cmd, callback] + const lastCall = mockExec.mock.lastCall; + expect(lastCall).toBeDefined(); + if (lastCall) { + expect(lastCall[0]).toContain("git diff"); + } + }); + + 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"); + } + }); +}); diff --git a/src/modules/admin/update.service.ts b/src/modules/admin/update.service.ts new file mode 100644 index 0000000..7858d6c --- /dev/null +++ b/src/modules/admin/update.service.ts @@ -0,0 +1,116 @@ +import { exec } from "child_process"; +import { promisify } from "util"; +import { writeFile, readFile, unlink, appendFile } from "fs/promises"; +import { Client, TextChannel } from "discord.js"; +import { createSuccessEmbed } from "@lib/embeds"; + +const execAsync = promisify(exec); + +export interface RestartContext { + channelId: string; + userId: string; + timestamp: number; + runMigrations: boolean; +} + +export class UpdateService { + private static readonly CONTEXT_FILE = ".restart_context.json"; + + static async checkForUpdates(): Promise<{ hasUpdates: boolean; log: string; branch: string }> { + const { stdout: branchName } = await execAsync("git rev-parse --abbrev-ref HEAD"); + const branch = branchName.trim(); + + await execAsync("git fetch --all"); + const { stdout: logOutput } = await execAsync(`git log HEAD..origin/${branch} --oneline`); + + return { + hasUpdates: !!logOutput.trim(), + log: logOutput.trim(), + branch + }; + } + + static async performUpdate(branch: string): Promise { + await execAsync(`git reset --hard origin/${branch}`); + } + + 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"); + } catch (e) { + console.error("Failed to check dependencies:", e); + return false; + } + } + + static async installDependencies(): Promise { + const { stdout } = await execAsync("bun install"); + return stdout; + } + + static async scheduleRestart(context: RestartContext): Promise { + await writeFile(this.CONTEXT_FILE, JSON.stringify(context)); + + // 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 + exec(process.env.RESTART_COMMAND).unref(); + } else { + // Fallback to touch + try { + await appendFile("src/index.ts", " "); + } catch (err) { + console.error("Failed to touch trigger:", err); + } + } + } + + static async handlePostRestart(client: Client): Promise { + try { + const contextData = await readFile(this.CONTEXT_FILE, "utf-8"); + const context: RestartContext = JSON.parse(contextData); + + if (Date.now() - context.timestamp > 10 * 60 * 1000) { + // Ignore stale contexts (> 10 mins) + return; + } + + const channel = await client.channels.fetch(context.channelId); + if (channel && channel.isSendable() && channel instanceof TextChannel) { + let migrationOutput = ""; + let migrationSuccess = true; + + if (context.runMigrations) { + try { + // Use drizzle-kit migrate + // Ensure migrations are generated + await execAsync("bun run generate"); + + // 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.runMigrations ? `\n\n**Migration Output:**\n\`\`\`\n${migrationOutput.substring(0, 1000)}\n\`\`\`` : ""}`, + migrationSuccess ? "Update Complete" : "Update Complete (Migration Failed)" + ) + ] + }); + } + + await unlink(this.CONTEXT_FILE); + } catch (e) { + // No context or read error, ignore + } + } +}