Files
AuroraBot-discord/src/modules/admin/update.service.ts

189 lines
6.3 KiB
TypeScript

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 type { PostRestartResult } from "./update.view";
const execAsync = promisify(exec);
// Constants
const STALE_CONTEXT_MS = 10 * 60 * 1000; // 10 minutes
export interface RestartContext {
channelId: string;
userId: string;
timestamp: number;
runMigrations: boolean;
installDependencies: boolean;
}
export interface UpdateCheckResult {
needsInstall: boolean;
needsMigrations: boolean;
error?: Error;
}
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 checkUpdateRequirements(branch: string): Promise<UpdateCheckResult> {
try {
const { stdout } = await execAsync(`git diff HEAD..origin/${branch} --name-only`);
return {
needsInstall: stdout.includes("package.json"),
needsMigrations: stdout.includes("schema.ts") || stdout.includes("drizzle/")
};
} catch (e) {
console.error("Failed to check update requirements:", e);
return {
needsInstall: false,
needsMigrations: false,
error: e instanceof Error ? e : new Error(String(e))
};
}
}
static async installDependencies(): Promise<string> {
const { stdout } = await execAsync("bun install");
return stdout;
}
static async prepareRestartContext(context: RestartContext): Promise<void> {
await writeFile(this.CONTEXT_FILE, JSON.stringify(context));
}
static async triggerRestart(): Promise<void> {
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);
}
}
static async handlePostRestart(client: Client): Promise<void> {
try {
const context = await this.loadRestartContext();
if (!context) return;
if (this.isContextStale(context)) {
await this.cleanupContext();
return;
}
const channel = await this.fetchNotificationChannel(client, context.channelId);
if (!channel) {
await this.cleanupContext();
return;
}
const result = await this.executePostRestartTasks(context, channel);
await this.notifyPostRestartResult(channel, result);
await this.cleanupContext();
} catch (e) {
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
}
}
}