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:
syntaxbullet
2026-01-17 16:20:33 +01:00
parent d7543d9f48
commit 17e636c4e5
9 changed files with 496 additions and 214 deletions

View File

@@ -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);

View File

@@ -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<UpdateInfo> {
const { stdout: branchName } = await execAsync("git rev-parse --abbrev-ref HEAD");
static async checkForUpdates(): Promise<UpdateInfo & { requirements: UpdateCheckResult }> {
// 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<UpdateCheckResult> {
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<string> {
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<boolean> {
if (this.rollbackPointExists !== null) {
return this.rollbackPointExists;
}
try {
await readFile(this.ROLLBACK_FILE, "utf-8");
this.rollbackPointExists = true;
return true;
} catch {
this.rollbackPointExists = false;
return false;
}
}
@@ -168,26 +226,32 @@ export class UpdateService {
* Perform the git update
*/
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
* Optimized: Parallel installation
*/
static async installDependencies(options: { root: boolean; web: boolean }): Promise<string> {
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<void> {
// 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
}
}

View 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"