feat: Implement stateful admin update with post-restart context, database migrations, and dedicated view components.

This commit is contained in:
syntaxbullet
2025-12-24 14:03:15 +01:00
parent e084b6fa4e
commit e2aa5ee760
4 changed files with 436 additions and 154 deletions

View File

@@ -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
}
}
}