189 lines
6.3 KiB
TypeScript
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
|
|
}
|
|
}
|
|
}
|