forked from syntaxbullet/aurorabot
feat: Overhaul Docker infrastructure with multi-stage builds, add a cleanup script, and refactor the update service to combine update and requirement checks.
This commit is contained in:
39
.dockerignore
Normal file
39
.dockerignore
Normal file
@@ -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
|
||||||
22
AGENTS.md
22
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
|
bun --hot web/src/index.ts # Run web dashboard with hot reload
|
||||||
|
|
||||||
# Testing
|
# 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 path/to/file.test.ts # Run single test file
|
||||||
bun test --watch # Watch mode
|
bun test --watch # Watch mode
|
||||||
bun test shared/modules/economy # Run tests in directory
|
bun test shared/modules/economy # Run tests in directory
|
||||||
@@ -71,6 +71,7 @@ import { localHelper } from "./helper";
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Available Aliases:**
|
**Available Aliases:**
|
||||||
|
|
||||||
- `@/*` - bot/
|
- `@/*` - bot/
|
||||||
- `@shared/*` - shared/
|
- `@shared/*` - shared/
|
||||||
- `@db/*` - shared/db/
|
- `@db/*` - shared/db/
|
||||||
@@ -81,7 +82,7 @@ import { localHelper } from "./helper";
|
|||||||
## Naming Conventions
|
## Naming Conventions
|
||||||
|
|
||||||
| Element | Convention | Example |
|
| Element | Convention | Example |
|
||||||
|---------|------------|---------|
|
| ---------------- | ----------------------- | ---------------------------------------- |
|
||||||
| Files | camelCase or kebab-case | `BotClient.ts`, `economy.service.ts` |
|
| Files | camelCase or kebab-case | `BotClient.ts`, `economy.service.ts` |
|
||||||
| Classes | PascalCase | `CommandHandler`, `UserError` |
|
| Classes | PascalCase | `CommandHandler`, `UserError` |
|
||||||
| Functions | camelCase | `createCommand`, `handleShopInteraction` |
|
| Functions | camelCase | `createCommand`, `handleShopInteraction` |
|
||||||
@@ -104,7 +105,7 @@ export const commandName = createCommand({
|
|||||||
execute: async (interaction) => {
|
execute: async (interaction) => {
|
||||||
await interaction.deferReply();
|
await interaction.deferReply();
|
||||||
// Implementation
|
// Implementation
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -153,7 +154,9 @@ try {
|
|||||||
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
|
await interaction.editReply({ embeds: [createErrorEmbed(error.message)] });
|
||||||
} else {
|
} else {
|
||||||
console.error("Unexpected error:", error);
|
console.error("Unexpected error:", error);
|
||||||
await interaction.editReply({ embeds: [createErrorEmbed("An unexpected error occurred.")] });
|
await interaction.editReply({
|
||||||
|
embeds: [createErrorEmbed("An unexpected error occurred.")],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -167,10 +170,13 @@ import { withTransaction } from "@/lib/db";
|
|||||||
|
|
||||||
return await withTransaction(async (tx) => {
|
return await withTransaction(async (tx) => {
|
||||||
const user = await tx.query.users.findFirst({
|
const user = await tx.query.users.findFirst({
|
||||||
where: eq(users.id, discordId)
|
where: eq(users.id, discordId),
|
||||||
});
|
});
|
||||||
|
|
||||||
await tx.update(users).set({ coins: newBalance }).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 });
|
await tx.insert(transactions).values({ userId: discordId, amount, type });
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
@@ -192,7 +198,7 @@ import { describe, it, expect, mock, beforeEach } from "bun:test";
|
|||||||
|
|
||||||
// Mock modules BEFORE imports
|
// Mock modules BEFORE imports
|
||||||
mock.module("@shared/db/DrizzleClient", () => ({
|
mock.module("@shared/db/DrizzleClient", () => ({
|
||||||
DrizzleClient: { query: mockQuery }
|
DrizzleClient: { query: mockQuery },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe("serviceName", () => {
|
describe("serviceName", () => {
|
||||||
@@ -228,7 +234,7 @@ describe("serviceName", () => {
|
|||||||
## Key Files Reference
|
## Key Files Reference
|
||||||
|
|
||||||
| Purpose | File |
|
| Purpose | File |
|
||||||
|---------|------|
|
| ------------- | ---------------------- |
|
||||||
| Bot entry | `bot/index.ts` |
|
| Bot entry | `bot/index.ts` |
|
||||||
| DB schema | `shared/db/schema.ts` |
|
| DB schema | `shared/db/schema.ts` |
|
||||||
| Error classes | `shared/lib/errors.ts` |
|
| Error classes | `shared/lib/errors.ts` |
|
||||||
|
|||||||
54
Dockerfile
54
Dockerfile
@@ -1,21 +1,55 @@
|
|||||||
|
# ============================================
|
||||||
|
# Base stage - shared configuration
|
||||||
|
# ============================================
|
||||||
FROM oven/bun:latest AS base
|
FROM oven/bun:latest AS base
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install system dependencies
|
# Install system dependencies with cleanup in same layer
|
||||||
RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
|
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 ./
|
COPY package.json bun.lock ./
|
||||||
RUN bun install --frozen-lockfile
|
|
||||||
|
|
||||||
# Install web project dependencies
|
|
||||||
COPY web/package.json web/bun.lock ./web/
|
COPY web/package.json web/bun.lock ./web/
|
||||||
RUN cd web && bun install --frozen-lockfile
|
|
||||||
|
|
||||||
# Copy source code
|
# Install all dependencies in one layer
|
||||||
COPY . .
|
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
|
EXPOSE 3000
|
||||||
|
|
||||||
# Default command
|
# Default command
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ async function handleUpdate(interaction: any) {
|
|||||||
const force = interaction.options.getBoolean("force") || false;
|
const force = interaction.options.getBoolean("force") || false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. Check for updates
|
// 1. Check for updates (now includes requirements in one call)
|
||||||
await interaction.editReply({ embeds: [getCheckingEmbed()] });
|
await interaction.editReply({ embeds: [getCheckingEmbed()] });
|
||||||
const updateInfo = await UpdateService.checkForUpdates();
|
const updateInfo = await UpdateService.checkForUpdates();
|
||||||
|
|
||||||
@@ -60,8 +60,8 @@ async function handleUpdate(interaction: any) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Analyze requirements
|
// 2. Extract requirements from the combined response
|
||||||
const requirements = await UpdateService.checkUpdateRequirements(updateInfo.branch);
|
const { requirements } = updateInfo;
|
||||||
const categories = UpdateService.categorizeChanges(requirements.changedFiles);
|
const categories = UpdateService.categorizeChanges(requirements.changedFiles);
|
||||||
|
|
||||||
// 3. Show confirmation with details
|
// 3. Show confirmation with details
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ services:
|
|||||||
# ports:
|
# ports:
|
||||||
# - "127.0.0.1:${DB_PORT}:5432"
|
# - "127.0.0.1:${DB_PORT}:5432"
|
||||||
volumes:
|
volumes:
|
||||||
|
# Host-mounted to preserve existing VPS data
|
||||||
- ./shared/db/data:/var/lib/postgresql/data
|
- ./shared/db/data:/var/lib/postgresql/data
|
||||||
- ./shared/db/log:/var/log/postgresql
|
|
||||||
networks:
|
networks:
|
||||||
- internal
|
- internal
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -23,17 +23,19 @@ services:
|
|||||||
app:
|
app:
|
||||||
container_name: aurora_app
|
container_name: aurora_app
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
image: aurora-app
|
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
target: development # Use development stage
|
||||||
working_dir: /app
|
working_dir: /app
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:3000:3000"
|
- "127.0.0.1:3000:3000"
|
||||||
volumes:
|
volumes:
|
||||||
|
# Mount source code for hot reloading
|
||||||
- .:/app
|
- .:/app
|
||||||
- /app/node_modules
|
# Use named volumes for node_modules (prevents host overwrite + caches deps)
|
||||||
- /app/web/node_modules
|
- app_node_modules:/app/node_modules
|
||||||
|
- web_node_modules:/app/web/node_modules
|
||||||
environment:
|
environment:
|
||||||
- HOST=0.0.0.0
|
- HOST=0.0.0.0
|
||||||
- DB_USER=${DB_USER}
|
- DB_USER=${DB_USER}
|
||||||
@@ -61,30 +63,20 @@ services:
|
|||||||
|
|
||||||
studio:
|
studio:
|
||||||
container_name: aurora_studio
|
container_name: aurora_studio
|
||||||
image: aurora-app
|
# Reuse the same built image as app (no duplicate builds!)
|
||||||
build:
|
extends:
|
||||||
context: .
|
service: app
|
||||||
dockerfile: Dockerfile
|
|
||||||
working_dir: /app
|
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:4983:4983"
|
- "127.0.0.1:4983:4983"
|
||||||
volumes:
|
# Override healthcheck since studio doesn't serve on port 3000
|
||||||
- .:/app
|
healthcheck:
|
||||||
- /app/node_modules
|
test: [ "CMD", "bun", "-e", "fetch('http://localhost:4983').then(r => process.exit(0)).catch(() => process.exit(1))" ]
|
||||||
- /app/web/node_modules
|
interval: 30s
|
||||||
environment:
|
timeout: 10s
|
||||||
- DB_USER=${DB_USER}
|
retries: 3
|
||||||
- DB_PASSWORD=${DB_PASSWORD}
|
start_period: 10s
|
||||||
- DB_NAME=${DB_NAME}
|
# Disable restart for studio (it's an on-demand tool)
|
||||||
- DB_PORT=5432
|
restart: "no"
|
||||||
- DB_HOST=db
|
|
||||||
- DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@db:5432/${DB_NAME}
|
|
||||||
depends_on:
|
|
||||||
db:
|
|
||||||
condition: service_healthy
|
|
||||||
networks:
|
|
||||||
- internal
|
|
||||||
- web
|
|
||||||
command: [ "bun", "x", "drizzle-kit", "studio", "--port", "4983", "--host", "0.0.0.0" ]
|
command: [ "bun", "x", "drizzle-kit", "studio", "--port", "4983", "--host", "0.0.0.0" ]
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
@@ -93,3 +85,10 @@ networks:
|
|||||||
internal: true # No external access
|
internal: true # No external access
|
||||||
web:
|
web:
|
||||||
driver: bridge # Can be accessed from host
|
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
|
||||||
|
|||||||
@@ -21,7 +21,8 @@
|
|||||||
"studio:remote": "bash shared/scripts/remote-studio.sh",
|
"studio:remote": "bash shared/scripts/remote-studio.sh",
|
||||||
"dashboard:remote": "bash shared/scripts/remote-dashboard.sh",
|
"dashboard:remote": "bash shared/scripts/remote-dashboard.sh",
|
||||||
"remote": "bash shared/scripts/remote.sh",
|
"remote": "bash shared/scripts/remote.sh",
|
||||||
"test": "bun test"
|
"test": "bun test",
|
||||||
|
"docker:cleanup": "bash shared/scripts/docker-cleanup.sh"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@napi-rs/canvas": "^0.1.84",
|
"@napi-rs/canvas": "^0.1.84",
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { describe, expect, test, mock, beforeEach, afterAll, spyOn } from "bun:test";
|
import { describe, expect, test, mock, beforeEach, afterAll } from "bun:test";
|
||||||
import * as fs from "fs/promises";
|
|
||||||
|
|
||||||
// Mock child_process BEFORE importing the service
|
// Mock child_process BEFORE importing the service
|
||||||
const mockExec = mock((cmd: string, callback?: any) => {
|
const mockExec = mock((cmd: string, callback?: any) => {
|
||||||
@@ -8,23 +7,32 @@ const mockExec = mock((cmd: string, callback?: any) => {
|
|||||||
return { unref: () => { } };
|
return { unref: () => { } };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cmd.includes("git rev-parse")) {
|
// Simulate successful command execution
|
||||||
callback(null, { stdout: "main\n" });
|
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")) {
|
} else if (cmd.includes("git fetch")) {
|
||||||
callback(null, { stdout: "" });
|
stdout = "";
|
||||||
} else if (cmd.includes("git log")) {
|
} 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")) {
|
} 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")) {
|
} 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")) {
|
} else if (cmd.includes("bun install")) {
|
||||||
callback(null, { stdout: "Installed dependencies" });
|
stdout = "Installed dependencies";
|
||||||
} else if (cmd.includes("drizzle-kit migrate")) {
|
} else if (cmd.includes("drizzle-kit migrate")) {
|
||||||
callback(null, { stdout: "Migrations applied" });
|
stdout = "Migrations applied";
|
||||||
} else {
|
} else if (cmd.includes("bun run build")) {
|
||||||
callback(null, { stdout: "" });
|
stdout = "Build completed";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
callback(null, stdout, "");
|
||||||
});
|
});
|
||||||
|
|
||||||
mock.module("child_process", () => ({
|
mock.module("child_process", () => ({
|
||||||
@@ -32,9 +40,9 @@ mock.module("child_process", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock fs/promises
|
// Mock fs/promises
|
||||||
const mockWriteFile = mock((path: string, content: string) => Promise.resolve());
|
const mockWriteFile = mock((_path: string, _content: string) => Promise.resolve());
|
||||||
const mockReadFile = mock((path: string, encoding: string) => Promise.resolve("{}"));
|
const mockReadFile = mock((_path: string, _encoding: string) => Promise.resolve("{}"));
|
||||||
const mockUnlink = mock((path: string) => Promise.resolve());
|
const mockUnlink = mock((_path: string) => Promise.resolve());
|
||||||
|
|
||||||
mock.module("fs/promises", () => ({
|
mock.module("fs/promises", () => ({
|
||||||
writeFile: mockWriteFile,
|
writeFile: mockWriteFile,
|
||||||
@@ -43,9 +51,9 @@ mock.module("fs/promises", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock view module to avoid import issues
|
// Mock view module to avoid import issues
|
||||||
mock.module("./update.view", () => ({
|
mock.module("@/modules/admin/update.view", () => ({
|
||||||
getPostRestartEmbed: () => ({ title: "Update Complete" }),
|
getPostRestartEmbed: () => ({ embeds: [{ title: "Update Complete" }], components: [] }),
|
||||||
getInstallingDependenciesEmbed: () => ({ title: "Installing..." }),
|
getPostRestartProgressEmbed: () => ({ title: "Progress..." }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe("UpdateService", () => {
|
describe("UpdateService", () => {
|
||||||
@@ -72,7 +80,8 @@ describe("UpdateService", () => {
|
|||||||
|
|
||||||
expect(result.hasUpdates).toBe(true);
|
expect(result.hasUpdates).toBe(true);
|
||||||
expect(result.branch).toBe("main");
|
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 () => {
|
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 fetch"))).toBe(true);
|
||||||
expect(calls.some((cmd: string) => cmd.includes("git log"))).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", () => {
|
describe("performUpdate", () => {
|
||||||
test("should run git reset --hard with correct branch", async () => {
|
test("should run git reset --hard with correct branch", async () => {
|
||||||
await UpdateService.performUpdate("main");
|
await UpdateService.performUpdate("main");
|
||||||
|
|
||||||
const lastCall = mockExec.mock.lastCall;
|
const calls = mockExec.mock.calls.map((c: any) => c[0]);
|
||||||
expect(lastCall).toBeDefined();
|
expect(calls.some((cmd: string) => cmd.includes("git reset --hard origin/main"))).toBe(true);
|
||||||
expect(lastCall![0]).toContain("git reset --hard origin/main");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("checkUpdateRequirements", () => {
|
describe("checkUpdateRequirements (deprecated)", () => {
|
||||||
test("should detect package.json and schema.ts changes", async () => {
|
test("should detect package.json and schema.ts changes", async () => {
|
||||||
const result = await UpdateService.checkUpdateRequirements("main");
|
const result = await UpdateService.checkUpdateRequirements("main");
|
||||||
|
|
||||||
expect(result.needsInstall).toBe(true);
|
expect(result.needsRootInstall).toBe(true);
|
||||||
expect(result.needsMigrations).toBe(false); // mock doesn't include schema.ts
|
expect(result.needsMigrations).toBe(true);
|
||||||
expect(result.error).toBeUndefined();
|
expect(result.error).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should call git diff with correct branch", async () => {
|
test("should call git diff with correct branch", async () => {
|
||||||
await UpdateService.checkUpdateRequirements("develop");
|
await UpdateService.checkUpdateRequirements("develop");
|
||||||
|
|
||||||
const lastCall = mockExec.mock.lastCall;
|
const calls = mockExec.mock.calls.map((c: any) => c[0]);
|
||||||
expect(lastCall).toBeDefined();
|
expect(calls.some((cmd: string) => cmd.includes("git diff HEAD..origin/develop"))).toBe(true);
|
||||||
expect(lastCall![0]).toContain("git diff HEAD..origin/develop");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("installDependencies", () => {
|
describe("installDependencies", () => {
|
||||||
test("should run bun install and return output", async () => {
|
test("should run bun install for root only", async () => {
|
||||||
const output = await UpdateService.installDependencies();
|
const output = await UpdateService.installDependencies({ root: true, web: false });
|
||||||
|
|
||||||
expect(output).toBe("Installed dependencies");
|
expect(output).toContain("Root");
|
||||||
const lastCall = mockExec.mock.lastCall;
|
const calls = mockExec.mock.calls.map((c: any) => c[0]);
|
||||||
expect(lastCall![0]).toBe("bun install");
|
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",
|
userId: "456",
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
runMigrations: true,
|
runMigrations: true,
|
||||||
installDependencies: false
|
installDependencies: false,
|
||||||
|
buildWebAssets: false,
|
||||||
|
previousCommit: "abc1234",
|
||||||
|
newCommit: "def5678"
|
||||||
};
|
};
|
||||||
|
|
||||||
await UpdateService.prepareRestartContext(context);
|
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", () => {
|
describe("triggerRestart", () => {
|
||||||
test("should use RESTART_COMMAND env var when set", async () => {
|
test("should use RESTART_COMMAND env var when set", async () => {
|
||||||
const originalEnv = process.env.RESTART_COMMAND;
|
const originalEnv = process.env.RESTART_COMMAND;
|
||||||
@@ -150,24 +234,19 @@ describe("UpdateService", () => {
|
|||||||
|
|
||||||
await UpdateService.triggerRestart();
|
await UpdateService.triggerRestart();
|
||||||
|
|
||||||
const lastCall = mockExec.mock.lastCall;
|
const calls = mockExec.mock.calls.map((c: any) => c[0]);
|
||||||
expect(lastCall).toBeDefined();
|
expect(calls.some((cmd: string) => cmd === "pm2 restart bot")).toBe(true);
|
||||||
expect(lastCall![0]).toBe("pm2 restart bot");
|
|
||||||
|
|
||||||
process.env.RESTART_COMMAND = originalEnv;
|
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;
|
const originalEnv = process.env.RESTART_COMMAND;
|
||||||
delete 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();
|
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;
|
process.env.RESTART_COMMAND = originalEnv;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -181,7 +260,7 @@ describe("UpdateService", () => {
|
|||||||
|
|
||||||
const createMockChannel = () => ({
|
const createMockChannel = () => ({
|
||||||
isSendable: () => true,
|
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 () => {
|
test("should ignore stale context (>10 mins old)", async () => {
|
||||||
@@ -190,7 +269,10 @@ describe("UpdateService", () => {
|
|||||||
userId: "456",
|
userId: "456",
|
||||||
timestamp: Date.now() - (15 * 60 * 1000), // 15 mins ago
|
timestamp: Date.now() - (15 * 60 * 1000), // 15 mins ago
|
||||||
runMigrations: true,
|
runMigrations: true,
|
||||||
installDependencies: true
|
installDependencies: true,
|
||||||
|
buildWebAssets: false,
|
||||||
|
previousCommit: "abc",
|
||||||
|
newCommit: "def"
|
||||||
};
|
};
|
||||||
|
|
||||||
mockReadFile.mockImplementationOnce(() => Promise.resolve(JSON.stringify(staleContext)));
|
mockReadFile.mockImplementationOnce(() => Promise.resolve(JSON.stringify(staleContext)));
|
||||||
@@ -227,7 +309,10 @@ describe("UpdateService", () => {
|
|||||||
userId: "456",
|
userId: "456",
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
runMigrations: false,
|
runMigrations: false,
|
||||||
installDependencies: false
|
installDependencies: false,
|
||||||
|
buildWebAssets: false,
|
||||||
|
previousCommit: "abc",
|
||||||
|
newCommit: "def"
|
||||||
};
|
};
|
||||||
|
|
||||||
mockReadFile.mockImplementationOnce(() => Promise.resolve(JSON.stringify(validContext)));
|
mockReadFile.mockImplementationOnce(() => Promise.resolve(JSON.stringify(validContext)));
|
||||||
@@ -236,7 +321,7 @@ describe("UpdateService", () => {
|
|||||||
const { TextChannel } = await import("discord.js");
|
const { TextChannel } = await import("discord.js");
|
||||||
const mockChannel = Object.create(TextChannel.prototype);
|
const mockChannel = Object.create(TextChannel.prototype);
|
||||||
mockChannel.isSendable = () => true;
|
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);
|
const mockClient = createMockClient(mockChannel);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { exec } from "child_process";
|
import { exec, type ExecException } from "child_process";
|
||||||
import { promisify } from "util";
|
import { promisify } from "util";
|
||||||
import { writeFile, readFile, unlink } from "fs/promises";
|
import { writeFile, readFile, unlink } from "fs/promises";
|
||||||
import { Client, TextChannel } from "discord.js";
|
import { Client, TextChannel } from "discord.js";
|
||||||
@@ -10,32 +10,69 @@ const execAsync = promisify(exec);
|
|||||||
|
|
||||||
// Constants
|
// Constants
|
||||||
const STALE_CONTEXT_MS = 10 * 60 * 1000; // 10 minutes
|
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 {
|
export class UpdateService {
|
||||||
private static readonly CONTEXT_FILE = ".restart_context.json";
|
private static readonly CONTEXT_FILE = ".restart_context.json";
|
||||||
private static readonly ROLLBACK_FILE = ".rollback_commit.txt";
|
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
|
* Check for available updates with detailed commit information
|
||||||
|
* Optimized: Parallel git commands and combined requirements check
|
||||||
*/
|
*/
|
||||||
static async checkForUpdates(): Promise<UpdateInfo> {
|
static async checkForUpdates(): Promise<UpdateInfo & { requirements: UpdateCheckResult }> {
|
||||||
const { stdout: branchName } = await execAsync("git rev-parse --abbrev-ref HEAD");
|
// Get branch first (needed for subsequent commands)
|
||||||
|
const { stdout: branchName } = await execWithTimeout("git rev-parse --abbrev-ref HEAD");
|
||||||
const branch = branchName.trim();
|
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
|
// Parse commit log
|
||||||
const { stdout: logOutput } = await execAsync(
|
const commits: CommitInfo[] = logResult.stdout
|
||||||
`git log HEAD..origin/${branch} --format="%h|%s|%an" --no-merges`
|
|
||||||
);
|
|
||||||
|
|
||||||
const commits: CommitInfo[] = logOutput
|
|
||||||
.trim()
|
.trim()
|
||||||
.split("\n")
|
.split("\n")
|
||||||
.filter(line => line.length > 0)
|
.filter(line => line.length > 0)
|
||||||
@@ -44,24 +81,26 @@ export class UpdateService {
|
|||||||
return { hash: hash || "", message: message || "", author: author || "" };
|
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 {
|
return {
|
||||||
hasUpdates: commits.length > 0,
|
hasUpdates: commits.length > 0,
|
||||||
branch,
|
branch,
|
||||||
currentCommit: currentCommit.trim(),
|
currentCommit,
|
||||||
latestCommit: latestCommit.trim(),
|
latestCommit,
|
||||||
commitCount: commits.length,
|
commitCount: commits.length,
|
||||||
commits
|
commits,
|
||||||
|
requirements
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Analyze what the update requires
|
* Analyze changed files to determine update requirements
|
||||||
|
* Extracted for reuse and clarity
|
||||||
*/
|
*/
|
||||||
static async checkUpdateRequirements(branch: string): Promise<UpdateCheckResult> {
|
private static analyzeChangedFiles(changedFiles: string[]): UpdateCheckResult {
|
||||||
try {
|
|
||||||
const { stdout } = await execAsync(`git diff HEAD..origin/${branch} --name-only`);
|
|
||||||
const changedFiles = stdout.trim().split("\n").filter(f => f.length > 0);
|
|
||||||
|
|
||||||
const needsRootInstall = changedFiles.some(file =>
|
const needsRootInstall = changedFiles.some(file =>
|
||||||
file === "package.json" || file === "bun.lock"
|
file === "package.json" || file === "bun.lock"
|
||||||
);
|
);
|
||||||
@@ -70,9 +109,9 @@ export class UpdateService {
|
|||||||
file === "web/package.json" || file === "web/bun.lock"
|
file === "web/package.json" || file === "web/bun.lock"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Detect if web source files changed (requires rebuild)
|
// Only rebuild web if essential source files changed
|
||||||
const needsWebBuild = changedFiles.some(file =>
|
const needsWebBuild = changedFiles.some(file =>
|
||||||
file.startsWith("web/src/") ||
|
file.match(/^web\/src\/(components|pages|lib|index)/) ||
|
||||||
file === "web/build.ts" ||
|
file === "web/build.ts" ||
|
||||||
file === "web/tailwind.config.ts" ||
|
file === "web/tailwind.config.ts" ||
|
||||||
file === "web/tsconfig.json"
|
file === "web/tsconfig.json"
|
||||||
@@ -89,6 +128,17 @@ export class UpdateService {
|
|||||||
needsMigrations,
|
needsMigrations,
|
||||||
changedFiles
|
changedFiles
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use checkForUpdates() which now includes requirements
|
||||||
|
* Kept for backwards compatibility
|
||||||
|
*/
|
||||||
|
static async checkUpdateRequirements(branch: string): Promise<UpdateCheckResult> {
|
||||||
|
try {
|
||||||
|
const { stdout } = await execWithTimeout(`git diff HEAD..origin/${branch} --name-only`);
|
||||||
|
const changedFiles = stdout.trim().split("\n").filter(f => f.length > 0);
|
||||||
|
return this.analyzeChangedFiles(changedFiles);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to check update requirements:", e);
|
console.error("Failed to check update requirements:", e);
|
||||||
return {
|
return {
|
||||||
@@ -129,9 +179,10 @@ export class UpdateService {
|
|||||||
* Save the current commit for potential rollback
|
* Save the current commit for potential rollback
|
||||||
*/
|
*/
|
||||||
static async saveRollbackPoint(): Promise<string> {
|
static async saveRollbackPoint(): Promise<string> {
|
||||||
const { stdout } = await execAsync("git rev-parse HEAD");
|
const { stdout } = await execWithTimeout("git rev-parse HEAD");
|
||||||
const commit = stdout.trim();
|
const commit = stdout.trim();
|
||||||
await writeFile(this.ROLLBACK_FILE, commit);
|
await writeFile(this.ROLLBACK_FILE, commit);
|
||||||
|
this.rollbackPointExists = true; // Cache the state
|
||||||
return commit;
|
return commit;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,8 +192,9 @@ export class UpdateService {
|
|||||||
static async rollback(): Promise<{ success: boolean; message: string }> {
|
static async rollback(): Promise<{ success: boolean; message: string }> {
|
||||||
try {
|
try {
|
||||||
const rollbackCommit = await readFile(this.ROLLBACK_FILE, "utf-8");
|
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);
|
await unlink(this.ROLLBACK_FILE);
|
||||||
|
this.rollbackPointExists = false;
|
||||||
return { success: true, message: `Rolled back to ${rollbackCommit.trim().substring(0, 7)}` };
|
return { success: true, message: `Rolled back to ${rollbackCommit.trim().substring(0, 7)}` };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return {
|
return {
|
||||||
@@ -154,12 +206,18 @@ export class UpdateService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a rollback point exists
|
* Check if a rollback point exists
|
||||||
|
* Uses cache when available to avoid file system access
|
||||||
*/
|
*/
|
||||||
static async hasRollbackPoint(): Promise<boolean> {
|
static async hasRollbackPoint(): Promise<boolean> {
|
||||||
|
if (this.rollbackPointExists !== null) {
|
||||||
|
return this.rollbackPointExists;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await readFile(this.ROLLBACK_FILE, "utf-8");
|
await readFile(this.ROLLBACK_FILE, "utf-8");
|
||||||
|
this.rollbackPointExists = true;
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
|
this.rollbackPointExists = false;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -168,26 +226,32 @@ export class UpdateService {
|
|||||||
* Perform the git update
|
* Perform the git update
|
||||||
*/
|
*/
|
||||||
static async performUpdate(branch: string): Promise<void> {
|
static async performUpdate(branch: string): Promise<void> {
|
||||||
await execAsync(`git reset --hard origin/${branch}`);
|
await execWithTimeout(`git reset --hard origin/${branch}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Install dependencies for specified projects
|
* Install dependencies for specified projects
|
||||||
|
* Optimized: Parallel installation
|
||||||
*/
|
*/
|
||||||
static async installDependencies(options: { root: boolean; web: boolean }): Promise<string> {
|
static async installDependencies(options: { root: boolean; web: boolean }): Promise<string> {
|
||||||
const outputs: string[] = [];
|
const tasks: Promise<{ label: string; output: string }>[] = [];
|
||||||
|
|
||||||
if (options.root) {
|
if (options.root) {
|
||||||
const { stdout } = await execAsync("bun install");
|
tasks.push(
|
||||||
outputs.push(`📦 Root: ${stdout.trim() || "Done"}`);
|
execWithTimeout("bun install", INSTALL_TIMEOUT_MS)
|
||||||
|
.then(({ stdout }) => ({ label: "📦 Root", output: stdout.trim() || "Done" }))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.web) {
|
if (options.web) {
|
||||||
const { stdout } = await execAsync("cd web && bun install");
|
tasks.push(
|
||||||
outputs.push(`🌐 Web: ${stdout.trim() || "Done"}`);
|
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);
|
const result = await this.executePostRestartTasks(context, channel);
|
||||||
await this.notifyPostRestartResult(channel, result, context);
|
await this.notifyPostRestartResult(channel, result);
|
||||||
await this.cleanupContext();
|
await this.cleanupContext();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to handle post-restart context:", 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) {
|
if (context.installDependencies) {
|
||||||
try {
|
try {
|
||||||
progress.currentStep = "install";
|
progress.currentStep = "install";
|
||||||
await updateProgress();
|
await updateProgress();
|
||||||
|
|
||||||
const { stdout: rootOutput } = await execAsync("bun install");
|
// Parallel installation of root and web dependencies
|
||||||
const { stdout: webOutput } = await execAsync("cd web && bun install");
|
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;
|
progress.installDone = true;
|
||||||
|
|
||||||
|
if (!result.installSuccess) {
|
||||||
|
console.error("Dependency Install Failed:", result.installOutput);
|
||||||
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
result.installSuccess = false;
|
result.installSuccess = false;
|
||||||
result.installOutput = err instanceof Error ? err.message : String(err);
|
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);
|
console.error("Dependency Install Failed:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -326,7 +402,7 @@ export class UpdateService {
|
|||||||
progress.currentStep = "build";
|
progress.currentStep = "build";
|
||||||
await updateProgress();
|
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";
|
result.webBuildOutput = stdout.trim() || "Build completed successfully";
|
||||||
progress.buildDone = true;
|
progress.buildDone = true;
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@@ -343,7 +419,7 @@ export class UpdateService {
|
|||||||
progress.currentStep = "migrate";
|
progress.currentStep = "migrate";
|
||||||
await updateProgress();
|
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;
|
result.migrationOutput = stdout;
|
||||||
progress.migrateDone = true;
|
progress.migrateDone = true;
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@@ -368,9 +444,9 @@ export class UpdateService {
|
|||||||
|
|
||||||
private static async notifyPostRestartResult(
|
private static async notifyPostRestartResult(
|
||||||
channel: TextChannel,
|
channel: TextChannel,
|
||||||
result: PostRestartResult,
|
result: PostRestartResult
|
||||||
context: RestartContext
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
// Use cached rollback state - we just saved it before restart
|
||||||
const hasRollback = await this.hasRollbackPoint();
|
const hasRollback = await this.hasRollbackPoint();
|
||||||
await channel.send(getPostRestartEmbed(result, hasRollback));
|
await channel.send(getPostRestartEmbed(result, hasRollback));
|
||||||
}
|
}
|
||||||
@@ -381,5 +457,6 @@ export class UpdateService {
|
|||||||
} catch {
|
} catch {
|
||||||
// File may not exist, ignore
|
// File may not exist, ignore
|
||||||
}
|
}
|
||||||
|
// Don't clear rollback cache here - rollback file persists
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
41
shared/scripts/docker-cleanup.sh
Executable file
41
shared/scripts/docker-cleanup.sh
Executable file
@@ -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"
|
||||||
Reference in New Issue
Block a user