forked from syntaxbullet/aurorabot
Remove the admin update service, command, and related files, and update Docker configurations.
This commit is contained in:
@@ -1,333 +0,0 @@
|
||||
import { describe, expect, test, mock, beforeEach, afterAll } from "bun:test";
|
||||
|
||||
// Mock child_process BEFORE importing the service
|
||||
const mockExec = mock((cmd: string, callback?: any) => {
|
||||
// Handle calls without callback (like exec().unref())
|
||||
if (!callback) {
|
||||
return { unref: () => { } };
|
||||
}
|
||||
|
||||
// Simulate successful command execution
|
||||
let stdout = "";
|
||||
|
||||
if (cmd.includes("git rev-parse --abbrev-ref")) {
|
||||
stdout = "main\n";
|
||||
} else if (cmd.includes("git rev-parse --short")) {
|
||||
stdout = "abc1234\n";
|
||||
} else if (cmd.includes("git rev-parse HEAD")) {
|
||||
stdout = "abc1234567890\n";
|
||||
} else if (cmd.includes("git fetch")) {
|
||||
stdout = "";
|
||||
} else if (cmd.includes("git log")) {
|
||||
stdout = "abcdef|Update 1|Author1\n123456|Update 2|Author2";
|
||||
} else if (cmd.includes("git diff")) {
|
||||
stdout = "package.json\nsrc/index.ts\nshared/lib/schema.ts";
|
||||
} else if (cmd.includes("git reset")) {
|
||||
stdout = "HEAD is now at abcdef Update 1";
|
||||
} else if (cmd.includes("bun install")) {
|
||||
stdout = "Installed dependencies";
|
||||
} else if (cmd.includes("drizzle-kit migrate")) {
|
||||
stdout = "Migrations applied";
|
||||
} else if (cmd.includes("bun run build")) {
|
||||
stdout = "Build completed";
|
||||
}
|
||||
|
||||
callback(null, stdout, "");
|
||||
});
|
||||
|
||||
mock.module("child_process", () => ({
|
||||
exec: mockExec
|
||||
}));
|
||||
|
||||
// Mock fs/promises
|
||||
const mockWriteFile = mock((_path: string, _content: string) => Promise.resolve());
|
||||
const mockReadFile = mock((_path: string, _encoding: string) => Promise.resolve("{}"));
|
||||
const mockUnlink = mock((_path: string) => Promise.resolve());
|
||||
|
||||
mock.module("fs/promises", () => ({
|
||||
writeFile: mockWriteFile,
|
||||
readFile: mockReadFile,
|
||||
unlink: mockUnlink
|
||||
}));
|
||||
|
||||
// Mock view module to avoid import issues
|
||||
mock.module("@/modules/admin/update.view", () => ({
|
||||
getPostRestartEmbed: () => ({ embeds: [{ title: "Update Complete" }], components: [] }),
|
||||
getPostRestartProgressEmbed: () => ({ title: "Progress..." }),
|
||||
}));
|
||||
|
||||
describe("UpdateService", () => {
|
||||
let UpdateService: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockExec.mockClear();
|
||||
mockWriteFile.mockClear();
|
||||
mockReadFile.mockClear();
|
||||
mockUnlink.mockClear();
|
||||
|
||||
// Dynamically import to ensure mock is used
|
||||
const module = await import("./update.service");
|
||||
UpdateService = module.UpdateService;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
describe("checkForUpdates", () => {
|
||||
test("should return updates if git log has output", async () => {
|
||||
const result = await UpdateService.checkForUpdates();
|
||||
|
||||
expect(result.hasUpdates).toBe(true);
|
||||
expect(result.branch).toBe("main");
|
||||
expect(result.commits.length).toBeGreaterThan(0);
|
||||
expect(result.commits[0].message).toContain("Update 1");
|
||||
});
|
||||
|
||||
test("should call git rev-parse, fetch, and log commands", async () => {
|
||||
await UpdateService.checkForUpdates();
|
||||
|
||||
const calls = mockExec.mock.calls.map((c: any) => c[0]);
|
||||
expect(calls.some((cmd: string) => cmd.includes("git rev-parse"))).toBe(true);
|
||||
expect(calls.some((cmd: string) => cmd.includes("git fetch"))).toBe(true);
|
||||
expect(calls.some((cmd: string) => cmd.includes("git log"))).toBe(true);
|
||||
});
|
||||
|
||||
test("should include requirements in the response", async () => {
|
||||
const result = await UpdateService.checkForUpdates();
|
||||
|
||||
expect(result.requirements).toBeDefined();
|
||||
expect(result.requirements.needsRootInstall).toBe(true); // package.json is in mock
|
||||
expect(result.requirements.needsMigrations).toBe(true); // schema.ts is in mock
|
||||
expect(result.requirements.changedFiles).toContain("package.json");
|
||||
});
|
||||
});
|
||||
|
||||
describe("performUpdate", () => {
|
||||
test("should run git reset --hard with correct branch", async () => {
|
||||
await UpdateService.performUpdate("main");
|
||||
|
||||
const calls = mockExec.mock.calls.map((c: any) => c[0]);
|
||||
expect(calls.some((cmd: string) => cmd.includes("git reset --hard origin/main"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkUpdateRequirements (deprecated)", () => {
|
||||
test("should detect package.json and schema.ts changes", async () => {
|
||||
const result = await UpdateService.checkUpdateRequirements("main");
|
||||
|
||||
expect(result.needsRootInstall).toBe(true);
|
||||
expect(result.needsMigrations).toBe(true);
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
|
||||
test("should call git diff with correct branch", async () => {
|
||||
await UpdateService.checkUpdateRequirements("develop");
|
||||
|
||||
const calls = mockExec.mock.calls.map((c: any) => c[0]);
|
||||
expect(calls.some((cmd: string) => cmd.includes("git diff HEAD..origin/develop"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("installDependencies", () => {
|
||||
test("should run bun install for root only", async () => {
|
||||
const output = await UpdateService.installDependencies({ root: true, web: false });
|
||||
|
||||
expect(output).toContain("Root");
|
||||
const calls = mockExec.mock.calls.map((c: any) => c[0]);
|
||||
expect(calls.some((cmd: string) => cmd === "bun install")).toBe(true);
|
||||
});
|
||||
|
||||
test("should run bun install for both root and web in parallel", async () => {
|
||||
const output = await UpdateService.installDependencies({ root: true, web: true });
|
||||
|
||||
expect(output).toContain("Root");
|
||||
expect(output).toContain("Web");
|
||||
const calls = mockExec.mock.calls.map((c: any) => c[0]);
|
||||
expect(calls.some((cmd: string) => cmd === "bun install")).toBe(true);
|
||||
expect(calls.some((cmd: string) => cmd.includes("cd web && bun install"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("categorizeChanges", () => {
|
||||
test("should categorize files correctly", () => {
|
||||
const files = [
|
||||
"bot/commands/admin/update.ts",
|
||||
"bot/modules/admin/update.view.ts",
|
||||
"web/src/components/Button.tsx",
|
||||
"shared/lib/utils.ts",
|
||||
"package.json",
|
||||
"drizzle/0001_migration.sql"
|
||||
];
|
||||
|
||||
const categories = UpdateService.categorizeChanges(files);
|
||||
|
||||
expect(categories["Commands"]).toBe(1);
|
||||
expect(categories["Modules"]).toBe(1);
|
||||
expect(categories["Web Dashboard"]).toBe(1);
|
||||
expect(categories["Library"]).toBe(1);
|
||||
expect(categories["Dependencies"]).toBe(1);
|
||||
expect(categories["Database"]).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("prepareRestartContext", () => {
|
||||
test("should write context to file", async () => {
|
||||
const context = {
|
||||
channelId: "123",
|
||||
userId: "456",
|
||||
timestamp: Date.now(),
|
||||
runMigrations: true,
|
||||
installDependencies: false,
|
||||
buildWebAssets: false,
|
||||
previousCommit: "abc1234",
|
||||
newCommit: "def5678"
|
||||
};
|
||||
|
||||
await UpdateService.prepareRestartContext(context);
|
||||
|
||||
expect(mockWriteFile).toHaveBeenCalled();
|
||||
const lastCall = mockWriteFile.mock.lastCall as [string, string] | undefined;
|
||||
expect(lastCall).toBeDefined();
|
||||
expect(lastCall![0]).toContain("restart_context");
|
||||
expect(JSON.parse(lastCall![1])).toEqual(context);
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveRollbackPoint", () => {
|
||||
test("should save current commit hash to file", async () => {
|
||||
const commit = await UpdateService.saveRollbackPoint();
|
||||
|
||||
expect(commit).toBeTruthy();
|
||||
expect(mockWriteFile).toHaveBeenCalled();
|
||||
const lastCall = mockWriteFile.mock.lastCall as [string, string] | undefined;
|
||||
expect(lastCall![0]).toContain("rollback_commit");
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasRollbackPoint", () => {
|
||||
test("should return true when rollback file exists", async () => {
|
||||
mockReadFile.mockImplementationOnce(() => Promise.resolve("abc123"));
|
||||
|
||||
// Clear cache first
|
||||
(UpdateService as any).rollbackPointExists = null;
|
||||
|
||||
const result = await UpdateService.hasRollbackPoint();
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false when rollback file does not exist", async () => {
|
||||
mockReadFile.mockImplementationOnce(() => Promise.reject(new Error("ENOENT")));
|
||||
|
||||
// Clear cache first
|
||||
(UpdateService as any).rollbackPointExists = null;
|
||||
|
||||
const result = await UpdateService.hasRollbackPoint();
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("triggerRestart", () => {
|
||||
test("should use RESTART_COMMAND env var when set", async () => {
|
||||
const originalEnv = process.env.RESTART_COMMAND;
|
||||
process.env.RESTART_COMMAND = "pm2 restart bot";
|
||||
|
||||
await UpdateService.triggerRestart();
|
||||
|
||||
const calls = mockExec.mock.calls.map((c: any) => c[0]);
|
||||
expect(calls.some((cmd: string) => cmd === "pm2 restart bot")).toBe(true);
|
||||
|
||||
process.env.RESTART_COMMAND = originalEnv;
|
||||
});
|
||||
|
||||
test("should call process.exit when no env var is set", async () => {
|
||||
const originalEnv = process.env.RESTART_COMMAND;
|
||||
delete process.env.RESTART_COMMAND;
|
||||
|
||||
// Just verify it doesn't throw - actual process.exit is mocked by setTimeout
|
||||
await UpdateService.triggerRestart();
|
||||
|
||||
process.env.RESTART_COMMAND = originalEnv;
|
||||
});
|
||||
});
|
||||
|
||||
describe("handlePostRestart", () => {
|
||||
const createMockClient = (channel: any = null) => ({
|
||||
channels: {
|
||||
fetch: mock(() => Promise.resolve(channel))
|
||||
}
|
||||
});
|
||||
|
||||
const createMockChannel = () => ({
|
||||
isSendable: () => true,
|
||||
send: mock(() => Promise.resolve({ edit: mock(() => Promise.resolve()), delete: mock(() => Promise.resolve()) }))
|
||||
});
|
||||
|
||||
test("should ignore stale context (>10 mins old)", async () => {
|
||||
const staleContext = {
|
||||
channelId: "123",
|
||||
userId: "456",
|
||||
timestamp: Date.now() - (15 * 60 * 1000), // 15 mins ago
|
||||
runMigrations: true,
|
||||
installDependencies: true,
|
||||
buildWebAssets: false,
|
||||
previousCommit: "abc",
|
||||
newCommit: "def"
|
||||
};
|
||||
|
||||
mockReadFile.mockImplementationOnce(() => Promise.resolve(JSON.stringify(staleContext)));
|
||||
|
||||
const mockChannel = createMockChannel();
|
||||
// Create mock with instanceof support
|
||||
const channel = Object.assign(mockChannel, { constructor: { name: "TextChannel" } });
|
||||
Object.setPrototypeOf(channel, Object.create({ constructor: { name: "TextChannel" } }));
|
||||
|
||||
const mockClient = createMockClient(channel);
|
||||
|
||||
await UpdateService.handlePostRestart(mockClient);
|
||||
|
||||
// Should not send any message for stale context
|
||||
expect(mockChannel.send).not.toHaveBeenCalled();
|
||||
// Should clean up the context file
|
||||
expect(mockUnlink).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should do nothing if no context file exists", async () => {
|
||||
mockReadFile.mockImplementationOnce(() => Promise.reject(new Error("ENOENT")));
|
||||
|
||||
const mockClient = createMockClient();
|
||||
|
||||
await UpdateService.handlePostRestart(mockClient);
|
||||
|
||||
// Should not throw and not try to clean up
|
||||
expect(mockUnlink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should clean up context file after processing", async () => {
|
||||
const validContext = {
|
||||
channelId: "123",
|
||||
userId: "456",
|
||||
timestamp: Date.now(),
|
||||
runMigrations: false,
|
||||
installDependencies: false,
|
||||
buildWebAssets: false,
|
||||
previousCommit: "abc",
|
||||
newCommit: "def"
|
||||
};
|
||||
|
||||
mockReadFile.mockImplementationOnce(() => Promise.resolve(JSON.stringify(validContext)));
|
||||
|
||||
// Create a proper TextChannel mock
|
||||
const { TextChannel } = await import("discord.js");
|
||||
const mockChannel = Object.create(TextChannel.prototype);
|
||||
mockChannel.isSendable = () => true;
|
||||
mockChannel.send = mock(() => Promise.resolve({ edit: mock(() => Promise.resolve()), delete: mock(() => Promise.resolve()) }));
|
||||
|
||||
const mockClient = createMockClient(mockChannel);
|
||||
|
||||
await UpdateService.handlePostRestart(mockClient);
|
||||
|
||||
expect(mockUnlink).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,601 +0,0 @@
|
||||
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";
|
||||
import type { RestartContext, UpdateCheckResult, UpdateInfo, CommitInfo } from "@/modules/admin/update.types";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
// Constants
|
||||
const STALE_CONTEXT_MS = 10 * 60 * 1000; // 10 minutes
|
||||
const DEFAULT_TIMEOUT_MS = 30_000; // 30 seconds for git commands
|
||||
const INSTALL_TIMEOUT_MS = 120_000; // 2 minutes for dependency installation
|
||||
const BUILD_TIMEOUT_MS = 180_000; // 3 minutes for web build
|
||||
|
||||
/**
|
||||
* Execute a command with timeout protection
|
||||
*/
|
||||
async function execWithTimeout(
|
||||
cmd: string,
|
||||
timeoutMs: number = DEFAULT_TIMEOUT_MS,
|
||||
options: { cwd?: string } = {}
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = exec(cmd, {
|
||||
cwd: options.cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
GIT_TERMINAL_PROMPT: "0",
|
||||
GIT_SSH_COMMAND: "ssh -o BatchMode=yes"
|
||||
}
|
||||
}, (error: ExecException | null, stdout: string, stderr: string) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
resolve({ stdout, stderr });
|
||||
}
|
||||
});
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
child.kill("SIGTERM");
|
||||
reject(new Error(`Command timed out after ${timeoutMs}ms: ${cmd}`));
|
||||
}, timeoutMs);
|
||||
|
||||
child.on("exit", () => clearTimeout(timer));
|
||||
});
|
||||
}
|
||||
|
||||
export class UpdateService {
|
||||
private static readonly CONTEXT_FILE = ".restart_context.json";
|
||||
private static readonly ROLLBACK_FILE = ".rollback_commit.txt";
|
||||
|
||||
// Cache for rollback state (set when we save, cleared on cleanup)
|
||||
private static rollbackPointExists: boolean | null = null;
|
||||
|
||||
/**
|
||||
* Check for available updates with detailed commit information
|
||||
* 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", 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", 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}`, 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();
|
||||
|
||||
// Parse commit log
|
||||
const commits: CommitInfo[] = logResult.stdout
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter(line => line.length > 0)
|
||||
.map(line => {
|
||||
const [hash, message, author] = line.split("|");
|
||||
return { hash: hash || "", message: message || "", author: author || "" };
|
||||
});
|
||||
|
||||
// Parse changed files and analyze requirements in one pass
|
||||
const changedFiles = diffResult.stdout.trim().split("\n").filter(f => f.length > 0);
|
||||
const requirements = this.analyzeChangedFiles(changedFiles);
|
||||
|
||||
return {
|
||||
hasUpdates: commits.length > 0,
|
||||
branch,
|
||||
currentCommit,
|
||||
latestCommit,
|
||||
commitCount: commits.length,
|
||||
commits,
|
||||
requirements
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze changed files to determine update requirements
|
||||
* Extracted for reuse and clarity
|
||||
*/
|
||||
private static analyzeChangedFiles(changedFiles: string[]): UpdateCheckResult {
|
||||
const needsRootInstall = changedFiles.some(file =>
|
||||
file === "package.json" || file === "bun.lock"
|
||||
);
|
||||
|
||||
const needsWebInstall = changedFiles.some(file =>
|
||||
file === "web/package.json" || file === "web/bun.lock"
|
||||
);
|
||||
|
||||
// Only rebuild web if essential source files changed
|
||||
const needsWebBuild = changedFiles.some(file =>
|
||||
file.match(/^web\/src\/(components|pages|lib|index)/) ||
|
||||
file === "web/build.ts" ||
|
||||
file === "web/tailwind.config.ts" ||
|
||||
file === "web/tsconfig.json"
|
||||
);
|
||||
|
||||
const needsMigrations = changedFiles.some(file =>
|
||||
file.includes("schema.ts") || file.startsWith("drizzle/")
|
||||
);
|
||||
|
||||
return {
|
||||
needsRootInstall,
|
||||
needsWebInstall,
|
||||
needsWebBuild,
|
||||
needsMigrations,
|
||||
changedFiles
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use checkForUpdates() which now includes requirements
|
||||
* 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`, DEFAULT_TIMEOUT_MS, { cwd });
|
||||
const changedFiles = stdout.trim().split("\n").filter(f => f.length > 0);
|
||||
return this.analyzeChangedFiles(changedFiles);
|
||||
} catch (e) {
|
||||
console.error("Failed to check update requirements:", e);
|
||||
return {
|
||||
needsRootInstall: false,
|
||||
needsWebInstall: false,
|
||||
needsWebBuild: 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 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
|
||||
return commit;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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()}`, DEFAULT_TIMEOUT_MS, { cwd });
|
||||
await unlink(this.ROLLBACK_FILE);
|
||||
this.rollbackPointExists = false;
|
||||
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
|
||||
* Uses cache when available to avoid file system access
|
||||
*/
|
||||
static async hasRollbackPoint(): Promise<boolean> {
|
||||
if (this.rollbackPointExists !== null) {
|
||||
return this.rollbackPointExists;
|
||||
}
|
||||
try {
|
||||
await readFile(this.ROLLBACK_FILE, "utf-8");
|
||||
this.rollbackPointExists = true;
|
||||
return true;
|
||||
} catch {
|
||||
this.rollbackPointExists = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform the git update
|
||||
*/
|
||||
static async performUpdate(branch: string): Promise<void> {
|
||||
const cwd = this.getDeployDir();
|
||||
await execWithTimeout(`git reset --hard origin/${branch}`, DEFAULT_TIMEOUT_MS, { cwd });
|
||||
}
|
||||
|
||||
/**
|
||||
* Install dependencies for specified projects
|
||||
* Optimized: Parallel installation
|
||||
*/
|
||||
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)
|
||||
.then(({ stdout }) => ({ label: "📦 Root", output: stdout.trim() || "Done" }))
|
||||
);
|
||||
}
|
||||
|
||||
if (options.web) {
|
||||
tasks.push(
|
||||
execWithTimeout("cd web && bun install", INSTALL_TIMEOUT_MS)
|
||||
.then(({ stdout }) => ({ label: "🌐 Web", output: stdout.trim() || "Done" }))
|
||||
);
|
||||
}
|
||||
|
||||
const results = await Promise.all(tasks);
|
||||
return results.map(r => `${r.label}: ${r.output}`).join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare restart context with rollback info
|
||||
*/
|
||||
static async prepareRestartContext(context: RestartContext): Promise<void> {
|
||||
const filePath = path.join(this.getDeployDir(), this.CONTEXT_FILE);
|
||||
await writeFile(filePath, JSON.stringify(context));
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a restart
|
||||
*
|
||||
* In production Docker (with restart: unless-stopped), exiting the process
|
||||
* will cause Docker to restart the container. For a full rebuild with code changes,
|
||||
* use the deploy.sh script or GitHub Actions CI/CD instead of this command.
|
||||
*
|
||||
* Note: The /update command works for hot-reloading in development and minor
|
||||
* restarts in production, but for production deployments with new code,
|
||||
* use: `cd ~/Aurora && git pull && docker compose -f docker-compose.prod.yml up -d --build`
|
||||
*/
|
||||
static async triggerRestart(): Promise<void> {
|
||||
if (process.env.RESTART_COMMAND) {
|
||||
// Custom restart command from environment
|
||||
exec(process.env.RESTART_COMMAND).unref();
|
||||
} else {
|
||||
// Exit process - Docker will restart container, dev mode will hot-reload
|
||||
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);
|
||||
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 filePath = path.join(this.getDeployDir(), this.CONTEXT_FILE);
|
||||
const contextData = await readFile(filePath, "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: "",
|
||||
webBuildSuccess: true,
|
||||
webBuildOutput: "",
|
||||
migrationSuccess: true,
|
||||
migrationOutput: "",
|
||||
ranInstall: context.installDependencies,
|
||||
ranWebBuild: context.buildWebAssets,
|
||||
ranMigrations: context.runMigrations,
|
||||
previousCommit: context.previousCommit,
|
||||
newCommit: context.newCommit
|
||||
};
|
||||
|
||||
// Track progress for consolidated message
|
||||
const progress: PostRestartProgress = {
|
||||
installDeps: context.installDependencies,
|
||||
buildWeb: context.buildWebAssets,
|
||||
runMigrations: context.runMigrations,
|
||||
currentStep: "starting"
|
||||
};
|
||||
|
||||
// Only send progress message if there are tasks to run
|
||||
const hasTasks = context.installDependencies || context.buildWebAssets || context.runMigrations;
|
||||
let progressMessage = hasTasks
|
||||
? await channel.send({ embeds: [getPostRestartProgressEmbed(progress)] })
|
||||
: null;
|
||||
|
||||
// Helper to update progress message
|
||||
const updateProgress = async () => {
|
||||
if (progressMessage) {
|
||||
await progressMessage.edit({ embeds: [getPostRestartProgressEmbed(progress)] });
|
||||
}
|
||||
};
|
||||
|
||||
// 1. Install Dependencies if needed (PARALLELIZED)
|
||||
if (context.installDependencies) {
|
||||
try {
|
||||
progress.currentStep = "install";
|
||||
await updateProgress();
|
||||
|
||||
// Parallel installation of root and web dependencies
|
||||
const [rootResult, webResult] = await Promise.all([
|
||||
execWithTimeout("bun install", INSTALL_TIMEOUT_MS)
|
||||
.then(({ stdout }) => ({ success: true, output: stdout.trim() || "Done" }))
|
||||
.catch(err => ({ success: false, output: err instanceof Error ? err.message : String(err) })),
|
||||
execWithTimeout("cd web && bun install", INSTALL_TIMEOUT_MS)
|
||||
.then(({ stdout }) => ({ success: true, output: stdout.trim() || "Done" }))
|
||||
.catch(err => ({ success: false, output: err instanceof Error ? err.message : String(err) }))
|
||||
]);
|
||||
|
||||
result.installSuccess = rootResult.success && webResult.success;
|
||||
result.installOutput = `📦 Root: ${rootResult.output}\n🌐 Web: ${webResult.output}`;
|
||||
progress.installDone = true;
|
||||
|
||||
if (!result.installSuccess) {
|
||||
console.error("Dependency Install Failed:", result.installOutput);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
result.installSuccess = false;
|
||||
result.installOutput = err instanceof Error ? err.message : String(err);
|
||||
progress.installDone = true;
|
||||
console.error("Dependency Install Failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Build Web Assets if needed
|
||||
if (context.buildWebAssets) {
|
||||
try {
|
||||
progress.currentStep = "build";
|
||||
await updateProgress();
|
||||
|
||||
const { stdout } = await execWithTimeout("cd web && bun run build", BUILD_TIMEOUT_MS);
|
||||
result.webBuildOutput = stdout.trim() || "Build completed successfully";
|
||||
progress.buildDone = true;
|
||||
} catch (err: unknown) {
|
||||
result.webBuildSuccess = false;
|
||||
result.webBuildOutput = err instanceof Error ? err.message : String(err);
|
||||
progress.buildDone = true;
|
||||
console.error("Web Build Failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Run Migrations
|
||||
if (context.runMigrations) {
|
||||
try {
|
||||
progress.currentStep = "migrate";
|
||||
await updateProgress();
|
||||
|
||||
const { stdout } = await execWithTimeout("bun x drizzle-kit migrate", DEFAULT_TIMEOUT_MS);
|
||||
result.migrationOutput = stdout;
|
||||
progress.migrateDone = true;
|
||||
} catch (err: unknown) {
|
||||
result.migrationSuccess = false;
|
||||
result.migrationOutput = err instanceof Error ? err.message : String(err);
|
||||
progress.migrateDone = true;
|
||||
console.error("Migration Failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete progress message before final result
|
||||
if (progressMessage) {
|
||||
try {
|
||||
await progressMessage.delete();
|
||||
} catch {
|
||||
// Message may already be deleted, ignore
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static async notifyPostRestartResult(
|
||||
channel: TextChannel,
|
||||
result: PostRestartResult
|
||||
): Promise<void> {
|
||||
// Use cached rollback state - we just saved it before restart
|
||||
const hasRollback = await this.hasRollbackPoint();
|
||||
await channel.send(getPostRestartEmbed(result, hasRollback));
|
||||
}
|
||||
|
||||
private static async cleanupContext(): Promise<void> {
|
||||
try {
|
||||
const filePath = path.join(this.getDeployDir(), this.CONTEXT_FILE);
|
||||
await unlink(filePath);
|
||||
} catch {
|
||||
// File may not exist, ignore
|
||||
}
|
||||
// Don't clear rollback cache here - rollback file persists
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Bot-Triggered Deployment Methods
|
||||
// =========================================================================
|
||||
|
||||
private static readonly DEPLOY_TIMEOUT_MS = 300_000; // 5 minutes for full deploy
|
||||
|
||||
/**
|
||||
* Get the deploy directory path from environment or default
|
||||
*/
|
||||
static getDeployDir(): string {
|
||||
return process.env.DEPLOY_DIR || "/app/deploy";
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if deployment is available (docker socket accessible)
|
||||
*/
|
||||
static async isDeployAvailable(): Promise<boolean> {
|
||||
try {
|
||||
await execWithTimeout("docker --version", 5000);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a full deployment: git pull + docker compose rebuild
|
||||
* This will restart the container with the new code
|
||||
*
|
||||
* @param onProgress - Callback for progress updates
|
||||
* @returns Object with success status, output, and commit info
|
||||
*/
|
||||
static async performDeploy(onProgress?: (step: string) => void): Promise<{
|
||||
success: boolean;
|
||||
previousCommit: string;
|
||||
newCommit: string;
|
||||
output: string;
|
||||
error?: string;
|
||||
}> {
|
||||
const deployDir = this.getDeployDir();
|
||||
let previousCommit = "";
|
||||
let newCommit = "";
|
||||
let output = "";
|
||||
|
||||
try {
|
||||
// 1. Get current commit
|
||||
onProgress?.("Getting current version...");
|
||||
const { stdout: currentSha } = await execWithTimeout(
|
||||
`cd ${deployDir} && git rev-parse --short HEAD`,
|
||||
DEFAULT_TIMEOUT_MS
|
||||
);
|
||||
previousCommit = currentSha.trim();
|
||||
output += `📍 Current: ${previousCommit}\n`;
|
||||
|
||||
// 2. Pull latest changes
|
||||
onProgress?.("Pulling latest code...");
|
||||
const { stdout: pullOutput } = await execWithTimeout(
|
||||
`cd ${deployDir} && git pull origin main`,
|
||||
DEFAULT_TIMEOUT_MS
|
||||
);
|
||||
output += `📥 Pull: ${pullOutput.includes("Already up to date") ? "Already up to date" : "Updated"}\n`;
|
||||
|
||||
// 3. Get new commit
|
||||
const { stdout: newSha } = await execWithTimeout(
|
||||
`cd ${deployDir} && git rev-parse --short HEAD`,
|
||||
DEFAULT_TIMEOUT_MS
|
||||
);
|
||||
newCommit = newSha.trim();
|
||||
output += `📍 New: ${newCommit}\n`;
|
||||
|
||||
// 4. Rebuild and restart container (this will kill the current process)
|
||||
onProgress?.("Rebuilding and restarting...");
|
||||
|
||||
// Use spawn with detached mode so the command continues after we exit
|
||||
const { spawn } = await import("child_process");
|
||||
const deployProcess = spawn(
|
||||
"sh",
|
||||
["-c", `cd ${deployDir} && docker compose -f docker-compose.prod.yml up -d --build`],
|
||||
{
|
||||
detached: true,
|
||||
stdio: "ignore"
|
||||
}
|
||||
);
|
||||
deployProcess.unref();
|
||||
|
||||
output += `🚀 Deploy triggered - container will restart\n`;
|
||||
console.log("Deploy triggered successfully:", output);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
previousCommit,
|
||||
newCommit,
|
||||
output
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
previousCommit,
|
||||
newCommit,
|
||||
output,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user