forked from syntaxbullet/AuroraBot-discord
refactor: initial moves
This commit is contained in:
318
shared/modules/admin/update.service.ts
Normal file
318
shared/modules/admin/update.service.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user