forked from syntaxbullet/AuroraBot-discord
319 lines
11 KiB
TypeScript
319 lines
11 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, getRunningMigrationsEmbed } from "./update.view";
|
|
import type { PostRestartResult } from "./update.view";
|
|
import type { RestartContext, UpdateCheckResult, UpdateInfo, CommitInfo } from "./update.types";
|
|
|
|
const execAsync = promisify(exec);
|
|
|
|
// Constants
|
|
const STALE_CONTEXT_MS = 10 * 60 * 1000; // 10 minutes
|
|
|
|
|
|
|
|
export class UpdateService {
|
|
private static readonly CONTEXT_FILE = ".restart_context.json";
|
|
private static readonly ROLLBACK_FILE = ".rollback_commit.txt";
|
|
|
|
/**
|
|
* Check for available updates with detailed commit information
|
|
*/
|
|
static async checkForUpdates(): Promise<UpdateInfo> {
|
|
const { stdout: branchName } = await execAsync("git rev-parse --abbrev-ref HEAD");
|
|
const branch = branchName.trim();
|
|
|
|
const { stdout: currentCommit } = await execAsync("git rev-parse --short HEAD");
|
|
|
|
await execAsync("git fetch --all");
|
|
|
|
const { stdout: latestCommit } = await execAsync(`git rev-parse --short origin/${branch}`);
|
|
|
|
// Get commit log with author info
|
|
const { stdout: logOutput } = await execAsync(
|
|
`git log HEAD..origin/${branch} --format="%h|%s|%an" --no-merges`
|
|
);
|
|
|
|
const commits: CommitInfo[] = logOutput
|
|
.trim()
|
|
.split("\n")
|
|
.filter(line => line.length > 0)
|
|
.map(line => {
|
|
const [hash, message, author] = line.split("|");
|
|
return { hash: hash || "", message: message || "", author: author || "" };
|
|
});
|
|
|
|
return {
|
|
hasUpdates: commits.length > 0,
|
|
branch,
|
|
currentCommit: currentCommit.trim(),
|
|
latestCommit: latestCommit.trim(),
|
|
commitCount: commits.length,
|
|
commits
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Analyze what the update requires
|
|
*/
|
|
static async checkUpdateRequirements(branch: string): Promise<UpdateCheckResult> {
|
|
try {
|
|
const { stdout } = await execAsync(`git diff HEAD..origin/${branch} --name-only`);
|
|
const changedFiles = stdout.trim().split("\n").filter(f => f.length > 0);
|
|
|
|
const needsRootInstall = changedFiles.some(file =>
|
|
file === "package.json" || file === "bun.lock"
|
|
);
|
|
|
|
const needsWebInstall = changedFiles.some(file =>
|
|
file === "web/package.json" || file === "web/bun.lock"
|
|
);
|
|
|
|
const needsMigrations = changedFiles.some(file =>
|
|
file.includes("schema.ts") || file.startsWith("drizzle/")
|
|
);
|
|
|
|
return {
|
|
needsRootInstall,
|
|
needsWebInstall,
|
|
needsMigrations,
|
|
changedFiles
|
|
};
|
|
} catch (e) {
|
|
console.error("Failed to check update requirements:", e);
|
|
return {
|
|
needsRootInstall: false,
|
|
needsWebInstall: false,
|
|
needsMigrations: false,
|
|
changedFiles: [],
|
|
error: e instanceof Error ? e : new Error(String(e))
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get a summary of changed file categories
|
|
*/
|
|
static categorizeChanges(changedFiles: string[]): Record<string, number> {
|
|
const categories: Record<string, number> = {};
|
|
|
|
for (const file of changedFiles) {
|
|
let category = "Other";
|
|
|
|
if (file.startsWith("bot/commands/")) category = "Commands";
|
|
else if (file.startsWith("bot/modules/")) category = "Modules";
|
|
else if (file.startsWith("web/")) category = "Web Dashboard";
|
|
else if (file.startsWith("bot/lib/") || file.startsWith("shared/lib/")) category = "Library";
|
|
else if (file.startsWith("drizzle/") || file.includes("schema")) category = "Database";
|
|
else if (file.endsWith(".test.ts")) category = "Tests";
|
|
else if (file.includes("package.json") || file.includes("lock")) category = "Dependencies";
|
|
|
|
categories[category] = (categories[category] || 0) + 1;
|
|
}
|
|
|
|
return categories;
|
|
}
|
|
|
|
/**
|
|
* Save the current commit for potential rollback
|
|
*/
|
|
static async saveRollbackPoint(): Promise<string> {
|
|
const { stdout } = await execAsync("git rev-parse HEAD");
|
|
const commit = stdout.trim();
|
|
await writeFile(this.ROLLBACK_FILE, commit);
|
|
return commit;
|
|
}
|
|
|
|
/**
|
|
* Rollback to the previous commit
|
|
*/
|
|
static async rollback(): Promise<{ success: boolean; message: string }> {
|
|
try {
|
|
const rollbackCommit = await readFile(this.ROLLBACK_FILE, "utf-8");
|
|
await execAsync(`git reset --hard ${rollbackCommit.trim()}`);
|
|
await unlink(this.ROLLBACK_FILE);
|
|
return { success: true, message: `Rolled back to ${rollbackCommit.trim().substring(0, 7)}` };
|
|
} catch (e) {
|
|
return {
|
|
success: false,
|
|
message: e instanceof Error ? e.message : "No rollback point available"
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if a rollback point exists
|
|
*/
|
|
static async hasRollbackPoint(): Promise<boolean> {
|
|
try {
|
|
await readFile(this.ROLLBACK_FILE, "utf-8");
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Perform the git update
|
|
*/
|
|
static async performUpdate(branch: string): Promise<void> {
|
|
await execAsync(`git reset --hard origin/${branch}`);
|
|
}
|
|
|
|
/**
|
|
* Install dependencies for specified projects
|
|
*/
|
|
static async installDependencies(options: { root: boolean; web: boolean }): Promise<string> {
|
|
const outputs: string[] = [];
|
|
|
|
if (options.root) {
|
|
const { stdout } = await execAsync("bun install");
|
|
outputs.push(`📦 Root: ${stdout.trim() || "Done"}`);
|
|
}
|
|
|
|
if (options.web) {
|
|
const { stdout } = await execAsync("cd web && bun install");
|
|
outputs.push(`🌐 Web: ${stdout.trim() || "Done"}`);
|
|
}
|
|
|
|
return outputs.join("\n");
|
|
}
|
|
|
|
/**
|
|
* Prepare restart context with rollback info
|
|
*/
|
|
static async prepareRestartContext(context: RestartContext): Promise<void> {
|
|
await writeFile(this.CONTEXT_FILE, JSON.stringify(context));
|
|
}
|
|
|
|
/**
|
|
* Trigger a restart
|
|
*/
|
|
static async triggerRestart(): Promise<void> {
|
|
if (process.env.RESTART_COMMAND) {
|
|
exec(process.env.RESTART_COMMAND).unref();
|
|
} else {
|
|
setTimeout(() => process.exit(0), 100);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle post-restart tasks
|
|
*/
|
|
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, context);
|
|
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,
|
|
previousCommit: context.previousCommit,
|
|
newCommit: context.newCommit
|
|
};
|
|
|
|
// 1. Install Dependencies if needed
|
|
if (context.installDependencies) {
|
|
try {
|
|
await channel.send({ embeds: [getInstallingDependenciesEmbed()] });
|
|
|
|
const { stdout: rootOutput } = await execAsync("bun install");
|
|
const { stdout: webOutput } = await execAsync("cd web && bun install");
|
|
|
|
result.installOutput = `📦 Root: ${rootOutput.trim() || "Done"}\n🌐 Web: ${webOutput.trim() || "Done"}`;
|
|
} 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 {
|
|
await channel.send({ embeds: [getRunningMigrationsEmbed()] });
|
|
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,
|
|
context: RestartContext
|
|
): Promise<void> {
|
|
const hasRollback = await this.hasRollbackPoint();
|
|
await channel.send(getPostRestartEmbed(result, hasRollback));
|
|
}
|
|
|
|
private static async cleanupContext(): Promise<void> {
|
|
try {
|
|
await unlink(this.CONTEXT_FILE);
|
|
} catch {
|
|
// File may not exist, ignore
|
|
}
|
|
}
|
|
}
|