diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0c0b95e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,39 @@ +# Dependencies - handled inside container +node_modules +web/node_modules + +# Git +.git +.gitignore + +# Logs and data +logs +*.log +shared/db/data +shared/db/log + +# Development tools +.env +.env.example +.opencode +.agent + +# Documentation +docs +*.md +!README.md + +# IDE +.vscode +.idea +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Build artifacts +dist +.cache +*.tsbuildinfo diff --git a/AGENTS.md b/AGENTS.md index ca802c9..627ccf0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,7 @@ bun --watch bot/index.ts # Run bot with hot reload bun --hot web/src/index.ts # Run web dashboard with hot reload # Testing -bun test # Run all tests +bun test # Run all tests ( expect some tests to fail when running all at once like this due to the nature of the tests ) bun test path/to/file.test.ts # Run single test file bun test --watch # Watch mode bun test shared/modules/economy # Run tests in directory @@ -71,6 +71,7 @@ import { localHelper } from "./helper"; ``` **Available Aliases:** + - `@/*` - bot/ - `@shared/*` - shared/ - `@db/*` - shared/db/ @@ -80,17 +81,17 @@ import { localHelper } from "./helper"; ## Naming Conventions -| Element | Convention | Example | -|---------|------------|---------| -| Files | camelCase or kebab-case | `BotClient.ts`, `economy.service.ts` | -| Classes | PascalCase | `CommandHandler`, `UserError` | -| Functions | camelCase | `createCommand`, `handleShopInteraction` | -| Constants | UPPER_SNAKE_CASE | `EVENTS`, `BRANDING` | -| Enums | PascalCase | `TimerType`, `TransactionType` | -| Services | camelCase singleton | `economyService`, `userService` | -| Types/Interfaces | PascalCase | `Command`, `Event`, `GameConfigType` | -| DB tables | snake_case | `users`, `moderation_cases` | -| Custom IDs | snake_case with prefix | `shop_buy_`, `trade_accept_` | +| Element | Convention | Example | +| ---------------- | ----------------------- | ---------------------------------------- | +| Files | camelCase or kebab-case | `BotClient.ts`, `economy.service.ts` | +| Classes | PascalCase | `CommandHandler`, `UserError` | +| Functions | camelCase | `createCommand`, `handleShopInteraction` | +| Constants | UPPER_SNAKE_CASE | `EVENTS`, `BRANDING` | +| Enums | PascalCase | `TimerType`, `TransactionType` | +| Services | camelCase singleton | `economyService`, `userService` | +| Types/Interfaces | PascalCase | `Command`, `Event`, `GameConfigType` | +| DB tables | snake_case | `users`, `moderation_cases` | +| Custom IDs | snake_case with prefix | `shop_buy_`, `trade_accept_` | ## Code Patterns @@ -98,13 +99,13 @@ import { localHelper } from "./helper"; ```typescript export const commandName = createCommand({ - data: new SlashCommandBuilder() - .setName("commandname") - .setDescription("Description"), - execute: async (interaction) => { - await interaction.deferReply(); - // Implementation - } + data: new SlashCommandBuilder() + .setName("commandname") + .setDescription("Description"), + execute: async (interaction) => { + await interaction.deferReply(); + // Implementation + }, }); ``` @@ -112,11 +113,11 @@ export const commandName = createCommand({ ```typescript export const serviceName = { - methodName: async (params: ParamType): Promise => { - return await withTransaction(async (tx) => { - // Database operations - }); - }, + methodName: async (params: ParamType): Promise => { + return await withTransaction(async (tx) => { + // Database operations + }); + }, }; ``` @@ -146,15 +147,17 @@ throw new SystemError("Database connection failed"); ```typescript try { - const result = await service.method(); - await interaction.editReply({ embeds: [createSuccessEmbed(result)] }); + const result = await service.method(); + await interaction.editReply({ embeds: [createSuccessEmbed(result)] }); } catch (error) { - if (error instanceof UserError) { - await interaction.editReply({ embeds: [createErrorEmbed(error.message)] }); - } else { - console.error("Unexpected error:", error); - await interaction.editReply({ embeds: [createErrorEmbed("An unexpected error occurred.")] }); - } + if (error instanceof UserError) { + await interaction.editReply({ embeds: [createErrorEmbed(error.message)] }); + } else { + console.error("Unexpected error:", error); + await interaction.editReply({ + embeds: [createErrorEmbed("An unexpected error occurred.")], + }); + } } ``` @@ -166,15 +169,18 @@ try { import { withTransaction } from "@/lib/db"; return await withTransaction(async (tx) => { - const user = await tx.query.users.findFirst({ - where: eq(users.id, discordId) - }); - - await tx.update(users).set({ coins: newBalance }).where(eq(users.id, discordId)); - await tx.insert(transactions).values({ userId: discordId, amount, type }); - - return user; -}, existingTx); // Pass existing tx if in nested transaction + const user = await tx.query.users.findFirst({ + where: eq(users.id, discordId), + }); + + await tx + .update(users) + .set({ coins: newBalance }) + .where(eq(users.id, discordId)); + await tx.insert(transactions).values({ userId: discordId, amount, type }); + + return user; +}, existingTx); // Pass existing tx if in nested transaction ``` ### Schema Notes @@ -192,25 +198,25 @@ import { describe, it, expect, mock, beforeEach } from "bun:test"; // Mock modules BEFORE imports mock.module("@shared/db/DrizzleClient", () => ({ - DrizzleClient: { query: mockQuery } + DrizzleClient: { query: mockQuery }, })); describe("serviceName", () => { - beforeEach(() => { - mockFn.mockClear(); - }); + beforeEach(() => { + mockFn.mockClear(); + }); - it("should handle expected case", async () => { - // Arrange - mockFn.mockResolvedValue(testData); - - // Act - const result = await service.method(input); - - // Assert - expect(result).toEqual(expected); - expect(mockFn).toHaveBeenCalledWith(expectedArgs); - }); + it("should handle expected case", async () => { + // Arrange + mockFn.mockResolvedValue(testData); + + // Act + const result = await service.method(input); + + // Assert + expect(result).toEqual(expected); + expect(mockFn).toHaveBeenCalledWith(expectedArgs); + }); }); ``` @@ -227,12 +233,12 @@ describe("serviceName", () => { ## Key Files Reference -| Purpose | File | -|---------|------| -| Bot entry | `bot/index.ts` | -| DB schema | `shared/db/schema.ts` | +| Purpose | File | +| ------------- | ---------------------- | +| Bot entry | `bot/index.ts` | +| DB schema | `shared/db/schema.ts` | | Error classes | `shared/lib/errors.ts` | | Config loader | `shared/lib/config.ts` | -| Environment | `shared/lib/env.ts` | -| Embed helpers | `bot/lib/embeds.ts` | -| Command utils | `shared/lib/utils.ts` | +| Environment | `shared/lib/env.ts` | +| Embed helpers | `bot/lib/embeds.ts` | +| Command utils | `shared/lib/utils.ts` | diff --git a/Dockerfile b/Dockerfile index a35eadf..b38e0e4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,21 +1,55 @@ +# ============================================ +# Base stage - shared configuration +# ============================================ FROM oven/bun:latest AS base WORKDIR /app -# Install system dependencies -RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/* +# Install system dependencies with cleanup in same layer +RUN apt-get update && \ + apt-get install -y --no-install-recommends git && \ + rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* -# Install root project dependencies +# ============================================ +# Dependencies stage - installs all deps +# ============================================ +FROM base AS deps + +# Copy only package files first (better layer caching) COPY package.json bun.lock ./ -RUN bun install --frozen-lockfile - -# Install web project dependencies COPY web/package.json web/bun.lock ./web/ -RUN cd web && bun install --frozen-lockfile -# Copy source code -COPY . . +# Install all dependencies in one layer +RUN bun install --frozen-lockfile && \ + cd web && bun install --frozen-lockfile -# Expose ports (3000 for web dashboard) +# ============================================ +# Development stage - for local dev with volume mounts +# ============================================ +FROM base AS development + +# Copy dependencies from deps stage +COPY --from=deps /app/node_modules ./node_modules +COPY --from=deps /app/web/node_modules ./web/node_modules + +# Expose ports +EXPOSE 3000 + +# Default command +CMD ["bun", "run", "dev"] + +# ============================================ +# Production stage - full app with source code +# ============================================ +FROM base AS production + +# Copy dependencies from deps stage +COPY --from=deps /app/node_modules ./node_modules +COPY --from=deps /app/web/node_modules ./web/node_modules + +# Copy source code +COPY . . + +# Expose ports EXPOSE 3000 # Default command diff --git a/bot/commands/admin/update.ts b/bot/commands/admin/update.ts index f0598e5..ab7d05f 100644 --- a/bot/commands/admin/update.ts +++ b/bot/commands/admin/update.ts @@ -49,7 +49,7 @@ async function handleUpdate(interaction: any) { const force = interaction.options.getBoolean("force") || false; try { - // 1. Check for updates + // 1. Check for updates (now includes requirements in one call) await interaction.editReply({ embeds: [getCheckingEmbed()] }); const updateInfo = await UpdateService.checkForUpdates(); @@ -60,8 +60,8 @@ async function handleUpdate(interaction: any) { return; } - // 2. Analyze requirements - const requirements = await UpdateService.checkUpdateRequirements(updateInfo.branch); + // 2. Extract requirements from the combined response + const { requirements } = updateInfo; const categories = UpdateService.categorizeChanges(requirements.changedFiles); // 3. Show confirmation with details diff --git a/docker-compose.yml b/docker-compose.yml index ef88aeb..985c069 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,8 +10,8 @@ services: # ports: # - "127.0.0.1:${DB_PORT}:5432" volumes: + # Host-mounted to preserve existing VPS data - ./shared/db/data:/var/lib/postgresql/data - - ./shared/db/log:/var/log/postgresql networks: - internal healthcheck: @@ -23,17 +23,19 @@ services: app: container_name: aurora_app restart: unless-stopped - image: aurora-app build: context: . dockerfile: Dockerfile + target: development # Use development stage working_dir: /app ports: - "127.0.0.1:3000:3000" volumes: + # Mount source code for hot reloading - .:/app - - /app/node_modules - - /app/web/node_modules + # Use named volumes for node_modules (prevents host overwrite + caches deps) + - app_node_modules:/app/node_modules + - web_node_modules:/app/web/node_modules environment: - HOST=0.0.0.0 - DB_USER=${DB_USER} @@ -61,30 +63,20 @@ services: studio: container_name: aurora_studio - image: aurora-app - build: - context: . - dockerfile: Dockerfile - working_dir: /app + # Reuse the same built image as app (no duplicate builds!) + extends: + service: app ports: - "127.0.0.1:4983:4983" - volumes: - - .:/app - - /app/node_modules - - /app/web/node_modules - environment: - - DB_USER=${DB_USER} - - DB_PASSWORD=${DB_PASSWORD} - - DB_NAME=${DB_NAME} - - DB_PORT=5432 - - DB_HOST=db - - DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@db:5432/${DB_NAME} - depends_on: - db: - condition: service_healthy - networks: - - internal - - web + # Override healthcheck since studio doesn't serve on port 3000 + healthcheck: + test: [ "CMD", "bun", "-e", "fetch('http://localhost:4983').then(r => process.exit(0)).catch(() => process.exit(1))" ] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + # Disable restart for studio (it's an on-demand tool) + restart: "no" command: [ "bun", "x", "drizzle-kit", "studio", "--port", "4983", "--host", "0.0.0.0" ] networks: @@ -93,3 +85,10 @@ networks: internal: true # No external access web: driver: bridge # Can be accessed from host + +volumes: + # Named volumes for node_modules caching + app_node_modules: + name: aurora_app_node_modules + web_node_modules: + name: aurora_web_node_modules diff --git a/package.json b/package.json index f4225b0..6b53462 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,8 @@ "studio:remote": "bash shared/scripts/remote-studio.sh", "dashboard:remote": "bash shared/scripts/remote-dashboard.sh", "remote": "bash shared/scripts/remote.sh", - "test": "bun test" + "test": "bun test", + "docker:cleanup": "bash shared/scripts/docker-cleanup.sh" }, "dependencies": { "@napi-rs/canvas": "^0.1.84", diff --git a/shared/modules/admin/update.service.test.ts b/shared/modules/admin/update.service.test.ts index 7eb0d17..4373c52 100644 --- a/shared/modules/admin/update.service.test.ts +++ b/shared/modules/admin/update.service.test.ts @@ -1,5 +1,4 @@ -import { describe, expect, test, mock, beforeEach, afterAll, spyOn } from "bun:test"; -import * as fs from "fs/promises"; +import { describe, expect, test, mock, beforeEach, afterAll } from "bun:test"; // Mock child_process BEFORE importing the service const mockExec = mock((cmd: string, callback?: any) => { @@ -8,23 +7,32 @@ const mockExec = mock((cmd: string, callback?: any) => { return { unref: () => { } }; } - if (cmd.includes("git rev-parse")) { - callback(null, { stdout: "main\n" }); + // 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")) { - callback(null, { stdout: "" }); + stdout = ""; } else if (cmd.includes("git log")) { - callback(null, { stdout: "abcdef Update 1\n123456 Update 2" }); + stdout = "abcdef|Update 1|Author1\n123456|Update 2|Author2"; } else if (cmd.includes("git diff")) { - callback(null, { stdout: "package.json\nsrc/index.ts" }); + stdout = "package.json\nsrc/index.ts\nshared/lib/schema.ts"; } else if (cmd.includes("git reset")) { - callback(null, { stdout: "HEAD is now at abcdef Update 1" }); + stdout = "HEAD is now at abcdef Update 1"; } else if (cmd.includes("bun install")) { - callback(null, { stdout: "Installed dependencies" }); + stdout = "Installed dependencies"; } else if (cmd.includes("drizzle-kit migrate")) { - callback(null, { stdout: "Migrations applied" }); - } else { - callback(null, { stdout: "" }); + stdout = "Migrations applied"; + } else if (cmd.includes("bun run build")) { + stdout = "Build completed"; } + + callback(null, stdout, ""); }); mock.module("child_process", () => ({ @@ -32,9 +40,9 @@ mock.module("child_process", () => ({ })); // 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()); +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, @@ -43,9 +51,9 @@ mock.module("fs/promises", () => ({ })); // Mock view module to avoid import issues -mock.module("./update.view", () => ({ - getPostRestartEmbed: () => ({ title: "Update Complete" }), - getInstallingDependenciesEmbed: () => ({ title: "Installing..." }), +mock.module("@/modules/admin/update.view", () => ({ + getPostRestartEmbed: () => ({ embeds: [{ title: "Update Complete" }], components: [] }), + getPostRestartProgressEmbed: () => ({ title: "Progress..." }), })); describe("UpdateService", () => { @@ -72,7 +80,8 @@ describe("UpdateService", () => { expect(result.hasUpdates).toBe(true); expect(result.branch).toBe("main"); - expect(result.log).toContain("Update 1"); + 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 () => { @@ -83,43 +92,82 @@ describe("UpdateService", () => { 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 lastCall = mockExec.mock.lastCall; - expect(lastCall).toBeDefined(); - expect(lastCall![0]).toContain("git reset --hard origin/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", () => { + describe("checkUpdateRequirements (deprecated)", () => { test("should detect package.json and schema.ts changes", async () => { const result = await UpdateService.checkUpdateRequirements("main"); - expect(result.needsInstall).toBe(true); - expect(result.needsMigrations).toBe(false); // mock doesn't include schema.ts + 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 lastCall = mockExec.mock.lastCall; - expect(lastCall).toBeDefined(); - expect(lastCall![0]).toContain("git diff HEAD..origin/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 and return output", async () => { - const output = await UpdateService.installDependencies(); + test("should run bun install for root only", async () => { + const output = await UpdateService.installDependencies({ root: true, web: false }); - expect(output).toBe("Installed dependencies"); - const lastCall = mockExec.mock.lastCall; - expect(lastCall![0]).toBe("bun install"); + 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); }); }); @@ -130,7 +178,10 @@ describe("UpdateService", () => { userId: "456", timestamp: Date.now(), runMigrations: true, - installDependencies: false + installDependencies: false, + buildWebAssets: false, + previousCommit: "abc1234", + newCommit: "def5678" }; await UpdateService.prepareRestartContext(context); @@ -143,6 +194,39 @@ describe("UpdateService", () => { }); }); + 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; @@ -150,24 +234,19 @@ describe("UpdateService", () => { await UpdateService.triggerRestart(); - const lastCall = mockExec.mock.lastCall; - expect(lastCall).toBeDefined(); - expect(lastCall![0]).toBe("pm2 restart bot"); + 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 write to trigger file when no env var", async () => { + 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(); - expect(mockWriteFile).toHaveBeenCalled(); - const lastCall = mockWriteFile.mock.lastCall as [string, string] | undefined; - expect(lastCall).toBeDefined(); - expect(lastCall![0]).toContain("restart_trigger"); - process.env.RESTART_COMMAND = originalEnv; }); }); @@ -181,7 +260,7 @@ describe("UpdateService", () => { const createMockChannel = () => ({ isSendable: () => true, - send: mock(() => Promise.resolve()) + send: mock(() => Promise.resolve({ edit: mock(() => Promise.resolve()), delete: mock(() => Promise.resolve()) })) }); test("should ignore stale context (>10 mins old)", async () => { @@ -190,7 +269,10 @@ describe("UpdateService", () => { userId: "456", timestamp: Date.now() - (15 * 60 * 1000), // 15 mins ago runMigrations: true, - installDependencies: true + installDependencies: true, + buildWebAssets: false, + previousCommit: "abc", + newCommit: "def" }; mockReadFile.mockImplementationOnce(() => Promise.resolve(JSON.stringify(staleContext))); @@ -227,7 +309,10 @@ describe("UpdateService", () => { userId: "456", timestamp: Date.now(), runMigrations: false, - installDependencies: false + installDependencies: false, + buildWebAssets: false, + previousCommit: "abc", + newCommit: "def" }; mockReadFile.mockImplementationOnce(() => Promise.resolve(JSON.stringify(validContext))); @@ -236,7 +321,7 @@ describe("UpdateService", () => { const { TextChannel } = await import("discord.js"); const mockChannel = Object.create(TextChannel.prototype); mockChannel.isSendable = () => true; - mockChannel.send = mock(() => Promise.resolve()); + mockChannel.send = mock(() => Promise.resolve({ edit: mock(() => Promise.resolve()), delete: mock(() => Promise.resolve()) })); const mockClient = createMockClient(mockChannel); diff --git a/shared/modules/admin/update.service.ts b/shared/modules/admin/update.service.ts index 11e4dcd..4de2175 100644 --- a/shared/modules/admin/update.service.ts +++ b/shared/modules/admin/update.service.ts @@ -1,4 +1,4 @@ -import { exec } from "child_process"; +import { exec, type ExecException } from "child_process"; import { promisify } from "util"; import { writeFile, readFile, unlink } from "fs/promises"; import { Client, TextChannel } from "discord.js"; @@ -10,32 +10,69 @@ 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 +): Promise<{ stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + const process = exec(cmd, (error: ExecException | null, stdout: string, stderr: string) => { + if (error) { + reject(error); + } else { + resolve({ stdout, stderr }); + } + }); + const timer = setTimeout(() => { + process.kill("SIGTERM"); + reject(new Error(`Command timed out after ${timeoutMs}ms: ${cmd}`)); + }, timeoutMs); + + process.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 { stdout: branchName } = await execAsync("git rev-parse --abbrev-ref HEAD"); + static async checkForUpdates(): Promise { + // Get branch first (needed for subsequent commands) + const { stdout: branchName } = await execWithTimeout("git rev-parse --abbrev-ref HEAD"); const branch = branchName.trim(); - const { stdout: currentCommit } = await execAsync("git rev-parse --short HEAD"); + // 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 + ]); + const currentCommit = currentResult.stdout.trim(); - await execAsync("git fetch --all"); + // 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`) + ]); - const { stdout: latestCommit } = await execAsync(`git rev-parse --short origin/${branch}`); + const latestCommit = latestResult.stdout.trim(); - // Get commit log with author info - const { stdout: logOutput } = await execAsync( - `git log HEAD..origin/${branch} --format="%h|%s|%an" --no-merges` - ); - - const commits: CommitInfo[] = logOutput + // Parse commit log + const commits: CommitInfo[] = logResult.stdout .trim() .split("\n") .filter(line => line.length > 0) @@ -44,51 +81,64 @@ export class UpdateService { 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: currentCommit.trim(), - latestCommit: latestCommit.trim(), + currentCommit, + latestCommit, commitCount: commits.length, - commits + commits, + requirements }; } /** - * Analyze what the update requires + * 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 { try { - const { stdout } = await execAsync(`git diff HEAD..origin/${branch} --name-only`); + const { stdout } = await execWithTimeout(`git diff HEAD..origin/${branch} --name-only`); const changedFiles = stdout.trim().split("\n").filter(f => f.length > 0); - - const needsRootInstall = changedFiles.some(file => - file === "package.json" || file === "bun.lock" - ); - - const needsWebInstall = changedFiles.some(file => - file === "web/package.json" || file === "web/bun.lock" - ); - - // Detect if web source files changed (requires rebuild) - const needsWebBuild = changedFiles.some(file => - file.startsWith("web/src/") || - 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 - }; + return this.analyzeChangedFiles(changedFiles); } catch (e) { console.error("Failed to check update requirements:", e); return { @@ -129,9 +179,10 @@ export class UpdateService { * Save the current commit for potential rollback */ static async saveRollbackPoint(): Promise { - const { stdout } = await execAsync("git rev-parse HEAD"); + const { stdout } = await execWithTimeout("git rev-parse HEAD"); const commit = stdout.trim(); await writeFile(this.ROLLBACK_FILE, commit); + this.rollbackPointExists = true; // Cache the state return commit; } @@ -141,8 +192,9 @@ export class UpdateService { static async rollback(): Promise<{ success: boolean; message: string }> { try { const rollbackCommit = await readFile(this.ROLLBACK_FILE, "utf-8"); - await execAsync(`git reset --hard ${rollbackCommit.trim()}`); + await execWithTimeout(`git reset --hard ${rollbackCommit.trim()}`); await unlink(this.ROLLBACK_FILE); + this.rollbackPointExists = false; return { success: true, message: `Rolled back to ${rollbackCommit.trim().substring(0, 7)}` }; } catch (e) { return { @@ -154,12 +206,18 @@ export class UpdateService { /** * 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; } } @@ -168,26 +226,32 @@ export class UpdateService { * Perform the git update */ static async performUpdate(branch: string): Promise { - await execAsync(`git reset --hard origin/${branch}`); + await execWithTimeout(`git reset --hard origin/${branch}`); } /** * Install dependencies for specified projects + * Optimized: Parallel installation */ static async installDependencies(options: { root: boolean; web: boolean }): Promise { - const outputs: string[] = []; + const tasks: Promise<{ label: string; output: string }>[] = []; if (options.root) { - const { stdout } = await execAsync("bun install"); - outputs.push(`๐Ÿ“ฆ Root: ${stdout.trim() || "Done"}`); + tasks.push( + execWithTimeout("bun install", INSTALL_TIMEOUT_MS) + .then(({ stdout }) => ({ label: "๐Ÿ“ฆ Root", output: stdout.trim() || "Done" })) + ); } if (options.web) { - const { stdout } = await execAsync("cd web && bun install"); - outputs.push(`๐ŸŒ Web: ${stdout.trim() || "Done"}`); + tasks.push( + execWithTimeout("cd web && bun install", INSTALL_TIMEOUT_MS) + .then(({ stdout }) => ({ label: "๐ŸŒ Web", output: stdout.trim() || "Done" })) + ); } - return outputs.join("\n"); + const results = await Promise.all(tasks); + return results.map(r => `${r.label}: ${r.output}`).join("\n"); } /** @@ -228,7 +292,7 @@ export class UpdateService { } const result = await this.executePostRestartTasks(context, channel); - await this.notifyPostRestartResult(channel, result, context); + await this.notifyPostRestartResult(channel, result); await this.cleanupContext(); } catch (e) { console.error("Failed to handle post-restart context:", e); @@ -301,21 +365,33 @@ export class UpdateService { } }; - // 1. Install Dependencies if needed + // 1. Install Dependencies if needed (PARALLELIZED) if (context.installDependencies) { try { progress.currentStep = "install"; await updateProgress(); - const { stdout: rootOutput } = await execAsync("bun install"); - const { stdout: webOutput } = await execAsync("cd web && bun install"); + // 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.installOutput = `๐Ÿ“ฆ Root: ${rootOutput.trim() || "Done"}\n๐ŸŒ Web: ${webOutput.trim() || "Done"}`; + 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; // Mark as done even on failure + progress.installDone = true; console.error("Dependency Install Failed:", err); } } @@ -326,7 +402,7 @@ export class UpdateService { progress.currentStep = "build"; await updateProgress(); - const { stdout } = await execAsync("cd web && bun run build"); + 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) { @@ -343,7 +419,7 @@ export class UpdateService { progress.currentStep = "migrate"; await updateProgress(); - const { stdout } = await execAsync("bun x drizzle-kit migrate"); + const { stdout } = await execWithTimeout("bun x drizzle-kit migrate", DEFAULT_TIMEOUT_MS); result.migrationOutput = stdout; progress.migrateDone = true; } catch (err: unknown) { @@ -368,9 +444,9 @@ export class UpdateService { private static async notifyPostRestartResult( channel: TextChannel, - result: PostRestartResult, - context: RestartContext + result: PostRestartResult ): Promise { + // Use cached rollback state - we just saved it before restart const hasRollback = await this.hasRollbackPoint(); await channel.send(getPostRestartEmbed(result, hasRollback)); } @@ -381,5 +457,6 @@ export class UpdateService { } catch { // File may not exist, ignore } + // Don't clear rollback cache here - rollback file persists } } diff --git a/shared/scripts/docker-cleanup.sh b/shared/scripts/docker-cleanup.sh new file mode 100755 index 0000000..a5a2e53 --- /dev/null +++ b/shared/scripts/docker-cleanup.sh @@ -0,0 +1,41 @@ +#!/bin/bash +# Cleanup script for Docker resources +# Use: ./shared/scripts/docker-cleanup.sh + +set -e + +echo "๐Ÿงน Aurora Docker Cleanup" +echo "========================" + +# Stop running containers for this project +echo "" +echo "๐Ÿ“ฆ Stopping Aurora containers..." +docker compose down 2>/dev/null || true + +# Remove dangling images (untagged images from failed builds) +echo "" +echo "๐Ÿ—‘๏ธ Removing dangling images..." +docker image prune -f + +# Optional: Remove unused build cache +echo "" +read -p "๐Ÿ”ง Remove Docker build cache? (y/N): " -n 1 -r +echo +if [[ $REPLY =~ ^[Yy]$ ]]; then + docker builder prune -f + echo "โœ“ Build cache cleared" +fi + +# Optional: Remove node_modules volumes (forces fresh install) +echo "" +read -p "๐Ÿ“ Remove node_modules volumes? (forces fresh install) (y/N): " -n 1 -r +echo +if [[ $REPLY =~ ^[Yy]$ ]]; then + docker volume rm aurora_app_node_modules aurora_web_node_modules 2>/dev/null || true + echo "โœ“ Node modules volumes removed" +fi + +echo "" +echo "โœ… Cleanup complete!" +echo "" +echo "Run 'docker compose up --build' to rebuild"