|
|
|
|
@@ -1,6 +1,7 @@
|
|
|
|
|
import { exec, type ExecException } from "child_process";
|
|
|
|
|
import { promisify } from "util";
|
|
|
|
|
import { writeFile, readFile, unlink } from "fs/promises";
|
|
|
|
|
import * as path from "path";
|
|
|
|
|
import { Client, TextChannel } from "discord.js";
|
|
|
|
|
import { getPostRestartEmbed, getPostRestartProgressEmbed, type PostRestartProgress } from "@/modules/admin/update.view";
|
|
|
|
|
import type { PostRestartResult } from "@/modules/admin/update.view";
|
|
|
|
|
@@ -19,10 +20,11 @@ const BUILD_TIMEOUT_MS = 180_000; // 3 minutes for web build
|
|
|
|
|
*/
|
|
|
|
|
async function execWithTimeout(
|
|
|
|
|
cmd: string,
|
|
|
|
|
timeoutMs: number = DEFAULT_TIMEOUT_MS
|
|
|
|
|
timeoutMs: number = DEFAULT_TIMEOUT_MS,
|
|
|
|
|
options: { cwd?: string } = {}
|
|
|
|
|
): Promise<{ stdout: string; stderr: string }> {
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
const process = exec(cmd, (error: ExecException | null, stdout: string, stderr: string) => {
|
|
|
|
|
const process = exec(cmd, { cwd: options.cwd }, (error: ExecException | null, stdout: string, stderr: string) => {
|
|
|
|
|
if (error) {
|
|
|
|
|
reject(error);
|
|
|
|
|
} else {
|
|
|
|
|
@@ -51,22 +53,24 @@ export class UpdateService {
|
|
|
|
|
* Optimized: Parallel git commands and combined requirements check
|
|
|
|
|
*/
|
|
|
|
|
static async checkForUpdates(): Promise<UpdateInfo & { requirements: UpdateCheckResult }> {
|
|
|
|
|
const cwd = this.getDeployDir();
|
|
|
|
|
|
|
|
|
|
// Get branch first (needed for subsequent commands)
|
|
|
|
|
const { stdout: branchName } = await execWithTimeout("git rev-parse --abbrev-ref HEAD");
|
|
|
|
|
const { stdout: branchName } = await execWithTimeout("git rev-parse --abbrev-ref HEAD", DEFAULT_TIMEOUT_MS, { cwd });
|
|
|
|
|
const branch = branchName.trim();
|
|
|
|
|
|
|
|
|
|
// Parallel execution: get current commit while fetching
|
|
|
|
|
const [currentResult] = await Promise.all([
|
|
|
|
|
execWithTimeout("git rev-parse --short HEAD"),
|
|
|
|
|
execWithTimeout(`git fetch origin ${branch} --prune`) // Only fetch current branch
|
|
|
|
|
execWithTimeout("git rev-parse --short HEAD", DEFAULT_TIMEOUT_MS, { cwd }),
|
|
|
|
|
execWithTimeout(`git fetch origin ${branch} --prune`, DEFAULT_TIMEOUT_MS, { cwd }) // Only fetch current branch
|
|
|
|
|
]);
|
|
|
|
|
const currentCommit = currentResult.stdout.trim();
|
|
|
|
|
|
|
|
|
|
// After fetch completes, get remote info in parallel
|
|
|
|
|
const [latestResult, logResult, diffResult] = await Promise.all([
|
|
|
|
|
execWithTimeout(`git rev-parse --short origin/${branch}`),
|
|
|
|
|
execWithTimeout(`git log HEAD..origin/${branch} --format="%h|%s|%an" --no-merges`),
|
|
|
|
|
execWithTimeout(`git diff HEAD..origin/${branch} --name-only`)
|
|
|
|
|
execWithTimeout(`git rev-parse --short origin/${branch}`, DEFAULT_TIMEOUT_MS, { cwd }),
|
|
|
|
|
execWithTimeout(`git log HEAD..origin/${branch} --format="%h|%s|%an" --no-merges`, DEFAULT_TIMEOUT_MS, { cwd }),
|
|
|
|
|
execWithTimeout(`git diff HEAD..origin/${branch} --name-only`, DEFAULT_TIMEOUT_MS, { cwd })
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
const latestCommit = latestResult.stdout.trim();
|
|
|
|
|
@@ -135,8 +139,9 @@ export class UpdateService {
|
|
|
|
|
* Kept for backwards compatibility
|
|
|
|
|
*/
|
|
|
|
|
static async checkUpdateRequirements(branch: string): Promise<UpdateCheckResult> {
|
|
|
|
|
const cwd = this.getDeployDir();
|
|
|
|
|
try {
|
|
|
|
|
const { stdout } = await execWithTimeout(`git diff HEAD..origin/${branch} --name-only`);
|
|
|
|
|
const { stdout } = await execWithTimeout(`git diff HEAD..origin/${branch} --name-only`, DEFAULT_TIMEOUT_MS, { cwd });
|
|
|
|
|
const changedFiles = stdout.trim().split("\n").filter(f => f.length > 0);
|
|
|
|
|
return this.analyzeChangedFiles(changedFiles);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
@@ -179,7 +184,8 @@ export class UpdateService {
|
|
|
|
|
* Save the current commit for potential rollback
|
|
|
|
|
*/
|
|
|
|
|
static async saveRollbackPoint(): Promise<string> {
|
|
|
|
|
const { stdout } = await execWithTimeout("git rev-parse HEAD");
|
|
|
|
|
const cwd = this.getDeployDir();
|
|
|
|
|
const { stdout } = await execWithTimeout("git rev-parse HEAD", DEFAULT_TIMEOUT_MS, { cwd });
|
|
|
|
|
const commit = stdout.trim();
|
|
|
|
|
await writeFile(this.ROLLBACK_FILE, commit);
|
|
|
|
|
this.rollbackPointExists = true; // Cache the state
|
|
|
|
|
@@ -190,9 +196,10 @@ export class UpdateService {
|
|
|
|
|
* Rollback to the previous commit
|
|
|
|
|
*/
|
|
|
|
|
static async rollback(): Promise<{ success: boolean; message: string }> {
|
|
|
|
|
const cwd = this.getDeployDir();
|
|
|
|
|
try {
|
|
|
|
|
const rollbackCommit = await readFile(this.ROLLBACK_FILE, "utf-8");
|
|
|
|
|
await execWithTimeout(`git reset --hard ${rollbackCommit.trim()}`);
|
|
|
|
|
await execWithTimeout(`git reset --hard ${rollbackCommit.trim()}`, DEFAULT_TIMEOUT_MS, { cwd });
|
|
|
|
|
await unlink(this.ROLLBACK_FILE);
|
|
|
|
|
this.rollbackPointExists = false;
|
|
|
|
|
return { success: true, message: `Rolled back to ${rollbackCommit.trim().substring(0, 7)}` };
|
|
|
|
|
@@ -226,7 +233,8 @@ export class UpdateService {
|
|
|
|
|
* Perform the git update
|
|
|
|
|
*/
|
|
|
|
|
static async performUpdate(branch: string): Promise<void> {
|
|
|
|
|
await execWithTimeout(`git reset --hard origin/${branch}`);
|
|
|
|
|
const cwd = this.getDeployDir();
|
|
|
|
|
await execWithTimeout(`git reset --hard origin/${branch}`, DEFAULT_TIMEOUT_MS, { cwd });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
@@ -236,6 +244,11 @@ export class UpdateService {
|
|
|
|
|
static async installDependencies(options: { root: boolean; web: boolean }): Promise<string> {
|
|
|
|
|
const tasks: Promise<{ label: string; output: string }>[] = [];
|
|
|
|
|
|
|
|
|
|
// Install dependencies in the App directory (not deploy dir) because we are updating the RUNNING app
|
|
|
|
|
// NOTE: If we hot-reload, we want to install in the current directory.
|
|
|
|
|
// If we restart, dependencies should be in the image.
|
|
|
|
|
// Assuming this method is used for hot-patching/minor updates without container rebuild.
|
|
|
|
|
|
|
|
|
|
if (options.root) {
|
|
|
|
|
tasks.push(
|
|
|
|
|
execWithTimeout("bun install", INSTALL_TIMEOUT_MS)
|
|
|
|
|
@@ -258,7 +271,8 @@ export class UpdateService {
|
|
|
|
|
* Prepare restart context with rollback info
|
|
|
|
|
*/
|
|
|
|
|
static async prepareRestartContext(context: RestartContext): Promise<void> {
|
|
|
|
|
await writeFile(this.CONTEXT_FILE, JSON.stringify(context));
|
|
|
|
|
const filePath = path.join(this.getDeployDir(), this.CONTEXT_FILE);
|
|
|
|
|
await writeFile(filePath, JSON.stringify(context));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
@@ -313,7 +327,8 @@ export class UpdateService {
|
|
|
|
|
|
|
|
|
|
private static async loadRestartContext(): Promise<RestartContext | null> {
|
|
|
|
|
try {
|
|
|
|
|
const contextData = await readFile(this.CONTEXT_FILE, "utf-8");
|
|
|
|
|
const filePath = path.join(this.getDeployDir(), this.CONTEXT_FILE);
|
|
|
|
|
const contextData = await readFile(filePath, "utf-8");
|
|
|
|
|
return JSON.parse(contextData) as RestartContext;
|
|
|
|
|
} catch {
|
|
|
|
|
return null;
|
|
|
|
|
@@ -463,7 +478,8 @@ export class UpdateService {
|
|
|
|
|
|
|
|
|
|
private static async cleanupContext(): Promise<void> {
|
|
|
|
|
try {
|
|
|
|
|
await unlink(this.CONTEXT_FILE);
|
|
|
|
|
const filePath = path.join(this.getDeployDir(), this.CONTEXT_FILE);
|
|
|
|
|
await unlink(filePath);
|
|
|
|
|
} catch {
|
|
|
|
|
// File may not exist, ignore
|
|
|
|
|
}
|
|
|
|
|
|