forked from syntaxbullet/AuroraBot-discord
feat: Implement stateful admin update with post-restart context, database migrations, and dedicated view components.
This commit is contained in:
@@ -1,7 +1,16 @@
|
|||||||
import { createCommand } from "@lib/utils";
|
import { createCommand } from "@lib/utils";
|
||||||
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, ButtonBuilder, ButtonStyle, ActionRowBuilder, ComponentType } from "discord.js";
|
import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, ComponentType } from "discord.js";
|
||||||
import { createErrorEmbed, createSuccessEmbed, createWarningEmbed, createInfoEmbed } from "@lib/embeds";
|
|
||||||
import { UpdateService } from "@/modules/admin/update.service";
|
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({
|
export const update = createCommand({
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
@@ -18,42 +27,17 @@ export const update = createCommand({
|
|||||||
const force = interaction.options.getBoolean("force") || false;
|
const force = interaction.options.getBoolean("force") || false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await interaction.editReply({
|
await interaction.editReply({ embeds: [getCheckingEmbed()] });
|
||||||
embeds: [createInfoEmbed("Checking for updates...", "System Update")]
|
|
||||||
});
|
|
||||||
|
|
||||||
const { hasUpdates, log, branch } = await UpdateService.checkForUpdates();
|
const { hasUpdates, log, branch } = await UpdateService.checkForUpdates();
|
||||||
|
|
||||||
if (!hasUpdates && !force) {
|
if (!hasUpdates && !force) {
|
||||||
await interaction.editReply({
|
await interaction.editReply({ embeds: [getNoUpdatesEmbed()] });
|
||||||
embeds: [createSuccessEmbed("The bot is already up to date.", "No Updates Found")]
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prepare confirmation UI
|
const { embeds, components } = getUpdatesAvailableMessage(branch, log, force);
|
||||||
const confirmButton = new ButtonBuilder()
|
const response = await interaction.editReply({ embeds, components });
|
||||||
.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<ButtonBuilder>()
|
|
||||||
.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]
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const confirmation = await response.awaitMessageComponent({
|
const confirmation = await response.awaitMessageComponent({
|
||||||
@@ -64,40 +48,34 @@ export const update = createCommand({
|
|||||||
|
|
||||||
if (confirmation.customId === "confirm_update") {
|
if (confirmation.customId === "confirm_update") {
|
||||||
await confirmation.update({
|
await confirmation.update({
|
||||||
embeds: [createInfoEmbed("⏳ Preparing update...", "Update In Progress")],
|
embeds: [getPreparingEmbed()],
|
||||||
components: []
|
components: []
|
||||||
});
|
});
|
||||||
|
|
||||||
// 1. Check dependencies
|
// 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({
|
await UpdateService.prepareRestartContext({
|
||||||
channelId: interaction.channelId,
|
channelId: interaction.channelId,
|
||||||
userId: interaction.user.id,
|
userId: interaction.user.id,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
runMigrations: true,
|
runMigrations: true,
|
||||||
installDependencies: needsDependencyInstall
|
installDependencies: needsInstall
|
||||||
});
|
});
|
||||||
|
|
||||||
// 3. Update UI to "Restarting" state now, because we might not get a chance later
|
// 3. Update UI to "Restarting" state
|
||||||
await interaction.editReply({
|
await interaction.editReply({ embeds: [getUpdatingEmbed(needsInstall)] });
|
||||||
embeds: [createWarningEmbed(
|
|
||||||
`Downloading and applying updates...\n${needsDependencyInstall ? `Expect a slightly longer startup for dependency installation.\n` : ""}The system will restart automatically.`,
|
|
||||||
"Updating & Restarting"
|
|
||||||
)]
|
|
||||||
});
|
|
||||||
|
|
||||||
// 4. Perform Update (Danger Zone)
|
// 4. Perform Update (Danger Zone)
|
||||||
await UpdateService.performUpdate(branch);
|
await UpdateService.performUpdate(branch);
|
||||||
|
|
||||||
// 5. Trigger Restart (if we are still alive)
|
// 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();
|
await UpdateService.triggerRestart();
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
await confirmation.update({
|
await confirmation.update({
|
||||||
embeds: [createInfoEmbed("Update cancelled.", "Cancelled")],
|
embeds: [getCancelledEmbed()],
|
||||||
components: []
|
components: []
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -105,7 +83,7 @@ export const update = createCommand({
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof Error && e.message.includes("time")) {
|
if (e instanceof Error && e.message.includes("time")) {
|
||||||
await interaction.editReply({
|
await interaction.editReply({
|
||||||
embeds: [createWarningEmbed("Update confirmation timed out.", "Timed Out")],
|
embeds: [getTimeoutEmbed()],
|
||||||
components: []
|
components: []
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -115,9 +93,7 @@ export const update = createCommand({
|
|||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Update failed:", error);
|
console.error("Update failed:", error);
|
||||||
await interaction.editReply({
|
await interaction.editReply({ embeds: [getErrorEmbed(error)] });
|
||||||
embeds: [createErrorEmbed(`Failed to update:\n\`\`\`\n${error instanceof Error ? error.message : String(error)}\n\`\`\``, "Update Failed")]
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { describe, expect, test, mock, beforeEach, afterAll } from "bun:test";
|
import { describe, expect, test, mock, beforeEach, afterAll, spyOn } from "bun:test";
|
||||||
import { appendFile } from "fs/promises";
|
import * as fs from "fs/promises";
|
||||||
|
|
||||||
// Mock child_process BEFORE importing the service
|
// Mock child_process BEFORE importing the service
|
||||||
const mockExec = mock((cmd: string, callback: any) => {
|
const mockExec = mock((cmd: string, callback?: any) => {
|
||||||
// console.log("Mock Exec Called with:", cmd);
|
// Handle calls without callback (like exec().unref())
|
||||||
|
if (!callback) {
|
||||||
|
return { unref: () => { } };
|
||||||
|
}
|
||||||
|
|
||||||
if (cmd.includes("git rev-parse")) {
|
if (cmd.includes("git rev-parse")) {
|
||||||
callback(null, { stdout: "main\n" });
|
callback(null, { stdout: "main\n" });
|
||||||
} else if (cmd.includes("git fetch")) {
|
} 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" });
|
callback(null, { stdout: "abcdef Update 1\n123456 Update 2" });
|
||||||
} else if (cmd.includes("git diff")) {
|
} else if (cmd.includes("git diff")) {
|
||||||
callback(null, { stdout: "package.json\nsrc/index.ts" });
|
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")) {
|
} else if (cmd.includes("bun install")) {
|
||||||
callback(null, { stdout: "Installed dependencies" });
|
callback(null, { stdout: "Installed dependencies" });
|
||||||
|
} else if (cmd.includes("drizzle-kit migrate")) {
|
||||||
|
callback(null, { stdout: "Migrations applied" });
|
||||||
} else {
|
} else {
|
||||||
callback(null, { stdout: "" });
|
callback(null, { stdout: "" });
|
||||||
}
|
}
|
||||||
@@ -23,12 +31,21 @@ mock.module("child_process", () => ({
|
|||||||
exec: mockExec
|
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", () => ({
|
mock.module("fs/promises", () => ({
|
||||||
writeFile: mock(() => Promise.resolve()),
|
writeFile: mockWriteFile,
|
||||||
readFile: mock(() => Promise.resolve()),
|
readFile: mockReadFile,
|
||||||
unlink: mock(() => Promise.resolve()),
|
unlink: mockUnlink
|
||||||
appendFile: mock(() => Promise.resolve())
|
}));
|
||||||
|
|
||||||
|
// Mock view module to avoid import issues
|
||||||
|
mock.module("./update.view", () => ({
|
||||||
|
getPostRestartEmbed: () => ({ title: "Update Complete" }),
|
||||||
|
getInstallingDependenciesEmbed: () => ({ title: "Installing..." }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe("UpdateService", () => {
|
describe("UpdateService", () => {
|
||||||
@@ -36,6 +53,10 @@ describe("UpdateService", () => {
|
|||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
mockExec.mockClear();
|
mockExec.mockClear();
|
||||||
|
mockWriteFile.mockClear();
|
||||||
|
mockReadFile.mockClear();
|
||||||
|
mockUnlink.mockClear();
|
||||||
|
|
||||||
// Dynamically import to ensure mock is used
|
// Dynamically import to ensure mock is used
|
||||||
const module = await import("./update.service");
|
const module = await import("./update.service");
|
||||||
UpdateService = module.UpdateService;
|
UpdateService = module.UpdateService;
|
||||||
@@ -45,46 +66,182 @@ describe("UpdateService", () => {
|
|||||||
mock.restore();
|
mock.restore();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("checkForUpdates should return updates if log is not empty", async () => {
|
describe("checkForUpdates", () => {
|
||||||
const result = await UpdateService.checkForUpdates();
|
test("should return updates if git log has output", async () => {
|
||||||
expect(result.hasUpdates).toBe(true);
|
const result = await UpdateService.checkForUpdates();
|
||||||
expect(result.branch).toBe("main");
|
|
||||||
expect(mockExec).toHaveBeenCalledTimes(3);
|
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 () => {
|
describe("performUpdate", () => {
|
||||||
const changed = await UpdateService.checkDependencies("main");
|
test("should run git reset --hard with correct branch", async () => {
|
||||||
expect(changed).toBe(true);
|
await UpdateService.performUpdate("main");
|
||||||
const lastCall = mockExec.mock.lastCall;
|
|
||||||
expect(lastCall).toBeDefined();
|
const lastCall = mockExec.mock.lastCall;
|
||||||
if (lastCall) {
|
expect(lastCall).toBeDefined();
|
||||||
expect(lastCall[0]).toContain("git diff");
|
expect(lastCall![0]).toContain("git reset --hard origin/main");
|
||||||
}
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("installDependencies should run bun install", async () => {
|
describe("checkDependencies", () => {
|
||||||
await UpdateService.installDependencies();
|
test("should detect package.json change", async () => {
|
||||||
const lastCall = mockExec.mock.lastCall;
|
const result = await UpdateService.checkDependencies("main");
|
||||||
expect(lastCall).toBeDefined();
|
|
||||||
if (lastCall) {
|
expect(result.needsInstall).toBe(true);
|
||||||
expect(lastCall[0]).toContain("bun install");
|
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 () => {
|
describe("installDependencies", () => {
|
||||||
// Ensure no env var
|
test("should run bun install and return output", async () => {
|
||||||
const originalEnv = process.env.RESTART_COMMAND;
|
const output = await UpdateService.installDependencies();
|
||||||
delete process.env.RESTART_COMMAND;
|
|
||||||
|
|
||||||
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.
|
describe("prepareRestartContext", () => {
|
||||||
// But since we mocked it above, we can assume it doesn't crash.
|
test("should write context to file", async () => {
|
||||||
// To verify it, we can check that exec was NOT called with a custom command?
|
const context = {
|
||||||
// But exec is called by other things.
|
channelId: "123",
|
||||||
// Let's at least ensure it runs without error.
|
userId: "456",
|
||||||
expect(true).toBe(true);
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
import { exec } from "child_process";
|
import { exec } from "child_process";
|
||||||
import { promisify } from "util";
|
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 { 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);
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
|
// Constants
|
||||||
|
const STALE_CONTEXT_MS = 10 * 60 * 1000; // 10 minutes
|
||||||
|
const RESTART_TRIGGER_FILE = ".restart_trigger";
|
||||||
|
|
||||||
export interface RestartContext {
|
export interface RestartContext {
|
||||||
channelId: string;
|
channelId: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -14,6 +19,11 @@ export interface RestartContext {
|
|||||||
installDependencies: boolean;
|
installDependencies: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DependencyCheckResult {
|
||||||
|
needsInstall: boolean;
|
||||||
|
error?: Error;
|
||||||
|
}
|
||||||
|
|
||||||
export class UpdateService {
|
export class UpdateService {
|
||||||
private static readonly CONTEXT_FILE = ".restart_context.json";
|
private static readonly CONTEXT_FILE = ".restart_context.json";
|
||||||
|
|
||||||
@@ -35,14 +45,16 @@ export class UpdateService {
|
|||||||
await execAsync(`git reset --hard origin/${branch}`);
|
await execAsync(`git reset --hard origin/${branch}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
static async checkDependencies(branch: string): Promise<boolean> {
|
static async checkDependencies(branch: string): Promise<DependencyCheckResult> {
|
||||||
try {
|
try {
|
||||||
// Check if package.json has changed between HEAD and upstream
|
|
||||||
const { stdout } = await execAsync(`git diff HEAD..origin/${branch} --name-only`);
|
const { stdout } = await execAsync(`git diff HEAD..origin/${branch} --name-only`);
|
||||||
return stdout.includes("package.json");
|
return { needsInstall: stdout.includes("package.json") };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to check dependencies:", 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<void> {
|
static async triggerRestart(): Promise<void> {
|
||||||
// Use custom restart command if available, otherwise fallback to touch
|
|
||||||
if (process.env.RESTART_COMMAND) {
|
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();
|
exec(process.env.RESTART_COMMAND).unref();
|
||||||
} else {
|
} else {
|
||||||
// Fallback to touch
|
// Fallback to writing a trigger file (avoids polluting source code)
|
||||||
try {
|
try {
|
||||||
await appendFile("src/index.ts", " ");
|
await writeFile(RESTART_TRIGGER_FILE, Date.now().toString());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to touch trigger:", err);
|
console.error("Failed to write restart trigger:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static async handlePostRestart(client: Client): Promise<void> {
|
static async handlePostRestart(client: Client): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const contextData = await readFile(this.CONTEXT_FILE, "utf-8");
|
const context = await this.loadRestartContext();
|
||||||
const context: RestartContext = JSON.parse(contextData);
|
if (!context) return;
|
||||||
|
|
||||||
if (Date.now() - context.timestamp > 10 * 60 * 1000) {
|
if (this.isContextStale(context)) {
|
||||||
// Ignore stale contexts (> 10 mins)
|
await this.cleanupContext();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const channel = await client.channels.fetch(context.channelId);
|
const channel = await this.fetchNotificationChannel(client, context.channelId);
|
||||||
if (channel && channel.isSendable() && channel instanceof TextChannel) {
|
if (!channel) {
|
||||||
let migrationOutput = "";
|
await this.cleanupContext();
|
||||||
let migrationSuccess = true;
|
return;
|
||||||
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"
|
|
||||||
)
|
|
||||||
]
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await unlink(this.CONTEXT_FILE);
|
const result = await this.executePostRestartTasks(context, channel);
|
||||||
|
await this.notifyPostRestartResult(channel, result);
|
||||||
|
await this.cleanupContext();
|
||||||
} catch (e) {
|
} 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<RestartContext | null> {
|
||||||
|
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<TextChannel | null> {
|
||||||
|
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<PostRestartResult> {
|
||||||
|
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<void> {
|
||||||
|
await channel.send({ embeds: [getPostRestartEmbed(result)] });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async cleanupContext(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await unlink(this.CONTEXT_FILE);
|
||||||
|
} catch {
|
||||||
|
// File may not exist, ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
100
src/modules/admin/update.view.ts
Normal file
100
src/modules/admin/update.view.ts
Normal file
@@ -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<ButtonBuilder>()
|
||||||
|
.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");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user