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,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<boolean> {
|
||||
static async checkDependencies(branch: string): Promise<DependencyCheckResult> {
|
||||
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<void> {
|
||||
// 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<void> {
|
||||
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<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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user