feat: implement a dedicated update service to centralize bot update logic, dependency checks, and post-restart handling.

This commit is contained in:
syntaxbullet
2025-12-24 13:38:45 +01:00
parent fcc82292f2
commit fc7afd7d22
4 changed files with 248 additions and 96 deletions

View File

@@ -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");
}
});
});

View File

@@ -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<void> {
await execAsync(`git reset --hard origin/${branch}`);
}
static async checkDependencies(branch: string): Promise<boolean> {
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<string> {
const { stdout } = await execAsync("bun install");
return stdout;
}
static async scheduleRestart(context: RestartContext): Promise<void> {
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<void> {
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
}
}
}