From c8bf69a96965e578641831ed6ef0f0d8e5906672 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Fri, 30 Jan 2026 15:29:50 +0100 Subject: [PATCH] Remove the admin update service, command, and related files, and update Docker configurations. --- Dockerfile.prod | 12 +- bot/commands/admin/update.ts | 247 -------- bot/events/ready.ts | 4 +- bot/modules/admin/update.types.ts | 35 -- bot/modules/admin/update.view.ts | 419 -------------- docker-compose.prod.yml | 30 +- shared/modules/admin/update.service.test.ts | 333 ----------- shared/modules/admin/update.service.ts | 601 -------------------- 8 files changed, 5 insertions(+), 1676 deletions(-) delete mode 100644 bot/commands/admin/update.ts delete mode 100644 bot/modules/admin/update.types.ts delete mode 100644 bot/modules/admin/update.view.ts delete mode 100644 shared/modules/admin/update.service.test.ts delete mode 100644 shared/modules/admin/update.service.ts diff --git a/Dockerfile.prod b/Dockerfile.prod index 648d438..d8e11be 100644 --- a/Dockerfile.prod +++ b/Dockerfile.prod @@ -30,17 +30,7 @@ WORKDIR /app # Create non-root user for security (bun user already exists with 1000:1000) # No need to create user/group -# Install runtime dependencies for update/deploy commands -RUN apt-get update && apt-get install -y \ - git \ - curl \ - gnupg \ - && curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg \ - && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/debian bookworm stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null \ - && apt-get update \ - && apt-get install -y docker-ce-cli \ - && rm -rf /var/lib/apt/lists/* \ - && git config --system --add safe.directory /app/deploy + # Copy only what's needed for production COPY --from=builder --chown=bun:bun /app/node_modules ./node_modules diff --git a/bot/commands/admin/update.ts b/bot/commands/admin/update.ts deleted file mode 100644 index 15b2548..0000000 --- a/bot/commands/admin/update.ts +++ /dev/null @@ -1,247 +0,0 @@ -import { createCommand } from "@shared/lib/utils"; -import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, ComponentType } from "discord.js"; -import { UpdateService } from "@shared/modules/admin/update.service"; -import { - getCheckingEmbed, - getNoUpdatesEmbed, - getUpdatesAvailableMessage, - getPreparingEmbed, - getUpdatingEmbed, - getCancelledEmbed, - getTimeoutEmbed, - getErrorEmbed, - getRollbackSuccessEmbed, - getRollbackFailedEmbed, - getDeployNotAvailableEmbed, - getDeployCheckingEmbed, - getDeployProgressEmbed, - getDeploySuccessEmbed, - getDeployErrorEmbed -} from "@/modules/admin/update.view"; - -export const update = createCommand({ - data: new SlashCommandBuilder() - .setName("update") - .setDescription("Check for updates and restart the bot") - .addSubcommand(sub => - sub.setName("check") - .setDescription("Check for and apply available updates") - .addBooleanOption(option => - option.setName("force") - .setDescription("Force update even if no changes detected") - .setRequired(false) - ) - ) - .addSubcommand(sub => - sub.setName("rollback") - .setDescription("Rollback to the previous version") - ) - .addSubcommand(sub => - sub.setName("deploy") - .setDescription("Pull latest code and rebuild container (production only)") - ) - .setDefaultMemberPermissions(PermissionFlagsBits.Administrator), - - execute: async (interaction) => { - const subcommand = interaction.options.getSubcommand(); - - if (subcommand === "rollback") { - await handleRollback(interaction); - } else if (subcommand === "deploy") { - await handleDeploy(interaction); - } else { - await handleUpdate(interaction); - } - } -}); - -async function handleUpdate(interaction: any) { - await interaction.deferReply({ flags: MessageFlags.Ephemeral }); - const force = interaction.options.getBoolean("force") || false; - - try { - // 1. Check for updates (now includes requirements in one call) - await interaction.editReply({ embeds: [getCheckingEmbed()] }); - const updateInfo = await UpdateService.checkForUpdates(); - - if (!updateInfo.hasUpdates && !force) { - await interaction.editReply({ - embeds: [getNoUpdatesEmbed(updateInfo.currentCommit)] - }); - return; - } - - // 2. Extract requirements from the combined response - const { requirements } = updateInfo; - const categories = UpdateService.categorizeChanges(requirements.changedFiles); - - // 3. Show confirmation with details - const { embeds, components } = getUpdatesAvailableMessage( - updateInfo, - requirements, - categories, - force - ); - const response = await interaction.editReply({ embeds, components }); - - // 4. Wait for confirmation - try { - const confirmation = await response.awaitMessageComponent({ - filter: (i: any) => i.user.id === interaction.user.id, - componentType: ComponentType.Button, - time: 30000 - }); - - if (confirmation.customId === "confirm_update") { - await confirmation.update({ - embeds: [getPreparingEmbed()], - components: [] - }); - - // 5. Save rollback point - const previousCommit = await UpdateService.saveRollbackPoint(); - - // 6. Prepare restart context - await UpdateService.prepareRestartContext({ - channelId: interaction.channelId, - userId: interaction.user.id, - timestamp: Date.now(), - runMigrations: requirements.needsMigrations, - installDependencies: requirements.needsRootInstall || requirements.needsWebInstall, - buildWebAssets: requirements.needsWebBuild, - previousCommit: previousCommit.substring(0, 7), - newCommit: updateInfo.latestCommit - }); - - // 7. Show updating status - await interaction.editReply({ - embeds: [getUpdatingEmbed(requirements)] - }); - - // 8. Perform update - await UpdateService.performUpdate(updateInfo.branch); - - // 9. Trigger restart - await UpdateService.triggerRestart(); - - } else { - await confirmation.update({ - embeds: [getCancelledEmbed()], - components: [] - }); - } - - } catch (e) { - if (e instanceof Error && e.message.includes("time")) { - await interaction.editReply({ - embeds: [getTimeoutEmbed()], - components: [] - }); - } else { - throw e; - } - } - - } catch (error) { - console.error("Update failed:", error); - await interaction.editReply({ - embeds: [getErrorEmbed(error)], - components: [] - }); - } -} - -async function handleRollback(interaction: any) { - await interaction.deferReply({ flags: MessageFlags.Ephemeral }); - - try { - const hasRollback = await UpdateService.hasRollbackPoint(); - - if (!hasRollback) { - await interaction.editReply({ - embeds: [getRollbackFailedEmbed("No rollback point available. Rollback is only possible after a recent update.")] - }); - return; - } - - const result = await UpdateService.rollback(); - - if (result.success) { - await interaction.editReply({ - embeds: [getRollbackSuccessEmbed(result.message.split(" ").pop() || "unknown")] - }); - - // Restart after rollback - setTimeout(() => UpdateService.triggerRestart(), 1000); - } else { - await interaction.editReply({ - embeds: [getRollbackFailedEmbed(result.message)] - }); - } - - } catch (error) { - console.error("Rollback failed:", error); - await interaction.editReply({ - embeds: [getErrorEmbed(error)] - }); - } -} - -async function handleDeploy(interaction: any) { - await interaction.deferReply({ flags: MessageFlags.Ephemeral }); - - try { - // Check if deploy is available - const available = await UpdateService.isDeployAvailable(); - if (!available) { - await interaction.editReply({ - embeds: [getDeployNotAvailableEmbed()] - }); - return; - } - - // Show checking status - await interaction.editReply({ - embeds: [getDeployCheckingEmbed()] - }); - - // Perform deployment with progress updates - const result = await UpdateService.performDeploy((step) => { - interaction.editReply({ - embeds: [getDeployProgressEmbed(step)] - }); - }); - - if (result.success) { - // Save restart context so we can notify on startup - await UpdateService.prepareRestartContext({ - channelId: interaction.channelId, - userId: interaction.user.id, - timestamp: Date.now(), - runMigrations: true, // Always check migrations on deploy - installDependencies: false, // Handled by Docker build - buildWebAssets: false, // Handled by Docker build - previousCommit: result.previousCommit, - newCommit: result.newCommit - }); - - await interaction.editReply({ - embeds: [getDeploySuccessEmbed( - result.previousCommit, - result.newCommit, - result.output - )] - }); - } else { - await interaction.editReply({ - embeds: [getDeployErrorEmbed(result.error || "Unknown error")] - }); - } - - } catch (error) { - console.error("Deploy failed:", error); - await interaction.editReply({ - embeds: [getDeployErrorEmbed(error instanceof Error ? error.message : String(error))] - }); - } -} diff --git a/bot/events/ready.ts b/bot/events/ready.ts index 159d8de..2af77ec 100644 --- a/bot/events/ready.ts +++ b/bot/events/ready.ts @@ -9,9 +9,7 @@ const event: Event = { console.log(`Ready! Logged in as ${c.user.tag}`); schedulerService.start(); - // Handle post-update tasks - const { UpdateService } = await import("@shared/modules/admin/update.service"); - await UpdateService.handlePostRestart(c); + }, }; diff --git a/bot/modules/admin/update.types.ts b/bot/modules/admin/update.types.ts deleted file mode 100644 index 8098d38..0000000 --- a/bot/modules/admin/update.types.ts +++ /dev/null @@ -1,35 +0,0 @@ - -export interface RestartContext { - channelId: string; - userId: string; - timestamp: number; - runMigrations: boolean; - installDependencies: boolean; - buildWebAssets: boolean; - previousCommit: string; - newCommit: string; -} - -export interface UpdateCheckResult { - needsRootInstall: boolean; - needsWebInstall: boolean; - needsWebBuild: boolean; - needsMigrations: boolean; - changedFiles: string[]; - error?: Error; -} - -export interface UpdateInfo { - hasUpdates: boolean; - branch: string; - currentCommit: string; - latestCommit: string; - commitCount: number; - commits: CommitInfo[]; -} - -export interface CommitInfo { - hash: string; - message: string; - author: string; -} diff --git a/bot/modules/admin/update.view.ts b/bot/modules/admin/update.view.ts deleted file mode 100644 index 1b39013..0000000 --- a/bot/modules/admin/update.view.ts +++ /dev/null @@ -1,419 +0,0 @@ -import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } from "discord.js"; -import { createInfoEmbed, createSuccessEmbed, createWarningEmbed, createErrorEmbed } from "@lib/embeds"; -import type { UpdateInfo, UpdateCheckResult } from "./update.types"; - -// Constants for UI -const LOG_TRUNCATE_LENGTH = 800; -const OUTPUT_TRUNCATE_LENGTH = 400; - -function truncate(text: string, maxLength: number): string { - if (!text) return ""; - return text.length > maxLength ? `${text.substring(0, maxLength)}...` : text; -} - -// ============ Pre-Update Embeds ============ - -export function getCheckingEmbed() { - return createInfoEmbed("šŸ” Fetching latest changes from remote...", "Checking for Updates"); -} - -export function getNoUpdatesEmbed(currentCommit: string) { - return createSuccessEmbed( - `You're running the latest version.\n\n**Current:** \`${currentCommit}\``, - "āœ… Already Up to Date" - ); -} - -export function getUpdatesAvailableMessage( - updateInfo: UpdateInfo, - requirements: UpdateCheckResult, - changeCategories: Record, - force: boolean -) { - const { branch, currentCommit, latestCommit, commitCount, commits } = updateInfo; - const { needsRootInstall, needsWebInstall, needsWebBuild, needsMigrations } = requirements; - - // Build commit list (max 5) - const commitList = commits - .slice(0, 5) - .map(c => `\`${c.hash}\` ${truncate(c.message, 50)}`) - .join("\n"); - - const moreCommits = commitCount > 5 ? `\n*...and ${commitCount - 5} more*` : ""; - - // Build change categories - const categoryList = Object.entries(changeCategories) - .map(([cat, count]) => `• ${cat}: ${count} file${count > 1 ? "s" : ""}`) - .join("\n"); - - // Build requirements list - const reqs: string[] = []; - if (needsRootInstall) reqs.push("šŸ“¦ Install root dependencies"); - if (needsWebInstall) reqs.push("🌐 Install web dependencies"); - if (needsWebBuild) reqs.push("šŸ—ļø Build web dashboard"); - if (needsMigrations) reqs.push("šŸ—ƒļø Run database migrations"); - if (reqs.length === 0) reqs.push("⚔ Quick update (no extra steps)"); - - const embed = new EmbedBuilder() - .setTitle("šŸ“„ Updates Available") - .setColor(force ? 0xFF6B6B : 0x5865F2) - .addFields( - { - name: "Version", - value: `\`${currentCommit}\` → \`${latestCommit}\``, - inline: true - }, - { - name: "Branch", - value: `\`${branch}\``, - inline: true - }, - { - name: "Commits", - value: `${commitCount} new commit${commitCount > 1 ? "s" : ""}`, - inline: true - }, - { - name: "Recent Changes", - value: commitList + moreCommits || "No commits", - inline: false - }, - { - name: "Files Changed", - value: categoryList || "Unknown", - inline: true - }, - { - name: "Update Actions", - value: reqs.join("\n"), - inline: true - } - ) - .setFooter({ text: force ? "āš ļø Force mode enabled" : "This will restart the bot" }) - .setTimestamp(); - - const confirmButton = new ButtonBuilder() - .setCustomId("confirm_update") - .setLabel(force ? "Force Update" : "Update Now") - .setEmoji(force ? "āš ļø" : "šŸš€") - .setStyle(force ? ButtonStyle.Danger : ButtonStyle.Success); - - const cancelButton = new ButtonBuilder() - .setCustomId("cancel_update") - .setLabel("Cancel") - .setStyle(ButtonStyle.Secondary); - - const row = new ActionRowBuilder().addComponents(confirmButton, cancelButton); - - return { embeds: [embed], components: [row] }; -} - -// ============ Update Progress Embeds ============ - -export function getPreparingEmbed() { - return createInfoEmbed( - "šŸ”’ Saving rollback point...\nšŸ“„ Preparing to download updates...", - "ā³ Preparing Update" - ); -} - -export function getUpdatingEmbed(requirements: UpdateCheckResult) { - const steps: string[] = ["āœ… Rollback point saved"]; - - steps.push("šŸ“„ Downloading updates..."); - - if (requirements.needsRootInstall || requirements.needsWebInstall) { - steps.push("šŸ“¦ Dependencies will be installed after restart"); - } - if (requirements.needsWebBuild) { - steps.push("šŸ—ļø Web dashboard will be rebuilt after restart"); - } - if (requirements.needsMigrations) { - steps.push("šŸ—ƒļø Migrations will run after restart"); - } - - steps.push("\nšŸ”„ **Restarting now...**"); - - return createWarningEmbed(steps.join("\n"), "šŸš€ Updating"); -} - -export function getCancelledEmbed() { - return createInfoEmbed("Update cancelled. No changes were made.", "āŒ Cancelled"); -} - -export function getTimeoutEmbed() { - return createWarningEmbed( - "No response received within 30 seconds.\nRun `/update` again when ready.", - "ā° Timed Out" - ); -} - -export function getErrorEmbed(error: unknown) { - const message = error instanceof Error ? error.message : String(error); - return createErrorEmbed( - `The update could not be completed:\n\`\`\`\n${truncate(message, 500)}\n\`\`\``, - "āŒ Update Failed" - ); -} - -// ============ Post-Restart Embeds ============ - -export interface PostRestartResult { - installSuccess: boolean; - installOutput: string; - webBuildSuccess: boolean; - webBuildOutput: string; - migrationSuccess: boolean; - migrationOutput: string; - ranInstall: boolean; - ranWebBuild: boolean; - ranMigrations: boolean; - previousCommit?: string; - newCommit?: string; -} - -export function getPostRestartEmbed(result: PostRestartResult, hasRollback: boolean) { - const isSuccess = result.installSuccess && result.webBuildSuccess && result.migrationSuccess; - - const embed = new EmbedBuilder() - .setTitle(isSuccess ? "āœ… Update Complete" : "āš ļø Update Completed with Issues") - .setColor(isSuccess ? 0x57F287 : 0xFEE75C) - .setTimestamp(); - - // Version info - if (result.previousCommit && result.newCommit) { - embed.addFields({ - name: "Version", - value: `\`${result.previousCommit}\` → \`${result.newCommit}\``, - inline: false - }); - } - - // Results summary - const results: string[] = []; - - if (result.ranInstall) { - results.push(result.installSuccess - ? "āœ… Dependencies installed" - : "āŒ Dependency installation failed" - ); - } - - if (result.ranWebBuild) { - results.push(result.webBuildSuccess - ? "āœ… Web dashboard built" - : "āŒ Web dashboard build failed" - ); - } - - if (result.ranMigrations) { - results.push(result.migrationSuccess - ? "āœ… Migrations applied" - : "āŒ Migration failed" - ); - } - - if (results.length > 0) { - embed.addFields({ - name: "Actions Performed", - value: results.join("\n"), - inline: false - }); - } - - // Output details (collapsed if too long) - if (result.installOutput && !result.installSuccess) { - embed.addFields({ - name: "Install Output", - value: `\`\`\`\n${truncate(result.installOutput, OUTPUT_TRUNCATE_LENGTH)}\n\`\`\``, - inline: false - }); - } - - if (result.webBuildOutput && !result.webBuildSuccess) { - embed.addFields({ - name: "Web Build Output", - value: `\`\`\`\n${truncate(result.webBuildOutput, OUTPUT_TRUNCATE_LENGTH)}\n\`\`\``, - inline: false - }); - } - - if (result.migrationOutput && !result.migrationSuccess) { - embed.addFields({ - name: "Migration Output", - value: `\`\`\`\n${truncate(result.migrationOutput, OUTPUT_TRUNCATE_LENGTH)}\n\`\`\``, - inline: false - }); - } - - // Footer with rollback hint - if (!isSuccess && hasRollback) { - embed.setFooter({ text: "šŸ’” Use /update rollback to revert if needed" }); - } - - // Build components - const components: ActionRowBuilder[] = []; - - if (!isSuccess && hasRollback) { - const rollbackButton = new ButtonBuilder() - .setCustomId("rollback_update") - .setLabel("Rollback") - .setEmoji("ā†©ļø") - .setStyle(ButtonStyle.Danger); - - components.push(new ActionRowBuilder().addComponents(rollbackButton)); - } - - return { embeds: [embed], components }; -} - -export function getInstallingDependenciesEmbed() { - return createInfoEmbed( - "šŸ“¦ Installing dependencies for root and web projects...\nThis may take a moment.", - "ā³ Installing Dependencies" - ); -} - -export function getRunningMigrationsEmbed() { - return createInfoEmbed( - "šŸ—ƒļø Applying database migrations...", - "ā³ Running Migrations" - ); -} - -export function getBuildingWebEmbed() { - return createInfoEmbed( - "🌐 Building web dashboard assets...\nThis may take a moment.", - "ā³ Building Web Dashboard" - ); -} - -export interface PostRestartProgress { - installDeps: boolean; - buildWeb: boolean; - runMigrations: boolean; - currentStep: "starting" | "install" | "build" | "migrate" | "done"; - installDone?: boolean; - buildDone?: boolean; - migrateDone?: boolean; -} - -export function getPostRestartProgressEmbed(progress: PostRestartProgress) { - const steps: string[] = []; - - // Installation step - if (progress.installDeps) { - if (progress.currentStep === "install") { - steps.push("ā³ Installing dependencies..."); - } else if (progress.installDone) { - steps.push("āœ… Dependencies installed"); - } else { - steps.push("⬚ Install dependencies"); - } - } - - // Web build step - if (progress.buildWeb) { - if (progress.currentStep === "build") { - steps.push("ā³ Building web dashboard..."); - } else if (progress.buildDone) { - steps.push("āœ… Web dashboard built"); - } else { - steps.push("⬚ Build web dashboard"); - } - } - - // Migrations step - if (progress.runMigrations) { - if (progress.currentStep === "migrate") { - steps.push("ā³ Running migrations..."); - } else if (progress.migrateDone) { - steps.push("āœ… Migrations applied"); - } else { - steps.push("⬚ Run migrations"); - } - } - - if (steps.length === 0) { - steps.push("⚔ Quick restart (no extra steps needed)"); - } - - return createInfoEmbed(steps.join("\n"), "šŸ”„ Post-Update Tasks"); -} - -export function getRollbackSuccessEmbed(commit: string) { - return createSuccessEmbed( - `Successfully rolled back to commit \`${commit}\`.\nThe bot will restart now.`, - "ā†©ļø Rollback Complete" - ); -} - -export function getRollbackFailedEmbed(error: string) { - return createErrorEmbed( - `Could not rollback:\n\`\`\`\n${error}\n\`\`\``, - "āŒ Rollback Failed" - ); -} - -// ============ Deploy Embeds ============ - -export function getDeployNotAvailableEmbed() { - return createErrorEmbed( - "Docker is not available in this environment.\n\n" + - "Deploy via Discord requires Docker socket access. " + - "Use the deploy script manually:\n" + - "```bash\ncd ~/Aurora && bash shared/scripts/deploy.sh\n```", - "āŒ Deploy Unavailable" - ); -} - -export function getDeployCheckingEmbed() { - return createInfoEmbed( - "šŸ” Checking for updates and preparing deployment...", - "šŸš€ Deploy" - ); -} - -export function getDeployProgressEmbed(step: string) { - return createInfoEmbed( - step, - "šŸš€ Deploying" - ); -} - -export function getDeploySuccessEmbed(previousCommit: string, newCommit: string, output: string) { - const embed = new EmbedBuilder() - .setTitle("šŸš€ Deployment Triggered") - .setColor(0x57F287) - .addFields( - { - name: "Version", - value: `\`${previousCommit}\` → \`${newCommit}\``, - inline: false - }, - { - name: "Status", - value: output || "Container rebuilding...", - inline: false - } - ) - .setFooter({ text: "Container will restart shortly" }) - .setTimestamp(); - - return embed; -} - -export function getDeployNoChangesEmbed(currentCommit: string) { - return createSuccessEmbed( - `Already running the latest version.\n\n**Current:** \`${currentCommit}\``, - "āœ… Up to Date" - ); -} - -export function getDeployErrorEmbed(error: string) { - return createErrorEmbed( - `Deployment failed:\n\`\`\`\n${truncate(error, 500)}\n\`\`\``, - "āŒ Deploy Failed" - ); -} - diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index ee21c1f..0f381aa 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -29,21 +29,6 @@ services: limits: memory: 512M - socket-proxy: - image: tecnativa/docker-socket-proxy - container_name: socket_proxy - restart: unless-stopped - environment: - - CONTAINERS=1 - - POST=1 - - BUILD=1 - - NETWORKS=1 - - IMAGES=1 # Needed for pulling/pruning - volumes: - - /var/run/docker.sock:/var/run/docker.sock:ro - networks: - - internal - app: container_name: aurora_app restart: unless-stopped @@ -54,13 +39,7 @@ services: image: aurora-app:latest ports: - "127.0.0.1:3000:3000" - # Volumes for bot-triggered deployments - volumes: - # Project directory - allows git pull and rebuild - - .:/app/deploy - # SSH Keys for git authentication - - ~/.ssh/aurora_bot_key:/home/bun/.ssh/id_ed25519:ro - - ~/.ssh/known_hosts:/home/bun/.ssh/known_hosts:ro + working_dir: /app environment: - NODE_ENV=production @@ -74,14 +53,11 @@ services: - DISCORD_GUILD_ID=${DISCORD_GUILD_ID} - DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID} - DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@db:5432/${DB_NAME} - # Deploy directory path for bot-triggered deployments - - DEPLOY_DIR=/app/deploy - - DOCKER_HOST=tcp://socket-proxy:2375 + depends_on: db: condition: service_healthy - socket-proxy: - condition: service_started + networks: - internal - web diff --git a/shared/modules/admin/update.service.test.ts b/shared/modules/admin/update.service.test.ts deleted file mode 100644 index 4373c52..0000000 --- a/shared/modules/admin/update.service.test.ts +++ /dev/null @@ -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(); - }); - }); -}); diff --git a/shared/modules/admin/update.service.ts b/shared/modules/admin/update.service.ts deleted file mode 100644 index 489963e..0000000 --- a/shared/modules/admin/update.service.ts +++ /dev/null @@ -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 { - 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 { - 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 { - const categories: Record = {}; - - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - // 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 { - 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 { - 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) - }; - } - } -}